Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
b3e51d2
Keep a coach's week on the calendar and run the background work.
rvegajr Sep 30, 2026
9f0b26b
Let a coach talk their week in and publish it.
rvegajr Sep 30, 2026
e961c0e
Stop a texting loop in the send path, and fix the flaky test servers.
rvegajr Sep 30, 2026
90bd7d9
Bind test servers to 127.0.0.1 so a test never reaches another program.
rvegajr Oct 1, 2026
77580a5
Run on Node 24 LTS and current dependencies, with one Node version ev…
rvegajr Oct 1, 2026
3e7cd8c
Correct how Railway picks production's Node version.
rvegajr Oct 1, 2026
c23f169
Move Railway from railway.json to Infrastructure as Code.
rvegajr Oct 1, 2026
f106ffc
Record the Node 24 rollout and Railway's production apply bug.
rvegajr Oct 1, 2026
374662c
Merge pull request #15 from YOLOVibeCode/feat/take-and-run
rvegajr Oct 1, 2026
982ff7c
Merge branch 'develop' into feat/voice-setup
rvegajr Oct 1, 2026
53ac4d6
Merge pull request #16 from YOLOVibeCode/feat/voice-setup
rvegajr Oct 1, 2026
7de1ea8
Merge branch 'develop' into feat/sms-ceilings
rvegajr Oct 1, 2026
1c86ff0
Merge pull request #17 from YOLOVibeCode/feat/sms-ceilings
rvegajr Oct 1, 2026
79c696a
Merge branch 'develop' into chore/node-24
rvegajr Oct 1, 2026
53a20ac
Merge pull request #18 from YOLOVibeCode/chore/node-24
rvegajr Oct 1, 2026
4a8bc90
Merge branch 'develop' into chore/railway-iac
rvegajr Oct 1, 2026
6090f04
Merge pull request #19 from YOLOVibeCode/chore/railway-iac
rvegajr Oct 1, 2026
a1c0be3
Put a week box on the home page so a coach can start from one sentence.
rvegajr Oct 1, 2026
f4e2b13
Merge pull request #20 from YOLOVibeCode/feat/landing-say-your-week
rvegajr Oct 1, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
37 changes: 36 additions & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -23,9 +23,12 @@ jobs:
with:
persist-credentials: false

# The required check. .nvmrc is the one place the project's Node
# version lives; Railway reads package.json engines (kept in step by
# test/toolchain.test.ts) and nvm reads .nvmrc locally.
- uses: actions/setup-node@v7
with:
node-version: 22
node-version-file: .nvmrc
cache: npm

# Fresh clone, no secrets. Tests use PGlite and in-process fakes
Expand All @@ -44,3 +47,35 @@ jobs:

- name: Build
run: npm run build

# Early warning, never blocking: the newest LTS and the newest Current
# release. When the next Node line becomes LTS, the lts/* job tests it
# before anyone moves .nvmrc and engines.
next-node:
name: Next Node (${{ matrix.node }})
runs-on: ubuntu-24.04
timeout-minutes: 10
continue-on-error: true
strategy:
fail-fast: false
matrix:
node: ['lts/*', 'latest']
steps:
- uses: actions/checkout@v7
with:
persist-credentials: false

- uses: actions/setup-node@v7
with:
node-version: ${{ matrix.node }}
check-latest: true
cache: npm

- name: Install
run: npm ci

- name: Typecheck
run: npm run typecheck

- name: Test
run: npm test
3 changes: 3 additions & 0 deletions .npmrc
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
# Refuse to install on a Node outside package.json "engines" instead of
# failing later in a confusing way. .nvmrc names the version to use.
engine-strict=true
1 change: 1 addition & 0 deletions .nvmrc
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
24
30 changes: 30 additions & 0 deletions .railway/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
# Railway configuration

`railway.ts` defines Coachatron's `web` service for every environment
(development from `develop`, uat from `staging`, production from `main`). It
replaced `railway.json`, which Railway stops reading on 2026-12-01.

Railway does not read this folder on deploy. Changes take effect only when
someone applies them, one environment at a time, from the repo root on Node 24:

```bash
railway environment development # then uat, then production
railway config plan # must say 0 to destroy
railway config apply
```

