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

@@ -0,0 +1,289 @@
import { describe, expect, it } from "vitest";
import { shiftDateKey } from "@/lib/tz/format";
import { NO_ACTIVITY_GOALS } from "@/lib/validations/activity-goals";
import {
BASELINE_DAYS,
MIN_BASELINE_DAYS,
activeMinutesOf,
baselineStats,
buildActivityCalendar,
comparePeriod,
dayLevel,
daysInMonth,
goalProgress,
periodBounds,
quantileSorted,
shiftMonth,
weekStartOf,
type ActivityDayInput,
} from "../calendar-math";
function day(
date: string,
over: Partial<ActivityDayInput> = {},
): ActivityDayInput {
return {
date,
steps: null,
exerciseMinutes: null,
workoutMinutes: 0,
workoutCount: 0,
workoutsBySport: {},
load: null,
...over,
};
}
describe("quantiles", () => {
it("interpolates linearly between ranks (PERCENTILE.INC)", () => {
const s = [1, 2, 3, 4, 5];
expect(quantileSorted(s, 0)).toBe(1);
expect(quantileSorted(s, 0.5)).toBe(3);
expect(quantileSorted(s, 0.25)).toBe(2);
expect(quantileSorted([10, 20], 0.5)).toBe(15);
expect(quantileSorted([10, 20, 30, 40], 0.9)).toBeCloseTo(37);
});
it("refuses a baseline below the day floor", () => {
expect(baselineStats(Array(MIN_BASELINE_DAYS - 1).fill(5000))).toBeNull();
const stats = baselineStats(
Array.from({ length: 20 }, (_, i) => (i + 1) * 1000),
);
expect(stats?.days).toBe(20);
expect(stats?.median).toBe(10500);
});
});
describe("day level against the person's own spread", () => {
const stats = baselineStats(Array.from({ length: 100 }, (_, i) => i))!;
it.each([
[null, "none"],
[5, "low"],
[20, "below"],
[50, "usual"],
[80, "above"],
[95, "high"],
] as const)("%s → %s", (value, level) => {
expect(dayLevel(value, stats)).toBe(level);
});
it("is unrated without a baseline, not guessed", () => {
expect(dayLevel(9000, null)).toBe("unrated");
});
});
describe("active minutes never double-count a workout", () => {
it("takes the larger of exercise and workout minutes", () => {
expect(
activeMinutesOf(
day("2026-05-01", { exerciseMinutes: 40, workoutMinutes: 30 }),
true,
),
).toBe(40);
expect(
activeMinutesOf(
day("2026-05-01", { exerciseMinutes: 10, workoutMinutes: 45 }),
true,
),
).toBe(45);
});
it("is 0 on a quiet day once the account has activity data, otherwise unknown", () => {
expect(activeMinutesOf(day("2026-05-01"), true)).toBe(0);
expect(activeMinutesOf(day("2026-05-01"), false)).toBeNull();
});
});
describe("period bounds", () => {
it("weeks run Monday to Sunday", () => {
// 2026-10-21 is a Wednesday.
expect(weekStartOf("2026-10-21")).toBe("2026-10-19");
expect(weekStartOf("2026-10-19")).toBe("2026-10-19");
expect(weekStartOf("2026-10-25")).toBe("2026-10-19");
const b = periodBounds("week", "2026-10-21");
expect([b.start, b.end]).toEqual(["2026-10-19", "2026-10-25"]);
expect(b.readStart).toBe(shiftDateKey("2026-10-19", -BASELINE_DAYS));
});
it("a month grid covers whole weeks and reads six months back", () => {
const b = periodBounds("month", "2026-02-14");
expect([b.start, b.end]).toEqual(["2026-02-01", "2026-02-28"]);
expect(b.gridStart).toBe("2026-01-26");
expect(b.gridEnd).toBe("2026-03-01");
expect(b.readStart).toBe("2025-08-01");
});
it("month arithmetic handles length and year turns", () => {
expect(daysInMonth("2024-02-01")).toBe(29);
expect(daysInMonth("2026-02-01")).toBe(28);
expect(shiftMonth("2026-01-01", -1)).toBe("2025-12-01");
expect(shiftMonth("2026-12-01", 1)).toBe("2027-01-01");
});
});
describe("period against the usual period", () => {
it("compares a finished week with the median of the twelve before", () => {
const values = new Map<string, number>();
// Twelve earlier weeks at 7 000 a day, this week at 10 000 a day.
const bounds = periodBounds("week", "2026-10-05");
for (let i = 1; i <= 84; i++)
values.set(shiftDateKey(bounds.start, -i), 7000);
for (let i = 0; i < 7; i++)
values.set(shiftDateKey(bounds.start, i), 10000);
const result = comparePeriod({
bounds,
today: "2026-10-21",
valueOf: (d) => values.get(d) ?? null,
mode: "sum",
});
expect(result.value).toBe(70000);
expect(result.usual).toBe(49000);
expect(result.usualToDate).toBe(49000);
expect(result.referencePeriods).toBe(12);
});
it("pro-rates the usual for a period still running", () => {
const bounds = periodBounds("week", "2026-10-21");
const values = new Map<string, number>();
for (let i = 1; i <= 84; i++)
values.set(shiftDateKey(bounds.start, -i), 1000);
const result = comparePeriod({
bounds,
today: "2026-10-21", // Wednesday: three days elapsed
valueOf: (d) =>
values.get(d) ?? (d <= "2026-10-21" && d >= bounds.start ? 2000 : null),
mode: "sum",
});
expect(result.value).toBe(6000);
expect(result.usual).toBe(7000);
expect(result.usualToDate).toBe(3000);
});
it("compares months per day, so February meets March fairly", () => {
const bounds = periodBounds("month", "2026-02-10");
const result = comparePeriod({
bounds,
today: "2026-10-21",
valueOf: (d) => (d < "2026-03-01" ? 100 : null),
mode: "sum",
});
expect(result.value).toBe(2800);
// Six earlier months at 100 a day → 28 days × 100.
expect(result.usual).toBe(2800);
});
it("gives no usual with fewer than three earlier periods of data", () => {
const bounds = periodBounds("week", "2026-10-05");
const result = comparePeriod({
bounds,
today: "2026-10-21",
valueOf: (d) => (d >= shiftDateKey(bounds.start, -14) ? 500 : null),
mode: "sum",
});
expect(result.referencePeriods).toBe(2);
expect(result.usual).toBeNull();
});
});
describe("goals", () => {
it("reports only goals that are set, for the period they belong to", () => {
const goals = {
...NO_ACTIVITY_GOALS,
workoutsPerWeek: 3,
stepsPerMonth: 200000,
};
expect(goalProgress("week", goals, { workouts: 4 })).toEqual([
{ key: "workoutsPerWeek", target: 3, value: 4, met: true },
]);
expect(goalProgress("month", goals, { steps: 150000 })).toEqual([
{ key: "stepsPerMonth", target: 200000, value: 150000, met: false },
]);
expect(goalProgress("week", NO_ACTIVITY_GOALS, { workouts: 9 })).toEqual(
[],
);
});
});
describe("the assembled calendar", () => {
const today = "2026-10-21";
const bounds = periodBounds("month", today);
// 84 baseline days of 5 000–9 000 steps, a run every Monday.
const input: ActivityDayInput[] = [];
for (let i = 1; i <= 200; i++) {
const date = shiftDateKey(bounds.start, -i);
input.push(
day(date, {
steps: 5000 + (i % 5) * 1000,
workoutCount: i % 7 === 0 ? 1 : 0,
workoutMinutes: i % 7 === 0 ? 30 : 0,
workoutsBySport: i % 7 === 0 ? { running: 1 } : {},
}),
);
}
input.push(
day("2026-10-02", {
steps: 15000,
workoutCount: 2,
workoutMinutes: 90,
workoutsBySport: { running: 1, cycling: 1 },
}),
);
input.push(day("2026-10-03", { steps: 2000 }));
const calendar = buildActivityCalendar({
bounds,
today,
input,
goals: { ...NO_ACTIVITY_GOALS, workoutsPerWeek: 2 },
});
it("draws whole weeks and marks the days after today as future", () => {
expect(calendar.days[0].date).toBe(calendar.gridStart);
expect(calendar.days.length % 7).toBe(0);
const future = calendar.days.find((d) => d.date === "2026-10-22");
expect(future?.future).toBe(true);
expect(future?.level.steps).toBe("none");
});
it("rates a big day high and a short one low against the person's own spread", () => {
expect(calendar.baseline.steps?.days).toBe(BASELINE_DAYS);
expect(
calendar.days.find((d) => d.date === "2026-10-02")?.level.steps,
).toBe("high");
expect(
calendar.days.find((d) => d.date === "2026-10-03")?.level.steps,
).toBe("low");
expect(
calendar.days.find((d) => d.date === "2026-10-04")?.level.steps,
).toBe("none");
});
it("counts workouts by sport in the period", () => {
expect(calendar.sports).toEqual([
{ sportType: "cycling", count: 1 },
{ sportType: "running", count: 1 },
]);
expect(calendar.summary.workouts.value).toBe(2);
});
it("marks weekly goals per calendar row, and none for a week not yet begun", () => {
const week = calendar.weeks.find((w) => w.start === "2026-09-28");
expect(week?.goals).toEqual([
{ key: "workoutsPerWeek", target: 2, value: 2, met: true },
]);
const last = calendar.weeks[calendar.weeks.length - 1];
expect(last.start > today).toBe(true);
expect(last.goals).toEqual([]);
expect(calendar.next).toBeNull();
expect(calendar.previous).toBe("2026-09-01");
});
it("offers only the signals the account has", () => {
expect(calendar.available).toEqual({
steps: true,
activeMinutes: true,
workouts: true,
load: false,
});
});
});
+609
View File
@@ -0,0 +1,609 @@
/**
* Activity calendar arithmetic (D1036). Pure: no database, no clock, no zone.
*
* The calendar compares a week or a month of activity with the person's own
* normal, never with a population norm. Two comparisons, each with its own
* honest reference:
*
* - A DAY is placed against the spread of the person's own days in the 84
* days before the period starts (`BASELINE_DAYS`): below the 10th
* percentile is `low`, below the 25th `below`, above the 75th `above`,
* above the 90th `high`, everything between `usual`. With fewer than
* `MIN_BASELINE_DAYS` days that carry a value the day is `unrated`:
* there is not enough history to say what usual is, and the calendar says
* so instead of guessing.
* - A PERIOD total is placed against the median total of the same kind of
* period before it: the twelve weeks before a week, the six months before
* a month. A month is compared per day (months differ in length), so the
* usual month is the median daily rate of the earlier months times this
* month's length. A period still running is compared with the usual rate
* times the days elapsed so far, not with a full period it cannot have
* reached yet. At least `MIN_REFERENCE_PERIODS` earlier periods with data
* are needed; fewer gives no usual.
*
* What a day's value is:
* - steps: the day's step total (the same canonical-source sum the steps
* chart draws); a day without a step reading has no value, it is not 0,
* because a watch left on the charger is not a day without steps.
* - active minutes: the larger of the day's exercise minutes and the day's
* workout minutes. Exercise minutes already contain the workouts a watch
* recorded, so adding the two would count a workout twice; taking the
* larger never does, and still counts a workout logged by hand on a day
* the watch recorded nothing. Once the account has any activity data in
* the window, a day without either is 0.
* - workouts: the number of workouts that started that day, after the
* cross-source duplicate collapse the workout list applies. Shown as
* counts, never rated: most days have none, so percentiles of a mostly-zero
* series say nothing.
* - load: the day's training-load score when a device or HealthLog computes
* one; no value otherwise.
*/
import {
daysBetweenDateKeys,
shiftDateKey,
weekdayOfDateKey,
} from "@/lib/tz/format";
import type {
ActivityGoalKey,
ActivityGoals,
} from "@/lib/validations/activity-goals";
/** Days before the period whose spread defines a usual day. */
export const BASELINE_DAYS = 84;
/** Days with a value needed before a day is rated against the baseline. */
export const MIN_BASELINE_DAYS = 14;
/** Earlier periods compared against: twelve weeks, six months. */
export const REFERENCE_WEEKS = 12;
export const REFERENCE_MONTHS = 6;
/** Earlier periods with data needed before a usual period total is given. */
export const MIN_REFERENCE_PERIODS = 3;
export type ActivityPeriod = "week" | "month";
export type RatedMetric = "steps" | "activeMinutes" | "load";
export type DayLevel =
"none" | "unrated" | "low" | "below" | "usual" | "above" | "high";
/** One local day of raw signals, as the reader assembles it. */
export interface ActivityDayInput {
/** Local calendar day, `YYYY-MM-DD`. */
date: string;
steps: number | null;
exerciseMinutes: number | null;
workoutMinutes: number;
workoutCount: number;
workoutsBySport: Record<string, number>;
load: number | null;
}
export interface BaselineStats {
median: number;
p10: number;
p25: number;
p75: number;
p90: number;
/** Days with a value the stats were computed from. */
days: number;
}
/**
* Linear-interpolation quantile (the R-7 / spreadsheet `PERCENTILE.INC`
* definition) over an ascending-sorted array. `q` in [0, 1].
*/
export function quantileSorted(sorted: readonly number[], q: number): number {
if (sorted.length === 0) return Number.NaN;
if (sorted.length === 1) return sorted[0];
const pos = (sorted.length - 1) * q;
const lo = Math.floor(pos);
const hi = Math.ceil(pos);
if (lo === hi) return sorted[lo];
return sorted[lo] + (sorted[hi] - sorted[lo]) * (pos - lo);
}
export function median(values: readonly number[]): number | null {
if (values.length === 0) return null;
const sorted = [...values].sort((a, b) => a - b);
return quantileSorted(sorted, 0.5);
}
export function baselineStats(values: readonly number[]): BaselineStats | null {
if (values.length < MIN_BASELINE_DAYS) return null;
const sorted = [...values].sort((a, b) => a - b);
return {
median: quantileSorted(sorted, 0.5),
p10: quantileSorted(sorted, 0.1),
p25: quantileSorted(sorted, 0.25),
p75: quantileSorted(sorted, 0.75),
p90: quantileSorted(sorted, 0.9),
days: sorted.length,
};
}
/** Where one day's value sits in the person's own spread. */
export function dayLevel(
value: number | null,
baseline: BaselineStats | null,
): DayLevel {
if (value === null) return "none";
if (!baseline) return "unrated";
if (value < baseline.p10) return "low";
if (value < baseline.p25) return "below";
if (value > baseline.p90) return "high";
if (value > baseline.p75) return "above";
return "usual";
}
/** The active-minutes rule from the header: the larger, never the sum. */
export function activeMinutesOf(
day: ActivityDayInput,
hasActivityData: boolean,
): number | null {
if (day.exerciseMinutes !== null) {
return Math.max(day.exerciseMinutes, day.workoutMinutes);
}
if (day.workoutMinutes > 0) return day.workoutMinutes;
return hasActivityData ? 0 : null;
}
export function metricValue(
day: ActivityDayInput,
metric: RatedMetric,
hasActivityData: boolean,
): number | null {
switch (metric) {
case "steps":
return day.steps;
case "activeMinutes":
return activeMinutesOf(day, hasActivityData);
case "load":
return day.load;
}
}
/** Monday on or before `date` (the calendar's weeks run Monday to Sunday). */
export function weekStartOf(date: string): string {
const weekday = weekdayOfDateKey(date); // 0 = Sunday
return shiftDateKey(date, -((weekday + 6) % 7));
}
export function monthStartOf(date: string): string {
return `${date.slice(0, 7)}-01`;
}
export function daysInMonth(monthStart: string): number {
const [y, m] = monthStart.split("-").map(Number);
return new Date(Date.UTC(y, m, 0)).getUTCDate();
}
/** First day of the month `delta` months from `monthStart`. */
export function shiftMonth(monthStart: string, delta: number): string {
const [y, m] = monthStart.split("-").map(Number);
const index = y * 12 + (m - 1) + delta;
const year = Math.floor(index / 12);
const month = (index % 12) + 1;
return `${String(year).padStart(4, "0")}-${String(month).padStart(2, "0")}-01`;
}
export interface PeriodBounds {
period: ActivityPeriod;
/** First and last day of the period, inclusive. */
start: string;
end: string;
/** The grid the calendar draws: whole weeks around the period. */
gridStart: string;
gridEnd: string;
/** The earliest day any comparison reads. */
readStart: string;
}
export function periodBounds(
period: ActivityPeriod,
anchor: string,
): PeriodBounds {
if (period === "week") {
const start = weekStartOf(anchor);
const end = shiftDateKey(start, 6);
return {
period,
start,
end,
gridStart: start,
gridEnd: end,
readStart: shiftDateKey(
start,
-Math.max(BASELINE_DAYS, REFERENCE_WEEKS * 7),
),
};
}
const start = monthStartOf(anchor);
const end = shiftDateKey(start, daysInMonth(start) - 1);
const gridStart = weekStartOf(start);
const gridEnd = shiftDateKey(weekStartOf(end), 6);
// Date keys order lexically, so the earliest is the smallest string.
const readStart = [
shiftMonth(start, -REFERENCE_MONTHS),
shiftDateKey(start, -BASELINE_DAYS),
gridStart,
].sort()[0];
return { period, start, end, gridStart, gridEnd, readStart };
}
/** Every day key from `from` to `to`, inclusive. */
export function dayRange(from: string, to: string): string[] {
const n = daysBetweenDateKeys(from, to);
const out: string[] = [];
for (let i = 0; i <= n; i++) out.push(shiftDateKey(from, i));
return out;
}
export interface PeriodComparison {
/** The period's total (steps, active minutes, workouts) or mean (load), over the days so far. */
value: number | null;
/** Days of the period that carry a value. */
daysWithData: number;
/** The usual full period, or null when there is not enough history. */
usual: number | null;
/** The usual figure for the days elapsed so far (equals `usual` for a finished period). */
usualToDate: number | null;
/** Earlier periods the usual was computed from. */
referencePeriods: number;
}
export type SummaryMetric = RatedMetric | "workouts";
function aggregate(
values: ReadonlyArray<number | null>,
mode: "sum" | "mean",
): { value: number | null; n: number } {
const present = values.filter((v): v is number => v !== null);
if (present.length === 0) return { value: null, n: 0 };
const total = present.reduce((a, b) => a + b, 0);
return {
value: mode === "sum" ? total : total / present.length,
n: present.length,
};
}
/** The earlier periods a period is compared with, as inclusive day ranges. */
export function referencePeriods(
bounds: Pick<PeriodBounds, "period" | "start">,
): Array<{ start: string; end: string; length: number }> {
const out: Array<{ start: string; end: string; length: number }> = [];
if (bounds.period === "week") {
for (let i = 1; i <= REFERENCE_WEEKS; i++) {
const start = shiftDateKey(bounds.start, -7 * i);
out.push({ start, end: shiftDateKey(start, 6), length: 7 });
}
return out;
}
for (let i = 1; i <= REFERENCE_MONTHS; i++) {
const start = shiftMonth(bounds.start, -i);
const length = daysInMonth(start);
out.push({ start, end: shiftDateKey(start, length - 1), length });
}
return out;
}
export function comparePeriod(input: {
bounds: Pick<PeriodBounds, "period" | "start" | "end">;
/** The last day that has happened (today in the person's zone). */
today: string;
valueOf: (date: string) => number | null;
mode: "sum" | "mean";
}): PeriodComparison {
const { bounds, today, valueOf, mode } = input;
const lastCounted = bounds.end < today ? bounds.end : today;
const length = daysBetweenDateKeys(bounds.start, bounds.end) + 1;
const elapsed =
lastCounted < bounds.start
? 0
: daysBetweenDateKeys(bounds.start, lastCounted) + 1;
const current =
elapsed === 0
? { value: null, n: 0 }
: aggregate(dayRange(bounds.start, lastCounted).map(valueOf), mode);
// Each earlier period becomes a per-day rate (sum) or a mean (mean), so a
// 28-day February and a 31-day March compare on the same footing.
const rates: number[] = [];
for (const ref of referencePeriods(bounds)) {
const agg = aggregate(dayRange(ref.start, ref.end).map(valueOf), mode);
if (agg.value === null) continue;
rates.push(mode === "sum" ? agg.value / ref.length : agg.value);
}
const usualRate =
rates.length >= MIN_REFERENCE_PERIODS ? median(rates) : null;
const usual =
usualRate === null ? null : mode === "sum" ? usualRate * length : usualRate;
const usualToDate =
usualRate === null
? null
: mode === "sum"
? usualRate * elapsed
: usualRate;
return {
value: current.value,
daysWithData: current.n,
usual,
usualToDate: elapsed === 0 ? null : usualToDate,
referencePeriods: rates.length,
};
}
export interface GoalProgress {
key: ActivityGoalKey;
target: number;
value: number;
met: boolean;
}
/** Which goals apply to which period, and which figure each one reads. */
const GOALS_BY_PERIOD: Record<
ActivityPeriod,
Array<{ key: ActivityGoalKey; metric: SummaryMetric }>
> = {
week: [
{ key: "workoutsPerWeek", metric: "workouts" },
{ key: "activeMinutesPerWeek", metric: "activeMinutes" },
{ key: "stepsPerWeek", metric: "steps" },
],
month: [{ key: "stepsPerMonth", metric: "steps" }],
};
export function goalProgress(
period: ActivityPeriod,
goals: ActivityGoals,
totals: Partial<Record<SummaryMetric, number | null>>,
): GoalProgress[] {
const out: GoalProgress[] = [];
for (const { key, metric } of GOALS_BY_PERIOD[period]) {
const target = goals[key];
if (target === null) continue;
const value = totals[metric] ?? 0;
out.push({ key, target, value, met: value >= target });
}
return out;
}
export interface CalendarDay {
date: string;
inPeriod: boolean;
future: boolean;
steps: number | null;
activeMinutes: number | null;
workoutCount: number;
workoutsBySport: Record<string, number>;
load: number | null;
level: Record<RatedMetric, DayLevel>;
}
export interface CalendarWeekRow {
start: string;
end: string;
steps: number | null;
activeMinutes: number | null;
workouts: number;
/** The weekly goals, met or not, for a week that has started. */
goals: GoalProgress[];
}
export interface ActivityCalendar {
period: ActivityPeriod;
start: string;
end: string;
gridStart: string;
gridEnd: string;
today: string;
days: CalendarDay[];
weeks: CalendarWeekRow[];
baseline: Record<RatedMetric, BaselineStats | null>;
summary: Record<SummaryMetric, PeriodComparison>;
sports: Array<{ sportType: string; count: number }>;
goals: ActivityGoals;
goalProgress: GoalProgress[];
available: Record<SummaryMetric, boolean>;
previous: string;
next: string | null;
}
/**
* Assemble the calendar from per-day inputs covering at least
* `bounds.readStart`..`bounds.gridEnd`. Days missing from `input` count as
* days without any reading.
*/
export function buildActivityCalendar(args: {
bounds: PeriodBounds;
today: string;
input: readonly ActivityDayInput[];
goals: ActivityGoals;
}): ActivityCalendar {
const { bounds, today, input, goals } = args;
const byDate = new Map(input.map((d) => [d.date, d]));
const empty = (date: string): ActivityDayInput => ({
date,
steps: null,
exerciseMinutes: null,
workoutMinutes: 0,
workoutCount: 0,
workoutsBySport: {},
load: null,
});
const dayOf = (date: string) => byDate.get(date) ?? empty(date);
// The first day with any activity signal: from there on, a day without
// workouts or exercise minutes is a day of 0 active minutes, not a gap.
const firstActivityDay =
input
.filter(
(d) =>
d.exerciseMinutes !== null ||
d.workoutCount > 0 ||
d.workoutMinutes > 0,
)
.map((d) => d.date)
.sort()[0] ?? null;
const hasActivityOn = (date: string) =>
firstActivityDay !== null && date >= firstActivityDay && date <= today;
const valueOf = (metric: SummaryMetric) => (date: string) => {
if (date > today) return null;
const day = dayOf(date);
if (metric === "workouts")
return hasActivityOn(date) ? day.workoutCount : null;
return metricValue(day, metric, hasActivityOn(date));
};
const baselineFrom = shiftDateKey(bounds.start, -BASELINE_DAYS);
const baselineTo = shiftDateKey(bounds.start, -1);
const baselineDays = dayRange(baselineFrom, baselineTo);
const ratedMetrics: RatedMetric[] = ["steps", "activeMinutes", "load"];
const baseline = Object.fromEntries(
ratedMetrics.map((metric) => [
metric,
baselineStats(
baselineDays
.map(valueOf(metric))
.filter((v): v is number => v !== null),
),
]),
) as Record<RatedMetric, BaselineStats | null>;
const days: CalendarDay[] = dayRange(bounds.gridStart, bounds.gridEnd).map(
(date) => {
const day = dayOf(date);
const future = date > today;
const steps = future ? null : day.steps;
const activeMinutes = future
? null
: activeMinutesOf(day, hasActivityOn(date));
const load = future ? null : day.load;
return {
date,
inPeriod: date >= bounds.start && date <= bounds.end,
future,
steps,
activeMinutes,
workoutCount: future ? 0 : day.workoutCount,
workoutsBySport: future ? {} : day.workoutsBySport,
load,
level: {
steps: dayLevel(steps, baseline.steps),
activeMinutes: dayLevel(activeMinutes, baseline.activeMinutes),
load: dayLevel(load, baseline.load),
},
};
},
);
const summary: Record<SummaryMetric, PeriodComparison> = {
steps: comparePeriod({
bounds,
today,
valueOf: valueOf("steps"),
mode: "sum",
}),
activeMinutes: comparePeriod({
bounds,
today,
valueOf: valueOf("activeMinutes"),
mode: "sum",
}),
workouts: comparePeriod({
bounds,
today,
valueOf: valueOf("workouts"),
mode: "sum",
}),
load: comparePeriod({
bounds,
today,
valueOf: valueOf("load"),
mode: "mean",
}),
};
const sportCounts = new Map<string, number>();
for (const date of dayRange(bounds.start, bounds.end)) {
if (date > today) break;
for (const [sport, count] of Object.entries(dayOf(date).workoutsBySport)) {
sportCounts.set(sport, (sportCounts.get(sport) ?? 0) + count);
}
}
const sports = [...sportCounts]
.map(([sportType, count]) => ({ sportType, count }))
.sort(
(a, b) => b.count - a.count || a.sportType.localeCompare(b.sportType),
);
const weeks: CalendarWeekRow[] = [];
for (
let start = bounds.gridStart;
start <= bounds.gridEnd;
start = shiftDateKey(start, 7)
) {
const end = shiftDateKey(start, 6);
const range = dayRange(start, end);
const sum = (metric: SummaryMetric) =>
aggregate(range.map(valueOf(metric)), "sum").value;
const row = {
start,
end,
steps: sum("steps"),
activeMinutes: sum("activeMinutes"),
workouts: sum("workouts") ?? 0,
};
weeks.push({
...row,
goals:
start > today
? []
: goalProgress("week", goals, {
steps: row.steps,
activeMinutes: row.activeMinutes,
workouts: row.workouts,
}),
});
}
const progress = goalProgress(bounds.period, goals, {
steps: summary.steps.value,
activeMinutes: summary.activeMinutes.value,
workouts: summary.workouts.value,
});
const anyIn = (pick: (d: ActivityDayInput) => boolean) => input.some(pick);
const available: Record<SummaryMetric, boolean> = {
steps: anyIn((d) => d.steps !== null),
activeMinutes: firstActivityDay !== null,
workouts: anyIn((d) => d.workoutCount > 0),
load: anyIn((d) => d.load !== null),
};
const previous =
bounds.period === "week"
? shiftDateKey(bounds.start, -7)
: shiftMonth(bounds.start, -1);
const nextStart =
bounds.period === "week"
? shiftDateKey(bounds.start, 7)
: shiftMonth(bounds.start, 1);
return {
period: bounds.period,
start: bounds.start,
end: bounds.end,
gridStart: bounds.gridStart,
gridEnd: bounds.gridEnd,
today,
days,
weeks,
baseline,
summary,
sports,
goals,
goalProgress: progress,
available,
previous,
next: nextStart > today ? null : nextStart,
};
}
+181
View File
@@ -0,0 +1,181 @@
/**
* Database side of the activity calendar (D1036): per local day, the step
* total, the exercise minutes, the training load and the workouts, over the
* window `periodBounds` asks for. The arithmetic lives in `calendar-math.ts`.
*
* Steps follow the same rule as the steps chart: each UTC day resolves to the
* source the person's priority ladder picks (`seriesRowsFrom`, the helper the
* series route reads through), and the picked rows are then cut at the
* person's own midnight. A calendar day and the chart's day therefore show
* the same number. Workouts go through the cross-source collapse the workout
* list applies, so a session recorded by two devices counts once.
*
* Bounded: four aggregate queries returning at most one row per day of the
* window (about 220 days for a month), plus the workouts of that window.
*/
import type { MeasurementType } from "@/generated/prisma/client";
import { prisma } from "@/lib/db";
import { pickCanonicalWorkoutRows } from "@/lib/measurements/pick-canonical-workout-rows";
import { seriesRowsFrom } from "@/lib/measurements/series-canonical";
import { loadUserSourcePriority } from "@/lib/rollups/measurement-read";
import { startOfLocalDayKey } from "@/lib/tz/local-day";
import { shiftDateKey, userDayKey } from "@/lib/tz/format";
import type { ActivityDayInput } from "./calendar-math";
/**
* Training-load types in the order the calendar prefers them: a device's own
* daily figure first (WHOOP day strain, Polar cardio load), HealthLog's
* computed strain score last. The first type with any reading in the window
* is the one shown; two scales are never mixed on one calendar.
*/
export const LOAD_TYPES: readonly MeasurementType[] = [
"DAY_STRAIN",
"CARDIO_LOAD",
"STRAIN_SCORE",
];
const DAY_MS = 86_400_000;
interface DayAggregateRow {
day: string;
total: number;
/** Readings that day; the load is their plain mean (one score a day, as a rule). */
n: number;
}
async function dailyAggregate(
userId: string,
tz: string,
priorityJson: unknown,
type: MeasurementType,
from: Date,
to: Date,
now: Date,
): Promise<Map<string, DayAggregateRow>> {
// `seriesRowsFrom` scopes its canonical pick to a trailing window counted
// from now; it is widened by a day so the edge day's source is picked from
// all of its readings, and the outer filter trims to the exact window.
const days = Math.min(
3649,
Math.max(1, Math.ceil((now.getTime() - from.getTime()) / DAY_MS) + 1),
);
const rows = await prisma.$queryRawUnsafe<DayAggregateRow[]>(
`
SELECT
to_char(
date_trunc('day', (m."measured_at" AT TIME ZONE 'UTC') AT TIME ZONE $2),
'YYYY-MM-DD'
) AS day,
SUM(m."value")::double precision AS total,
COUNT(*)::int AS n
FROM ${seriesRowsFrom(priorityJson, type, days)}
WHERE m."measured_at" >= $3
AND m."measured_at" < $4
GROUP BY 1
`,
userId,
tz,
from,
to,
);
return new Map(rows.map((r) => [r.day, r]));
}
function meanOf(row: DayAggregateRow | undefined): number | null {
return row && row.n > 0 ? row.total / row.n : null;
}
export interface ActivityReadResult {
days: ActivityDayInput[];
loadType: MeasurementType | null;
}
/**
* Read `fromKey`..`toKey` (inclusive local days) for one account.
* `today` caps the read: nothing after the person's today is fetched.
*/
export async function readActivityDays(args: {
userId: string;
tz: string;
fromKey: string;
toKey: string;
now?: Date;
includeWorkouts: boolean;
}): Promise<ActivityReadResult> {
const { userId, tz, fromKey, toKey, includeWorkouts } = args;
const now = args.now ?? new Date();
const from = startOfLocalDayKey(fromKey, tz);
const to = startOfLocalDayKey(shiftDateKey(toKey, 1), tz);
const priorityJson = await loadUserSourcePriority(userId);
const [steps, exercise, loads, workoutRows] = await Promise.all([
dailyAggregate(userId, tz, priorityJson, "ACTIVITY_STEPS", from, to, now),
dailyAggregate(userId, tz, priorityJson, "EXERCISE_MINUTES", from, to, now),
Promise.all(
LOAD_TYPES.map((type) =>
dailyAggregate(userId, tz, priorityJson, type, from, to, now),
),
),
includeWorkouts
? prisma.workout.findMany({
where: { userId, startedAt: { gte: from, lt: to } },
select: {
startedAt: true,
durationSec: true,
sportType: true,
source: true,
avgHeartRate: true,
maxHeartRate: true,
metadata: true,
},
orderBy: { startedAt: "asc" },
})
: Promise.resolve([]),
]);
const loadIndex = loads.findIndex((m) => m.size > 0);
const load = loadIndex >= 0 ? loads[loadIndex] : null;
const workouts = pickCanonicalWorkoutRows(workoutRows, priorityJson);
const workoutsByDay = new Map<
string,
{ minutes: number; count: number; bySport: Record<string, number> }
>();
for (const w of workouts) {
const key = userDayKey(w.startedAt, tz);
const slot = workoutsByDay.get(key) ?? {
minutes: 0,
count: 0,
bySport: {},
};
slot.minutes += Math.max(0, w.durationSec) / 60;
slot.count += 1;
slot.bySport[w.sportType] = (slot.bySport[w.sportType] ?? 0) + 1;
workoutsByDay.set(key, slot);
}
const keys = new Set<string>([
...steps.keys(),
...exercise.keys(),
...(load ? load.keys() : []),
...workoutsByDay.keys(),
]);
const days: ActivityDayInput[] = [...keys]
.filter((k) => k >= fromKey && k <= toKey)
.sort()
.map((date) => {
const w = workoutsByDay.get(date);
return {
date,
steps: steps.get(date)?.total ?? null,
exerciseMinutes: exercise.get(date)?.total ?? null,
workoutMinutes: w ? Math.round(w.minutes) : 0,
workoutCount: w?.count ?? 0,
workoutsBySport: w?.bySport ?? {},
load: meanOf(load?.get(date)),
};
});
return { days, loadType: loadIndex >= 0 ? LOAD_TYPES[loadIndex] : null };
}
+27
View File
@@ -0,0 +1,27 @@
/**
* The activity-goal keys and their bounds, without a schema library, so the
* goals sheet can read them in the browser. The schema that enforces them
* on the server is `src/lib/validations/activity-goals.ts`.
*/
export const ACTIVITY_GOAL_KEYS = [
"workoutsPerWeek",
"activeMinutesPerWeek",
"stepsPerWeek",
"stepsPerMonth",
] as const;
export type ActivityGoalKey = (typeof ACTIVITY_GOAL_KEYS)[number];
/**
* Wide on purpose: they refuse typing mistakes (a goal of 5 000 000 steps a
* week), they do not judge what a sensible goal is.
*/
export const ACTIVITY_GOAL_BOUNDS: Record<
ActivityGoalKey,
{ min: number; max: number }
> = {
workoutsPerWeek: { min: 1, max: 28 },
activeMinutesPerWeek: { min: 10, max: 5000 },
stepsPerWeek: { min: 1000, max: 700_000 },
stepsPerMonth: { min: 1000, max: 3_000_000 },
};
+1
View File
@@ -487,6 +487,7 @@ export const USER_RESET = {
moodTagLayoutJson: Prisma.DbNull,
documentsLayoutJson: Prisma.DbNull,
moduleListLayoutJson: Prisma.DbNull,
activityGoalsJson: Prisma.DbNull,
reportSelectionJson: Prisma.DbNull,
modulePreferencesJson: Prisma.DbNull,
healthScoreConfigJson: Prisma.DbNull,
+33
View File
@@ -61,6 +61,7 @@ import {
import { UNREADABLE_EXPORT_MARKER } from "@/lib/export/unreadable-marker";
import { locales } from "@/lib/i18n/config";
import { thresholdsUpdateSchema } from "@/lib/validations/thresholds";
import { activityGoalsSchema } from "@/lib/validations/activity-goals";
import { isPublicUrl } from "@/lib/validations/notifications";
import {
glucoseUnitPatchSchema,
@@ -267,6 +268,37 @@ const thresholds: SettingCodec = {
},
};
/**
* The activity goals, goal by goal. A goal outside its bounds, or a key this
* release does not know, is dropped and named; the valid goals beside it
* still come back.
*/
const activityGoals: SettingCodec = {
write: (row) => ({ activityGoalsJson: row.activityGoalsJson ?? null }),
read: (entry) => {
if (!has(entry, "activityGoalsJson")) return undefined;
const value = entry.activityGoalsJson;
if (value === null) return Prisma.DbNull;
if (typeof value !== "object" || Array.isArray(value)) {
return new Refused("activityGoalsJson");
}
const kept: Record<string, number> = {};
const refused: string[] = [];
for (const [key, goal] of Object.entries(value)) {
const parsed = activityGoalsSchema.safeParse({ [key]: goal });
if (!parsed.success) {
refused.push(`activityGoalsJson.${key}`);
} else if (typeof goal === "number") {
kept[key] = goal;
}
}
return new PartlyRefused(
Object.keys(kept).length === 0 ? Prisma.DbNull : kept,
refused,
);
},
};
const injectionSites = Object.values(InjectionSite) as string[];
/** The injection sites to avoid everywhere; an unknown site is dropped. */
@@ -505,6 +537,7 @@ const ACCOUNT_SETTING_CODECS: Readonly<
documentsLayoutJson: json("documentsLayoutJson"),
moduleListLayoutJson: json("moduleListLayoutJson"),
previewFeaturesJson: json("previewFeaturesJson"),
activityGoalsJson: activityGoals,
reportSelectionJson: json("reportSelectionJson"),
globalExcludedInjectionSites: excludedSites,
healthKitConfigJson: json("healthKitConfigJson"),
+1
View File
@@ -1015,6 +1015,7 @@ export const USER_COLUMN_BACKUP_CLASS = {
documentsLayoutJson: "SETTING",
moduleListLayoutJson: "SETTING",
previewFeaturesJson: "SETTING",
activityGoalsJson: "SETTING",
reportSelectionJson: "SETTING",
globalExcludedInjectionSites: "SETTING",
healthKitConfigJson: "SETTING",
+1
View File
@@ -40,6 +40,7 @@ import { DEFAULT_ERROR_CODES } from "@/lib/api/default-error-codes";
*/
export const ERROR_CODE_CATALOGUE: Readonly<Record<string, readonly string[]>> =
{
activity: ["activity.calendar.future_date", "activity.calendar.too_old"],
ai: [
"ai.budget.exceeded",
"ai.provider.none",
+102
View File
@@ -0,0 +1,102 @@
/**
* OpenAPI route table — the activity calendar and activity goals (D1036).
*
* Part of the OpenAPI route table; aggregated in `./index.ts`. The schemas
* are the runtime ones from `@/lib/validations/activity-*`.
*/
import type { ZodOpenApiObject } from "zod-openapi";
import {
activityCalendarQuerySchema,
activityCalendarResponseSchema,
activityGoalsResponseSchema,
} from "@/lib/validations/activity-calendar";
import { activityGoalsSchema } from "@/lib/validations/activity-goals";
import {
dataEnvelope,
MODULE_DISABLED_DESCRIPTION,
recordRefusal,
recordWriteRateLimitResponse,
stdResponses,
} from "./shared";
const activityGoalsPatch = activityGoalsSchema.meta({
id: "ActivityGoalsPatch",
description:
"The goals to change. A key that is present replaces the stored goal (`null` clears it); a key that is absent stays as it is. Unknown keys are refused.",
});
export const activityPaths: NonNullable<ZodOpenApiObject["paths"]> = {
"/api/activity/calendar": {
get: {
tags: ["Measurements"],
summary: "Activity calendar against the person's own normal",
description:
"A week (Monday to Sunday) or a month of activity, computed server-side without AI: per local day the step total (the canonical-source sum the steps chart draws), active minutes (the larger of exercise minutes and workout minutes), workouts by sport after the cross-source collapse, and a training-load score when one exists. Each day is placed against the person's own spread over the 84 days before the period; the period total against the median of the twelve weeks or six months before it, pro-rated while the period runs. Never a population norm. Weekly goals are reported per calendar row, the monthly step goal for a month. Owned by the Workouts module.",
requestParams: { query: activityCalendarQuerySchema },
responses: {
"200": {
description: "The calendar.",
content: {
"application/json": {
schema: dataEnvelope(
activityCalendarResponseSchema,
"ActivityCalendarEnvelope",
),
},
},
},
...recordRefusal(MODULE_DISABLED_DESCRIPTION),
...stdResponses,
},
},
},
"/api/activity/goals": {
get: {
tags: ["Measurements"],
summary: "Activity goals",
description:
"The person's own activity goals: workouts, active minutes and steps per week, steps per month. `null` = not set; there is no default. Owner only; a delegate reads the goals inside the calendar.",
responses: {
"200": {
description: "The goals.",
content: {
"application/json": {
schema: dataEnvelope(
activityGoalsResponseSchema,
"ActivityGoalsEnvelope",
),
},
},
},
...stdResponses,
},
},
patch: {
tags: ["Measurements"],
summary: "Set activity goals",
description:
"Sets the goals the body names and keeps the rest; `null` clears a goal. Owner only: a shared or delegated session cannot change another person's goals.",
requestBody: {
required: true,
content: { "application/json": { schema: activityGoalsPatch } },
},
responses: {
"200": {
description: "The goals after the change.",
content: {
"application/json": {
schema: dataEnvelope(
activityGoalsResponseSchema,
"ActivityGoalsPatchEnvelope",
),
},
},
},
...stdResponses,
...recordWriteRateLimitResponse,
},
},
},
};
+2
View File
@@ -27,6 +27,7 @@ import {
recordEpochParameter,
recordScopeParameter,
} from "./account-sharing";
import { activityPaths } from "./activity";
import { adminDiagnosticPaths, adminInvitePaths } from "./admin";
import { aiUsagePaths } from "./ai-usage";
import { dawarichPaths } from "./dawarich";
@@ -105,6 +106,7 @@ export const openApiPaths: NonNullable<ZodOpenApiObject["paths"]> = {
...syncPaths,
...measurementPaths,
...workoutPaths,
...activityPaths,
...devicePaths,
...medicationPaths,
...profilePaths,
+11
View File
@@ -0,0 +1,11 @@
/**
* Query keys — the activity calendar and activity goals (D1036).
* Part of the centralized factory; aggregated in `./index.ts`.
*/
export const activityKeys = {
/** Root of every activity-calendar cell; a goal save invalidates it. */
activityCalendarAll: () => ["activity-calendar"] as const,
activityCalendar: (period: "week" | "month", date: string | null) =>
["activity-calendar", period, date ?? "today"] as const,
activityGoals: () => ["activity-goals"] as const,
};
+2
View File
@@ -47,6 +47,7 @@ import { searchKeys } from "./search";
import { sharingKeys } from "./sharing";
import { vaccinationKeys } from "./vaccinations";
import { workoutKeys } from "./workouts";
import { activityKeys } from "./activity";
export const queryKeys = {
...authKeys,
@@ -60,6 +61,7 @@ export const queryKeys = {
...adminKeys,
...integrationKeys,
...workoutKeys,
...activityKeys,
...settingsKeys,
...cycleKeys,
...measurementReminderKeys,
+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(),