diff --git a/CHANGELOG.md b/CHANGELOG.md index b95f825af..dde2a2770 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,15 @@ ## [Unreleased] +## [1.30.14] — 2026-07-18 + +The workouts page joins the pages that paint on first load. + +- **The workouts list opens with the sessions already there.** It was the last high-traffic surface still fetching its rows after the page mounted, so it showed a loading skeleton first and filled in a moment later. The list is now read on the server and handed to the page populated, the same way the dashboard, insights, medications, and coach pages already work. The list logic moved into a shared read so the page and the API endpoint go through one cached, deduplicated pass instead of two. +- **API docs: the heart-rate bucket example matches what the server accepts.** The aggregated heart-rate `externalId` was documented with an hourly example from its first version; the contract has been 10-minute buckets since v1.30.7. The description, the example timestamp, and the min/max field notes now describe the shipped contract. + +No breaking changes. + ## [1.30.13] — 2026-07-18 More pages that paint on first load. diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index cd2e7f157..0658bfef3 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -1,7 +1,7 @@ openapi: 3.1.0 info: title: HealthLog API - version: 1.30.13 + version: 1.30.14 description: >- Self-hosted personal-health-tracking PWA — public API surface for the iOS native client and external ingest. @@ -11268,18 +11268,20 @@ components: description: HealthKit identifier (e.g. `HKQuantityTypeIdentifierBodyMass`). value: type: number - description: Raw HealthKit reading in Apple's native unit; the server applies any canonical scaling at ingest. For an - hourly heart-rate bucket (see `externalId`) this is the hour's AVERAGE bpm; the hour's spread rides + description: Raw HealthKit reading in Apple's native unit; the server applies any canonical scaling at ingest. For a + 10-minute heart-rate bucket (see `externalId`) this is the bucket's AVERAGE bpm; the bucket's spread rides `valueMin` / `valueMax`. valueMin: - description: "v1.19.2 (iOS #34 extension) — the hour's MINIMUM bpm for an hourly heart-rate bucket (see `externalId`). - Persisted ONLY on a well-formed `stats:HKQuantityTypeIdentifierHeartRate:` row; ignored (stored null) - on every other entry. Omit on a pre-v1.19.2 client — the bucket keeps the avg-only contract." + description: "v1.19.2 (iOS #34 extension) — the bucket's MINIMUM bpm for a 10-minute heart-rate bucket (see + `externalId`). Persisted ONLY on a well-formed `stats:HKQuantityTypeIdentifierHeartRate:` row; + ignored (stored null) on every other entry. Omit on a pre-v1.19.2 client — the bucket keeps the avg-only + contract." type: number valueMax: - description: "v1.19.2 (iOS #34 extension) — the hour's MAXIMUM bpm for an hourly heart-rate bucket (see `externalId`). - Persisted ONLY on a well-formed `stats:HKQuantityTypeIdentifierHeartRate:` row; ignored (stored null) - on every other entry. Omit on a pre-v1.19.2 client — the bucket keeps the avg-only contract." + description: "v1.19.2 (iOS #34 extension) — the bucket's MAXIMUM bpm for a 10-minute heart-rate bucket (see + `externalId`). Persisted ONLY on a well-formed `stats:HKQuantityTypeIdentifierHeartRate:` row; + ignored (stored null) on every other entry. Omit on a pre-v1.19.2 client — the bucket keeps the avg-only + contract." type: number unit: type: string @@ -11319,12 +11321,12 @@ components: • `stats::` — a per-day cumulative total (Steps, Active Energy, Sleep Duration, Walking/Running Distance, Flights Climbed). A re-post OVERWRITES the row and returns `updated`. - • `stats:HKQuantityTypeIdentifierHeartRate:` — v1.19.0 (iOS #34) hourly heart-rate bucket - carrying the hour's AVERAGE bpm as one PULSE row. `` is the ISO-8601 UTC hour boundary - with zeroed minutes/seconds/millis and a trailing `Z` (e.g. `2026-06-21T14:00:00.000Z`). A re-post within - the hour OVERWRITES the row and returns `updated`, so iOS uploads ~24 rows/day instead of one per raw HR - sample. A row that targets this prefix with a malformed hour suffix is `skipped` with reason - `malformed_hr_bucket_id`. + • `stats:HKQuantityTypeIdentifierHeartRate:` — v1.19.0 (iOS #34), refined to 10-minute + granularity in v1.30.7, heart-rate bucket carrying the bucket's AVERAGE bpm as one PULSE row. + `` is the ISO-8601 UTC 10-minute boundary — zeroed seconds/millis, minutes ∈ + {00,10,20,30,40,50}, trailing `Z` (e.g. `2026-06-21T14:10:00.000Z`). A re-post OVERWRITES the row and + returns `updated`, so iOS uploads ~144 rows/day instead of one per raw HR sample. A row that targets this + prefix with a malformed bucket-start suffix is `skipped` with reason `malformed_hr_bucket_id`. externalSourceVersion: type: string minLength: 1 diff --git a/package.json b/package.json index af9745706..18aa38d37 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "healthlog", - "version": "1.30.13", + "version": "1.30.14", "description": "Self-hosted personal-health-tracking PWA with Withings integration, AI insights, and doctor-report PDF export.", "license": "PolyForm-Noncommercial-1.0.0", "homepage": "https://healthlog.dev", diff --git a/public/sw.js b/public/sw.js index 0f9cb5a07..5f5202b79 100644 --- a/public/sw.js +++ b/public/sw.js @@ -36,7 +36,7 @@ try { // v1.4.38.4 → v1.4.42. Do not hand-edit; bump `package.json` and rebuild. const CACHE_VERSION = (typeof self !== "undefined" && self.__APP_VERSION__) || - /* @sw-version-fallback */ "v1.30.13"; + /* @sw-version-fallback */ "v1.30.14"; const STATIC_CACHE = `healthlog-static-${CACHE_VERSION}`; const PAGE_CACHE = `healthlog-pages-${CACHE_VERSION}`; // v1.18.6 — read-only data cache for a curated allowlist of safe GET `/api/*` diff --git a/src/app/api/workouts/route.ts b/src/app/api/workouts/route.ts index 742f60c46..8cd5b2fd7 100644 --- a/src/app/api/workouts/route.ts +++ b/src/app/api/workouts/route.ts @@ -57,187 +57,15 @@ */ import { NextRequest } from "next/server"; -import { prisma } from "@/lib/db"; import { apiHandler, requireAuth } from "@/lib/api-handler"; import { annotate } from "@/lib/logging/context"; import { apiSuccess } from "@/lib/api-response"; -import { pickCanonicalWorkoutRows } from "@/lib/measurements/pick-canonical-workout-rows"; -import { cached, caches, type ServerCache } from "@/lib/cache/server-cache"; +import { readWorkoutsListCached } from "@/lib/workouts/list-read"; import { requireModuleEnabled } from "@/lib/modules/gate"; const DEFAULT_LIMIT = 50; const MAX_LIMIT = 200; -interface WorkoutsParams { - readonly limit: number; - readonly offset: number; - readonly since: string | null; - readonly until: string | null; - readonly sportType: string | null; -} - -type WorkoutFilterParams = Pick< - WorkoutsParams, - "since" | "until" | "sportType" ->; - -interface CanonicalWorkoutRow { - id: string; - source: string; - externalId: string | null; - sportType: string | null; - startedAt: Date | null; - endedAt: Date | null; - durationSec: number | null; - totalDistanceM: number | null; - totalEnergyKcal: number | null; - avgHeartRate: number | null; - maxHeartRate: number | null; - createdAt: Date; - // #67 list glyphs — cheap relation-id selects so the list can flag - // which sessions open into a rich detail (map / HR curve). - route: { id: string } | null; - samples: { sampleCount: number } | null; -} - -/** The cached, deduped, most-recent-first row set for one filter combination. */ -interface WorkoutsProjection { - canonical: CanonicalWorkoutRow[]; - rawCount: number; -} - -interface WorkoutsResult { - workouts: Array<{ - id: string; - sportType: string | null; - startedAt: Date | null; - endedAt: Date | null; - durationSec: number | null; - distanceM: number | null; - activeEnergyKcal: number | null; - avgHr: number | null; - maxHr: number | null; - source: string; - externalId: string | null; - hasRoute: boolean; - hasHrSeries: boolean; - }>; - meta: { - total: number; - limit: number; - offset: number; - droppedDuplicates: number; - }; -} - -async function buildWorkoutsProjection( - userId: string, - params: WorkoutFilterParams, -): Promise { - const { since, until, sportType } = params; - - const where: Record = { userId }; - if (since || until) { - const range: Record = {}; - if (since) { - const d = new Date(since); - if (!Number.isNaN(d.getTime())) range.gte = d; - } - if (until) { - const d = new Date(until); - if (!Number.isNaN(d.getTime())) range.lte = d; - } - if (Object.keys(range).length > 0) { - where.startedAt = range; - } - } - if (sportType) { - where.sportType = sportType; - } - - // Independent reads — the sourcePriorityJson lookup doesn't depend on the - // row read, so run them concurrently rather than paying two sequential - // round trips. - const [rows, userRow] = await Promise.all([ - prisma.workout.findMany({ - where, - orderBy: [{ startedAt: "desc" }, { id: "asc" }], - select: { - id: true, - source: true, - externalId: true, - sportType: true, - startedAt: true, - endedAt: true, - durationSec: true, - totalDistanceM: true, - totalEnergyKcal: true, - avgHeartRate: true, - maxHeartRate: true, - createdAt: true, - route: { select: { id: true } }, - samples: { select: { sampleCount: true } }, - }, - }), - prisma.user.findUnique({ - where: { id: userId }, - select: { sourcePriorityJson: true }, - }), - ]); - - const canonical = pickCanonicalWorkoutRows( - rows, - userRow?.sourcePriorityJson ?? null, - ); - - // `pickCanonicalWorkoutRows` re-sorts its output to `startedAt ASC` (its - // documented contract for the rollup callers). This surface is a - // most-recent-first list, so the DB `orderBy startedAt desc` must be - // re-established AFTER the pick — otherwise `offset`/`limit` slice the - // OLDEST page and a user with a long history never sees recent workouts. - canonical.sort((a, b) => { - const delta = (b.startedAt?.getTime() ?? 0) - (a.startedAt?.getTime() ?? 0); - if (delta !== 0) return delta; - return a.id.localeCompare(b.id); - }); - - return { canonical, rawCount: rows.length }; -} - -function sliceWorkoutsProjection( - projection: WorkoutsProjection, - limit: number, - offset: number, -): WorkoutsResult { - const page = projection.canonical - .slice(offset, offset + limit) - .map((row) => ({ - id: row.id, - sportType: row.sportType, - startedAt: row.startedAt, - endedAt: row.endedAt, - durationSec: row.durationSec, - distanceM: row.totalDistanceM, - activeEnergyKcal: row.totalEnergyKcal, - avgHr: row.avgHeartRate, - maxHr: row.maxHeartRate, - source: row.source, - externalId: row.externalId, - hasRoute: row.route != null, - hasHrSeries: row.samples != null && row.samples.sampleCount > 0, - })); - - return { - workouts: page, - meta: { - total: projection.canonical.length, - limit, - offset, - droppedDuplicates: projection.rawCount - projection.canonical.length, - }, - }; -} - export const GET = apiHandler(async (request: NextRequest) => { const { user } = await requireAuth(); annotate({ action: { name: "workouts.list" } }); @@ -266,18 +94,13 @@ export const GET = apiHandler(async (request: NextRequest) => { const until = searchParams.get("until"); const sportType = searchParams.get("sportType"); - // Deliberately excludes `limit`/`offset` — every page of one filter - // combination shares this cached projection (see the docblock above). - const projectionKey = `${user.id}|${since ?? ""}|${until ?? ""}|${sportType ?? ""}`; - - const projection = await cached( - caches.workouts as ServerCache, - projectionKey, - () => buildWorkoutsProjection(user.id, { since, until, sportType }), - annotate, - ); - - const result = sliceWorkoutsProjection(projection, limit, offset); + const result = await readWorkoutsListCached(user.id, { + limit, + offset, + since, + until, + sportType, + }); return apiSuccess(result); }); diff --git a/src/app/insights/workouts/__tests__/page-prefetch.test.tsx b/src/app/insights/workouts/__tests__/page-prefetch.test.tsx new file mode 100644 index 000000000..3139ce489 --- /dev/null +++ b/src/app/insights/workouts/__tests__/page-prefetch.test.tsx @@ -0,0 +1,158 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { HydrationBoundary, hashKey } from "@tanstack/react-query"; +import type { ReactElement } from "react"; + +import { queryKeys } from "@/lib/query-keys"; + +/** + * The `/insights/workouts` server-prefetch key crux + fail-soft. + * + * The RSC wrapper (`src/app/insights/workouts/page.tsx`) dehydrates the + * workouts list so the client `useWorkouts({ limit: 100 })` cell reads it back + * on mount instead of flashing the loading skeleton. These tests pin the + * load-bearing rules: + * - the server dehydrates under the EXACT key the hook builds (byte-identical + * hash) — the hook passes `{ limit, offset, since, sportType }` with the + * last three `undefined`, so the seeded key must carry the same shape; + * - the value is JSON-round-tripped to the wire shape (Dates → ISO strings); + * - any prefetch error / a disabled module / no session fails soft to the bare + * client path (the client cell owns the fetch). + */ + +const getSession = vi.fn(); +const resolveModuleMap = vi.fn(); +const readWorkoutsListCached = vi.fn(); + +vi.mock("@/lib/auth/session", () => ({ getSession: () => getSession() })); +vi.mock("@/lib/modules/gate", () => ({ + resolveModuleMap: (id: string) => resolveModuleMap(id), +})); +vi.mock("@/lib/workouts/list-read", () => ({ + readWorkoutsListCached: (u: unknown, p: unknown) => + readWorkoutsListCached(u, p), +})); +vi.mock("../page-client", () => ({ + default: () => null, +})); + +import InsightsWorkoutsPage from "../page"; + +const SESSION = { user: { id: "u1", timezone: "Europe/Berlin" } }; + +const LIST = { + workouts: [{ id: "w1", sportType: "Running" }], + meta: { total: 1, limit: 100, offset: 0, droppedDuplicates: 0 }, +}; + +/** + * The key EXACTLY as `useWorkouts({ limit: 100 })` builds it — the hook spreads + * its options object through, so `offset` / `since` / `sportType` arrive as + * `undefined` rather than being omitted. + */ +const CLIENT_KEY = queryKeys.workoutsRecentList({ + limit: 100, + offset: undefined, + since: undefined, + sportType: undefined, +}); + +afterEach(() => { + vi.clearAllMocks(); + delete process.env.DASHBOARD_SSR_PREFETCH; +}); + +/** The single dehydrated query on a HydrationBoundary element, or null. */ +function dehydratedQuery( + el: ReactElement, +): { queryHash: string; state: { data: unknown } } | null { + if (el.type !== HydrationBoundary) return null; + const props = el.props as { + state?: { queries: { queryHash: string; state: { data: unknown } }[] }; + }; + const q = props.state?.queries?.[0]; + return q ? { queryHash: q.queryHash, state: q.state } : null; +} + +describe("/insights/workouts RSC prefetch", () => { + it("dehydrates the list under the EXACT client key (byte-identical hash)", async () => { + getSession.mockResolvedValue(SESSION); + resolveModuleMap.mockResolvedValue({ workouts: true }); + readWorkoutsListCached.mockResolvedValue(LIST); + + const el = (await InsightsWorkoutsPage()) as ReactElement; + const q = dehydratedQuery(el); + expect(q).not.toBeNull(); + // The client cell looks this key up; a drift here silently no-ops the + // prefetch and the skeleton flash comes back. + expect(q!.queryHash).toBe(hashKey(CLIENT_KEY)); + expect(q!.state.data).toEqual(LIST); + }); + + it("reads the same filter the client's `limit: 100` request would", async () => { + getSession.mockResolvedValue(SESSION); + resolveModuleMap.mockResolvedValue({ workouts: true }); + readWorkoutsListCached.mockResolvedValue(LIST); + + await InsightsWorkoutsPage(); + // All-null filter params → the same `userId|||` projection cache slot the + // `?limit=100` API request builds, so the two callers share one dedup pass. + expect(readWorkoutsListCached).toHaveBeenCalledWith("u1", { + limit: 100, + offset: 0, + since: null, + until: null, + sportType: null, + }); + }); + + it("JSON-round-trips the value to the wire shape (Dates → ISO strings)", async () => { + getSession.mockResolvedValue(SESSION); + resolveModuleMap.mockResolvedValue({ workouts: true }); + const startedAt = new Date("2026-07-18T08:30:00.000Z"); + readWorkoutsListCached.mockResolvedValue({ + ...LIST, + workouts: [{ id: "w1", startedAt }], + }); + + const el = (await InsightsWorkoutsPage()) as ReactElement; + const q = dehydratedQuery(el); + // A Date must land as its ISO string — the shape the client's + // (await res.json()).data would carry — never a live Date object. + expect( + (q!.state.data as { workouts: { startedAt: unknown }[] }).workouts[0] + .startedAt, + ).toBe(startedAt.toISOString()); + }); + + it("fails soft to the bare client when the read throws", async () => { + getSession.mockResolvedValue(SESSION); + resolveModuleMap.mockResolvedValue({ workouts: true }); + readWorkoutsListCached.mockRejectedValue(new Error("db blip")); + + const el = (await InsightsWorkoutsPage()) as ReactElement; + expect(el.type).not.toBe(HydrationBoundary); + }); + + it("skips the prefetch when the workouts module is off", async () => { + getSession.mockResolvedValue(SESSION); + resolveModuleMap.mockResolvedValue({ workouts: false }); + + const el = (await InsightsWorkoutsPage()) as ReactElement; + expect(el.type).not.toBe(HydrationBoundary); + expect(readWorkoutsListCached).not.toHaveBeenCalled(); + }); + + it("fails soft when there is no session", async () => { + getSession.mockResolvedValue(null); + const el = (await InsightsWorkoutsPage()) as ReactElement; + expect(el.type).not.toBe(HydrationBoundary); + expect(readWorkoutsListCached).not.toHaveBeenCalled(); + }); + + it("honours the DASHBOARD_SSR_PREFETCH kill-switch (no session read)", async () => { + process.env.DASHBOARD_SSR_PREFETCH = "false"; + const el = (await InsightsWorkoutsPage()) as ReactElement; + expect(el.type).not.toBe(HydrationBoundary); + expect(getSession).not.toHaveBeenCalled(); + }); +}); diff --git a/src/app/insights/workouts/page-client.tsx b/src/app/insights/workouts/page-client.tsx new file mode 100644 index 000000000..f4feb898b --- /dev/null +++ b/src/app/insights/workouts/page-client.tsx @@ -0,0 +1,79 @@ +"use client"; + +import { Activity, Loader2 } from "lucide-react"; + +import { useTranslations } from "@/lib/i18n/context"; +import { useWorkouts } from "@/hooks/use-workouts"; +import { useModulePageGuard } from "@/hooks/use-module-page-guard"; +import { MetricEmptyState } from "@/components/insights/metric-empty-state"; +import { SubPageShell } from "@/components/insights/sub-page-shell"; +import { WorkoutList } from "@/components/insights/workout-list"; +import { Skeleton } from "@/components/ui/skeleton"; + +/** + * v1.4.32 — `/insights/workouts`. + * + * Workouts list sub-page. Pairs with the iOS HKWorkout ingest path + * (`POST /api/workouts/batch`) and the v1.4.30 cross-source picker so + * twin workouts from Apple Watch + Withings ScanWatch collapse to a + * single row. The page is the user-visible surface for the workout + * data the iOS client already syncs. + * + * v1.30.14 — the `page.tsx` RSC wrapper server-prefetches this list's + * `useWorkouts({ limit: 100 })` cell so the first paint shows the rows + * instead of the loading skeleton (the "Sport lädt langsam" report). + * The `useWorkouts` cell keeps its own fetch — the prefetch only warms + * the cache; a background refresh still runs when the data goes stale. + * + * Renders three states: + * - loading shell while the workouts query resolves, + * - empty state when the user has no workouts yet (CTA points at + * the iOS / Apple Health onboarding cue — there is no manual + * workout-entry form today), + * - the deduped list otherwise. Each row links to + * `/insights/workouts/[id]` for the detail surface. + */ +export default function InsightsWorkoutsPageClient() { + const { t } = useTranslations(); + const { ready } = useModulePageGuard("workouts"); + const { data, isLoading, isEmpty } = useWorkouts({ limit: 100 }); + + // v1.18.0 B1 — bounce a direct URL hit on a disabled-workouts account. + if (!ready) { + return ( +
+ +
+ ); + } + + return ( + + {/* No `` here: workouts are session records, not a + MeasurementType series, so the period-over-period range read has + nothing to aggregate. */} + {isLoading ? ( +
+ + + +
+ ) : isEmpty ? ( + } + title={t("insights.workouts.emptyState.title")} + description={t("insights.workouts.emptyState.description")} + cta={null} + coachPrefill="I haven't logged any workouts yet — why does tracking them matter, and what should I focus on first?" + /> + ) : data ? ( + + ) : null} +
+ ); +} diff --git a/src/app/insights/workouts/page.tsx b/src/app/insights/workouts/page.tsx index 3190fbfcf..4e6301083 100644 --- a/src/app/insights/workouts/page.tsx +++ b/src/app/insights/workouts/page.tsx @@ -1,73 +1,103 @@ -"use client"; +import { + dehydrate, + HydrationBoundary, + QueryClient, +} from "@tanstack/react-query"; -import { Activity, Loader2 } from "lucide-react"; +import { getSession } from "@/lib/auth/session"; +import { readWorkoutsListCached } from "@/lib/workouts/list-read"; +import { resolveModuleMap } from "@/lib/modules/gate"; +import { queryKeys } from "@/lib/query-keys"; -import { useTranslations } from "@/lib/i18n/context"; -import { useWorkouts } from "@/hooks/use-workouts"; -import { useModulePageGuard } from "@/hooks/use-module-page-guard"; -import { MetricEmptyState } from "@/components/insights/metric-empty-state"; -import { SubPageShell } from "@/components/insights/sub-page-shell"; -import { WorkoutList } from "@/components/insights/workout-list"; -import { Skeleton } from "@/components/ui/skeleton"; +import InsightsWorkoutsPageClient from "./page-client"; /** - * v1.4.32 — `/insights/workouts`. + * Thin RSC wrapper around the (client) workouts list page. * - * Workouts list sub-page. Pairs with the iOS HKWorkout ingest path - * (`POST /api/workouts/batch`) and the v1.4.30 cross-source picker so - * twin workouts from Apple Watch + Withings ScanWatch collapse to a - * single row. The page is the user-visible surface for the workout - * data the iOS client already syncs. + * `/insights/workouts` is the workouts surface. Its above-the-fold content — + * the workout rows — waited for the client `useWorkouts({ limit: 100 })` cell + * to fetch `/api/workouts?limit=100` after hydrate, so the first paint flashed + * the loading skeleton before the list filled in. This wrapper runs the SAME + * cached read the API route uses (`readWorkoutsListCached`, the shared + * `caches.workouts` projection cell) during SSR and hands it to TanStack + * through `HydrationBoundary`, so the mounted list cell starts warm instead of + * skeleton-first. * - * Renders three states: - * - loading shell while the workouts query resolves, - * - empty state when the user has no workouts yet (CTA points at - * the iOS / Apple Health onboarding cue — there is no manual - * workout-entry form today), - * - the deduped list otherwise. Each row links to - * `/insights/workouts/[id]` for the detail surface. + * Contract notes (the v1.30.9 dashboard / v1.30.13 medications template): + * - The query key comes ONLY from the central factory. The client cell keys on + * `queryKeys.workoutsRecentList({ limit: 100, offset, since, sportType })` + * with `offset` / `since` / `sportType` all `undefined`, so this seeded key + * is built from the same factory call with the same argument shape — the + * server-seeded key equals the client's `useQuery` key by construction. + * - The projection filter matches the route's too: all-null filter params hash + * to the same `userId|||` projection cache slot a `?limit=100` request + * builds, so the prefetch and the API path share one dedup pass. + * - The dehydrated VALUE is JSON-round-tripped so the hydrated shape is exactly + * what the client `queryFn` produces from the wire ((await res.json()).data — + * Prisma `Date`s as ISO strings), never a Date-carrying sibling that would + * poison the cell. + * - Module-gate parity: the client page bounces a workouts-off account + * (`useModulePageGuard`), and the API route refuses it server-side; skip the + * prefetch when the module is off so a disabled page never seeds a cache it + * will not read. + * - The client cell keeps its own fetch (`staleTime: 60s`): the prefetch seeds + * fresh data, so the mounted cell paints immediately and only refetches once + * the TTL lapses — the "empty then fills" flash is gone without dropping the + * freshness path. + * - Fail-soft: no session, a module lookup hiccup, or a DB blip renders the + * page exactly as before this wrapper existed — the client cell owns the + * fetch. The prefetch is an accelerator, never a gate. */ -export default function InsightsWorkoutsPage() { - const { t } = useTranslations(); - const { ready } = useModulePageGuard("workouts"); - const { data, isLoading, isEmpty } = useWorkouts({ limit: 100 }); - - // v1.18.0 B1 — bounce a direct URL hit on a disabled-workouts account. - if (!ready) { - return ( -
- -
- ); +export default async function InsightsWorkoutsPage() { + // Global SSR-prefetch kill-switch shared with the dashboard wrapper. The e2e + // server sets `DASHBOARD_SSR_PREFETCH=false` so Playwright route mocks — + // which only see CLIENT fetches — keep governing what every prefetched page + // paints. + if (process.env.DASHBOARD_SSR_PREFETCH === "false") { + return ; } + let dehydratedState = null; + try { + const session = await getSession(); + if (session) { + const { user } = session; + const modules = await resolveModuleMap(user.id); + // Mirror the client guard (`user?.modules?.workouts !== false`): an + // absent key reads as enabled; only an explicit `false` disables. + if (modules.workouts !== false) { + const list = await readWorkoutsListCached(user.id, { + limit: 100, + offset: 0, + since: null, + until: null, + sportType: null, + }); + const queryClient = new QueryClient(); + // Match the client cell's key + wire shape exactly (JSON semantics, ISO + // date strings) — same-key-different-shape is silent cache poison. + queryClient.setQueryData( + queryKeys.workoutsRecentList({ + limit: 100, + offset: undefined, + since: undefined, + sportType: undefined, + }), + JSON.parse(JSON.stringify(list)), + ); + dehydratedState = dehydrate(queryClient); + } + } + } catch { + // Prefetch is an accelerator, never a gate — the client path stands. + } + + if (dehydratedState === null) { + return ; + } return ( - - {/* No `` here: workouts are session records, not a - MeasurementType series, so the period-over-period range read has - nothing to aggregate. */} - {isLoading ? ( -
- - - -
- ) : isEmpty ? ( - } - title={t("insights.workouts.emptyState.title")} - description={t("insights.workouts.emptyState.description")} - cta={null} - coachPrefill="I haven't logged any workouts yet — why does tracking them matter, and what should I focus on first?" - /> - ) : data ? ( - - ) : null} -
+ + + ); } diff --git a/src/lib/openapi/routes/measurements.ts b/src/lib/openapi/routes/measurements.ts index 3c15c8d21..9b8ec81fd 100644 --- a/src/lib/openapi/routes/measurements.ts +++ b/src/lib/openapi/routes/measurements.ts @@ -30,21 +30,21 @@ const batchEntrySchema = z .number() .finite() .describe( - "Raw HealthKit reading in Apple's native unit; the server applies any canonical scaling at ingest. For an hourly heart-rate bucket (see `externalId`) this is the hour's AVERAGE bpm; the hour's spread rides `valueMin` / `valueMax`.", + "Raw HealthKit reading in Apple's native unit; the server applies any canonical scaling at ingest. For a 10-minute heart-rate bucket (see `externalId`) this is the bucket's AVERAGE bpm; the bucket's spread rides `valueMin` / `valueMax`.", ), valueMin: z .number() .finite() .optional() .describe( - "v1.19.2 (iOS #34 extension) — the hour's MINIMUM bpm for an hourly heart-rate bucket (see `externalId`). Persisted ONLY on a well-formed `stats:HKQuantityTypeIdentifierHeartRate:` row; ignored (stored null) on every other entry. Omit on a pre-v1.19.2 client — the bucket keeps the avg-only contract.", + "v1.19.2 (iOS #34 extension) — the bucket's MINIMUM bpm for a 10-minute heart-rate bucket (see `externalId`). Persisted ONLY on a well-formed `stats:HKQuantityTypeIdentifierHeartRate:` row; ignored (stored null) on every other entry. Omit on a pre-v1.19.2 client — the bucket keeps the avg-only contract.", ), valueMax: z .number() .finite() .optional() .describe( - "v1.19.2 (iOS #34 extension) — the hour's MAXIMUM bpm for an hourly heart-rate bucket (see `externalId`). Persisted ONLY on a well-formed `stats:HKQuantityTypeIdentifierHeartRate:` row; ignored (stored null) on every other entry. Omit on a pre-v1.19.2 client — the bucket keeps the avg-only contract.", + "v1.19.2 (iOS #34 extension) — the bucket's MAXIMUM bpm for a 10-minute heart-rate bucket (see `externalId`). Persisted ONLY on a well-formed `stats:HKQuantityTypeIdentifierHeartRate:` row; ignored (stored null) on every other entry. Omit on a pre-v1.19.2 client — the bucket keeps the avg-only contract.", ), unit: z.string().min(1).max(60), startDate: z.iso.datetime({ offset: true }), @@ -75,7 +75,7 @@ const batchEntrySchema = z "Dedup key for the `(userId, type, source, externalId)` composite index. Three shapes:\n" + "• `HKSample.uuid` — a per-sample reading. First-write-wins: a re-post returns `duplicate` and the stored row is immutable.\n" + "• `stats::` — a per-day cumulative total (Steps, Active Energy, Sleep Duration, Walking/Running Distance, Flights Climbed). A re-post OVERWRITES the row and returns `updated`.\n" + - "• `stats:HKQuantityTypeIdentifierHeartRate:` — v1.19.0 (iOS #34) hourly heart-rate bucket carrying the hour's AVERAGE bpm as one PULSE row. `` is the ISO-8601 UTC hour boundary with zeroed minutes/seconds/millis and a trailing `Z` (e.g. `2026-06-21T14:00:00.000Z`). A re-post within the hour OVERWRITES the row and returns `updated`, so iOS uploads ~24 rows/day instead of one per raw HR sample. A row that targets this prefix with a malformed hour suffix is `skipped` with reason `malformed_hr_bucket_id`.", + "• `stats:HKQuantityTypeIdentifierHeartRate:` — v1.19.0 (iOS #34), refined to 10-minute granularity in v1.30.7, heart-rate bucket carrying the bucket's AVERAGE bpm as one PULSE row. `` is the ISO-8601 UTC 10-minute boundary — zeroed seconds/millis, minutes ∈ {00,10,20,30,40,50}, trailing `Z` (e.g. `2026-06-21T14:10:00.000Z`). A re-post OVERWRITES the row and returns `updated`, so iOS uploads ~144 rows/day instead of one per raw HR sample. A row that targets this prefix with a malformed bucket-start suffix is `skipped` with reason `malformed_hr_bucket_id`.", ), externalSourceVersion: z.string().min(1).max(120).optional(), // v1.8.6 W6 — optional per-entry source tag. Defaults to diff --git a/src/lib/workouts/list-read.ts b/src/lib/workouts/list-read.ts new file mode 100644 index 000000000..10ff8f26e --- /dev/null +++ b/src/lib/workouts/list-read.ts @@ -0,0 +1,218 @@ +/** + * Shared cached workouts-list read for the two entry points: + * + * - `GET /api/workouts` (the client cell's endpoint), and + * - the workouts RSC wrapper (`src/app/insights/workouts/page.tsx`), which + * server-prefetches the same payload into a dehydrated TanStack cache so + * the first HTML paints the workout rows instead of a skeleton-until-JS + * flash (the "Sport lädt langsam" report). + * + * Both read through the SAME `caches.workouts` projection cell (keyed on the + * FILTER `userId|since|until|sportType`, deliberately WITHOUT `offset`/`limit` + * so every page of one filter shares one deduped projection), so an RSC + * prefetch warms the API path and vice versa — the dedup pass never runs twice + * for one filter within the 60 s TTL. Mirrors `src/lib/medications/list-read.ts` + * and `src/lib/dashboard/snapshot-read.ts`. + */ +import { prisma } from "@/lib/db"; +import { annotate } from "@/lib/logging/context"; +import { pickCanonicalWorkoutRows } from "@/lib/measurements/pick-canonical-workout-rows"; +import { cached, caches, type ServerCache } from "@/lib/cache/server-cache"; + +export interface WorkoutFilterParams { + readonly since: string | null; + readonly until: string | null; + readonly sportType: string | null; +} + +interface CanonicalWorkoutRow { + id: string; + source: string; + externalId: string | null; + sportType: string | null; + startedAt: Date | null; + endedAt: Date | null; + durationSec: number | null; + totalDistanceM: number | null; + totalEnergyKcal: number | null; + avgHeartRate: number | null; + maxHeartRate: number | null; + createdAt: Date; + // #67 list glyphs — cheap relation-id selects so the list can flag + // which sessions open into a rich detail (map / HR curve). + route: { id: string } | null; + samples: { sampleCount: number } | null; +} + +/** The cached, deduped, most-recent-first row set for one filter combination. */ +export interface WorkoutsProjection { + canonical: CanonicalWorkoutRow[]; + rawCount: number; +} + +export interface WorkoutsResult { + workouts: Array<{ + id: string; + sportType: string | null; + startedAt: Date | null; + endedAt: Date | null; + durationSec: number | null; + distanceM: number | null; + activeEnergyKcal: number | null; + avgHr: number | null; + maxHr: number | null; + source: string; + externalId: string | null; + hasRoute: boolean; + hasHrSeries: boolean; + }>; + meta: { + total: number; + limit: number; + offset: number; + droppedDuplicates: number; + }; +} + +export async function buildWorkoutsProjection( + userId: string, + params: WorkoutFilterParams, +): Promise { + const { since, until, sportType } = params; + + const where: Record = { userId }; + if (since || until) { + const range: Record = {}; + if (since) { + const d = new Date(since); + if (!Number.isNaN(d.getTime())) range.gte = d; + } + if (until) { + const d = new Date(until); + if (!Number.isNaN(d.getTime())) range.lte = d; + } + if (Object.keys(range).length > 0) { + where.startedAt = range; + } + } + if (sportType) { + where.sportType = sportType; + } + + // Independent reads — the sourcePriorityJson lookup doesn't depend on the + // row read, so run them concurrently rather than paying two sequential + // round trips. + const [rows, userRow] = await Promise.all([ + prisma.workout.findMany({ + where, + orderBy: [{ startedAt: "desc" }, { id: "asc" }], + select: { + id: true, + source: true, + externalId: true, + sportType: true, + startedAt: true, + endedAt: true, + durationSec: true, + totalDistanceM: true, + totalEnergyKcal: true, + avgHeartRate: true, + maxHeartRate: true, + createdAt: true, + route: { select: { id: true } }, + samples: { select: { sampleCount: true } }, + }, + }), + prisma.user.findUnique({ + where: { id: userId }, + select: { sourcePriorityJson: true }, + }), + ]); + + const canonical = pickCanonicalWorkoutRows( + rows, + userRow?.sourcePriorityJson ?? null, + ); + + // `pickCanonicalWorkoutRows` re-sorts its output to `startedAt ASC` (its + // documented contract for the rollup callers). This surface is a + // most-recent-first list, so the DB `orderBy startedAt desc` must be + // re-established AFTER the pick — otherwise `offset`/`limit` slice the + // OLDEST page and a user with a long history never sees recent workouts. + canonical.sort((a, b) => { + const delta = (b.startedAt?.getTime() ?? 0) - (a.startedAt?.getTime() ?? 0); + if (delta !== 0) return delta; + return a.id.localeCompare(b.id); + }); + + return { canonical, rawCount: rows.length }; +} + +export function sliceWorkoutsProjection( + projection: WorkoutsProjection, + limit: number, + offset: number, +): WorkoutsResult { + const page = projection.canonical + .slice(offset, offset + limit) + .map((row) => ({ + id: row.id, + sportType: row.sportType, + startedAt: row.startedAt, + endedAt: row.endedAt, + durationSec: row.durationSec, + distanceM: row.totalDistanceM, + activeEnergyKcal: row.totalEnergyKcal, + avgHr: row.avgHeartRate, + maxHr: row.maxHeartRate, + source: row.source, + externalId: row.externalId, + hasRoute: row.route != null, + hasHrSeries: row.samples != null && row.samples.sampleCount > 0, + })); + + return { + workouts: page, + meta: { + total: projection.canonical.length, + limit, + offset, + droppedDuplicates: projection.rawCount - projection.canonical.length, + }, + }; +} + +/** + * Cached read for one page of the deduped workouts list. The projection cell + * is shared with the API route; the slice is per-request. Both callers hand + * the same filter, so a route call and an RSC prefetch collapse to one dedup + * pass within the TTL. + */ +export async function readWorkoutsListCached( + userId: string, + params: { + limit: number; + offset: number; + since: string | null; + until: string | null; + sportType: string | null; + }, +): Promise { + // Deliberately excludes `limit`/`offset` — every page of one filter + // combination shares this cached projection. + const projectionKey = `${userId}|${params.since ?? ""}|${params.until ?? ""}|${params.sportType ?? ""}`; + + const projection = await cached( + caches.workouts as ServerCache, + projectionKey, + () => + buildWorkoutsProjection(userId, { + since: params.since, + until: params.until, + sportType: params.sportType, + }), + annotate, + ); + + return sliceWorkoutsProjection(projection, params.limit, params.offset); +}