mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 21:10:59 +00:00
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:
1 parent
1b694ec222
commit
2dc73be233
4 files changed
+40
-9
No files matched your search
@@ -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 () => {
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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()
|
||||
|
||||
Reference in new issue
Block a user