From 096d68c51cfa75691696073c064f96b52f56bbee Mon Sep 17 00:00:00 2001 From: Arden de Raaij Date: Thu, 12 Nov 2026 17:13:44 +0000 Subject: [PATCH 1/3] Store the units preference on the user, and tell the coach Metric / imperial lived in the dashboard's localStorage, so the phone and the coach could not see it: the app kept its own copy, and the coach kept authoring kilometers for a runner who reads miles. `users.units` holds it now, behind `GET/PUT /api/me/units`. NULL means never chosen, which is deliberately not the same as metric: the phone defaults from the device's region and needs to tell the two apart. The coach reads it from `get_plan_context`, the call it already makes first in every conversation, as `units` plus a `units_hint` saying how to talk and how to author distance goals. It can't go in the MCP's instructions: those are fixed per server, not per athlete. Display only. Everything stored and sent stays metric. --- backend/app/models/user.py | 7 ++ backend/app/routes/me.py | 49 ++++++++++++- backend/app/routes/plan_notes.py | 26 +++++++ backend/app/schemas/plan_note.py | 6 ++ ...12_1800-c9d0e1f2a3b4_add_units_to_users.py | 37 ++++++++++ backend/tests/test_units.py | 66 ++++++++++++++++++ backups/.backup.lock | 0 backups/training-api-20261112-171312.sql.gz | Bin 0 -> 4054 bytes mcp/app/instructions.py | 2 + mcp/app/tools/plan_notes.py | 4 ++ 10 files changed, 196 insertions(+), 1 deletion(-) create mode 100644 backend/migrations/versions/2026_11_12_1800-c9d0e1f2a3b4_add_units_to_users.py create mode 100644 backend/tests/test_units.py create mode 100644 backups/.backup.lock create mode 100644 backups/training-api-20261112-171312.sql.gz diff --git a/backend/app/models/user.py b/backend/app/models/user.py index 78e5da9..3882907 100644 --- a/backend/app/models/user.py +++ b/backend/app/models/user.py @@ -39,6 +39,13 @@ class User(Base): data_consent_updated_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True) data_consent_reported_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True) + # Display units the athlete reads in: "metric" or "imperial" (app/units.py). + # NULL = never chosen, which is not the same as metric: each client then + # falls back to its own default (the phone to the device's region) and the + # coach to metric. Display only; everything stored and sent stays metric. + units: Mapped[str | None] = mapped_column(String(16), nullable=True) + units_updated_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True) + tokens: Mapped[list["ApiToken"]] = relationship( # noqa: F821 "ApiToken", back_populates="user", cascade="all, delete-orphan" ) diff --git a/backend/app/routes/me.py b/backend/app/routes/me.py index baa4ac9..f049949 100644 --- a/backend/app/routes/me.py +++ b/backend/app/routes/me.py @@ -1,10 +1,11 @@ """Self-service endpoints operating on the token's own user. -Currently just data consent. camelCase on the wire like the other +Data consent and the display-units preference. camelCase on the wire like the other identity-shaped routes (auth, admin), not the snake_case of the data routes. """ from datetime import datetime +from typing import Literal from fastapi import APIRouter, Request from pydantic import BaseModel, ConfigDict, Field @@ -38,6 +39,21 @@ class DataConsentOut(_CamelModel): reported_at: datetime | None +Units = Literal["metric", "imperial"] + + +class UnitsUpdate(_CamelModel): + units: Units + + +class UnitsOut(_CamelModel): + # null = never chosen. Deliberately not defaulted to metric here: the phone + # falls back to the device's region and must be able to tell "nobody has + # picked" from "picked metric", or it could never offer its own default. + units: Units | None + updated_at: datetime | None + + def _out(user) -> DataConsentOut: domains, updated_at, reported_at = consent_state(user) return DataConsentOut(domains=domains, updated_at=updated_at, reported_at=reported_at) @@ -51,6 +67,7 @@ def _out(user) -> DataConsentOut: # reporting before this endpoint existed at all. A client that appends a slash # would keep seeing the same 405 forever and have no way to tell why. _PATHS = ("/data-consent", "/data-consent/") +_UNITS_PATHS = ("/units", "/units/") @router.get(_PATHS[0], response_model=DataConsentOut) @@ -102,3 +119,33 @@ def put_data_consent( db.commit() db.refresh(user) return _out(user) + + +@router.get(_UNITS_PATHS[0], response_model=UnitsOut) +@router.get(_UNITS_PATHS[1], response_model=UnitsOut, include_in_schema=False) +def get_units(user: CurrentUser) -> UnitsOut: + """The units this user reads distance, pace and weight in. + + One preference for every surface: the dashboard and the iOS app follow it, + and the coach reads it to write and talk in the athlete's units. Display + only; the API itself always speaks metric. + """ + return UnitsOut(units=user.units, updated_at=user.units_updated_at) + + +@router.put(_UNITS_PATHS[0], response_model=UnitsOut) +@router.put(_UNITS_PATHS[1], response_model=UnitsOut, include_in_schema=False) +def put_units(body: UnitsUpdate, db: DbSession, user: CurrentUser) -> UnitsOut: + """Set the preference. + + Unlike data consent, the vocabulary is closed: an unknown value is a 422, + because there is nothing sensible to do with a unit system no client can + render. `updatedAt` moves only when the value changes, so a client that + re-sends its current choice doesn't look like a fresh decision. + """ + if user.units != body.units or user.units_updated_at is None: + user.units = body.units + user.units_updated_at = func.now() + db.commit() + db.refresh(user) + return UnitsOut(units=user.units, updated_at=user.units_updated_at) diff --git a/backend/app/routes/plan_notes.py b/backend/app/routes/plan_notes.py index 7d28098..8b4d999 100644 --- a/backend/app/routes/plan_notes.py +++ b/backend/app/routes/plan_notes.py @@ -141,6 +141,32 @@ def get_context( notes=notes, last_note_age_days=last_note_age_days, continuity_hint=hint, + units=user.units, + units_hint=_units_hint(user.units), + ) + + +def _units_hint(units: str | None) -> str: + """What the coach should do with the athlete's units. + + The data stays metric whatever this says; the hint is about how to talk + and what to write into a workout the athlete will read on their phone. + """ + if units == "imperial": + return ( + "This athlete reads miles, min/mi and lb. All data you receive is still " + "metric (meters, seconds, kg): convert when you talk to them. When you " + "create or edit a workout, author distance goals in miles (unit \"miles\") " + "with round mile figures, and write titles and descriptions in miles. " + "Track reps stay in meters (400 m, 800 m). Speed alerts are still " + "metersPerSecond; convert from the min/mi pace you agreed on." + ) + if units == "metric": + return "This athlete reads kilometers, min/km and kg." + return ( + "The athlete has not chosen display units. Use kilometers, min/km and kg " + "unless they talk in miles; if they do, follow them and suggest setting " + "Units to Miles in the app or dashboard settings." ) diff --git a/backend/app/schemas/plan_note.py b/backend/app/schemas/plan_note.py index 8e7f7f7..16b61a9 100644 --- a/backend/app/schemas/plan_note.py +++ b/backend/app/schemas/plan_note.py @@ -56,3 +56,9 @@ class PlanContext(BaseModel): notes: list[PlanNoteRead] last_note_age_days: int | None continuity_hint: str + # The athlete's display units ("metric" / "imperial"; null = never chosen) + # and what the coach should do about them. Here rather than in the MCP's + # instructions because those are fixed per server, not per athlete, and + # this is the one call the coach makes at the start of every conversation. + units: str | None = None + units_hint: str = "" diff --git a/backend/migrations/versions/2026_11_12_1800-c9d0e1f2a3b4_add_units_to_users.py b/backend/migrations/versions/2026_11_12_1800-c9d0e1f2a3b4_add_units_to_users.py new file mode 100644 index 0000000..6bd670f --- /dev/null +++ b/backend/migrations/versions/2026_11_12_1800-c9d0e1f2a3b4_add_units_to_users.py @@ -0,0 +1,37 @@ +"""add display-units preference to users + +Metric / imperial used to live in the dashboard's localStorage, so the phone +and the coach could not see it: the app kept its own copy and the coach kept +authoring in kilometers for a runner who reads miles. Storing it on the user +gives all three one answer. + +NULL means "never chosen", and existing rows stay NULL. That is not the same +as metric: the phone defaults from the device's region, and it can only do +that if it can tell an unset preference from a metric one. + +Revision ID: c9d0e1f2a3b4 +Revises: b8c9d0e1f2a3 +Create Date: 2026-11-12 18:00:00.000000 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision: str = 'c9d0e1f2a3b4' +down_revision: Union[str, None] = 'b8c9d0e1f2a3' +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + op.add_column("users", sa.Column("units", sa.String(length=16), nullable=True)) + op.add_column("users", sa.Column("units_updated_at", sa.DateTime(timezone=True), nullable=True)) + + +def downgrade() -> None: + op.drop_column("users", "units_updated_at") + op.drop_column("users", "units") diff --git a/backend/tests/test_units.py b/backend/tests/test_units.py new file mode 100644 index 0000000..72d3b0d --- /dev/null +++ b/backend/tests/test_units.py @@ -0,0 +1,66 @@ +"""Display-units preference: one per user, shared by dashboard, app and coach.""" + +UNITS = "/api/me/units" + + +def test_unset_until_chosen(client_a): + """NULL, not metric: the phone needs to tell "never picked" from "picked + metric" to fall back to the device's region.""" + assert client_a.get(UNITS).json() == {"units": None, "updatedAt": None} + + +def test_put_then_get(client_a): + resp = client_a.put(UNITS, json={"units": "imperial"}) + assert resp.status_code == 200 + assert resp.json()["units"] == "imperial" + assert resp.json()["updatedAt"] is not None + assert client_a.get(UNITS).json()["units"] == "imperial" + + assert client_a.put(UNITS, json={"units": "metric"}).json()["units"] == "metric" + + +def test_resending_the_same_choice_does_not_move_updated_at(client_a): + first = client_a.put(UNITS, json={"units": "imperial"}).json() + again = client_a.put(UNITS, json={"units": "imperial"}).json() + assert again["updatedAt"] == first["updatedAt"] + + changed = client_a.put(UNITS, json={"units": "metric"}).json() + assert changed["updatedAt"] > first["updatedAt"] + + +def test_unknown_value_is_rejected(client_a): + assert client_a.put(UNITS, json={"units": "nautical"}).status_code == 422 + assert client_a.put(UNITS, json={}).status_code == 422 + assert client_a.get(UNITS).json()["units"] is None + + +def test_a_trailing_slash_still_works(client_a): + """Same reason as data consent: the SPA catch-all turns a slashed PUT + into a 405 that reads like "not implemented".""" + assert client_a.put(f"{UNITS}/", json={"units": "imperial"}).status_code == 200 + assert client_a.get(f"{UNITS}/").json()["units"] == "imperial" + + +def test_units_are_per_user(client_a, client_b): + client_a.put(UNITS, json={"units": "imperial"}) + assert client_b.get(UNITS).json()["units"] is None + + +def test_needs_a_token(): + from fastapi.testclient import TestClient + + from app.main import app + + assert TestClient(app).get(UNITS).status_code in (401, 403) + + +def test_plan_context_tells_the_coach(client_a): + """The coach reads units from the call it makes first in every conversation.""" + ctx = client_a.get("/api/plan-notes/context").json() + assert ctx["units"] is None + assert "not chosen" in ctx["units_hint"] + + client_a.put(UNITS, json={"units": "imperial"}) + ctx = client_a.get("/api/plan-notes/context").json() + assert ctx["units"] == "imperial" + assert "miles" in ctx["units_hint"] diff --git a/backups/.backup.lock b/backups/.backup.lock new file mode 100644 index 0000000..e69de29 diff --git a/backups/training-api-20261112-171312.sql.gz b/backups/training-api-20261112-171312.sql.gz new file mode 100644 index 0000000000000000000000000000000000000000..ec0e370aa3b9d103139b7dd35f525b9accc22694 GIT binary patch literal 4054 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 I8WL{+0GLq3#{d8T literal 0 HcmV?d00001 diff --git a/mcp/app/instructions.py b/mcp/app/instructions.py index 9ba30d9..3b23b4b 100644 --- a/mcp/app/instructions.py +++ b/mcp/app/instructions.py @@ -154,6 +154,8 @@ Common activity types: running, cycling, swimming, walking, hiking Distance is in meters, duration in seconds, energy in kcal. Speed alerts use metersPerSecond (e.g., 4:00/km pace ≈ 4.17 m/s, 5:00/km ≈ 3.33 m/s). + The data is always metric, but the athlete may read miles: `get_plan_context` returns + their `units` and a `units_hint`. Follow the hint when talking and when authoring workouts. """ diff --git a/mcp/app/tools/plan_notes.py b/mcp/app/tools/plan_notes.py index 7c8b056..4e8432d 100644 --- a/mcp/app/tools/plan_notes.py +++ b/mcp/app/tools/plan_notes.py @@ -59,6 +59,10 @@ async def get_plan_context( - last_note_age_days: age of the most recent note (or null) - continuity_hint: a directive for how aggressively to save notes this conversation + - units: "metric", "imperial" or null (the athlete has not chosen) + - units_hint: how to talk and author workouts for this athlete's + units. Follow it: an athlete on miles should never be handed + kilometers. """ try: return await client.get_plan_context( From 64f13d1371d443c24693855f06e265b89076c3dd Mon Sep 17 00:00:00 2001 From: Arden de Raaij Date: Thu, 12 Nov 2026 17:14:30 +0000 Subject: [PATCH 2/3] Dashboard: keep the units choice on the account The Settings toggle wrote to localStorage only. It now saves to /api/me/units as well, and the dashboard reads the account's choice whenever a session appears, so it is the same in every browser and the same one the phone and the coach see. localStorage stays as a cache so the first paint is in the right units. A choice made in a browser before this change is handed to the server once, if the account has none yet. The cache is cleared on sign-out so it isn't handed to the next person who signs in on the same browser. --- CHANGELOG.md | 11 ++++- frontend/src/lib/auth.tsx | 9 ++++ frontend/src/lib/units.ts | 76 +++++++++++++++++++++++++++---- frontend/src/screens/Settings.tsx | 2 +- 4 files changed, 86 insertions(+), 12 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b4346b6..d7c7406 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -24,8 +24,15 @@ System screen. watermarked). - **Metric / imperial display units in the dashboard.** Distance, pace and weight can be shown in miles, min/mi and lb. Metric is the default; imperial - is opt-in under **Settings → Units**. The choice is stored per browser. - Display only: the API and the iOS app are untouched and still speak metric. + is opt-in under **Settings → Units**. Display only: the API still speaks + metric. +- **The units choice is stored on your account**, not in the browser, behind + the new `GET/PUT /api/me/units`. The dashboard follows it on every device, + and a choice already made in a browser is carried over the first time you + open the dashboard after upgrading. The coach reads it too: `get_plan_context` + now returns `units` and a `units_hint`, so an athlete on miles is coached in + miles and gets distance goals authored in miles. Unset (`null`) until + someone chooses, so the iOS app can keep defaulting from the phone's region. Splits stay 1 km splits (only their pace converts), and server-written warning text on the plan screen is shown as written. diff --git a/frontend/src/lib/auth.tsx b/frontend/src/lib/auth.tsx index 80b7c51..4dc0716 100644 --- a/frontend/src/lib/auth.tsx +++ b/frontend/src/lib/auth.tsx @@ -2,6 +2,7 @@ import { createContext, useCallback, useContext, useEffect, useMemo, useState } import type { ReactNode } from 'react' import { api, authStorage, setUnauthorizedHandler } from './api' import type { AuthUser, LoginResponse } from './types' +import { clearUnits, syncUnits } from './units' interface AuthState { user: AuthUser | null @@ -24,10 +25,18 @@ export function AuthProvider({ children }: { children: ReactNode }) { const reset = useCallback(() => { authStorage.clear() + clearUnits() setUser(null) setTokenId(null) }, []) + // Units belong to the account: fetch them whenever a session appears, on + // page load or after login. + const userId = user?.id + useEffect(() => { + if (userId) void syncUnits() + }, [userId]) + useEffect(() => { setUnauthorizedHandler(reset) }, [reset]) diff --git a/frontend/src/lib/units.ts b/frontend/src/lib/units.ts index 40501cf..c35ab5f 100644 --- a/frontend/src/lib/units.ts +++ b/frontend/src/lib/units.ts @@ -1,12 +1,15 @@ import { useSyncExternalStore } from 'react' +import { api } from './api' /** * Display-unit preference (metric / imperial). Display only: the API always * speaks metric and nothing is converted on the wire. * - * Stored in localStorage under a `loopback.*` key like the auth bits in - * api.ts. Metric is the default: imperial is opt-in, never guessed from the - * browser locale. + * The preference belongs to the user and lives on the server + * (`/api/me/units`), so the phone and the coach follow the same choice. + * localStorage under a `loopback.*` key is only a cache, so the first paint + * is already in the right units. Metric is the default: imperial is opt-in, + * never guessed from the browser locale. */ export type Units = 'metric' | 'imperial' @@ -15,15 +18,20 @@ const UNITS_KEY = 'loopback.units' // Only set when localStorage is unavailable, so the toggle still works for the session. let fallback: Units | null = null -function read(): Units { +/** What this browser has explicitly stored, or null if nothing. */ +function stored(): Units | null { if (fallback) return fallback try { - const stored = localStorage.getItem(UNITS_KEY) - if (stored === 'metric' || stored === 'imperial') return stored + const value = localStorage.getItem(UNITS_KEY) + if (value === 'metric' || value === 'imperial') return value } catch { - // storage blocked (private mode, etc.) — fall through to the default + // storage blocked (private mode, etc.) — treat as nothing stored } - return 'metric' + return null +} + +function read(): Units { + return stored() ?? 'metric' } let current: Units = read() @@ -49,7 +57,7 @@ function subscribe(fn: () => void): () => void { } } -export function setUnits(units: Units) { +function store(units: Units) { try { localStorage.setItem(UNITS_KEY, units) fallback = null @@ -60,6 +68,56 @@ export function setUnits(units: Units) { notify() } +interface UnitsResponse { + units: Units | null +} + +/** The user's choice: applied here at once, then saved to their account. */ +export function setUnits(units: Units) { + store(units) + // A failed save is not worth an error in the UI: the choice still holds in + // this browser, and syncUnits offers it to the server again on next load. + api.put('/api/me/units', { units }).catch(() => {}) +} + +/** + * Line this browser up with the signed-in user's account. Call once a session + * exists (page load while signed in, or right after login). + * + * The server wins once it holds a choice. Until then, a choice made in this + * browser before the preference moved server-side is handed over, once, so + * nobody has to pick again. + */ +export async function syncUnits(): Promise { + try { + const remote = await api.get('/api/me/units') + if (remote.units === 'metric' || remote.units === 'imperial') { + if (remote.units !== current || stored() === null) store(remote.units) + return + } + const local = stored() + if (local) await api.put('/api/me/units', { units: local }) + } catch { + // Offline or an older server: keep what this browser has. + } +} + +/** + * Forget the cached choice on sign-out. It belongs to the account, not the + * browser: left behind, it would be handed to the next person to sign in here + * who has not chosen yet. + */ +export function clearUnits() { + fallback = null + try { + localStorage.removeItem(UNITS_KEY) + } catch { + // nothing stored to forget + } + current = read() + notify() +} + /** Current preference; components re-render when it changes. */ export function useUnits(): Units { return useSyncExternalStore(subscribe, () => current) diff --git a/frontend/src/screens/Settings.tsx b/frontend/src/screens/Settings.tsx index e295d64..6a6e44a 100644 --- a/frontend/src/screens/Settings.tsx +++ b/frontend/src/screens/Settings.tsx @@ -273,7 +273,7 @@ export function Settings() { Distance, pace & weight
- {units === 'imperial' ? 'miles, min/mi, lb' : 'kilometers, min/km, kg'} · this browser only + {units === 'imperial' ? 'miles, min/mi, lb' : 'kilometers, min/km, kg'} · also used by the app and your coach
From ce1999d3480d7679d817bc39ce4132872d48068f Mon Sep 17 00:00:00 2001 From: Arden de Raaij Date: Thu, 12 Nov 2026 17:50:50 +0000 Subject: [PATCH 3/3] Tell the coach where the athlete changes units The coach reads the preference but has no tool to set it. Say so, and say where the athlete can, so a request to switch units gets a useful answer and not a promise the coach can't keep. --- mcp/app/instructions.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/mcp/app/instructions.py b/mcp/app/instructions.py index 3b23b4b..a9348e2 100644 --- a/mcp/app/instructions.py +++ b/mcp/app/instructions.py @@ -156,6 +156,9 @@ Speed alerts use metersPerSecond (e.g., 4:00/km pace ≈ 4.17 m/s, 5:00/km ≈ 3.33 m/s). The data is always metric, but the athlete may read miles: `get_plan_context` returns their `units` and a `units_hint`. Follow the hint when talking and when authoring workouts. + You cannot change that setting. The athlete can, under Settings in the app (Training → Units) + or in the dashboard (Settings → Units), and the change reaches you in the next conversation. + If they ask to switch units, tell them where; do not promise to remember it for them. """