From 0094445f0788ec033421224e5ed12a2178448cb6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Thu, 30 Jul 2026 23:03:35 +0200 Subject: [PATCH] Date a workout by the user's timezone on the detail endpoint The detail payload carried the session's start timestamp but nothing that said which local day it belongs to. Anything that wants the day around a workout had to slice the ISO string, which is the browser's day, not the person's: a 20:00 UTC session reads as the 15th in London and the 16th in Auckland. Resolve the day server-side from the user's timezone and ship it as dayKey. Additive, so a native client that does not read it is untouched. --- docs/api/openapi.yaml | 3 ++ src/app/api/workouts/[id]/route.ts | 14 +++++++ src/app/api/workouts/__tests__/detail.test.ts | 39 ++++++++++++++++++- src/hooks/use-workouts.ts | 8 ++++ src/lib/openapi/routes/workouts.ts | 7 ++++ 5 files changed, 70 insertions(+), 1 deletion(-) 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(),