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
11 changes: 9 additions & 2 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
7 changes: 7 additions & 0 deletions backend/app/models/user.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"
)
49 changes: 48 additions & 1 deletion backend/app/routes/me.py
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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)
Expand All @@ -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)
Expand Down Expand Up @@ -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)
26 changes: 26 additions & 0 deletions backend/app/routes/plan_notes.py
Original file line number Diff line number Diff line change
Expand Up @@ -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."
)


Expand Down
6 changes: 6 additions & 0 deletions backend/app/schemas/plan_note.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 = ""
Original file line number Diff line number Diff line change
@@ -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")
66 changes: 66 additions & 0 deletions backend/tests/test_units.py
Original file line number Diff line number Diff line change
@@ -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"]
Empty file added backups/.backup.lock
Empty file.
Binary file added backups/training-api-20261112-171312.sql.gz
Binary file not shown.
9 changes: 9 additions & 0 deletions frontend/src/lib/auth.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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])
Expand Down
76 changes: 67 additions & 9 deletions frontend/src/lib/units.ts
Original file line number Diff line number Diff line change
@@ -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'

Expand All @@ -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()
Expand All @@ -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
Expand All @@ -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<UnitsResponse>('/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<void> {
try {
const remote = await api.get<UnitsResponse>('/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<UnitsResponse>('/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)
Expand Down
Loading
Loading