mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 20:42:43 +00:00
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:
1 parent
a4cffbbea5
commit
0094445f07
5 files changed
+70
-1
No files matched your search
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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(),
|
||||
|
||||
Reference in new issue
Block a user