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.
This commit is contained in:
Marc-André Bombeck committed 2026-07-30 23:23:36 +02:00
1 parent a4cffbbea5
commit 0094445f07
5 files changed
+70 -1

No files matched your search

+3
View File
@@ -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
+14
View File
@@ -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,
+38 -1
View File
@@ -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",
+8
View File
@@ -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;
+7
View File
@@ -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(),