diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index 3dcd229cc..0af40fc6b 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -19968,6 +19968,8 @@ components: type: string format: date-time 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])))T(?:(?:[01]\d|2[0-3]):[0-5]\d(?::[0-5]\d(?:\.\d+)?)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$ + dayKey: + type: string durationSec: type: integer minimum: 0 @@ -20076,6 +20078,7 @@ components: - sportType - startedAt - endedAt + - dayKey - durationSec - distanceM - activeEnergyKcal diff --git a/src/app/api/workouts/[id]/route.ts b/src/app/api/workouts/[id]/route.ts index 439dbe5f2..beaeeba12 100644 --- a/src/app/api/workouts/[id]/route.ts +++ b/src/app/api/workouts/[id]/route.ts @@ -41,6 +41,8 @@ import { import { computeSplits } from "@/lib/workouts/splits"; import { buildSportContext } from "@/lib/workouts/sport-context"; import type { RouteCoordinate } from "@/lib/workouts/route-svg"; +import { resolveUserTimezone } from "@/lib/tz/resolver"; +import { userDayKey } from "@/lib/tz/format"; type RouteParams = { params: Promise<{ id: string }> }; @@ -129,6 +131,14 @@ export const GET = apiHandler( where: { id: user.id }, select: { sourcePriorityJson: true, dateOfBirth: true }, }); + // The local calendar day this session belongs to. Resolved here, in the + // user's timezone, so the day linkage stays server-authoritative — a + // client that derived it from `startedAt` would fall back to the browser + // zone and put a late-evening session on the wrong day whenever the two + // disagree. + const timezone = await resolveUserTimezone(user.id); + const dayKey = userDayKey(row.startedAt, timezone); + const canonicalCluster = pickCanonicalWorkoutRows( clusterRows, userRow?.sourcePriorityJson ?? null, @@ -250,6 +260,10 @@ export const GET = apiHandler( sportType: row.sportType, startedAt: row.startedAt, endedAt: row.endedAt, + // `YYYY-MM-DD` in the user's timezone. Additive; it addresses the + // day-scoped reads (intraday pulse, that night's sleep, the day's + // mood) the detail surface renders around the session. + dayKey, durationSec: row.durationSec, distanceM: row.totalDistanceM, activeEnergyKcal: row.totalEnergyKcal, diff --git a/src/app/api/workouts/__tests__/detail.test.ts b/src/app/api/workouts/__tests__/detail.test.ts index 28981227b..830812867 100644 --- a/src/app/api/workouts/__tests__/detail.test.ts +++ b/src/app/api/workouts/__tests__/detail.test.ts @@ -15,7 +15,10 @@ * - the optional `route` field flows through GeoJSON geometry when a * `WorkoutRoute` row is attached, and is null when missing; * - `aiInsight` is a pure READ: it serves a stored paragraph when one - * exists, null when none does, and never generates one. + * exists, null when none does, and never generates one; + * - `dayKey` dates the session in the user's timezone, so a session that + * starts late in the UTC evening lands on the local day the person + * actually trained on. */ import { afterEach, describe, expect, it, vi, beforeEach } from "vitest"; import { NextRequest } from "next/server"; @@ -65,6 +68,7 @@ import { GET } from "../[id]/route"; import { prisma } from "@/lib/db"; import { getSession } from "@/lib/auth/session"; import { isModuleEnabled, requireModuleEnabled } from "@/lib/modules/gate"; +import { invalidateUserTimezone } from "@/lib/tz/resolver"; const SESSION_OK = { session: { id: "sess-1", expiresAt: new Date(Date.now() + 3_600_000) }, @@ -125,6 +129,9 @@ describe("GET /api/workouts/{id}", () => { enabled: true, } as never); vi.mocked(isModuleEnabled).mockResolvedValue(true); + // The timezone resolver caches per user for 60 s in process; drop the + // entry so a test that pins a zone is not served the previous one. + invalidateUserTimezone(SESSION_OK.user.id); }); it("returns the workout with iOS-contract field names", async () => { @@ -153,6 +160,36 @@ describe("GET /api/workouts/{id}", () => { expect(body.data.route).toBeNull(); }); + it("dates the session by the user's timezone, not by UTC", async () => { + // 20:00 UTC is already the NEXT calendar day in Auckland. A client that + // sliced the ISO timestamp would read 2026-05-15 and then pull the wrong + // day's pulse, sleep and mood around the session. + vi.mocked(prisma.user.findUnique).mockResolvedValue({ + sourcePriorityJson: null, + dateOfBirth: null, + timezone: "Pacific/Auckland", + } as never); + vi.mocked(prisma.workout.findUnique).mockResolvedValueOnce({ + ...BASE_ROW, + startedAt: new Date("2026-05-15T20:00:00Z"), + endedAt: new Date("2026-05-15T20:30:00Z"), + } as never); + vi.mocked(prisma.workout.findMany).mockResolvedValueOnce([ + { + id: "w-1", + source: "APPLE_HEALTH", + startedAt: new Date("2026-05-15T20:00:00Z"), + sportType: "RUNNING", + }, + ] as never); + + const res = await GET(makeRequest(), makeParams("w-1")); + const body = await res.json(); + + expect(res.status).toBe(200); + expect(body.data.dayKey).toBe("2026-05-16"); + }); + it("enriches the Apple winner with only matched WHOOP HR and zones", async () => { const routeGeometry = { type: "LineString", diff --git a/src/hooks/use-workouts.ts b/src/hooks/use-workouts.ts index 91cf885fd..1295f33cd 100644 --- a/src/hooks/use-workouts.ts +++ b/src/hooks/use-workouts.ts @@ -290,6 +290,14 @@ export type WorkoutActivityInsight = { } | null; export interface WorkoutDetailPayload extends WorkoutListEntry { + /** + * The session's local calendar day, `YYYY-MM-DD`. Computed server-side + * from `startedAt` in the user's timezone — never re-derived here, so a + * browser in a different zone can't move the workout to another day. It + * addresses the day-scoped reads the detail page renders around the + * session. + */ + dayKey: string; minHr: number | null; stepCount: number | null; elevationM: number | null; diff --git a/src/lib/openapi/routes/workouts.ts b/src/lib/openapi/routes/workouts.ts index 51fdc4c45..d23980ff2 100644 --- a/src/lib/openapi/routes/workouts.ts +++ b/src/lib/openapi/routes/workouts.ts @@ -166,6 +166,13 @@ const workoutDetailResponse = z sportType: z.string(), startedAt: z.iso.datetime({ offset: true }), endedAt: z.iso.datetime({ offset: true }), + /** + * The local calendar day the session belongs to, `YYYY-MM-DD`, resolved + * server-side from `startedAt` in the user's timezone. Clients address + * the day-scoped reads with it instead of deriving a day from the + * timestamp in their own zone. + */ + dayKey: z.string(), durationSec: z.number().int().nonnegative(), distanceM: z.number().nullable(), activeEnergyKcal: z.number().nullable(),