- **Partial `web`.** The file owns only the web service. Postgres, its volume,
and slack-cards are not in it and apply never touches them.
- **Variables.** Every value stays in Railway; the file marks each one
`preserve()`. Never put a secret here.
- **Node version.** `RAILPACK_NODE_VERSION` comes from `../.nvmrc`. Railpack
reads that variable before `package.json` engines, so it decides the Node a
deploy runs. To apply everything except a Node change, set
`COACHATRON_IAC_PRESERVE_NODE=1`.
- **Known Railway bug (production).** `railway config apply` with any change
fails in production with "Custom-domain registration is not supported" for
`coachatron.com` and `www.coachatron.com`, though both exist and `plan`
shows only the intended change. Until Railway fixes it, make the planned
change directly (for a variable: `railway variable set KEY=VALUE --service
web --environment production`), then run `railway config plan` to confirm
it says "already up to date".
67 changes: 67 additions & 0 deletions .railway/railway.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
import { readFileSync } from "node:fs";
import { defineRailway, github, preserve, project, service } from "railway/iac";

// Coachatron's web service, in every environment. Replaces railway.json
// (Config as Code), which Railway stops reading on 2026-12-01.
//
// Railway does not read this file on deploy. Preview and apply it per
// environment, from the repo root, with the environment linked:
// railway environment development # or uat, production
// railway config plan
// railway config apply
//
// "web" is a partial: this file owns only the web service. Postgres, its
// volume, and slack-cards (production) are managed elsewhere and are never
// touched by apply.
export const partial = "web";

// The Node version comes from .nvmrc, the one place the repo names it
// (test/toolchain.test.ts keeps package.json engines in step). Railpack reads
// RAILPACK_NODE_VERSION before engines, so this variable is what decides the
// Node a deploy runs. COACHATRON_IAC_PRESERVE_NODE=1 keeps the live value
// instead, for applying the rest of this file without changing Node.
function nodeVersion() {
if (process.env.COACHATRON_IAC_PRESERVE_NODE === "1") return preserve();
return readFileSync(new URL("../.nvmrc", import.meta.url), "utf8").trim();
}

const ENVIRONMENTS = {
development: { branch: "develop", domains: ["dev.coachatron.com"] },
uat: { branch: "staging", domains: ["uat.coachatron.com"] },
production: { branch: "main", domains: ["coachatron.com", "www.coachatron.com"] },
} as const;

export default defineRailway((ctx) => {
const name = (Object.keys(ENVIRONMENTS) as Array<keyof typeof ENVIRONMENTS>).find((env) => ctx.isEnvironment(env));
if (!name) throw new Error(`No web settings for Railway environment "${ctx.environmentName}"`);
const env = ENVIRONMENTS[name];

const web = service("web", {
source: github("YOLOVibeCode/coachatron", { branch: env.branch, checkSuites: false }),
build: "npm run build",
start: "npm start",
healthcheck: "/",
healthcheckTimeout: 30,
replicas: { "us-east4-eqdc4a": 1 },
domains: env.domains.map((domain) => ({ domain, port: 3000 })),
// Values live in Railway, never in this file. preserve() keeps each one.
env: {
APP_BASE_URL: preserve(),
APP_ENV: preserve(),
DATABASE_URL: preserve(),
LITELLM_API_KEY: preserve(),
LITELLM_BASE: preserve(),
NODE_ENV: preserve(),
PORT: preserve(),
RAILPACK_NODE_VERSION: nodeVersion(),
RELAY_API_KEY: preserve(),
RELAY_BASE_URL: preserve(),
RELAY_CONNECT_PRODUCT: preserve(),
RELAY_WEBHOOK_SECRET: preserve(),
SESSION_SECRET: preserve(),
STORE_BASE_URL: preserve(),
},
});

return project("coachatron", { resources: [web] });
});
3 changes: 2 additions & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ files win over a generic stack preference.
- One service. Postgres in production. Tests may use PGlite so `npm test` needs no daemon.
- Payments go through signed Noctusoft marketplace buy links (`GET /buy/connect/coachatron/:seller`). Do not import a Square SDK or card form.
- SMS and email go through the Noctusoft relay. Do not import Twilio or SendGrid.
- The model is one interface. The production client calls LiteLLM on litellm-vm at `https://ai.noctusoft.com/v1`. No provider SDK. Tests use a fake. The text assistant (SMS + the schedule-screen field) is the only caller.
- The model is one interface. The production client calls LiteLLM on litellm-vm at `https://ai.noctusoft.com/v1`. No provider SDK. Tests use a fake. The text assistant and voice setup (SMS + the schedule screen) are the only callers.

