diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index 339c7b7c5..8ae7afe7f 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -5023,6 +5023,89 @@ paths: schema: $ref: "#/components/schemas/ErrorEnvelope" "429": *a5 + /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." + parameters: + - in: query + name: period + schema: + default: month + description: "Calendar span: one week (Monday to Sunday) or one month." + type: string + enum: + - week + - month + description: "Calendar span: one week (Monday to Sunday) or one month." + - in: query + name: date + schema: + description: 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. + type: string + format: date + pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))$ + description: 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. + responses: + "200": + description: The calendar. + content: + application/json: + schema: + $ref: "#/components/schemas/ActivityCalendarEnvelope" + "401": *a1 + "403": *a13 + "422": *a4 + "429": *a5 + /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: + $ref: "#/components/schemas/ActivityGoalsEnvelope" + "401": *a1 + "422": *a4 + "429": *a5 + 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: + $ref: "#/components/schemas/ActivityGoalsPatch" + responses: + "200": + description: The goals after the change. + content: + application/json: + schema: + $ref: "#/components/schemas/ActivityGoalsPatchEnvelope" + "401": *a1 + "422": *a4 + "429": *a12 /api/devices: post: tags: @@ -27531,6 +27614,41 @@ components: speedMs?, power?, cadence? }]`, up to 30 000 points). The `samples` series is the strain-engine input for indoor workouts that have no GPS route. Up to 100 workouts per call; nested route geometry capped at 20 000 points. Withings server-to-server callers pass source: WITHINGS and ship no route (Withings reports aggregates only)." + ActivityGoalsPatch: + type: object + properties: + workoutsPerWeek: + description: Workouts per week (Monday to Sunday). `null` clears the goal. + anyOf: + - type: integer + minimum: 1 + maximum: 28 + - type: "null" + activeMinutesPerWeek: + description: "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." + anyOf: + - type: integer + minimum: 10 + maximum: 5000 + - type: "null" + stepsPerWeek: + description: Steps per week. `null` clears the goal. + anyOf: + - type: integer + minimum: 1000 + maximum: 700000 + - type: "null" + stepsPerMonth: + description: Steps per calendar month. `null` clears the goal. + anyOf: + - type: integer + minimum: 1000 + maximum: 3000000 + - type: "null" + additionalProperties: false + 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. DeviceRegisterRequest: type: object properties: @@ -36469,10 +36587,11 @@ components: one the native client names; the integration-probe classes (`credentials_rejected`, `rate_limited`, `upstream_error`, `timeout`, `connection_failed` and the per-provider additions) are enumerated in each `/test` operation instead, where the differences can be stated; and a 401 raised by a route checking a - credential of its own carries the generic `http.unauthenticated`. ai — ai.budget.exceeded, - ai.provider.none, ai.record.notPermitted, ai.unavailable, ai.usage.aboveCeiling, - ai.usage.defaultAboveCeiling, ai.usage.invalid, ai.usage.levelTooLow, ai.usage.rateLimited; ai_provider - — ai_provider.invalid, ai_provider.no_fields; aiRuns — aiRuns.failed, aiRuns.notFound, aiRuns.timedOut, + credential of its own carries the generic `http.unauthenticated`. activity — + activity.calendar.future_date, activity.calendar.too_old; ai — ai.budget.exceeded, ai.provider.none, + ai.record.notPermitted, ai.unavailable, ai.usage.aboveCeiling, ai.usage.defaultAboveCeiling, + ai.usage.invalid, ai.usage.levelTooLow, ai.usage.rateLimited; ai_provider — ai_provider.invalid, + ai_provider.no_fields; aiRuns — aiRuns.failed, aiRuns.notFound, aiRuns.timedOut, aiRuns.workerUnavailable; allergy — allergy.invalid; analytics — analytics.invalid_query; anamnesis — anamnesis.auditC.invalid, anamnesis.auditC.notFound, anamnesis.auditC.rateLimited, anamnesis.fact.conflict, anamnesis.fact.currentExists, anamnesis.fact.invalidValue, @@ -39082,6 +39201,7 @@ components: documentsLayoutJson: {} moduleListLayoutJson: {} previewFeaturesJson: {} + activityGoalsJson: {} reportSelectionJson: {} globalExcludedInjectionSites: type: array @@ -49122,6 +49242,454 @@ components: - index - status additionalProperties: false + ActivityCalendarEnvelope: + type: object + properties: + data: + $ref: "#/components/schemas/ActivityCalendar" + error: + type: "null" + meta: + type: object + properties: + requestId: + type: string + additionalProperties: false + required: + - data + - error + additionalProperties: false + ActivityCalendar: + type: object + properties: + period: + type: string + enum: + - week + - month + start: + type: string + format: date + pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))$ + end: + type: string + format: date + pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))$ + gridStart: + type: string + format: date + pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))$ + description: First day drawn (the Monday on or before `start`). + gridEnd: + type: string + format: date + pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))$ + description: Last day drawn (the Sunday on or after `end`). + today: + type: string + format: date + pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))$ + timezone: + type: string + description: The IANA zone the days are cut in. + loadType: + anyOf: + - type: string + enum: + - DAY_STRAIN + - CARDIO_LOAD + - STRAIN_SCORE + - type: "null" + description: Which training-load score `load` carries; null when the account has none. + days: + type: array + items: + type: object + properties: + date: + type: string + format: date + pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))$ + inPeriod: + type: boolean + future: + type: boolean + steps: + type: + - number + - "null" + activeMinutes: + description: The larger of the day's exercise minutes and workout minutes (never their sum). + type: + - number + - "null" + workoutCount: + type: integer + minimum: -9007199254740991 + maximum: 9007199254740991 + workoutsBySport: + type: object + propertyNames: + type: string + additionalProperties: + type: integer + minimum: -9007199254740991 + maximum: 9007199254740991 + load: + type: + - number + - "null" + level: + type: object + properties: + steps: + type: string + enum: + - none + - unrated + - low + - below + - usual + - above + - high + description: "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." + activeMinutes: + type: string + enum: + - none + - unrated + - low + - below + - usual + - above + - high + description: "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." + load: + type: string + enum: + - none + - unrated + - low + - below + - usual + - above + - high + description: "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." + required: + - steps + - activeMinutes + - load + additionalProperties: false + required: + - date + - inPeriod + - future + - steps + - activeMinutes + - workoutCount + - workoutsBySport + - load + - level + additionalProperties: false + weeks: + type: array + items: + type: object + properties: + start: + type: string + format: date + pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))$ + end: + type: string + format: date + pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))$ + steps: + type: + - number + - "null" + activeMinutes: + type: + - number + - "null" + workouts: + type: integer + minimum: -9007199254740991 + maximum: 9007199254740991 + goals: + type: array + items: + $ref: "#/components/schemas/ActivityGoalProgress" + description: The weekly goals for a week that has begun. + required: + - start + - end + - steps + - activeMinutes + - workouts + - goals + additionalProperties: false + baseline: + type: object + properties: + steps: + $ref: "#/components/schemas/ActivityBaselineStats" + activeMinutes: + $ref: "#/components/schemas/ActivityBaselineStats" + load: + $ref: "#/components/schemas/ActivityBaselineStats" + required: + - steps + - activeMinutes + - load + additionalProperties: false + summary: + type: object + properties: + steps: + $ref: "#/components/schemas/ActivityPeriodComparison" + activeMinutes: + $ref: "#/components/schemas/ActivityPeriodComparison" + workouts: + $ref: "#/components/schemas/ActivityPeriodComparison" + load: + $ref: "#/components/schemas/ActivityPeriodComparison" + required: + - steps + - activeMinutes + - workouts + - load + additionalProperties: false + sports: + type: array + items: + type: object + properties: + sportType: + type: string + count: + type: integer + minimum: -9007199254740991 + maximum: 9007199254740991 + required: + - sportType + - count + additionalProperties: false + goals: + $ref: "#/components/schemas/ActivityGoals" + goalProgress: + type: array + items: + $ref: "#/components/schemas/ActivityGoalProgress" + available: + type: object + properties: + steps: + type: boolean + activeMinutes: + type: boolean + workouts: + type: boolean + load: + type: boolean + required: + - steps + - activeMinutes + - workouts + - load + additionalProperties: false + previous: + type: string + format: date + pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))$ + description: A day in the previous period. + next: + anyOf: + - type: string + format: date + pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))$ + - type: "null" + description: A day in the next period, or null when that lies after today. + required: + - period + - start + - end + - gridStart + - gridEnd + - today + - timezone + - loadType + - days + - weeks + - baseline + - summary + - sports + - goals + - goalProgress + - available + - previous + - next + additionalProperties: false + ActivityGoalProgress: + type: object + properties: + key: + type: string + enum: + - workoutsPerWeek + - activeMinutesPerWeek + - stepsPerWeek + - stepsPerMonth + target: + type: integer + minimum: -9007199254740991 + maximum: 9007199254740991 + value: + type: number + met: + type: boolean + required: + - key + - target + - value + - met + additionalProperties: false + ActivityBaselineStats: + anyOf: + - type: object + properties: + median: + type: number + p10: + type: number + p25: + type: number + p75: + type: number + p90: + type: number + days: + type: integer + minimum: -9007199254740991 + maximum: 9007199254740991 + description: Days with a value the spread was computed from. + required: + - median + - p10 + - p25 + - p75 + - p90 + - days + additionalProperties: false + - type: "null" + ActivityPeriodComparison: + type: object + properties: + value: + description: "The period so far: a total (steps, active minutes, workouts) or a mean (load)." + type: + - number + - "null" + daysWithData: + type: integer + minimum: -9007199254740991 + maximum: 9007199254740991 + usual: + description: "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." + type: + - number + - "null" + usualToDate: + description: The usual figure for the days elapsed so far; equals `usual` once the period is over. + type: + - number + - "null" + referencePeriods: + type: integer + minimum: -9007199254740991 + maximum: 9007199254740991 + required: + - value + - daysWithData + - usual + - usualToDate + - referencePeriods + additionalProperties: false + ActivityGoals: + type: object + properties: + workoutsPerWeek: + anyOf: + - type: integer + minimum: -9007199254740991 + maximum: 9007199254740991 + - type: "null" + activeMinutesPerWeek: + anyOf: + - type: integer + minimum: -9007199254740991 + maximum: 9007199254740991 + - type: "null" + stepsPerWeek: + anyOf: + - type: integer + minimum: -9007199254740991 + maximum: 9007199254740991 + - type: "null" + stepsPerMonth: + anyOf: + - type: integer + minimum: -9007199254740991 + maximum: 9007199254740991 + - type: "null" + required: + - workoutsPerWeek + - activeMinutesPerWeek + - stepsPerWeek + - stepsPerMonth + additionalProperties: false + description: The person's own goals; `null` = not set. There is no default goal. + ActivityGoalsEnvelope: + type: object + properties: + data: + $ref: "#/components/schemas/ActivityGoals" + error: + type: "null" + meta: + type: object + properties: + requestId: + type: string + additionalProperties: false + required: + - data + - error + additionalProperties: false + ActivityGoalsPatchEnvelope: + type: object + properties: + data: + $ref: "#/components/schemas/ActivityGoals" + error: + type: "null" + meta: + type: object + properties: + requestId: + type: string + additionalProperties: false + required: + - data + - error + additionalProperties: false DeviceRegisterResponse: type: object properties: diff --git a/e2e/activity-calendar.spec.ts b/e2e/activity-calendar.spec.ts new file mode 100644 index 000000000..3cfae6517 --- /dev/null +++ b/e2e/activity-calendar.spec.ts @@ -0,0 +1,68 @@ +import { expect, test } from "./setup/test"; + +import { STORAGE_STATE_PATH } from "./setup/global-setup"; + +/** + * The activity calendar on the workouts page (D1036), at phone width: the + * card renders a whole-week grid, pages back a period, switches to the week + * view, and the goals sheet saves a goal the calendar then reports. + */ +test.describe("activity calendar", () => { + test.use({ + storageState: STORAGE_STATE_PATH, + viewport: { width: 390, height: 844 }, + }); + + test("draws the month, pages back and switches to a week", async ({ + page, + }) => { + await page.goto("/insights/workouts"); + const card = page.locator('[data-slot="activity-calendar"]'); + await expect(card).toBeVisible(); + await expect( + page.locator('[data-slot="activity-calendar-label"]'), + ).not.toHaveText(""); + + const days = page.locator('[data-slot="activity-calendar-day"]'); + const monthCount = await days.count(); + if (monthCount > 0) expect(monthCount % 7).toBe(0); + + const label = page.locator('[data-slot="activity-calendar-label"]'); + const before = await label.textContent(); + await page.locator('[data-slot="activity-calendar-previous"]').click(); + await expect(label).not.toHaveText(before ?? ""); + + await card.getByRole("radio", { name: "Week" }).click(); + if ((await days.count()) > 0) await expect(days).toHaveCount(7); + + // Nothing in the card pushes the page sideways on a phone. + const overflow = await page.evaluate( + () => + document.documentElement.scrollWidth - + document.documentElement.clientWidth, + ); + expect(overflow).toBeLessThanOrEqual(0); + }); + + test("saves a goal and shows its progress", async ({ page }) => { + await page.goto("/insights/workouts"); + await page.locator('[data-slot="activity-calendar-set-goals"]').click(); + await page.getByLabel("Steps per month").fill("150000"); + await page.locator('[data-slot="activity-goals-save"]').click(); + await expect( + page + .locator('[data-slot="activity-calendar-goals"]') + .getByText("Steps per month"), + ).toBeVisible(); + + // Clear it again so the account is left as it was found. + await page.locator('[data-slot="activity-calendar-set-goals"]').click(); + await page.getByLabel("Steps per month").fill(""); + await page.locator('[data-slot="activity-goals-save"]').click(); + await expect( + page + .locator('[data-slot="activity-calendar-goals"]') + .getByText("Steps per month"), + ).toHaveCount(0); + }); +}); diff --git a/messages/de.json b/messages/de.json index a72dcb006..077caaefb 100644 --- a/messages/de.json +++ b/messages/de.json @@ -13471,5 +13471,73 @@ "clinicalUpper": "Klinische Obergrenze", "clinicalLower": "Klinische Untergrenze" } + }, + "activityCalendar": { + "title": "Aktivitätskalender", + "periodLabel": "Zeitraum", + "period": { + "week": "Woche", + "month": "Monat" + }, + "weekRange": "{start} bis {end}", + "previous": "Vorheriger Zeitraum", + "next": "Nächster Zeitraum", + "metricLabel": "Tage einfärben nach", + "metric": { + "steps": "Schritte", + "activeMinutes": "Aktive Minuten", + "load": "Trainingslast" + }, + "empty": "In diesem Zeitraum gibt es noch keine Schritte, aktiven Minuten oder Trainings.", + "loadError": "Der Aktivitätskalender konnte nicht geladen werden.", + "weekGoalsColumn": "Wochenziele", + "weekGoalsMet": "Alle Wochenziele erreicht", + "weekGoalsNotMet": "Nicht alle Wochenziele erreicht", + "cell": { + "steps": "{value} Schritte", + "activeMinutes": "{value} aktive Minuten", + "workouts": "{value} Trainings: {sports}" + }, + "level": { + "none": "Keine Daten", + "unrated": "Noch zu wenig Verlauf", + "low": "Weit unter deinem Üblichen", + "below": "Unter deinem Üblichen", + "usual": "Etwa wie üblich", + "above": "Über deinem Üblichen", + "high": "Weit über deinem Üblichen" + }, + "legend": "Verglichen mit deinen 12 Wochen davor:", + "noBaseline": "Dein üblicher Bereich erscheint nach 14 Tagen mit Daten.", + "summary": { + "steps": "{value} Schritte", + "activeMinutes": "{value} aktive Minuten", + "workouts": "{value} Trainings", + "load": "Durchschnittliche Trainingslast {value}", + "usual": "Bis hierher üblich bei dir: {value}", + "noUsual": "Noch zu wenige frühere Wochen oder Monate zum Vergleichen." + }, + "sportCount": "{sport}: {count}", + "goal": { + "workoutsPerWeek": "Trainings pro Woche", + "activeMinutesPerWeek": "Aktive Minuten pro Woche", + "stepsPerWeek": "Schritte pro Woche", + "stepsPerMonth": "Schritte pro Monat" + }, + "goalOf": "{value} von {target}", + "setGoals": "Ziele festlegen", + "editGoals": "Ziele ändern", + "goalsSheet": { + "title": "Aktivitätsziele", + "description": "Lass ein Feld leer, wenn du dort kein Ziel möchtest.", + "none": "Kein Ziel", + "invalid": "Eine ganze Zahl von {min} bis {max}.", + "saveError": "Die Ziele konnten nicht gespeichert werden." + }, + "crossLink": { + "title": "Aktivitätskalender", + "body": "Deine Schritte als Woche oder Monat, verglichen mit deinem Üblichen.", + "cta": "Kalender öffnen" + } } } diff --git a/messages/en.json b/messages/en.json index 8ca1e04c3..41c212bfc 100644 --- a/messages/en.json +++ b/messages/en.json @@ -13471,5 +13471,73 @@ "clinicalUpper": "Clinical upper limit", "clinicalLower": "Clinical lower limit" } + }, + "activityCalendar": { + "title": "Activity calendar", + "periodLabel": "Calendar span", + "period": { + "week": "Week", + "month": "Month" + }, + "weekRange": "{start} to {end}", + "previous": "Previous period", + "next": "Next period", + "metricLabel": "Colour the days by", + "metric": { + "steps": "Steps", + "activeMinutes": "Active minutes", + "load": "Training load" + }, + "empty": "No steps, active minutes or workouts in this period yet.", + "loadError": "The activity calendar could not be loaded.", + "weekGoalsColumn": "Weekly goals", + "weekGoalsMet": "All weekly goals met", + "weekGoalsNotMet": "Not all weekly goals met", + "cell": { + "steps": "{value} steps", + "activeMinutes": "{value} active minutes", + "workouts": "{value} workouts: {sports}" + }, + "level": { + "none": "No data", + "unrated": "Not enough history yet", + "low": "Far below your usual", + "below": "Below your usual", + "usual": "About your usual", + "above": "Above your usual", + "high": "Far above your usual" + }, + "legend": "Compared with your 12 weeks before:", + "noBaseline": "Your usual range shows after 14 days with data.", + "summary": { + "steps": "{value} steps", + "activeMinutes": "{value} active minutes", + "workouts": "{value} workouts", + "load": "Average training load {value}", + "usual": "Your usual by this point: {value}", + "noUsual": "Not enough earlier weeks or months to compare with yet." + }, + "sportCount": "{sport}: {count}", + "goal": { + "workoutsPerWeek": "Workouts per week", + "activeMinutesPerWeek": "Active minutes per week", + "stepsPerWeek": "Steps per week", + "stepsPerMonth": "Steps per month" + }, + "goalOf": "{value} of {target}", + "setGoals": "Set goals", + "editGoals": "Change goals", + "goalsSheet": { + "title": "Activity goals", + "description": "Leave a field empty for no goal.", + "none": "No goal", + "invalid": "A whole number from {min} to {max}.", + "saveError": "The goals could not be saved." + }, + "crossLink": { + "title": "Activity calendar", + "body": "Your steps as a week or a month, against your own usual.", + "cta": "Open calendar" + } } } diff --git a/messages/es.json b/messages/es.json index 3239be811..7a297a960 100644 --- a/messages/es.json +++ b/messages/es.json @@ -13471,5 +13471,73 @@ "clinicalUpper": "Límite clínico superior", "clinicalLower": "Límite clínico inferior" } + }, + "activityCalendar": { + "title": "Calendario de actividad", + "periodLabel": "Periodo del calendario", + "period": { + "week": "Semana", + "month": "Mes" + }, + "weekRange": "{start} a {end}", + "previous": "Periodo anterior", + "next": "Periodo siguiente", + "metricLabel": "Colorear los días por", + "metric": { + "steps": "Pasos", + "activeMinutes": "Minutos activos", + "load": "Carga de entrenamiento" + }, + "empty": "Todavía no hay pasos, minutos activos ni entrenamientos en este periodo.", + "loadError": "No se pudo cargar el calendario de actividad.", + "weekGoalsColumn": "Objetivos semanales", + "weekGoalsMet": "Todos los objetivos semanales cumplidos", + "weekGoalsNotMet": "No se cumplieron todos los objetivos semanales", + "cell": { + "steps": "{value} pasos", + "activeMinutes": "{value} minutos activos", + "workouts": "{value} entrenamientos: {sports}" + }, + "level": { + "none": "Sin datos", + "unrated": "Aún no hay suficiente historial", + "low": "Muy por debajo de lo habitual", + "below": "Por debajo de lo habitual", + "usual": "Como de costumbre", + "above": "Por encima de lo habitual", + "high": "Muy por encima de lo habitual" + }, + "legend": "Comparado con tus 12 semanas anteriores:", + "noBaseline": "Tu rango habitual aparece tras 14 días con datos.", + "summary": { + "steps": "{value} pasos", + "activeMinutes": "{value} minutos activos", + "workouts": "{value} entrenamientos", + "load": "Carga de entrenamiento media {value}", + "usual": "Lo habitual hasta este punto: {value}", + "noUsual": "Aún no hay suficientes semanas o meses anteriores para comparar." + }, + "sportCount": "{sport}: {count}", + "goal": { + "workoutsPerWeek": "Entrenamientos por semana", + "activeMinutesPerWeek": "Minutos activos por semana", + "stepsPerWeek": "Pasos por semana", + "stepsPerMonth": "Pasos por mes" + }, + "goalOf": "{value} de {target}", + "setGoals": "Fijar objetivos", + "editGoals": "Cambiar objetivos", + "goalsSheet": { + "title": "Objetivos de actividad", + "description": "Deja un campo vacío si no quieres un objetivo.", + "none": "Sin objetivo", + "invalid": "Un número entero de {min} a {max}.", + "saveError": "No se pudieron guardar los objetivos." + }, + "crossLink": { + "title": "Calendario de actividad", + "body": "Tus pasos por semana o por mes, frente a lo que es habitual para ti.", + "cta": "Abrir calendario" + } } } diff --git a/messages/fr.json b/messages/fr.json index 7f395b5e5..e9c8f45c1 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -13471,5 +13471,73 @@ "clinicalUpper": "Limite clinique haute", "clinicalLower": "Limite clinique basse" } + }, + "activityCalendar": { + "title": "Calendrier d'activité", + "periodLabel": "Période du calendrier", + "period": { + "week": "Semaine", + "month": "Mois" + }, + "weekRange": "{start} au {end}", + "previous": "Période précédente", + "next": "Période suivante", + "metricLabel": "Colorer les jours selon", + "metric": { + "steps": "Pas", + "activeMinutes": "Minutes actives", + "load": "Charge d'entraînement" + }, + "empty": "Aucun pas, aucune minute active ni aucun entraînement sur cette période pour l'instant.", + "loadError": "Le calendrier d'activité n'a pas pu être chargé.", + "weekGoalsColumn": "Objectifs hebdomadaires", + "weekGoalsMet": "Tous les objectifs hebdomadaires atteints", + "weekGoalsNotMet": "Objectifs hebdomadaires pas tous atteints", + "cell": { + "steps": "{value} pas", + "activeMinutes": "{value} minutes actives", + "workouts": "{value} entraînements : {sports}" + }, + "level": { + "none": "Aucune donnée", + "unrated": "Pas encore assez d'historique", + "low": "Bien en dessous de ton habitude", + "below": "En dessous de ton habitude", + "usual": "Comme d'habitude", + "above": "Au-dessus de ton habitude", + "high": "Bien au-dessus de ton habitude" + }, + "legend": "Comparé à tes 12 semaines précédentes :", + "noBaseline": "Ta plage habituelle apparaît après 14 jours avec des données.", + "summary": { + "steps": "{value} pas", + "activeMinutes": "{value} minutes actives", + "workouts": "{value} entraînements", + "load": "Charge d'entraînement moyenne {value}", + "usual": "Ton habitude à ce stade : {value}", + "noUsual": "Pas encore assez de semaines ou de mois précédents pour comparer." + }, + "sportCount": "{sport} : {count}", + "goal": { + "workoutsPerWeek": "Entraînements par semaine", + "activeMinutesPerWeek": "Minutes actives par semaine", + "stepsPerWeek": "Pas par semaine", + "stepsPerMonth": "Pas par mois" + }, + "goalOf": "{value} sur {target}", + "setGoals": "Définir des objectifs", + "editGoals": "Modifier les objectifs", + "goalsSheet": { + "title": "Objectifs d'activité", + "description": "Laisse un champ vide pour ne pas fixer d'objectif.", + "none": "Aucun objectif", + "invalid": "Un nombre entier de {min} à {max}.", + "saveError": "Les objectifs n'ont pas pu être enregistrés." + }, + "crossLink": { + "title": "Calendrier d'activité", + "body": "Tes pas par semaine ou par mois, comparés à ton habitude.", + "cta": "Ouvrir le calendrier" + } } } diff --git a/messages/it.json b/messages/it.json index 85e890708..c522cb6d7 100644 --- a/messages/it.json +++ b/messages/it.json @@ -13471,5 +13471,73 @@ "clinicalUpper": "Limite clinico superiore", "clinicalLower": "Limite clinico inferiore" } + }, + "activityCalendar": { + "title": "Calendario attività", + "periodLabel": "Periodo del calendario", + "period": { + "week": "Settimana", + "month": "Mese" + }, + "weekRange": "dal {start} al {end}", + "previous": "Periodo precedente", + "next": "Periodo successivo", + "metricLabel": "Colora i giorni per", + "metric": { + "steps": "Passi", + "activeMinutes": "Minuti attivi", + "load": "Carico di allenamento" + }, + "empty": "Ancora nessun passo, minuto attivo o allenamento in questo periodo.", + "loadError": "Impossibile caricare il calendario attività.", + "weekGoalsColumn": "Obiettivi settimanali", + "weekGoalsMet": "Tutti gli obiettivi settimanali raggiunti", + "weekGoalsNotMet": "Non tutti gli obiettivi settimanali raggiunti", + "cell": { + "steps": "{value} passi", + "activeMinutes": "{value} minuti attivi", + "workouts": "{value} allenamenti: {sports}" + }, + "level": { + "none": "Nessun dato", + "unrated": "Storico ancora insufficiente", + "low": "Molto sotto il tuo solito", + "below": "Sotto il tuo solito", + "usual": "Come al solito", + "above": "Sopra il tuo solito", + "high": "Molto sopra il tuo solito" + }, + "legend": "Rispetto alle tue 12 settimane precedenti:", + "noBaseline": "Il tuo intervallo abituale compare dopo 14 giorni con dati.", + "summary": { + "steps": "{value} passi", + "activeMinutes": "{value} minuti attivi", + "workouts": "{value} allenamenti", + "load": "Carico di allenamento medio {value}", + "usual": "Il tuo solito fino a questo punto: {value}", + "noUsual": "Non ci sono ancora abbastanza settimane o mesi precedenti per un confronto." + }, + "sportCount": "{sport}: {count}", + "goal": { + "workoutsPerWeek": "Allenamenti a settimana", + "activeMinutesPerWeek": "Minuti attivi a settimana", + "stepsPerWeek": "Passi a settimana", + "stepsPerMonth": "Passi al mese" + }, + "goalOf": "{value} di {target}", + "setGoals": "Imposta obiettivi", + "editGoals": "Modifica obiettivi", + "goalsSheet": { + "title": "Obiettivi di attività", + "description": "Lascia vuoto un campo per non avere un obiettivo.", + "none": "Nessun obiettivo", + "invalid": "Un numero intero da {min} a {max}.", + "saveError": "Impossibile salvare gli obiettivi." + }, + "crossLink": { + "title": "Calendario attività", + "body": "I tuoi passi per settimana o per mese, rispetto al tuo solito.", + "cta": "Apri il calendario" + } } } diff --git a/messages/ko.json b/messages/ko.json index 0b844ffae..91597e3e2 100644 --- a/messages/ko.json +++ b/messages/ko.json @@ -13471,5 +13471,73 @@ "clinicalUpper": "임상 기준 상한", "clinicalLower": "임상 기준 하한" } + }, + "activityCalendar": { + "title": "활동 캘린더", + "periodLabel": "캘린더 기간", + "period": { + "week": "주", + "month": "월" + }, + "weekRange": "{start} ~ {end}", + "previous": "이전 기간", + "next": "다음 기간", + "metricLabel": "날짜 색상 기준", + "metric": { + "steps": "걸음 수", + "activeMinutes": "활동 시간(분)", + "load": "운동 부하" + }, + "empty": "이 기간에는 아직 걸음 수, 활동 시간, 운동 기록이 없습니다.", + "loadError": "활동 캘린더를 불러오지 못했습니다.", + "weekGoalsColumn": "주간 목표", + "weekGoalsMet": "주간 목표를 모두 달성함", + "weekGoalsNotMet": "주간 목표를 모두 달성하지는 못함", + "cell": { + "steps": "{value}걸음", + "activeMinutes": "활동 {value}분", + "workouts": "운동 {value}회: {sports}" + }, + "level": { + "none": "데이터 없음", + "unrated": "아직 기록이 충분하지 않음", + "low": "평소보다 훨씬 적음", + "below": "평소보다 적음", + "usual": "평소와 비슷함", + "above": "평소보다 많음", + "high": "평소보다 훨씬 많음" + }, + "legend": "이전 12주와 비교:", + "noBaseline": "데이터가 있는 날이 14일이 되면 평소 범위가 표시됩니다.", + "summary": { + "steps": "{value}걸음", + "activeMinutes": "활동 {value}분", + "workouts": "운동 {value}회", + "load": "평균 운동 부하 {value}", + "usual": "이 시점까지의 평소 값: {value}", + "noUsual": "비교할 이전 주나 달이 아직 충분하지 않습니다." + }, + "sportCount": "{sport}: {count}", + "goal": { + "workoutsPerWeek": "주당 운동 횟수", + "activeMinutesPerWeek": "주당 활동 시간(분)", + "stepsPerWeek": "주당 걸음 수", + "stepsPerMonth": "월간 걸음 수" + }, + "goalOf": "{target} 중 {value}", + "setGoals": "목표 설정", + "editGoals": "목표 변경", + "goalsSheet": { + "title": "활동 목표", + "description": "목표를 두지 않으려면 칸을 비워 두세요.", + "none": "목표 없음", + "invalid": "{min}부터 {max}까지의 정수를 입력하세요.", + "saveError": "목표를 저장하지 못했습니다." + }, + "crossLink": { + "title": "활동 캘린더", + "body": "주 또는 월 단위의 걸음 수를 내 평소 값과 비교합니다.", + "cta": "캘린더 열기" + } } } diff --git a/messages/nl.json b/messages/nl.json index c44560507..85559a6eb 100644 --- a/messages/nl.json +++ b/messages/nl.json @@ -13471,5 +13471,73 @@ "clinicalUpper": "Klinische bovengrens", "clinicalLower": "Klinische ondergrens" } + }, + "activityCalendar": { + "title": "Activiteitenkalender", + "periodLabel": "Kalenderperiode", + "period": { + "week": "Week", + "month": "Maand" + }, + "weekRange": "{start} t/m {end}", + "previous": "Vorige periode", + "next": "Volgende periode", + "metricLabel": "Dagen kleuren op", + "metric": { + "steps": "Stappen", + "activeMinutes": "Actieve minuten", + "load": "Trainingsbelasting" + }, + "empty": "Nog geen stappen, actieve minuten of trainingen in deze periode.", + "loadError": "De activiteitenkalender kon niet worden geladen.", + "weekGoalsColumn": "Weekdoelen", + "weekGoalsMet": "Alle weekdoelen gehaald", + "weekGoalsNotMet": "Niet alle weekdoelen gehaald", + "cell": { + "steps": "{value} stappen", + "activeMinutes": "{value} actieve minuten", + "workouts": "{value} trainingen: {sports}" + }, + "level": { + "none": "Geen gegevens", + "unrated": "Nog te weinig geschiedenis", + "low": "Ver onder je gewone niveau", + "below": "Onder je gewone niveau", + "usual": "Ongeveer zoals gewoonlijk", + "above": "Boven je gewone niveau", + "high": "Ver boven je gewone niveau" + }, + "legend": "Vergeleken met je 12 weken daarvoor:", + "noBaseline": "Je gebruikelijke bereik verschijnt na 14 dagen met gegevens.", + "summary": { + "steps": "{value} stappen", + "activeMinutes": "{value} actieve minuten", + "workouts": "{value} trainingen", + "load": "Gemiddelde trainingsbelasting {value}", + "usual": "Gebruikelijk tot dit punt: {value}", + "noUsual": "Nog niet genoeg eerdere weken of maanden om mee te vergelijken." + }, + "sportCount": "{sport}: {count}", + "goal": { + "workoutsPerWeek": "Trainingen per week", + "activeMinutesPerWeek": "Actieve minuten per week", + "stepsPerWeek": "Stappen per week", + "stepsPerMonth": "Stappen per maand" + }, + "goalOf": "{value} van {target}", + "setGoals": "Doelen instellen", + "editGoals": "Doelen wijzigen", + "goalsSheet": { + "title": "Activiteitsdoelen", + "description": "Laat een veld leeg als je geen doel wilt.", + "none": "Geen doel", + "invalid": "Een heel getal van {min} tot {max}.", + "saveError": "De doelen konden niet worden opgeslagen." + }, + "crossLink": { + "title": "Activiteitenkalender", + "body": "Je stappen per week of maand, vergeleken met wat gewoon is voor jou.", + "cta": "Kalender openen" + } } } diff --git a/messages/pl.json b/messages/pl.json index c1812715f..0e77760d4 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -13471,5 +13471,73 @@ "clinicalUpper": "Kliniczna górna granica", "clinicalLower": "Kliniczna dolna granica" } + }, + "activityCalendar": { + "title": "Kalendarz aktywności", + "periodLabel": "Zakres kalendarza", + "period": { + "week": "Tydzień", + "month": "Miesiąc" + }, + "weekRange": "od {start} do {end}", + "previous": "Poprzedni okres", + "next": "Następny okres", + "metricLabel": "Koloruj dni według", + "metric": { + "steps": "Kroki", + "activeMinutes": "Aktywne minuty", + "load": "Obciążenie treningowe" + }, + "empty": "W tym okresie nie ma jeszcze kroków, aktywnych minut ani treningów.", + "loadError": "Nie udało się wczytać kalendarza aktywności.", + "weekGoalsColumn": "Cele tygodniowe", + "weekGoalsMet": "Wszystkie cele tygodniowe osiągnięte", + "weekGoalsNotMet": "Nie wszystkie cele tygodniowe osiągnięte", + "cell": { + "steps": "Kroki: {value}", + "activeMinutes": "Aktywne minuty: {value}", + "workouts": "Treningi: {value} ({sports})" + }, + "level": { + "none": "Brak danych", + "unrated": "Za mało historii", + "low": "Znacznie poniżej twojej normy", + "below": "Poniżej twojej normy", + "usual": "Mniej więcej jak zwykle", + "above": "Powyżej twojej normy", + "high": "Znacznie powyżej twojej normy" + }, + "legend": "W porównaniu z twoimi 12 wcześniejszymi tygodniami:", + "noBaseline": "Twój zwykły zakres pojawi się po 14 dniach z danymi.", + "summary": { + "steps": "Kroki: {value}", + "activeMinutes": "Aktywne minuty: {value}", + "workouts": "Treningi: {value}", + "load": "Średnie obciążenie treningowe {value}", + "usual": "Twoja norma do tego momentu: {value}", + "noUsual": "Za mało wcześniejszych tygodni lub miesięcy do porównania." + }, + "sportCount": "{sport}: {count}", + "goal": { + "workoutsPerWeek": "Treningi w tygodniu", + "activeMinutesPerWeek": "Aktywne minuty w tygodniu", + "stepsPerWeek": "Kroki w tygodniu", + "stepsPerMonth": "Kroki w miesiącu" + }, + "goalOf": "{value} z {target}", + "setGoals": "Ustaw cele", + "editGoals": "Zmień cele", + "goalsSheet": { + "title": "Cele aktywności", + "description": "Zostaw pole puste, jeśli nie chcesz celu.", + "none": "Brak celu", + "invalid": "Liczba całkowita od {min} do {max}.", + "saveError": "Nie udało się zapisać celów." + }, + "crossLink": { + "title": "Kalendarz aktywności", + "body": "Twoje kroki w tygodniu lub miesiącu na tle twojej normy.", + "cta": "Otwórz kalendarz" + } } } diff --git a/prisma/migrations/0470_user_activity_goals/migration.sql b/prisma/migrations/0470_user_activity_goals/migration.sql new file mode 100644 index 000000000..49d25a9d1 --- /dev/null +++ b/prisma/migrations/0470_user_activity_goals/migration.sql @@ -0,0 +1,9 @@ +-- Optional per-user activity goals for the activity calendar: workouts, +-- active minutes and steps per week, steps per month. +-- +-- Nullable, no default, no backfill: null reads as "no goal set", which is +-- every account's state today. +-- +-- Reversibility: dropping the column removes the goals; nothing else reads it. + +ALTER TABLE "users" ADD COLUMN "activity_goals_json" JSONB; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 254c052d1..7c2a5b0e1 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -380,6 +380,12 @@ model User { /// Settings > System through `/api/settings/preview-features`. /// @pending-consumer(topics preview) the `topics` flag is stored and shown, but no surface reads it yet; the topic overview pages will. previewFeaturesJson Json? @map("preview_features_json") + /// Optional activity goals the person set for themselves: workouts, active + /// minutes and steps per week, steps per month. Null or an absent key = no + /// goal. Parsed by `src/lib/validations/activity-goals.ts`, written through + /// `/api/activity/goals`, read by the activity calendar + /// (`/api/activity/calendar`), which reports progress against each goal set. + activityGoalsJson Json? @map("activity_goals_json") // Blood-glucose display unit preference. Canonical storage is mg/dL. glucoseUnit String? @map("glucose_unit") // "mg/dL" | "mmol/L" | null = default "mg/dL" // v1.18.6 — explicit "I have diabetes / clinician glucose targets" opt-in. diff --git a/src/__tests__/sharing-surface-guard.test.ts b/src/__tests__/sharing-surface-guard.test.ts index d6ec5452e..cdd3e4159 100644 --- a/src/__tests__/sharing-surface-guard.test.ts +++ b/src/__tests__/sharing-surface-guard.test.ts @@ -1077,6 +1077,10 @@ const DELEGABLE_ROUTES: Record = { 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 = { * 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` — diff --git a/src/__tests__/success-affordance-guard.test.ts b/src/__tests__/success-affordance-guard.test.ts index 858a950ab..361c8afa2 100644 --- a/src/__tests__/success-affordance-guard.test.ts +++ b/src/__tests__/success-affordance-guard.test.ts @@ -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 }, diff --git a/src/__tests__/workout-canonical-identity-guard.test.ts b/src/__tests__/workout-canonical-identity-guard.test.ts index 416faa7e5..4525be0f6 100644 --- a/src/__tests__/workout-canonical-identity-guard.test.ts +++ b/src/__tests__/workout-canonical-identity-guard.test.ts @@ -43,6 +43,9 @@ const IDENTITY_OWNER: Record = { "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 diff --git a/src/app/api/activity/__tests__/routes.test.ts b/src/app/api/activity/__tests__/routes.test.ts new file mode 100644 index 000000000..e189a3cea --- /dev/null +++ b/src/app/api/activity/__tests__/routes.test.ts @@ -0,0 +1,168 @@ +/** + * `/api/activity/calendar` and `/api/activity/goals` (D1036). + * + * The goals: a PATCH replaces only what it names, `null` clears, an unknown + * key or a goal out of bounds is a 422 and writes nothing, and clearing the + * last goal returns the column to NULL rather than storing an empty object. + * The calendar: the module gate answers before any read, a future or malformed + * day is refused, and the reader is asked for exactly the window the period + * needs and never past today. + */ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { NextRequest } from "next/server"; + +const mocks = vi.hoisted(() => ({ + findUnique: vi.fn(), + update: vi.fn(), + readActivityDays: vi.fn(), + requireModuleEnabled: vi.fn(), + auditLog: vi.fn(), +})); + +vi.mock("@/lib/api-handler", () => ({ + apiHandler: (handler: unknown) => handler, + requireAuth: vi.fn(async () => ({ user: { id: "user-1" } })), + requireRecordAuth: vi.fn(async () => ({ + user: { id: "user-1" }, + actor: { id: "user-1" }, + grantId: null, + })), +})); +vi.mock("@/lib/logging/context", () => ({ annotate: vi.fn() })); +vi.mock("@/lib/auth/audit", () => ({ auditLog: mocks.auditLog })); +vi.mock("@/lib/db", () => ({ + prisma: { user: { findUnique: mocks.findUnique, update: mocks.update } }, + toJson: (v: unknown) => v, +})); +vi.mock("@/lib/modules/gate", () => ({ + requireModuleEnabled: mocks.requireModuleEnabled, +})); +vi.mock("@/lib/activity/calendar-read", () => ({ + readActivityDays: mocks.readActivityDays, +})); +vi.mock("@/lib/tz/resolver", () => ({ + resolveUserTimezone: vi.fn(async () => "Europe/Berlin"), +})); + +import { Prisma } from "@/generated/prisma/client"; +import { GET as calendarGet } from "../calendar/route"; +import { GET as goalsGet, PATCH as goalsPatch } from "../goals/route"; + +type Handler = (req: NextRequest) => Promise; + +function patch(body: unknown) { + return (goalsPatch as unknown as Handler)( + new NextRequest("http://localhost/api/activity/goals", { + method: "PATCH", + headers: { "content-type": "application/json" }, + body: JSON.stringify(body), + }), + ); +} + +function calendar(query: string) { + return (calendarGet as unknown as Handler)( + new NextRequest(`http://localhost/api/activity/calendar${query}`), + ); +} + +beforeEach(() => { + vi.clearAllMocks(); + mocks.findUnique.mockResolvedValue({ + activityGoalsJson: { workoutsPerWeek: 3, stepsPerWeek: 50000 }, + }); + mocks.update.mockResolvedValue({}); + mocks.requireModuleEnabled.mockResolvedValue({ enabled: true }); + mocks.readActivityDays.mockResolvedValue({ days: [], loadType: null }); +}); + +describe("activity goals", () => { + it("reads every goal, unset ones as null", async () => { + const res = await (goalsGet as unknown as () => Promise)(); + const body = await res.json(); + expect(body.data).toEqual({ + workoutsPerWeek: 3, + activeMinutesPerWeek: null, + stepsPerWeek: 50000, + stepsPerMonth: null, + }); + }); + + it("changes what the body names and keeps the rest", async () => { + const res = await patch({ stepsPerWeek: null, stepsPerMonth: 250000 }); + expect(res.status).toBe(200); + expect(mocks.update).toHaveBeenCalledWith({ + where: { id: "user-1" }, + data: { + activityGoalsJson: { + workoutsPerWeek: 3, + activeMinutesPerWeek: null, + stepsPerWeek: null, + stepsPerMonth: 250000, + }, + }, + }); + expect(mocks.auditLog).toHaveBeenCalledWith( + "activity.goals.update", + expect.objectContaining({ userId: "user-1" }), + ); + }); + + it("stores NULL once the last goal is cleared", async () => { + await patch({ workoutsPerWeek: null, stepsPerWeek: null }); + expect(mocks.update.mock.calls[0][0].data.activityGoalsJson).toBe( + Prisma.DbNull, + ); + }); + + it.each([ + [{ caloriesPerWeek: 3000 }], + [{ workoutsPerWeek: 0 }], + [{ stepsPerWeek: 2.5 }], + [{ stepsPerMonth: "200000" }], + ])("refuses %j without writing", async (body) => { + const res = await patch(body); + expect(res.status).toBe(422); + expect(mocks.update).not.toHaveBeenCalled(); + }); +}); + +describe("activity calendar", () => { + it("answers the module gate before reading anything", async () => { + mocks.requireModuleEnabled.mockResolvedValue({ + enabled: false, + response: new Response(null, { status: 403 }), + }); + const res = await calendar("?period=month"); + expect(res.status).toBe(403); + expect(mocks.readActivityDays).not.toHaveBeenCalled(); + }); + + it.each(["?period=year", "?date=2026-13-01", "?date=yesterday"])( + "refuses %s", + async (q) => { + const res = await calendar(q); + expect(res.status).toBe(422); + }, + ); + + it("refuses a day after today", async () => { + const res = await calendar("?date=2999-01-01"); + expect(res.status).toBe(422); + expect(mocks.readActivityDays).not.toHaveBeenCalled(); + }); + + it("reads the baseline window before the period and nothing past the grid", async () => { + const res = await calendar("?period=week&date=2025-03-12"); + expect(res.status).toBe(200); + const args = mocks.readActivityDays.mock.calls[0][0]; + // Week of Monday 2025-03-10; 84 days before it. + expect(args.fromKey).toBe("2024-12-16"); + expect(args.toKey).toBe("2025-03-16"); + expect(args.tz).toBe("Europe/Berlin"); + const body = await res.json(); + expect(body.data.start).toBe("2025-03-10"); + expect(body.data.goals.workoutsPerWeek).toBe(3); + expect(body.data.days).toHaveLength(7); + }); +}); diff --git a/src/app/api/activity/calendar/route.ts b/src/app/api/activity/calendar/route.ts new file mode 100644 index 000000000..9ee482f07 --- /dev/null +++ b/src/app/api/activity/calendar/route.ts @@ -0,0 +1,98 @@ +/** + * `GET /api/activity/calendar` — a week or a month of activity against the + * person's own normal, with their weekly and monthly goals (D1036). + * + * Computed server-side, without AI, from the stored readings: steps (the + * canonical-source day totals the steps chart draws), exercise minutes, + * workouts after the cross-source collapse, and a training-load score when a + * device or HealthLog provides one. What "usual" means is in + * `src/lib/activity/calendar-math.ts`; it is always the person's own history, + * never a population norm. + * + * Owned by the Workouts module, like the page that shows it: a switched-off + * module answers 403 here as `/api/workouts` does. + */ +import { NextRequest } from "next/server"; + +import { apiHandler, requireRecordAuth } from "@/lib/api-handler"; +import { apiError, apiSuccess, returnAllZodIssues } from "@/lib/api-response"; +import { prisma } from "@/lib/db"; +import { annotate } from "@/lib/logging/context"; +import { requireModuleEnabled } from "@/lib/modules/gate"; +import { + buildActivityCalendar, + periodBounds, +} from "@/lib/activity/calendar-math"; +import { readActivityDays } from "@/lib/activity/calendar-read"; +import { daysBetweenDateKeys, userDayKey } from "@/lib/tz/format"; +import { resolveUserTimezone } from "@/lib/tz/resolver"; +import { activityCalendarQuerySchema } from "@/lib/validations/activity-calendar"; +import { parseActivityGoals } from "@/lib/validations/activity-goals"; + +export const dynamic = "force-dynamic"; + +/** How far back the calendar pages: ten years, the series route's ceiling. */ +const MAX_DAYS_BACK = 3650 - 200; + +export const GET = apiHandler(async (request: NextRequest) => { + const { user } = await requireRecordAuth("read", "measurements"); + annotate({ action: { name: "activity.calendar.read" } }); + + const gate = await requireModuleEnabled(user.id, "workouts"); + if (!gate.enabled) return gate.response; + + const params = new URL(request.url).searchParams; + const parsed = activityCalendarQuerySchema.safeParse({ + period: params.get("period") ?? undefined, + date: params.get("date") ?? undefined, + }); + if (!parsed.success) return returnAllZodIssues(parsed.error, 422); + + const tz = await resolveUserTimezone(user.id); + const now = new Date(); + const today = userDayKey(now, tz); + const anchor = parsed.data.date ?? today; + if (anchor > today) { + return apiError("The calendar cannot show a day after today", 422, { + errorCode: "activity.calendar.future_date", + }); + } + if (daysBetweenDateKeys(anchor, today) > MAX_DAYS_BACK) { + return apiError("The calendar reaches back ten years", 422, { + errorCode: "activity.calendar.too_old", + }); + } + + const bounds = periodBounds(parsed.data.period, anchor); + const lastRead = bounds.gridEnd < today ? bounds.gridEnd : today; + const [{ days, loadType }, goalRow] = await Promise.all([ + readActivityDays({ + userId: user.id, + tz, + fromKey: bounds.readStart, + toKey: lastRead, + now, + includeWorkouts: true, + }), + prisma.user.findUnique({ + where: { id: user.id }, + select: { activityGoalsJson: true }, + }), + ]); + + const calendar = buildActivityCalendar({ + bounds, + today, + input: days, + goals: parseActivityGoals(goalRow?.activityGoalsJson ?? null), + }); + + annotate({ + meta: { + activity_calendar_period: bounds.period, + activity_calendar_days_read: days.length, + activity_calendar_load_type: loadType ?? "none", + }, + }); + return apiSuccess({ ...calendar, timezone: tz, loadType }); +}); diff --git a/src/app/api/activity/goals/route.ts b/src/app/api/activity/goals/route.ts new file mode 100644 index 000000000..2a2ef4c5d --- /dev/null +++ b/src/app/api/activity/goals/route.ts @@ -0,0 +1,90 @@ +/** + * GET / PATCH the caller's activity goals (D1036). + * + * Every goal is optional and set by the person; there is no default goal. + * PATCH replaces the goals the body names (`null` clears one) and keeps the + * rest; an unknown key or an out-of-bounds goal is refused with the + * multi-issue 422. The goals live on their own `User` column + * (`activity_goals_json`) and the activity calendar reads them. + */ +import { apiHandler, requireAuth } from "@/lib/api-handler"; +import { + apiSuccess, + getClientIp, + returnAllZodIssues, + safeJson, +} from "@/lib/api-response"; +import { auditLog } from "@/lib/auth/audit"; +import { Prisma } from "@/generated/prisma/client"; +import { prisma, toJson } from "@/lib/db"; +import { annotate } from "@/lib/logging/context"; +import { + ACTIVITY_GOAL_KEYS, + activityGoalsSchema, + hasNoActivityGoals, + mergeActivityGoals, + parseActivityGoals, + type ActivityGoals, +} from "@/lib/validations/activity-goals"; + +export const dynamic = "force-dynamic"; + +async function readGoals(userId: string): Promise { + const row = await prisma.user.findUnique({ + where: { id: userId }, + select: { activityGoalsJson: true }, + }); + return parseActivityGoals(row?.activityGoalsJson ?? null); +} + +function setKeys(goals: ActivityGoals): string[] { + return ACTIVITY_GOAL_KEYS.filter((key) => goals[key] !== null); +} + +export const GET = apiHandler(async () => { + // Owner-only, like the write: a delegate sees the goals where they matter, + // inside the calendar, which carries them. + const { user } = await requireAuth(); + const goals = await readGoals(user.id); + annotate({ + action: { name: "activity.goals.read" }, + meta: { set: setKeys(goals) }, + }); + return apiSuccess(goals); +}); + +export const PATCH = apiHandler(async (request: Request) => { + const { user } = await requireAuth(); + + const { data: body, error: jsonError } = await safeJson(request, { + maxBytes: 1024, + }); + if (jsonError) return jsonError; + + const parsed = activityGoalsSchema.safeParse(body); + if (!parsed.success) { + annotate({ action: { name: "activity.goals.validation-failed" } }); + return returnAllZodIssues(parsed.error, 422); + } + + const next = mergeActivityGoals(await readGoals(user.id), parsed.data); + await prisma.user.update({ + where: { id: user.id }, + data: { + activityGoalsJson: hasNoActivityGoals(next) + ? Prisma.DbNull + : toJson(next), + }, + }); + + await auditLog("activity.goals.update", { + userId: user.id, + ipAddress: getClientIp(request), + details: { set: setKeys(next) }, + }); + annotate({ + action: { name: "activity.goals.update" }, + meta: { set: setKeys(next) }, + }); + return apiSuccess(next); +}); diff --git a/src/app/insights/steps/page.tsx b/src/app/insights/steps/page.tsx index 57783168e..a562c4baf 100644 --- a/src/app/insights/steps/page.tsx +++ b/src/app/insights/steps/page.tsx @@ -1,8 +1,11 @@ "use client"; -import { Footprints } from "lucide-react"; +import { CalendarDays, Footprints } from "lucide-react"; +import { CrossLinkCard } from "@/components/insights/cross-link-card"; import { HealthKitMetricPage } from "@/components/insights/healthkit-metric-page"; +import { useSurfaceVisible } from "@/hooks/use-surface-visible"; +import { useTranslations } from "@/lib/i18n/context"; /** * v1.12 — `/insights/steps`. @@ -15,8 +18,14 @@ import { HealthKitMetricPage } from "@/components/insights/healthkit-metric-page * user having step data. Reuses the generic HealthKitMetricPage scaffold; * empty-state carries no manual-entry CTA — the series arrives from Apple * Health or Withings sync. + * + * v1.44 — links across to the activity calendar on the workouts page, which + * shows the same day totals as a week or month against the person's usual. + * The link follows the Workouts module: switched off, it is not offered. */ export default function InsightsStepsPage() { + const { t } = useTranslations(); + const calendarVisible = useSurfaceVisible("insights-page:workouts"); return ( + ) : null + } /> ); } diff --git a/src/app/insights/workouts/page-client.tsx b/src/app/insights/workouts/page-client.tsx index c307d65af..40e58529d 100644 --- a/src/app/insights/workouts/page-client.tsx +++ b/src/app/insights/workouts/page-client.tsx @@ -21,6 +21,7 @@ import { canLogWorkout } from "@/lib/workouts/manual-entry"; import { QueryErrorCard } from "@/components/ui/query-error-card"; import { translateOrNull } from "@/lib/i18n/translate-code"; import { EmptyState } from "@/components/ui/empty-state"; +import { ActivityCalendarCard } from "@/components/activity/activity-calendar-card"; /** * v1.4.32 — `/insights/workouts`. @@ -107,6 +108,11 @@ export default function InsightsWorkoutsPageClient() { {showLog ? ( ) : null} + {/* The activity calendar (D1036): steps, active minutes and workouts + against the person's own usual. It stands above the list and draws + from steps alone too, so an account without workouts still sees + its routine. */} + {/* No `` here: workouts are session records, not a MeasurementType series, so the period-over-period range read has nothing to aggregate. */} diff --git a/src/components/activity/__tests__/activity-calendar-card.test.tsx b/src/components/activity/__tests__/activity-calendar-card.test.tsx new file mode 100644 index 000000000..a005c664f --- /dev/null +++ b/src/components/activity/__tests__/activity-calendar-card.test.tsx @@ -0,0 +1,157 @@ +/** + * `` draws what the server computed (SSR markup, like + * the other component suites). The arithmetic is pinned in + * `src/lib/activity/__tests__/calendar-math.test.ts`; this suite pins that + * the card shows each day's level, the workouts, the summary against the + * usual and the goals, and that a shared record gets no goal editor. + */ +import { describe, expect, it, vi } from "vitest"; +import { renderToStaticMarkup } from "react-dom/server"; + +import { I18nProvider } from "@/lib/i18n/context"; +import { + buildActivityCalendar, + periodBounds, + type ActivityDayInput, +} from "@/lib/activity/calendar-math"; +import { shiftDateKey } from "@/lib/tz/format"; +import { NO_ACTIVITY_GOALS } from "@/lib/validations/activity-goals"; + +const state: { data: unknown; shared: boolean } = { + data: undefined, + shared: false, +}; + +vi.mock("@/hooks/use-activity-calendar", () => ({ + useActivityCalendar: () => ({ + data: state.data, + isError: false, + error: null, + refetch: vi.fn(), + }), + useSaveActivityGoals: () => ({ + mutate: vi.fn(), + reset: vi.fn(), + isPending: false, + isError: false, + }), +})); +vi.mock("@/hooks/use-record-capabilities", () => ({ + useRecordCapabilities: () => ({ inSharedRecord: state.shared }), +})); + +import { ActivityCalendarCard } from "../activity-calendar-card"; +import { parseGoalField } from "../activity-goals-sheet"; + +function calendar() { + const today = "2026-10-21"; + const bounds = periodBounds("month", today); + const input: ActivityDayInput[] = []; + for (let i = 1; i <= 200; i++) { + input.push({ + date: shiftDateKey(bounds.start, -i), + steps: 6000 + (i % 4) * 500, + exerciseMinutes: 30, + workoutMinutes: 0, + workoutCount: 0, + workoutsBySport: {}, + load: null, + }); + } + input.push({ + date: "2026-10-05", + steps: 20000, + exerciseMinutes: 90, + workoutMinutes: 75, + workoutCount: 1, + workoutsBySport: { running: 1 }, + load: null, + }); + return { + ...buildActivityCalendar({ + bounds, + today, + input, + goals: { + ...NO_ACTIVITY_GOALS, + workoutsPerWeek: 1, + stepsPerMonth: 100000, + }, + }), + timezone: "Europe/Berlin", + loadType: null, + }; +} + +function render() { + return renderToStaticMarkup( + + + , + ); +} + +describe("ActivityCalendarCard", () => { + it("shows a loading frame before the data arrives", () => { + state.data = undefined; + expect(render()).toContain('data-slot="activity-calendar-loading"'); + }); + + it("draws every day of the grid with its level", () => { + state.data = calendar(); + const html = render(); + const days = html.match(/data-slot="activity-calendar-day"/g) ?? []; + expect(days.length).toBe(35); + expect(html).toMatch(/data-date="2026-10-05" data-level="high"/); + expect(html).toMatch(/data-date="2026-10-30" data-level="none"/); + // The accessible name carries the figures and the level in words. + expect(html).toContain("20,000 steps"); + expect(html).toContain("Far above your usual"); + }); + + it("summarises the period against the usual and lists the sports", () => { + state.data = calendar(); + const html = render(); + expect(html).toContain('data-metric="steps"'); + expect(html).toContain("Your usual by this point:"); + expect(html).toContain("Running: 1"); + }); + + it("shows goal progress and a week tick column", () => { + state.data = calendar(); + const html = render(); + expect(html).toContain("Steps per month"); + expect(html).toContain( + 'data-slot="activity-calendar-week-goal" data-met="true"', + ); + expect(html).toContain('data-slot="activity-calendar-set-goals"'); + }); + + it("offers no goal editor inside somebody else's record", () => { + state.data = calendar(); + state.shared = true; + const html = render(); + state.shared = false; + expect(html).not.toContain('data-slot="activity-calendar-set-goals"'); + }); +}); + +describe("goal field parsing", () => { + it("blank clears, digits within bounds pass, anything else is refused", () => { + expect(parseGoalField("workoutsPerWeek", " ")).toEqual({ + ok: true, + value: null, + }); + expect(parseGoalField("workoutsPerWeek", "4")).toEqual({ + ok: true, + value: 4, + }); + expect(parseGoalField("workoutsPerWeek", "0")).toEqual({ ok: false }); + expect(parseGoalField("stepsPerWeek", "10.000")).toEqual({ ok: false }); + expect(parseGoalField("stepsPerWeek", "70000")).toEqual({ + ok: true, + value: 70000, + }); + expect(parseGoalField("stepsPerMonth", "99999999")).toEqual({ ok: false }); + }); +}); diff --git a/src/components/activity/activity-calendar-card.tsx b/src/components/activity/activity-calendar-card.tsx new file mode 100644 index 000000000..b5ec47a75 --- /dev/null +++ b/src/components/activity/activity-calendar-card.tsx @@ -0,0 +1,509 @@ +"use client"; + +import { useMemo, useState } from "react"; +import { + CalendarDays, + Check, + ChevronLeft, + ChevronRight, + Target, +} from "lucide-react"; + +import { TileHeader } from "@/components/insights/tile-header"; +import { Button } from "@/components/ui/button"; +import { Card, CardContent, CardHeader } from "@/components/ui/card"; +import { EmptyState } from "@/components/ui/empty-state"; +import { InlineAction } from "@/components/ui/inline-action"; +import { Progress } from "@/components/ui/progress"; +import { QueryErrorRow } from "@/components/ui/query-error-row"; +import { ScorePill } from "@/components/ui/score-pill"; +import { Segmented } from "@/components/ui/segmented"; +import { Skeleton } from "@/components/ui/skeleton"; +import { TagChip } from "@/components/ui/tag-chip"; +import { ActivityGoalsSheet } from "@/components/activity/activity-goals-sheet"; +import { useActivityCalendar } from "@/hooks/use-activity-calendar"; +import { useRecordCapabilities } from "@/hooks/use-record-capabilities"; +import { useFormatters, useTranslations } from "@/lib/i18n/context"; +import { translateOrNull } from "@/lib/i18n/translate-code"; +import { cn } from "@/lib/utils"; +import type { ActivityCalendarResponse } from "@/lib/validations/activity-calendar"; + +type Period = "week" | "month"; +type RatedMetric = "steps" | "activeMinutes" | "load"; +type CalendarDay = ActivityCalendarResponse["days"][number]; +type DayLevel = CalendarDay["level"]["steps"]; + +/** + * One hue, rising intensity: the calendar shows more and less than usual, + * not good and bad, so no status colour takes part. The bar is a graphic + * (3:1 floor), the day number beside it stays foreground text. + */ +const LEVEL_BAR: Record = { + none: "bg-transparent", + unrated: "bg-muted-foreground/60", + low: "bg-chart-2/20", + below: "bg-chart-2/40", + usual: "bg-chart-2/60", + above: "bg-chart-2/80", + high: "bg-chart-2", +}; + +const LEGEND_LEVELS: DayLevel[] = ["low", "below", "usual", "above", "high"]; + +/** 2024-01-01 is a Monday: seven UTC days from it name the week's columns. */ +function weekdayLabels(locale: string): string[] { + const fmt = new Intl.DateTimeFormat(locale, { + weekday: "narrow", + timeZone: "UTC", + }); + return Array.from({ length: 7 }, (_, i) => + fmt.format(new Date(Date.UTC(2024, 0, 1 + i))), + ); +} + +function dateOfKey(key: string): Date { + return new Date(`${key}T12:00:00.000Z`); +} + +/** + * The activity calendar (D1036): a week or a month of steps, active minutes, + * workouts and training load against the person's own usual, with the goals + * they set. Every figure comes from `/api/activity/calendar`; this component + * draws, it does not compute. + */ +export function ActivityCalendarCard() { + const { t, locale } = useTranslations(); + const fmt = useFormatters(); + const capabilities = useRecordCapabilities(); + const [period, setPeriod] = useState("month"); + const [anchor, setAnchor] = useState(null); + const [metricChoice, setMetricChoice] = useState(null); + const [goalsOpen, setGoalsOpen] = useState(false); + // Each opening mounts a fresh sheet, so its draft starts from the stored + // goals; keyed on the count rather than on `open`, so closing keeps the + // sheet mounted for its exit animation. + const [goalsOpenCount, setGoalsOpenCount] = useState(0); + const query = useActivityCalendar(period, anchor); + const data = query.data; + const weekdays = useMemo(() => weekdayLabels(locale), [locale]); + + const ratedOptions = useMemo(() => { + if (!data) return [] as RatedMetric[]; + return (["steps", "activeMinutes", "load"] as const).filter( + (m) => data.available[m], + ); + }, [data]); + const metric: RatedMetric | null = + metricChoice && ratedOptions.includes(metricChoice) + ? metricChoice + : (ratedOptions[0] ?? null); + + const periodLabel = data + ? period === "month" + ? new Intl.DateTimeFormat(locale, { + month: "long", + year: "numeric", + timeZone: "UTC", + }).format(dateOfKey(data.start)) + : t("activityCalendar.weekRange", { + start: fmt.dateShort(dateOfKey(data.start)), + end: fmt.dateShort(dateOfKey(data.end)), + }) + : ""; + + const formatMetric = (m: RatedMetric | "workouts", value: number) => + m === "load" ? fmt.number(value, 1) : fmt.integer(Math.round(value)); + + const sportLabel = (sport: string) => + translateOrNull(t, `insights.workouts.sport.${sport}`) ?? sport; + + const weeklyGoalsSet = + data !== undefined && + (data.goals.workoutsPerWeek !== null || + data.goals.activeMinutesPerWeek !== null || + data.goals.stepsPerWeek !== null); + + const levelText = (level: DayLevel) => t(`activityCalendar.level.${level}`); + + const dayLabel = (day: CalendarDay) => { + const parts = [fmt.dateWithWeekday(dateOfKey(day.date))]; + if (day.future) return parts.join(", "); + if (day.steps !== null) + parts.push( + t("activityCalendar.cell.steps", { value: fmt.integer(day.steps) }), + ); + if (day.activeMinutes !== null) + parts.push( + t("activityCalendar.cell.activeMinutes", { + value: fmt.integer(day.activeMinutes), + }), + ); + if (day.workoutCount > 0) + parts.push( + t("activityCalendar.cell.workouts", { + value: fmt.integer(day.workoutCount), + sports: Object.keys(day.workoutsBySport).map(sportLabel).join(", "), + }), + ); + if (metric) parts.push(levelText(day.level[metric])); + return parts.join(", "); + }; + + const rows: CalendarDay[][] = []; + if (data) { + for (let i = 0; i < data.days.length; i += 7) { + rows.push(data.days.slice(i, i + 7)); + } + } + + return ( + + + { + setPeriod(next); + setAnchor(data ? data.start : null); + }} + options={[ + { value: "week", label: t("activityCalendar.period.week") }, + { value: "month", label: t("activityCalendar.period.month") }, + ]} + /> + } + /> + + + {query.isError ? ( + query.refetch()} + /> + ) : !data ? ( +
+ + +
+ ) : ( + <> +
+ +

+ {periodLabel} +

+ +
+ + {ratedOptions.length > 1 && metric ? ( + ({ + value: m, + label: t(`activityCalendar.metric.${m}`), + }))} + /> + ) : null} + + {!data.available.steps && + !data.available.activeMinutes && + !data.available.workouts && + !data.available.load ? ( + + ) : ( +
+
+ {weekdays.map((label, i) => ( + + {label} + + ))} + {weeklyGoalsSet && period === "month" ? ( + + + {t("activityCalendar.weekGoalsColumn")} + + + ) : null} +
+ {rows.map((week, rowIndex) => { + const weekRow = data.weeks[rowIndex]; + const allMet = + weekRow && weekRow.goals.length > 0 + ? weekRow.goals.every((g) => g.met) + : null; + return ( +
+ {week.map((day) => { + const level = metric ? day.level[metric] : "none"; + const value = + metric && !day.future ? day[metric] : null; + return ( +
+ +
+ ); + })} + {weeklyGoalsSet && period === "month" ? ( + + {allMet ? ( + + ) : allMet === false ? ( + + {t("activityCalendar.weekGoalsNotMet")} + + ) : null} + + ) : null} +
+ ); + })} +
+ )} + + {metric ? ( +
+ {t("activityCalendar.legend")} + {LEGEND_LEVELS.map((level) => ( + + + ))} + {data.baseline[metric] === null ? ( + + {t("activityCalendar.noBaseline")} + + ) : null} +
+ ) : null} + +
    + {(["steps", "activeMinutes", "workouts", "load"] as const) + .filter((m) => data.available[m]) + .map((m) => { + const s = data.summary[m]; + const delta = + s.value !== null && s.usualToDate + ? (s.value - s.usualToDate) / s.usualToDate + : null; + return ( +
  • +
    +

    + {t(`activityCalendar.summary.${m}`, { + value: + s.value === null ? "–" : formatMetric(m, s.value), + })} +

    +

    + {s.usualToDate === null + ? t("activityCalendar.summary.noUsual") + : t("activityCalendar.summary.usual", { + value: formatMetric(m, s.usualToDate), + })} +

    +
    + {delta !== null ? ( + + {`${delta > 0 ? "+" : ""}${fmt.percent(delta)}`} + + ) : null} +
  • + ); + })} +
