From 2dc73be2334067cc87712a3e992344821884abf1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Thu, 1 Oct 2026 09:27:56 +0200 Subject: [PATCH] fix(dashboard): put the mood card's instant on the user's local day The mood series carries local day keys, and the summary wrote the newest one out as UTC midnight. West of UTC that instant is the previous evening, so a client formatting it in its own zone showed today's entry as yesterday's. Emit the first instant of the day in the account's zone instead; the field keeps its shape and type. --- docs/api/openapi.yaml | 7 +++-- .../dashboard/summary/__tests__/route.test.ts | 28 ++++++++++++++++++- src/app/api/dashboard/summary/route.ts | 12 +++++--- src/lib/openapi/routes/dashboard.ts | 2 +- 4 files changed, 40 insertions(+), 9 deletions(-) diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index 20072acf6..80bd0007c 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -70819,9 +70819,10 @@ components: 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)))$ - type: "null" - description: Instant behind `latestValue`, falling back to the metric's newest reading. For `mood` this is the day START - of the latest daily bucket, not a reading time — the series pins a day, so inventing an instant would be a - lie. For `bmi` it is the WEIGHT metadata, since BMI moves exactly when weight does. + description: Instant behind `latestValue`, falling back to the metric's newest reading. For `mood` this is the first + instant of the latest daily bucket's day in the account's time zone, not a reading time (the series pins a + local day, not an instant); formatted in that zone it always lands on the day the entry was logged. For + `bmi` it is the WEIGHT metadata, since BMI moves exactly when weight does. allTimeCount: type: integer minimum: -9007199254740991 diff --git a/src/app/api/dashboard/summary/__tests__/route.test.ts b/src/app/api/dashboard/summary/__tests__/route.test.ts index 57fb570ad..e61286e5b 100644 --- a/src/app/api/dashboard/summary/__tests__/route.test.ts +++ b/src/app/api/dashboard/summary/__tests__/route.test.ts @@ -105,6 +105,7 @@ import { prisma } from "@/lib/db"; import { getSession } from "@/lib/auth/session"; import { __resetAllCachesForTests } from "@/lib/cache/server-cache"; import { buildMoodDailySeries } from "@/lib/analytics/mood-series"; +import { userDayKey } from "@/lib/tz/format"; const SESSION_OK = { session: { id: "sess-1", expiresAt: new Date(Date.now() + 3_600_000) }, @@ -1167,7 +1168,32 @@ describe("GET /api/dashboard/summary — mood + BMI cards", () => { expect(mood?.allTimeCount).toBe(3); expect(mood?.titleKey).toBe("dashboard.metric.title.mood"); expect(mood?.unitKey).toBe("dashboard.metric.unit.mood"); - expect(mood?.lastSeenAt).toBe("2026-07-03T00:00:00.000Z"); + // The day key is the account's local day (default zone, summer time): + // its first instant, not the key written out as UTC midnight. + expect(mood?.lastSeenAt).toBe("2026-07-02T22:00:00.000Z"); + expect(mood?.updatedAt).toBe("2026-07-02T22:00:00.000Z"); + }); + + it("puts the mood instant on the logged day for a zone west of UTC", async () => { + // The day key as UTC midnight is the previous evening in Los Angeles, + // so a client formatting the instant in its own zone showed today's + // entry as yesterday's. + vi.mocked(getSession).mockResolvedValue({ + ...SESSION_OK, + user: { ...SESSION_OK.user, timezone: "America/Los_Angeles" }, + } as never); + vi.mocked(buildMoodDailySeries).mockResolvedValue({ + entries: [{ date: "2026-07-03", score: 4, samples: 1 }], + summary: null, + entryCount: 1, + source: "rollup", + } as never); + + const res = await callGet(makeReq()); + const mood = cardsOf(await res.json()).find((c) => c.kind === "mood"); + const at = new Date(mood?.updatedAt as string); + expect(userDayKey(at, "America/Los_Angeles")).toBe("2026-07-03"); + expect(mood?.updatedAt).toBe("2026-07-03T07:00:00.000Z"); }); it("omits the mood card when the account has never logged a mood", async () => { diff --git a/src/app/api/dashboard/summary/route.ts b/src/app/api/dashboard/summary/route.ts index 084b12087..26d76552f 100644 --- a/src/app/api/dashboard/summary/route.ts +++ b/src/app/api/dashboard/summary/route.ts @@ -65,6 +65,7 @@ import { type Locale } from "@/lib/i18n/config"; import { resolveJobLocale } from "@/lib/i18n/job-locale"; import { userDayKey, DEFAULT_TIMEZONE } from "@/lib/tz/resolver"; import { validTimezoneOr } from "@/lib/tz/format"; +import { startOfLocalDayKey } from "@/lib/tz/local-day"; import { cachedSwr, caches, type ServerCache } from "@/lib/cache/server-cache"; import { buildMedsTodayBlock } from "@/lib/dashboard/meds-today"; import { computeStreak } from "@/lib/dashboard/streak"; @@ -879,11 +880,14 @@ async function buildDashboardSummary( if (moodSeries.entryCount > 0) { const spark = moodSeries.entries.slice(-SPARK_DAYS).map((e) => e.score); const latestEntry = moodSeries.entries[moodSeries.entries.length - 1]; - // The series carries UTC day labels, not timestamps — the entry's day is - // the only instant the source actually pins, so it is emitted as the - // day's start rather than inventing a reading time. + // The series carries the user's LOCAL day keys (`MoodEntry.date`), not + // timestamps, so the only honest instant is the first instant of that + // day in the user's zone. Writing the key as `T00:00Z` instead put the + // instant on the previous calendar day for anyone west of UTC, and a + // client formatting it in its own zone showed today's entry as + // yesterday's. const latestAt = latestEntry - ? new Date(`${latestEntry.date}T00:00:00.000Z`).toISOString() + ? startOfLocalDayKey(latestEntry.date, userTz).toISOString() : null; metrics.push({ id: "mood", diff --git a/src/lib/openapi/routes/dashboard.ts b/src/lib/openapi/routes/dashboard.ts index e71ded325..2779f7448 100644 --- a/src/lib/openapi/routes/dashboard.ts +++ b/src/lib/openapi/routes/dashboard.ts @@ -223,7 +223,7 @@ const dashboardMetricCard = z .datetime({ offset: true }) .nullable() .describe( - "Instant behind `latestValue`, falling back to the metric's newest reading. For `mood` this is the day START of the latest daily bucket, not a reading time — the series pins a day, so inventing an instant would be a lie. For `bmi` it is the WEIGHT metadata, since BMI moves exactly when weight does.", + "Instant behind `latestValue`, falling back to the metric's newest reading. For `mood` this is the first instant of the latest daily bucket's day in the account's time zone, not a reading time (the series pins a local day, not an instant); formatted in that zone it always lands on the day the entry was logged. For `bmi` it is the WEIGHT metadata, since BMI moves exactly when weight does.", ), allTimeCount: z .number()