Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
2 changes: 1 addition & 1 deletion api/coach/core/categories.js
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@
* builds from it, so the screen cannot drift from the payload. */
export const DATA_CATEGORIES = Object.freeze([
'plan', // routines, exercises, sets/reps, schedule, progression settings
'training', // logged sets, targets, effort ratings, durations, PRs in the review window
'training', // logged sets, targets, effort ratings, durations, PRs, session notes in the review window
'bodyweight', // weigh-ins in the window and your goal weight
'profile', // the intake answers you gave the Coach, including any limitations
'prefs' // unit, language, effort scale
Expand Down
7 changes: 6 additions & 1 deletion api/coach/core/payload.js
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,11 @@ export const CONTRACT = 1;
// makes the payload bigger and the reading vaguer, not better.
export const MAX_WEEKS = 12;
export const MAX_SESSIONS = 60;
// The most a session note can be: the app's own cap (frontend/src/lib/history.js NOTE_MAX, pinned
// by api/test/payload-parity.test.js), so a note the athlete wrote arrives whole and the bound only
// holds for a document edited outside the app. It was 300 here, and a 500-character note reached
// the model cut off mid-sentence.
export const NOTE_MAX = 500;
// Last-resort ceiling for a free-text note/refine (issue #267). The real limit is the admin's
// `maxMessageLen`, enforced in jobs.js before a message ever reaches this module — this module
// stays a pure allowlist with no config import of its own, so it keeps its own constant instead.
Expand Down Expand Up @@ -389,7 +394,7 @@ function cleanWorkout(w) {
name: word(w.name, NAME_MAX),
minutes: w.end && w.start ? Math.round((w.end - w.start) / 60000) : null,
...(w.rating ? { rating: typeof w.rating === 'number' ? w.rating : text(String(w.rating), 20) } : {}),
...(w.note ? { note: String(w.note).slice(0, 300) } : {}),
...(w.note ? { note: String(w.note).slice(0, NOTE_MAX) } : {}),
prs: list(w.prs).length,
entries: entriesOf(w).map(en => ({
id: ident(en.id),
Expand Down
7 changes: 7 additions & 0 deletions api/test/payload-parity.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -14,11 +14,18 @@ import assert from 'node:assert/strict';
import { tempData, sampleState } from './helpers.mjs';
import { isWarmupRow } from '../../frontend/src/lib/workout-model.js';
import { readSession as frontendReadSession } from '../../frontend/src/lib/progression.js';
import { NOTE_MAX as frontendNoteMax } from '../../frontend/src/lib/history.js';

tempData();
const payload = await import('../coach/core/payload.js');
const { handleFor } = await import('../coach/handle.js');

/* The payload bounds a session note at the app's own cap, so a note the app let the athlete write
is never cut on its way to the model. */
test('the payload\'s NOTE_MAX is the app\'s NOTE_MAX', () => {
assert.equal(payload.NOTE_MAX, frontendNoteMax);
});

test('isWarmupSet agrees with the frontend isWarmupRow', () => {
const cases = [
{}, null, undefined,
Expand Down
18 changes: 18 additions & 0 deletions api/test/payload.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -375,3 +375,21 @@ test('a workout\'s photos and videos never reach the payload', () => {
}
}
});

/* The session note is the athlete's own explanation of the numbers, and the app lets it run to
NOTE_MAX (500) characters. It was cut at 300 here, so a note explaining how a dumbbell weight
had been counted reached the model mid-sentence. */
test('a session note reaches the model whole, bounded only at the app\'s own cap', () => {
const S = sampleState();
const whole = 'It is confusing whether the weight is total or per dumbbell. '.repeat(8).trim(); // 487 chars: past the old 300, within the cap
const mk = (d, note) => ({
id: 'w' + d, d, name: 'A', start: 0, end: 60000, prs: [], note,
entries: [{ id: '0001', target: { sets: 3, reps: 10, weight: 20 }, sets: [{ w: 20, r: 10, done: true }] }]
});
S.workouts = [mk('2026-09-02', whole), mk('2026-09-03', 'y'.repeat(payload.NOTE_MAX + 100))]; // only a document edited outside the app carries the second
const p = payload.build(S, { handle: handleFor('u1'), kind: 'review' });
const [full, edited] = p.window.workouts;
assert.equal(full.compact, undefined, 'both sessions are in full detail');
assert.equal(full.note, whole, 'the session note arrives whole (was cut at 300)');
assert.equal(edited.note.length, payload.NOTE_MAX, 'a note no app could have written is bounded at the cap');
});
2 changes: 1 addition & 1 deletion docs/AI_COACH.md
Original file line number Diff line number Diff line change
Expand Up @@ -236,7 +236,7 @@ renders from, so the screen cannot drift from the payload — are:
| Category | What it covers |
| --- | --- |
| `plan` | routines, exercises, sets/reps, schedule, progression settings |
| `training` | logged sets, targets, effort ratings, durations, PRs in the review window |
| `training` | logged sets, targets, effort ratings, durations, PRs and session notes in the review window |
| `bodyweight` | weigh-ins in the window and your goal weight |
| `profile` | the intake answers you gave the Coach, including any limitations |
| `cohort` (optional) | only with comparison on and your own opt-in: anonymous medians from the other people sharing — never their data, and never yours to them beyond the same medians |
Expand Down
6 changes: 4 additions & 2 deletions frontend/src/lib/coach.js
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,9 @@ import { POLICIES } from './progression.js'
import { t } from './i18n.js'

// Bumping this re-prompts everyone: it means what we share, or who we share it with, changed.
export const CONSENT_VERSION = 1
// 2: the training line names the session notes, which the payload carries and version 1 never
// mentioned.
export const CONSENT_VERSION = 2

// Bounds. The whole state has to stay inside the server's 5 MB body limit, and a Coach log
// that grows forever is exactly the kind of thing that eats it invisibly. Worst case here is
Expand Down Expand Up @@ -64,7 +66,7 @@ export const coachAvailable = (config, user, { demo, mobile, coachMode } = {}) =
// builder uses (api/coach/core/categories.js), so the screen cannot promise less than leaves.
export const CATEGORY_TEXT = {
plan: ['Your plan', 'Routines, exercises, sets and reps, your weekly schedule and progression settings.'],
training: ['Your logged training', 'Sets you logged in the review window — weights, reps, times, effort ratings and how long sessions took.'],
training: ['Your logged training', 'Sets you logged in the review window — weights, reps, times, effort ratings, how long sessions took, and your session notes.'],
bodyweight: ['Body weight', 'Weigh-ins from the same window, and your goal weight if you set one.'],
profile: ['What you tell the Coach', 'Your intake answers, including any limitations or injuries you describe.'],
prefs: ['A few preferences', 'Your unit, your language and which effort scale you log.']
Expand Down
3 changes: 2 additions & 1 deletion frontend/src/views/CoachChat.demo-failure.test.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { createRoot } from 'react-dom/client'
import { parseHTML } from 'linkedom'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import CoachChat from './CoachChat.jsx'
import { CONSENT_VERSION } from '../lib/coach.js' // the consent a fixture grants is the current one, whatever its number

// The demo Coach's failure state, end to end. Every other CoachChat test mocks lib/coach-api.js;
// this one runs the real thing with DEMO forced on, so the chain under test is exactly what the
Expand Down Expand Up @@ -74,7 +75,7 @@ const state = () => ({
unit: 'kg', lang: 'en', customEx: [], workouts: [brokenWorkout()], bodyweight: [], exWeights: {},
dayPlan: {}, routines: [], week: {},
coach: {
consent: { agreedAt: '2026-07-01T00:00:00Z', version: 1 },
consent: { agreedAt: '2026-07-01T00:00:00Z', version: CONSENT_VERSION },
profile: { goal: 'muscle', experience: 'new', daysPerWeek: 3, sessionMin: 60, preferredDays: [1, 3, 5], equipment: [] },
log: [], snapshots: [], chat: [{ id: 'c1', role: 'user', kind: 'intake', at: 1 }], timings: []
},
Expand Down
3 changes: 2 additions & 1 deletion frontend/src/views/CoachChat.fast-failure.test.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import React, { act } from 'react'
import { createRoot } from 'react-dom/client'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import CoachChat from './CoachChat.jsx'
import { CONSENT_VERSION } from '../lib/coach.js' // the consent a fixture grants is the current one, whatever its number
import { requestPlan, settleAwaited } from '../lib/coach-api.js'

// A run that fails before the first status poll. A provider that refuses the connection fails
Expand Down Expand Up @@ -81,7 +82,7 @@ const state = routines => ({
unit: 'kg', lang: 'en', customEx: [], workouts: [{ id: 'w1', name: 'Push Day', d: '2026-09-01', entries: [] }], bodyweight: [], exWeights: {},
dayPlan: {}, routines, week: {},
coach: {
consent: { agreedAt: '2026-07-01T00:00:00Z', version: 1 },
consent: { agreedAt: '2026-07-01T00:00:00Z', version: CONSENT_VERSION },
profile: { goal: 'muscle', experience: 'new', daysPerWeek: 3, sessionMin: 60, preferredDays: [1, 3, 5], equipment: [] },
log: [], snapshots: [], chat: [{ id: 'c1', role: 'user', kind: 'intake', at: 1 }], timings: []
},
Expand Down
3 changes: 2 additions & 1 deletion frontend/src/views/CoachChat.test.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { createRoot } from 'react-dom/client'
import { parseHTML } from 'linkedom'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import CoachChat from './CoachChat.jsx'
import { CONSENT_VERSION } from '../lib/coach.js' // the consent a fixture grants is the current one, whatever its number
import { effectiveRoutine } from '../lib/history.js'
import { todayISO } from '../lib/format.js'

Expand Down Expand Up @@ -70,7 +71,7 @@ const state = () => ({
unit: 'kg', lang: 'en', customEx: [], workouts: [], bodyweight: [], exWeights: {},
dayPlan: {}, routines: [], week: {},
coach: {
consent: { agreedAt: '2026-07-01T00:00:00Z', version: 1 },
consent: { agreedAt: '2026-07-01T00:00:00Z', version: CONSENT_VERSION },
profile: { goal: 'muscle', experience: 'new', daysPerWeek: 3, sessionMin: 60, preferredDays: [1, 3, 5], equipment: [] },
log: [], snapshots: [], chat: [{ id: 'c1', role: 'user', kind: 'intake', at: 1 }], timings: []
},
Expand Down
9 changes: 7 additions & 2 deletions frontend/src/views/CoachIntake.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -59,7 +59,11 @@ export default function CoachIntake() {
const update = useStore(s => s.update)
const toast = useUI(s => s.toast)
const [needConsent] = useState(() => !editing && !hasConsent(S))
const STEPS = [...(needConsent ? ['consent'] : []), 'goal', 'experience', 'days', 'length', 'equipment', 'limits', 'extras']
// An athlete who already answered the intake and only lacks the CURRENT consent (the wording
// changed and CONSENT_VERSION moved) reads the new terms and goes back to the Coach. Without
// this the redirect from the chat replayed all seven questions and ended in a new plan request.
const [reconsentOnly] = useState(() => !editing && !hasConsent(S) && !!S.coach?.profile)
const STEPS = reconsentOnly ? ['consent'] : [...(needConsent ? ['consent'] : []), 'goal', 'experience', 'days', 'length', 'equipment', 'limits', 'extras']
const [step, setStep] = useState(0)
const [busy, setBusy] = useState(false)
const [p, setP] = useState(() => ({
Expand All @@ -81,7 +85,8 @@ export default function CoachIntake() {

const agree = () => {
update(s => { s.coach = { ...(s.coach || emptyCoach()), consent: { agreedAt: new Date().toISOString(), version: CONSENT_VERSION } } })
setStep(step + 1)
if (reconsentOnly) nav('/coach', { replace: true })
else setStep(step + 1)
}

const finish = async () => {
Expand Down
22 changes: 22 additions & 0 deletions frontend/src/views/CoachIntake.test.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -147,6 +147,28 @@ describe('CoachIntake — the consent screen', () => {
expect(mocks.nav).toHaveBeenCalledWith('/plan')
})

it('a returning athlete whose consent is out of date reads the new terms only, then goes back to the Coach', () => {
// CONSENT_VERSION moved (the training line now names the session notes). The chat's redirect
// used to replay all seven questions and end in a new plan request.
mocks.S.coach = { consent: { agreedAt: '2026-09-01T00:00:00Z', version: CONSENT_VERSION - 1 }, profile: { goal: 'strength', experience: 'intermediate', daysPerWeek: 3 }, chat: [] }
mount()
expect(eyebrow()).toBe('Before we start')
expect(all('.ob-dot')).toHaveLength(0)
tap('button', 'I understand')
expect(mocks.S.coach.consent.version).toBe(CONSENT_VERSION)
expect(mocks.S.coach.profile.goal).toBe('strength') // the answers stay as they were
expect(mocks.nav).toHaveBeenCalledWith('/coach', { replace: true })
expect(vi.mocked(requestPlan)).not.toHaveBeenCalled()
})

it('the training line names the session notes, which the Coach reads', () => {
// api/coach/core/payload.js sends each recent session's note (cleanWorkout). The consent
// screen listed weights, reps, times, effort and durations, and never said so.
mount()
const training = all('.ob-consent-row').find(r => r.querySelector('b').textContent === 'Your logged training')
expect(training.textContent).toContain('your session notes')
})

it('lists the built-in categories first, then whatever the server says actually goes', async () => {
vi.mocked(disclosure).mockResolvedValue({ categories: ['plan', 'prefs'], providerLabel: 'Anthropic', payer: 'you', host: 'api.anthropic.com' })
mount()
Expand Down
Loading