+ + {data.sports.length > 0 ? ( +
+ {data.sports.map((s) => ( + + {t("activityCalendar.sportCount", { + sport: sportLabel(s.sportType), + count: fmt.integer(s.count), + })} + + ))} +
+ ) : null} + +
+ {data.goalProgress.map((g) => ( +
+
+ + + + {t("activityCalendar.goalOf", { + value: fmt.integer(Math.round(g.value)), + target: fmt.integer(g.target), + })} + +
+ +
+ ))} + {!capabilities.inSharedRecord ? ( + { + setGoalsOpenCount((n) => n + 1); + setGoalsOpen(true); + }} + > + {data.goalProgress.length > 0 || weeklyGoalsSet + ? t("activityCalendar.editGoals") + : t("activityCalendar.setGoals")} + + ) : null} +
+ + )} +
+ {data && !capabilities.inSharedRecord ? ( + + ) : null} +
+ ); +} diff --git a/src/components/activity/activity-goals-sheet.tsx b/src/components/activity/activity-goals-sheet.tsx new file mode 100644 index 000000000..d5f1082b2 --- /dev/null +++ b/src/components/activity/activity-goals-sheet.tsx @@ -0,0 +1,151 @@ +"use client"; + +import { useState } from "react"; + +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { ResponsiveSheet } from "@/components/ui/responsive-sheet"; +import { useSaveActivityGoals } from "@/hooks/use-activity-calendar"; +import { + ACTIVITY_GOAL_BOUNDS, + ACTIVITY_GOAL_KEYS, + type ActivityGoalKey, +} from "@/lib/activity/goal-bounds"; +import { useFormatters, useTranslations } from "@/lib/i18n/context"; + +type Goals = Record; +type Draft = Record; + +function toDraft(goals: Goals): Draft { + return Object.fromEntries( + ACTIVITY_GOAL_KEYS.map((k) => [ + k, + goals[k] === null ? "" : String(goals[k]), + ]), + ) as Draft; +} + +/** + * Parse one field. Blank clears the goal; anything else must be a whole + * number inside the bounds the server enforces. Digits only, so a grouping + * separator typed by habit ("10.000") is refused rather than misread. + */ +export function parseGoalField( + key: ActivityGoalKey, + raw: string, +): { ok: true; value: number | null } | { ok: false } { + const trimmed = raw.trim(); + if (trimmed === "") return { ok: true, value: null }; + if (!/^\d+$/.test(trimmed)) return { ok: false }; + const value = Number(trimmed); + const { min, max } = ACTIVITY_GOAL_BOUNDS[key]; + if (value < min || value > max) return { ok: false }; + return { ok: true, value }; +} + +/** + * Set or clear the activity goals. Every goal is optional; an empty field is + * no goal. There is no suggested number: the goal is the person's own. + * + * The draft starts from `goals` when the sheet mounts; the caller remounts it + * on every open (a `key`), so a reopened sheet never shows a stale draft. + */ +export function ActivityGoalsSheet({ + open, + onOpenChange, + goals, +}: { + open: boolean; + onOpenChange: (open: boolean) => void; + goals: Goals; +}) { + const { t } = useTranslations(); + const fmt = useFormatters(); + const save = useSaveActivityGoals(); + const [draft, setDraft] = useState(() => toDraft(goals)); + const [invalid, setInvalid] = useState([]); + + const submit = () => { + const bad: ActivityGoalKey[] = []; + const patch: Partial = {}; + for (const key of ACTIVITY_GOAL_KEYS) { + const parsed = parseGoalField(key, draft[key]); + if (!parsed.ok) bad.push(key); + else patch[key] = parsed.value; + } + setInvalid(bad); + if (bad.length > 0) return; + save.mutate(patch, { onSuccess: () => onOpenChange(false) }); + }; + + return ( + + {t("common.save")} + + } + > +
{ + e.preventDefault(); + submit(); + }} + > + {ACTIVITY_GOAL_KEYS.map((key) => { + const id = `activity-goal-${key}`; + const { min, max } = ACTIVITY_GOAL_BOUNDS[key]; + const isInvalid = invalid.includes(key); + return ( +
+ + + setDraft((d) => ({ ...d, [key]: e.target.value })) + } + /> + {isInvalid ? ( + + ) : null} +
+ ); + })} + {save.isError ? ( +

+ {t("activityCalendar.goalsSheet.saveError")} +

+ ) : null} +
+
+ ); +} diff --git a/src/hooks/use-activity-calendar.ts b/src/hooks/use-activity-calendar.ts new file mode 100644 index 000000000..c69f843ea --- /dev/null +++ b/src/hooks/use-activity-calendar.ts @@ -0,0 +1,49 @@ +"use client"; + +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; + +import { apiGet, apiPatch } from "@/lib/api/api-fetch"; +import { queryKeys } from "@/lib/query-keys"; +import type { ActivityCalendarResponse } from "@/lib/validations/activity-calendar"; +import type { + ActivityGoals, + ActivityGoalsInput, +} from "@/lib/validations/activity-goals"; + +/** + * `GET /api/activity/calendar` for one period. `date` null = the period that + * contains today. The server computes everything, the client only draws it. + */ +export function useActivityCalendar( + period: "week" | "month", + date: string | null, + opts?: { enabled?: boolean }, +) { + return useQuery({ + queryKey: queryKeys.activityCalendar(period, date), + queryFn: () => { + const params = new URLSearchParams({ period }); + if (date) params.set("date", date); + return apiGet( + `/api/activity/calendar?${params.toString()}`, + ); + }, + enabled: opts?.enabled, + staleTime: 60_000, + }); +} + +/** `PATCH /api/activity/goals`; refreshes every calendar cell on success. */ +export function useSaveActivityGoals() { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: (patch: ActivityGoalsInput) => + apiPatch("/api/activity/goals", patch), + onSuccess: (goals) => { + queryClient.setQueryData(queryKeys.activityGoals(), goals); + void queryClient.invalidateQueries({ + queryKey: queryKeys.activityCalendarAll(), + }); + }, + }); +} diff --git a/src/lib/activity/__tests__/calendar-math.test.ts b/src/lib/activity/__tests__/calendar-math.test.ts new file mode 100644 index 000000000..816cec6ab --- /dev/null +++ b/src/lib/activity/__tests__/calendar-math.test.ts @@ -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 { + 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(); + // 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(); + 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, + }); + }); +}); diff --git a/src/lib/activity/calendar-math.ts b/src/lib/activity/calendar-math.ts new file mode 100644 index 000000000..6f4a5e225 --- /dev/null +++ b/src/lib/activity/calendar-math.ts @@ -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; + 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, + 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, +): 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; + /** 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>, +): 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; + load: number | null; + level: Record; +} + +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; + summary: Record; + sports: Array<{ sportType: string; count: number }>; + goals: ActivityGoals; + goalProgress: GoalProgress[]; + available: Record; + 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; + + 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 = { + 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(); + 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 = { + 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, + }; +} diff --git a/src/lib/activity/calendar-read.ts b/src/lib/activity/calendar-read.ts new file mode 100644 index 000000000..470fc0aa9 --- /dev/null +++ b/src/lib/activity/calendar-read.ts @@ -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> { + // `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( + ` + 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 { + 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 } + >(); + 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([ + ...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 }; +} diff --git a/src/lib/activity/goal-bounds.ts b/src/lib/activity/goal-bounds.ts new file mode 100644 index 000000000..40b59721a --- /dev/null +++ b/src/lib/activity/goal-bounds.ts @@ -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 }, +}; diff --git a/src/lib/data-wipe/wipe-plan.ts b/src/lib/data-wipe/wipe-plan.ts index 54d59b8d9..92cf76056 100644 --- a/src/lib/data-wipe/wipe-plan.ts +++ b/src/lib/data-wipe/wipe-plan.ts @@ -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, diff --git a/src/lib/export/account-settings-backup.ts b/src/lib/export/account-settings-backup.ts index d2f2bced6..6a0ad5f02 100644 --- a/src/lib/export/account-settings-backup.ts +++ b/src/lib/export/account-settings-backup.ts @@ -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 = {}; + 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"), diff --git a/src/lib/export/backup-plan.ts b/src/lib/export/backup-plan.ts index 527b43b74..4cfe196cf 100644 --- a/src/lib/export/backup-plan.ts +++ b/src/lib/export/backup-plan.ts @@ -1015,6 +1015,7 @@ export const USER_COLUMN_BACKUP_CLASS = { documentsLayoutJson: "SETTING", moduleListLayoutJson: "SETTING", previewFeaturesJson: "SETTING", + activityGoalsJson: "SETTING", reportSelectionJson: "SETTING", globalExcludedInjectionSites: "SETTING", healthKitConfigJson: "SETTING", diff --git a/src/lib/openapi/error-codes.ts b/src/lib/openapi/error-codes.ts index d43ec2437..0aac71fa4 100644 --- a/src/lib/openapi/error-codes.ts +++ b/src/lib/openapi/error-codes.ts @@ -40,6 +40,7 @@ import { DEFAULT_ERROR_CODES } from "@/lib/api/default-error-codes"; */ export const ERROR_CODE_CATALOGUE: Readonly> = { + activity: ["activity.calendar.future_date", "activity.calendar.too_old"], ai: [ "ai.budget.exceeded", "ai.provider.none", diff --git a/src/lib/openapi/routes/activity.ts b/src/lib/openapi/routes/activity.ts new file mode 100644 index 000000000..52fa5072e --- /dev/null +++ b/src/lib/openapi/routes/activity.ts @@ -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 = { + "/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, + }, + }, + }, +}; diff --git a/src/lib/openapi/routes/index.ts b/src/lib/openapi/routes/index.ts index 96c0d4375..6cfa8666c 100644 --- a/src/lib/openapi/routes/index.ts +++ b/src/lib/openapi/routes/index.ts @@ -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 = { ...syncPaths, ...measurementPaths, ...workoutPaths, + ...activityPaths, ...devicePaths, ...medicationPaths, ...profilePaths, diff --git a/src/lib/query-keys/activity.ts b/src/lib/query-keys/activity.ts new file mode 100644 index 000000000..812841224 --- /dev/null +++ b/src/lib/query-keys/activity.ts @@ -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, +}; diff --git a/src/lib/query-keys/index.ts b/src/lib/query-keys/index.ts index 7fc422cc6..59425c8ab 100644 --- a/src/lib/query-keys/index.ts +++ b/src/lib/query-keys/index.ts @@ -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, diff --git a/src/lib/validations/activity-calendar.ts b/src/lib/validations/activity-calendar.ts new file mode 100644 index 000000000..75ba90c65 --- /dev/null +++ b/src/lib/validations/activity-calendar.ts @@ -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; + +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 +>; diff --git a/src/lib/validations/activity-goals.ts b/src/lib/validations/activity-goals.ts new file mode 100644 index 000000000..3b34ff80a --- /dev/null +++ b/src/lib/validations/activity-goals.ts @@ -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; + +/** The resolved goals: every key present, `null` = not set. */ +export type ActivityGoals = Record; + +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; + 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); +} diff --git a/src/lib/validations/backup.ts b/src/lib/validations/backup.ts index 257f7c6b7..b08727ca5 100644 --- a/src/lib/validations/backup.ts +++ b/src/lib/validations/backup.ts @@ -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(), diff --git a/tests/integration/activity-calendar-read.test.ts b/tests/integration/activity-calendar-read.test.ts new file mode 100644 index 000000000..230281993 --- /dev/null +++ b/tests/integration/activity-calendar-read.test.ts @@ -0,0 +1,172 @@ +/** + * The activity calendar's reader against a real Postgres (D1036). + * + * - steps resolve to the source the person's ladder picks per day, so a day + * two devices counted shows one device's total, never the sum; + * - the day is the person's local day: a reading at 23:30 Berlin time lands + * on that day, not on the UTC day after; + * - a workout two devices recorded counts once, its minutes once; + * - the window is exact: nothing before `fromKey` or after `toKey`. + */ +import { beforeAll, describe, expect, it, vi } from "vitest"; + +import { getPrismaClient, truncateAllTables } from "./setup"; +import { readActivityDays } from "@/lib/activity/calendar-read"; +import { invalidateUserTimezone } from "@/lib/tz/resolver"; +import { userDayKey } from "@/lib/tz/format"; + +vi.mock("@/lib/db-compat", () => ({ + ensureDbCompatibility: vi.fn().mockResolvedValue(undefined), +})); + +const prisma = getPrismaClient(); +const TZ = "Europe/Berlin"; +const USER = "activity-reader"; +const DAY = 86_400_000; +const NOW = new Date(); + +function daysAgo(n: number, hourUtc = 10): Date { + const d = new Date(NOW.getTime() - n * DAY); + d.setUTCHours(hourUtc, 0, 0, 0); + return d; +} + +beforeAll(async () => { + await truncateAllTables(prisma); + await prisma.user.create({ + data: { + id: USER, + username: USER, + timezone: TZ, + sourcePriorityJson: { + metricPriority: { steps: ["FITBIT", "APPLE_HEALTH"] }, + }, + }, + }); + invalidateUserTimezone(USER); + + await prisma.measurement.createMany({ + data: [ + // Day 3: both devices; the ladder picks Fitbit's 8 000. + { + userId: USER, + type: "ACTIVITY_STEPS", + value: 9500, + unit: "steps", + source: "APPLE_HEALTH", + measuredAt: daysAgo(3), + }, + { + userId: USER, + type: "ACTIVITY_STEPS", + value: 8000, + unit: "steps", + source: "FITBIT", + measuredAt: daysAgo(3), + }, + // Day 2: Apple only, two slices that add up. + { + userId: USER, + type: "ACTIVITY_STEPS", + value: 3000, + unit: "steps", + source: "APPLE_HEALTH", + measuredAt: daysAgo(2, 8), + }, + { + userId: USER, + type: "ACTIVITY_STEPS", + value: 4000, + unit: "steps", + source: "APPLE_HEALTH", + measuredAt: daysAgo(2, 15), + }, + // 21:30 UTC is 23:30 in Berlin (summer) or 22:30 (winter): still day 5 locally. + { + userId: USER, + type: "ACTIVITY_STEPS", + value: 1200, + unit: "steps", + source: "APPLE_HEALTH", + measuredAt: daysAgo(5, 21), + }, + { + userId: USER, + type: "EXERCISE_MINUTES", + value: 25, + unit: "min", + source: "APPLE_HEALTH", + measuredAt: daysAgo(3, 9), + }, + { + userId: USER, + type: "EXERCISE_MINUTES", + value: 15, + unit: "min", + source: "APPLE_HEALTH", + measuredAt: daysAgo(3, 17), + }, + // Outside the window asked for below. + { + userId: USER, + type: "ACTIVITY_STEPS", + value: 777, + unit: "steps", + source: "APPLE_HEALTH", + measuredAt: daysAgo(40), + }, + ], + }); + + const start = daysAgo(3, 7); + await prisma.workout.createMany({ + data: [ + { + userId: USER, + sportType: "running", + startedAt: start, + endedAt: new Date(start.getTime() + 40 * 60_000), + durationSec: 2400, + source: "APPLE_HEALTH", + externalId: "hk-1", + }, + { + userId: USER, + sportType: "running", + startedAt: new Date(start.getTime() + 30_000), + endedAt: new Date(start.getTime() + 40 * 60_000), + durationSec: 2370, + source: "FITBIT", + externalId: "fb-1", + }, + ], + }); +}); + +describe("readActivityDays", () => { + it("follows the ladder, the local day and the workout collapse", async () => { + const fromKey = userDayKey(daysAgo(10), TZ); + const toKey = userDayKey(NOW, TZ); + const { days } = await readActivityDays({ + userId: USER, + tz: TZ, + fromKey, + toKey, + now: NOW, + includeWorkouts: true, + }); + const by = new Map(days.map((d) => [d.date, d])); + + const d3 = by.get(userDayKey(daysAgo(3), TZ)); + expect(d3?.steps).toBe(8000); + expect(d3?.exerciseMinutes).toBe(40); + expect(d3?.workoutCount).toBe(1); + expect(d3?.workoutsBySport).toEqual({ running: 1 }); + expect(d3?.workoutMinutes).toBe(40); + + expect(by.get(userDayKey(daysAgo(2), TZ))?.steps).toBe(7000); + expect(by.get(userDayKey(daysAgo(5, 21), TZ))?.steps).toBe(1200); + expect(days.every((d) => d.date >= fromKey && d.date <= toKey)).toBe(true); + expect(days.some((d) => d.steps === 777)).toBe(false); + }); +});