## Commands

Expand All @@ -23,6 +23,7 @@ Fill these in as the project gains them, and keep them true:
| Test | `npm test` |
| Typecheck | `npm run typecheck` |
| Dev | `npm run dev` |
| Eval voice setup (calls the live model; run on purpose only) | `LITELLM_API_KEY=... npm run eval:setup` |

## Never

Expand Down
37 changes: 34 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,9 @@ Later, the coach runs all of it by texting the number in plain English.

## Prerequisites

- Node.js ≥ 20.11 (see `engines` in `package.json`). That's the whole list.
- Node.js 24 (the current LTS). `.nvmrc` names it, so `nvm use` (or a shell
that switches on `.nvmrc`) picks it up; `npm ci` refuses an older Node
(`engine-strict` in `.npmrc`). That's the whole list.
- No Postgres, no Docker, no database to install — tests run against
[PGlite](https://pglite.dev) (Postgres compiled to WASM, in-memory).
- No accounts to create and no secrets required to run locally. Payments
Expand Down Expand Up @@ -91,9 +93,9 @@ The twelve screens of Coachatron (Slice 1):
| # | Screen | Route(s) | Who |
|---|--------|----------|-----|
| 1 | Sign in | `/signin`, `/signin/otp`, `/signin/verify` | Coach |
| 2 | Schedule | `/app/schedule` | Coach |
| 2 | Schedule (and voice setup) | `/app/schedule`, `/app/setup`, `/app/setup/publish`, `/app/setup/discard` | Coach |
| 3 | Session detail | `/app/sessions/:id`, `/app/sessions/:id/bookings/:bookingId/attendance`, `/app/sessions/:id/cancel` | Coach |
| 4 | Session types | `/app/session-types`, `/app/session-types/:id/generate-week` | Coach |
| 4 | Session types | `/app/session-types`, `/app/session-types/:id/generate-week` (weekly times), `/app/session-types/:id/slots/:slotId/remove` | Coach |
| 5 | Pricing | `/app/pricing`, `/app/pricing/package`, `/app/pricing/plan` | Coach |
| 6 | Roster | `/app/roster`, `/app/roster/:id/priority` | Coach |
| 7 | Money (and connecting payments) | `/app/money`, `/app/money/payments` | Coach |
Expand Down Expand Up @@ -137,6 +139,35 @@ Do not commit secrets. `npm test` uses the in-process fake in `test/fakes/relay.
coach is recorded on the same session via `assigned_roster_member_id`.
See `ROADMAP.md` M4 for why.

## Texting ceilings

Every outbound text goes through `sendText()` in `src/domain/outbound.ts`
(a test fails if anything calls the relay directly). Before it sends, it:
fits the text into one segment (flattening curly quotes, dashes, and the
narrow space `Intl` puts before "PM", so a text never silently becomes
UCS-2); refuses numbers outside `+1` (US and Canada, the destinations priced
near $0.0083 a segment); and stops at 300 texts per coach per local day,
2,000 per coach per month, 400 per active coach per day for the product
(never below 500), and 5 sign-in codes per number per day. Every attempt,
sent or stopped, is written to `message_log`.

## Voice-setup eval (live model, opt-in)

`npm test` only ever uses a fake model. To check extraction against the
real one on litellm-vm, run `LITELLM_API_KEY=... npm run eval:setup`. It
runs the production prompt, schema, and validation over
`eval/setup-utterances.json` and prints PASS/MISS per case. The starter
cases are synthetic; add the launch coach's real descriptions before
trusting the number.

## Background job

The running server ticks once a minute (`src/jobs/tick.ts`). Each tick keeps
every weekly time 8 weeks ahead on the calendar, passes an expired overflow
offer to the next backup coach, and sends 24-hour reminders outside quiet
hours (9pm–8am in the coach's timezone). Tests call `runTick()` directly
with a fixed clock; `createApp()` never starts the timer.

None of these affect the three journeys in the product brief (booking +
payment, the overflow cascade, the read-only Money screen), which are all
covered by `test/journeys.test.ts`.
Expand Down
Loading
Loading