feat(activity): add an activity calendar with personal baseline and goals

A week or month of steps, active minutes, workouts by sport and training
load, each day placed against the person's own spread over the 84 days
before the period and each period against the median of the twelve weeks
or six months before it. Optional weekly and monthly goals live on the
account (activity_goals_json), travel with the backup and are cleared by
a data wipe. The calendar sits on the workouts page; the steps page links
to it. Computed server-side without AI.
This commit is contained in:
Marc-André Bombeck committed 2026-10-21 21:38:32 +02:00
1 parent e1b70537cc
commit 5eb7caebae
40 files changed
+4172 -6

No files matched your search

+5 -1
View File
@@ -1077,6 +1077,10 @@ const DELEGABLE_ROUTES: Record<string, DelegableEntry> = {
domain: "measurements",
why: "The record's workout list. Verified to carry no AI-written paragraph: the single-workout route beside it does, and stays refused for exactly that reason.",
},
"app/api/activity/calendar/route.ts": {
domain: "measurements",
why: "The activity calendar: day totals of steps, active minutes and load, workouts by sport, and the record owner's goals, computed without AI. Read-only, scoped to the resolved user, module-gated on the record's own `workouts` switch like the workout list beside it.",
},
"app/api/cycle/cycles/route.ts": {
domain: "cycle",
why: "The record's cycle history. Whether cycle tracking applies is a property of the record, and the gate gets there by re-loading the profile and gender for the resolved user id — note that the `gender` argument at the call site is dead (`requireCycleEnabled` names it `_gender`), so it is the id that carries the substitution, not that argument.",
@@ -2172,7 +2176,7 @@ const ACTOR_ROUTES: Record<string, string> = {
* v1.44 -- a guardian connects and disconnects a managed profile's OAuth
* providers: two guardian-only entries. 266 -> 268.
*/
const FROZEN_ENTRY_COUNT = 268;
const FROZEN_ENTRY_COUNT = 269;
/**
* The two surfaces that authenticate a Bearer token outside `requireAuth` —
@@ -82,6 +82,9 @@ const PINNED_AFFORDANCES: Record<
// the two status grids used to spell the tint at every call site and now
// name a tone instead, so the marker lives here once.
"src/components/admin/_shared.tsx": { "text-success": 3, "toast.success": 1 },
// v1.44: the activity calendar's per-week tick for "every weekly goal met",
// a state of a row the person reads, not the outcome of a write.
"src/components/activity/activity-calendar-card.tsx": { "text-success": 1 },
// v1.42: the readiness inventory's "carries" status icon, one arm of the
// carries / thin / empty tone table, beside its spoken status label.
"src/components/timeline/readiness-inventory.tsx": { "text-success": 1 },
@@ -43,6 +43,9 @@ const IDENTITY_OWNER: Record<string, string> = {
"lib/jobs/workout-insight-generate.ts",
"lib/workouts/list-read.ts": "lib/workouts/list-read.ts",
"lib/workouts/sport-context.ts": "lib/workouts/sport-context.ts",
// The activity calendar counts workouts per day; a session two devices
// recorded must count once there, as it does in the list.
"lib/activity/calendar-read.ts": "lib/activity/calendar-read.ts",
// The only indirection in the set. The Coach block is handed rows it does not
// read itself; the snapshot builder fetches them in parallel with every other
// block's data. Recording the owner here rather than assuming the call site