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.
This commit is contained in:
Marc-André Bombeck committed 2026-10-01 09:27:56 +02:00
1 parent 1b694ec222
commit 2dc73be233
4 files changed
+40 -9

No files matched your search

+4 -3
View File
@@ -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
@@ -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 () => {
+8 -4
View File
@@ -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",
+1 -1
View File
@@ -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()