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()