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

+182
View File
@@ -0,0 +1,182 @@
/**
* Query of `GET /api/activity/calendar` (D1036).
*/
import { z } from "zod/v4";
export const activityCalendarQuerySchema = z.object({
period: z
.enum(["week", "month"])
.default("month")
.describe("Calendar span: one week (Monday to Sunday) or one month."),
date: z.iso
.date()
.optional()
.describe(
"Any day inside the period to show (`YYYY-MM-DD`, the person's local day). Defaults to today. A day after today, or more than ten years back, is refused.",
),
});
export type ActivityCalendarQuery = z.infer<typeof activityCalendarQuerySchema>;
const dateKey = z.iso.date();
const dayLevelEnum = z
.enum(["none", "unrated", "low", "below", "usual", "above", "high"])
.describe(
"Where the day sits in the person's own spread over the 84 days before the period: `low` below their 10th percentile, `below` below the 25th, `above` above the 75th, `high` above the 90th, `usual` between. `none` = no value that day; `unrated` = fewer than 14 days of history to compare with.",
);
const baselineStatsSchema = z
.object({
median: z.number(),
p10: z.number(),
p25: z.number(),
p75: z.number(),
p90: z.number(),
days: z
.number()
.int()
.describe("Days with a value the spread was computed from."),
})
.nullable()
.meta({ id: "ActivityBaselineStats" });
const periodComparisonSchema = z
.object({
value: z
.number()
.nullable()
.describe(
"The period so far: a total (steps, active minutes, workouts) or a mean (load).",
),
daysWithData: z.number().int(),
usual: z
.number()
.nullable()
.describe(
"The person's usual full period: the median of the twelve weeks (or, per day, of the six months) before it. Null with fewer than three earlier periods of data.",
),
usualToDate: z
.number()
.nullable()
.describe(
"The usual figure for the days elapsed so far; equals `usual` once the period is over.",
),
referencePeriods: z.number().int(),
})
.meta({ id: "ActivityPeriodComparison" });
const goalProgressSchema = z
.object({
key: z.enum([
"workoutsPerWeek",
"activeMinutesPerWeek",
"stepsPerWeek",
"stepsPerMonth",
]),
target: z.number().int(),
value: z.number(),
met: z.boolean(),
})
.meta({ id: "ActivityGoalProgress" });
const goalsSchema = z
.object({
workoutsPerWeek: z.number().int().nullable(),
activeMinutesPerWeek: z.number().int().nullable(),
stepsPerWeek: z.number().int().nullable(),
stepsPerMonth: z.number().int().nullable(),
})
.meta({
id: "ActivityGoals",
description:
"The person's own goals; `null` = not set. There is no default goal.",
});
export const activityGoalsResponseSchema = goalsSchema;
export const activityCalendarResponseSchema = z
.object({
period: z.enum(["week", "month"]),
start: dateKey,
end: dateKey,
gridStart: dateKey.describe(
"First day drawn (the Monday on or before `start`).",
),
gridEnd: dateKey.describe("Last day drawn (the Sunday on or after `end`)."),
today: dateKey,
timezone: z.string().describe("The IANA zone the days are cut in."),
loadType: z
.enum(["DAY_STRAIN", "CARDIO_LOAD", "STRAIN_SCORE"])
.nullable()
.describe(
"Which training-load score `load` carries; null when the account has none.",
),
days: z.array(
z.object({
date: dateKey,
inPeriod: z.boolean(),
future: z.boolean(),
steps: z.number().nullable(),
activeMinutes: z
.number()
.nullable()
.describe(
"The larger of the day's exercise minutes and workout minutes (never their sum).",
),
workoutCount: z.number().int(),
workoutsBySport: z.record(z.string(), z.number().int()),
load: z.number().nullable(),
level: z.object({
steps: dayLevelEnum,
activeMinutes: dayLevelEnum,
load: dayLevelEnum,
}),
}),
),
weeks: z.array(
z.object({
start: dateKey,
end: dateKey,
steps: z.number().nullable(),
activeMinutes: z.number().nullable(),
workouts: z.number().int(),
goals: z
.array(goalProgressSchema)
.describe("The weekly goals for a week that has begun."),
}),
),
baseline: z.object({
steps: baselineStatsSchema,
activeMinutes: baselineStatsSchema,
load: baselineStatsSchema,
}),
summary: z.object({
steps: periodComparisonSchema,
activeMinutes: periodComparisonSchema,
workouts: periodComparisonSchema,
load: periodComparisonSchema,
}),
sports: z.array(
z.object({ sportType: z.string(), count: z.number().int() }),
),
goals: goalsSchema,
goalProgress: z.array(goalProgressSchema),
available: z.object({
steps: z.boolean(),
activeMinutes: z.boolean(),
workouts: z.boolean(),
load: z.boolean(),
}),
previous: dateKey.describe("A day in the previous period."),
next: dateKey
.nullable()
.describe(
"A day in the next period, or null when that lies after today.",
),
})
.meta({ id: "ActivityCalendar" });
export type ActivityCalendarResponse = z.infer<
typeof activityCalendarResponseSchema
>;
+98
View File
@@ -0,0 +1,98 @@
/**
* Activity goals a person sets for themselves (D1036).
*
* Stored in `User.activityGoalsJson`. Every goal is optional and a `null`
* clears it; an account that never set one stores nothing and reads as "no
* goals". The calendar reports progress against the goals that are set and
* says nothing about the ones that are not: there is no default goal and no
* population norm here, only the person's own number.
*
* The keys and bounds live in `src/lib/activity/goal-bounds.ts` so the
* browser can read them without the schema.
*/
import { z } from "zod/v4";
import {
ACTIVITY_GOAL_BOUNDS,
ACTIVITY_GOAL_KEYS,
type ActivityGoalKey,
} from "@/lib/activity/goal-bounds";
export { ACTIVITY_GOAL_BOUNDS, ACTIVITY_GOAL_KEYS, type ActivityGoalKey };
function goalField(key: ActivityGoalKey) {
const { min, max } = ACTIVITY_GOAL_BOUNDS[key];
return z.number().int().min(min).max(max).nullable().optional();
}
/** The stored shape and the PATCH body: every key optional, `null` clears. */
export const activityGoalsSchema = z
.object({
workoutsPerWeek: goalField("workoutsPerWeek").describe(
"Workouts per week (Monday to Sunday). `null` clears the goal.",
),
activeMinutesPerWeek: goalField("activeMinutesPerWeek").describe(
"Active minutes per week: the larger of the day's exercise minutes and the day's workout minutes, summed over the week. `null` clears the goal.",
),
stepsPerWeek: goalField("stepsPerWeek").describe(
"Steps per week. `null` clears the goal.",
),
stepsPerMonth: goalField("stepsPerMonth").describe(
"Steps per calendar month. `null` clears the goal.",
),
})
.strict();
export type ActivityGoalsInput = z.infer<typeof activityGoalsSchema>;
/** The resolved goals: every key present, `null` = not set. */
export type ActivityGoals = Record<ActivityGoalKey, number | null>;
export const NO_ACTIVITY_GOALS: ActivityGoals = {
workoutsPerWeek: null,
activeMinutesPerWeek: null,
stepsPerWeek: null,
stepsPerMonth: null,
};
/**
* Read a stored value. Anything that does not parse (a hand-edited row, a
* shape from a future release) reads key by key: a valid goal survives, an
* invalid one reads as not set. Never throws.
*/
export function parseActivityGoals(raw: unknown): ActivityGoals {
const out: ActivityGoals = { ...NO_ACTIVITY_GOALS };
if (raw === null || typeof raw !== "object" || Array.isArray(raw)) {
return out;
}
const record = raw as Record<string, unknown>;
for (const key of ACTIVITY_GOAL_KEYS) {
const parsed = goalField(key).safeParse(record[key]);
if (parsed.success && typeof parsed.data === "number") {
out[key] = parsed.data;
}
}
return out;
}
/**
* Apply a validated PATCH to the stored goals: a key the body names replaces
* the stored value (`null` clears it), a key it leaves out stays as it was.
* Built key by key, never by spreading the body.
*/
export function mergeActivityGoals(
current: ActivityGoals,
patch: ActivityGoalsInput,
): ActivityGoals {
const next: ActivityGoals = { ...current };
for (const key of ACTIVITY_GOAL_KEYS) {
const value = patch[key];
if (value !== undefined) next[key] = value;
}
return next;
}
/** True when no goal is set, so the column can go back to null. */
export function hasNoActivityGoals(goals: ActivityGoals): boolean {
return ACTIVITY_GOAL_KEYS.every((key) => goals[key] === null);
}
+1
View File
@@ -2156,6 +2156,7 @@ const accountSettingsBackupSchema = z
documentsLayoutJson: z.unknown().optional(),
moduleListLayoutJson: z.unknown().optional(),
previewFeaturesJson: z.unknown().optional(),
activityGoalsJson: z.unknown().optional(),
reportSelectionJson: z.unknown().optional(),
globalExcludedInjectionSites: z.array(z.string()).optional(),
healthKitConfigJson: z.unknown().optional(),