diff --git a/CHANGELOG.md b/CHANGELOG.md index 47aa92854..21e9d0264 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,25 @@ ## [Unreleased] +## [1.32.12] — 2026-07-24 + +A mood you log late in the evening now lands on the day you logged it, in your +own timezone, both on the dashboard and in the Coach's mood view. + +- **Your late-night mood stays on the right day.** The pre-computed mood history + used to file each entry by its UTC date, so a mood logged after dark in a + timezone that runs ahead of or behind UTC could jump to the neighbouring day + once the nightly rebuild ran. Every mood now files under the calendar day it + was logged in your own timezone, matching the raw entry list. +- **The dashboard and the Coach agree.** The daily mood series behind the + dashboard tile and the Coach's mood view now read the same day for an entry as + the entry list does, so a value no longer shifts by a day when the faster + pre-computed path takes over from the live one. +- **The correction applies itself.** On upgrade the pre-computed mood history is + cleared and rebuilt under the corrected keying. While it rebuilds, mood reads + fall back to the live calculation, which was already correct, so there is no + window where the wrong day is shown. + ## [1.32.11] — 2026-07-24 Signing in to the iOS app on a self-hosted domain now runs through your own web diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index 9f3624014..6d294958d 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -1,7 +1,7 @@ openapi: 3.1.0 info: title: HealthLog API - version: 1.32.11 + version: 1.32.12 description: >- Self-hosted personal-health-tracking PWA — public API surface for the iOS native client and external ingest. diff --git a/package.json b/package.json index 0104458fe..7263d8e93 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "healthlog", - "version": "1.32.11", + "version": "1.32.12", "description": "Self-hosted personal-health-tracking PWA with Withings integration, AI insights, and doctor-report PDF export.", "license": "PolyForm-Noncommercial-1.0.0", "homepage": "https://healthlog.dev", diff --git a/prisma/migrations/0269_rebuild_mood_rollups_tz/migration.sql b/prisma/migrations/0269_rebuild_mood_rollups_tz/migration.sql new file mode 100644 index 000000000..01b679aab --- /dev/null +++ b/prisma/migrations/0269_rebuild_mood_rollups_tz/migration.sql @@ -0,0 +1,18 @@ +-- v1.32.12 — mood rollup timezone re-key. +-- +-- The mood rollup writer used to bucket on the UTC truncation of +-- `mood_logged_at`, so a mood logged late in the evening (or after +-- midnight) in a non-UTC timezone landed on the wrong calendar day. +-- The writer now keys every bucket on the canonical per-row +-- `mood_entries."date"` label instead, byte-identical to the live +-- fallback and to the day the user logged the mood in their own zone. +-- +-- Data-only migration: no schema change. `MoodEntryRollup` is a derived +-- cache, fully reconstructible from `mood_entries` at any time. Emptying +-- the table drops every stale UTC-keyed row; the app then serves correct +-- data through the live `date`-keyed fallback until the rollups are +-- rebuilt, and the boot-time full-backfill worker +-- (`enqueueBootTimeMoodRollupBackfill` → delete-then-refold) re-mints +-- every mood-owning user's buckets under the new keying. No mood data is +-- touched. +DELETE FROM "mood_entry_rollups"; diff --git a/public/sw.js b/public/sw.js index 00e69c754..64fa96d62 100644 --- a/public/sw.js +++ b/public/sw.js @@ -36,7 +36,7 @@ try { // v1.4.38.4 → v1.4.42. Do not hand-edit; bump `package.json` and rebuild. const CACHE_VERSION = (typeof self !== "undefined" && self.__APP_VERSION__) || - /* @sw-version-fallback */ "v1.32.11"; + /* @sw-version-fallback */ "v1.32.12"; const STATIC_CACHE = `healthlog-static-${CACHE_VERSION}`; const PAGE_CACHE = `healthlog-pages-${CACHE_VERSION}`; // v1.18.6 — read-only data cache for a curated allowlist of safe GET `/api/*` diff --git a/src/app/api/integrations/moodlog/webhook/route.ts b/src/app/api/integrations/moodlog/webhook/route.ts index 7d5445811..6dc89846e 100644 --- a/src/app/api/integrations/moodlog/webhook/route.ts +++ b/src/app/api/integrations/moodlog/webhook/route.ts @@ -133,7 +133,7 @@ export const POST = apiHandler(async (request: NextRequest) => { // webhook 200 response (the rollup is a cache tier, the source-of- // truth is the mood_entries write that already committed). try { - await recomputeMoodBucketsForEntry(user.id, moodLoggedAt); + await recomputeMoodBucketsForEntry(user.id, entry.date); } catch (rollupErr) { getEvent()?.addWarning( "Mood rollup recompute failed: " + diff --git a/src/app/api/mood-entries/[id]/route.ts b/src/app/api/mood-entries/[id]/route.ts index 7e7639a1d..48269ba1a 100644 --- a/src/app/api/mood-entries/[id]/route.ts +++ b/src/app/api/mood-entries/[id]/route.ts @@ -247,13 +247,16 @@ export const PUT = apiHandler( // (user, day) tuples) so we fan them out in parallel. Best- // effort: rollup failures must not surface as 5xx. try { - const targets = new Set([entry.moodLoggedAt.getTime()]); - if (existing.moodLoggedAt.getTime() !== entry.moodLoggedAt.getTime()) { - targets.add(existing.moodLoggedAt.getTime()); + // v1.32.12 — key on the `date` label. When the update moved the + // entry across a local-day boundary its `date` changed, so both + // the old and new labels need a recompute (independent buckets). + const targets = new Set([entry.date]); + if (existing.date !== entry.date) { + targets.add(existing.date); } await Promise.all( - Array.from(targets).map((t) => - recomputeMoodBucketsForEntry(user.id, new Date(t)), + Array.from(targets).map((label) => + recomputeMoodBucketsForEntry(user.id, label), ), ); } catch (rollupErr) { @@ -324,7 +327,7 @@ export const DELETE = apiHandler( // deleted entry's bucket; the recompute helper handles the // "now-empty day → drop the rollup row" branch internally. try { - await recomputeMoodBucketsForEntry(user.id, existing.moodLoggedAt); + await recomputeMoodBucketsForEntry(user.id, existing.date); } catch (rollupErr) { annotate({ meta: { diff --git a/src/app/api/mood-entries/bulk-delete/__tests__/route.test.ts b/src/app/api/mood-entries/bulk-delete/__tests__/route.test.ts index c81a4d768..f90b842bc 100644 --- a/src/app/api/mood-entries/bulk-delete/__tests__/route.test.ts +++ b/src/app/api/mood-entries/bulk-delete/__tests__/route.test.ts @@ -110,11 +110,11 @@ describe("POST /api/mood-entries/bulk-delete", () => { }); it("collapses the rollup recompute to the unique day set, not per-row", async () => { - // 3 deleted rows across 2 distinct UTC days → 2 recomputes. + // 3 deleted rows across 2 distinct date labels → 2 recomputes. vi.mocked(prisma.moodEntry.findMany).mockResolvedValue([ - { moodLoggedAt: new Date("2026-01-01T08:00:00Z") }, - { moodLoggedAt: new Date("2026-01-01T22:00:00Z") }, - { moodLoggedAt: new Date("2026-01-02T09:00:00Z") }, + { date: "2026-01-01" }, + { date: "2026-01-01" }, + { date: "2026-01-02" }, ] as never); vi.mocked(prisma.moodEntry.updateMany).mockResolvedValue({ count: 3, diff --git a/src/app/api/mood-entries/bulk-delete/route.ts b/src/app/api/mood-entries/bulk-delete/route.ts index 4f0616be3..96e8c301b 100644 --- a/src/app/api/mood-entries/bulk-delete/route.ts +++ b/src/app/api/mood-entries/bulk-delete/route.ts @@ -75,7 +75,7 @@ async function postBulkDelete(request: NextRequest): Promise { // it is from the mutation below — no existence leak. const affected = await prisma.moodEntry.findMany({ where: { id: { in: ids }, userId: user.id, deletedAt: null }, - select: { moodLoggedAt: true }, + select: { date: true }, }); // Soft-delete (tombstone) in one statement. The `where` pins `userId` @@ -101,21 +101,19 @@ async function postBulkDelete(request: NextRequest): Promise { // v1.4.34 IW-G — bust per-user mood + achievements + analytics caches. invalidateUserMood(user.id); - // v1.4.39 W-MOOD — collapse the deleted rows to the unique - // `(user, dayStart)` set BEFORE recomputing so a bulk delete spanning - // one day fires one recompute, not one per row. Best-effort — a - // populator hiccup never fails the user's delete. - const touchedDayStarts = new Set(); + // v1.32.12 — collapse the deleted rows to the unique set of `date` + // labels BEFORE recomputing so a bulk delete spanning one local day + // fires one recompute, not one per row, and keys byte-identically to + // the stored `MoodEntry.date`. Best-effort — a populator hiccup + // never fails the user's delete. + const touchedLabels = new Set(); for (const row of affected) { - const d = row.moodLoggedAt; - touchedDayStarts.add( - Date.UTC(d.getUTCFullYear(), d.getUTCMonth(), d.getUTCDate()), - ); + touchedLabels.add(row.date); } try { await Promise.all( - Array.from(touchedDayStarts).map((t) => - recomputeMoodBucketsForEntry(user.id, new Date(t)), + Array.from(touchedLabels).map((label) => + recomputeMoodBucketsForEntry(user.id, label), ), ); } catch (rollupErr) { diff --git a/src/app/api/mood-entries/bulk/route.ts b/src/app/api/mood-entries/bulk/route.ts index 0ff709b63..9076ab551 100644 --- a/src/app/api/mood-entries/bulk/route.ts +++ b/src/app/api/mood-entries/bulk/route.ts @@ -432,23 +432,20 @@ async function postBulk(request: NextRequest): Promise { // unique `(user, dayStart)` set first to bound the recompute count. // Best-effort: rollup failures must not surface as 5xx. if (inserted > 0 || duplicates > 0) { - const touchedDayStarts = new Set(); + // v1.32.12 — collapse to the unique set of `date` labels touched by + // this batch (the same per-row `moodDateKey` the insert used), so + // the rollup keys byte-identically to the stored `MoodEntry.date`. + const touchedLabels = new Set(); for (let i = 0; i < entries.length; i++) { const status = results[i]?.status; if (status === "inserted" || status === "duplicate") { - const d = entries[i].moodLoggedAt; - const dayStart = Date.UTC( - d.getUTCFullYear(), - d.getUTCMonth(), - d.getUTCDate(), - ); - touchedDayStarts.add(dayStart); + touchedLabels.add(moodDateKey(entries[i].moodLoggedAt, tz)); } } try { await Promise.all( - Array.from(touchedDayStarts).map((t) => - recomputeMoodBucketsForEntry(user.id, new Date(t)), + Array.from(touchedLabels).map((label) => + recomputeMoodBucketsForEntry(user.id, label), ), ); } catch (rollupErr) { diff --git a/src/app/api/mood-entries/restore/__tests__/route.test.ts b/src/app/api/mood-entries/restore/__tests__/route.test.ts index 51772d529..7ff413f63 100644 --- a/src/app/api/mood-entries/restore/__tests__/route.test.ts +++ b/src/app/api/mood-entries/restore/__tests__/route.test.ts @@ -111,11 +111,11 @@ describe("POST /api/mood-entries/restore", () => { }); it("collapses the mood rollup recompute to the unique day set, not per-row", async () => { - // 3 restored rows across 2 distinct UTC days → 2 recomputes. + // 3 restored rows across 2 distinct date labels → 2 recomputes. vi.mocked(prisma.moodEntry.findMany).mockResolvedValue([ - { moodLoggedAt: new Date("2026-01-01T08:00:00Z") }, - { moodLoggedAt: new Date("2026-01-01T20:00:00Z") }, - { moodLoggedAt: new Date("2026-01-02T09:00:00Z") }, + { date: "2026-01-01" }, + { date: "2026-01-01" }, + { date: "2026-01-02" }, ] as never); vi.mocked(prisma.moodEntry.updateMany).mockResolvedValue({ count: 3, diff --git a/src/app/api/mood-entries/restore/route.ts b/src/app/api/mood-entries/restore/route.ts index fd4ab345b..3146061c3 100644 --- a/src/app/api/mood-entries/restore/route.ts +++ b/src/app/api/mood-entries/restore/route.ts @@ -76,7 +76,7 @@ async function postRestore(request: NextRequest): Promise { // is from the mutation below — no existence leak. const affected = await prisma.moodEntry.findMany({ where: { id: { in: ids }, userId: user.id, deletedAt: { not: null } }, - select: { moodLoggedAt: true }, + select: { date: true }, }); // Un-tombstone in one statement. The `where` pins `userId` so a forged @@ -102,20 +102,18 @@ async function postRestore(request: NextRequest): Promise { // Bust per-user mood + achievements + analytics caches. invalidateUserMood(user.id); - // Collapse the restored rows to the unique `(user, dayStart)` set - // BEFORE recomputing — mirrors the bulk-delete path. Best-effort: a + // v1.32.12 — collapse the restored rows to the unique set of `date` + // labels BEFORE recomputing — mirrors the bulk-delete path and keys + // byte-identically to the stored `MoodEntry.date`. Best-effort: a // populator hiccup never fails the user's restore. - const touchedDayStarts = new Set(); + const touchedLabels = new Set(); for (const row of affected) { - const d = row.moodLoggedAt; - touchedDayStarts.add( - Date.UTC(d.getUTCFullYear(), d.getUTCMonth(), d.getUTCDate()), - ); + touchedLabels.add(row.date); } try { await Promise.all( - Array.from(touchedDayStarts).map((t) => - recomputeMoodBucketsForEntry(user.id, new Date(t)), + Array.from(touchedLabels).map((label) => + recomputeMoodBucketsForEntry(user.id, label), ), ); } catch (rollupErr) { diff --git a/src/app/api/mood-entries/route.ts b/src/app/api/mood-entries/route.ts index 47725d78d..11820d872 100644 --- a/src/app/api/mood-entries/route.ts +++ b/src/app/api/mood-entries/route.ts @@ -334,7 +334,7 @@ async function postMoodEntry(request: NextRequest) { // Best-effort: a failure here must not surface as a 5xx to the // user, the rollup is a cache tier, not a write-path invariant. try { - await recomputeMoodBucketsForEntry(user.id, moodLoggedAt); + await recomputeMoodBucketsForEntry(user.id, date); } catch (rollupErr) { annotate({ meta: { diff --git a/src/lib/analytics/mood-series.ts b/src/lib/analytics/mood-series.ts index 6076602f4..676eb62e4 100644 --- a/src/lib/analytics/mood-series.ts +++ b/src/lib/analytics/mood-series.ts @@ -43,12 +43,14 @@ export interface MoodDailySeries { } /** - * Format a UTC `Date` as a YYYY-MM-DD label. The rollup tier anchors on - * UTC midnight (same convention as the measurement rollup tier). For - * tenants within ±3 h of UTC (Berlin year-round) the label agrees with - * the local wall-clock day on every entry whose timestamp doesn't - * straddle the UTC boundary — i.e. every realistic mood log. v1.5 - * per-user-tz bucketing (audit P7) closes the DST fall-back-night gap. + * Format a rollup `bucketStart` as a YYYY-MM-DD label from its UTC + * calendar parts. Since v1.32.12 the mood rollup writer stores + * `bucketStart` as the UTC-midnight encoding of the canonical per-row + * `MoodEntry.date` label, so reading the UTC parts back yields exactly + * that label — byte-identical to the live `entry.date` fallback below, + * across every timezone and DST boundary. (Before v1.32.12 the writer + * UTC-truncated `mood_logged_at`, which shifted the day for any local + * mood straddling the UTC boundary; that gap is now closed.) */ function utcDayLabel(d: Date): string { const yyyy = d.getUTCFullYear(); diff --git a/src/lib/jobs/reminder/register-rollup.ts b/src/lib/jobs/reminder/register-rollup.ts index 2c51a2c10..1b9a0ae01 100644 --- a/src/lib/jobs/reminder/register-rollup.ts +++ b/src/lib/jobs/reminder/register-rollup.ts @@ -498,8 +498,16 @@ export async function registerRollupQueues( for (const job of jobs) { const { userId } = job.data; try { - const { rowsUpserted, durationMs } = - await recomputeUserMoodRollups(userId); + // v1.32.12 — delete-then-refold. Empties the user's rollup + // rows before refolding so the boot backfill is self-cleaning + // against any row minted under an older keying (e.g. a + // rolling-deploy old-container write during the swap window). + // The brief empty window serves correct data via the live + // `entry.date` fallback. + const { rowsUpserted, durationMs } = await recomputeUserMoodRollups( + userId, + { replace: true }, + ); workerLog( "info", `[mood-rollup-full-backfill] user=${userId} rows=${rowsUpserted} duration=${durationMs}ms`, diff --git a/src/lib/mcp/writes.ts b/src/lib/mcp/writes.ts index d27114742..daa79b28e 100644 --- a/src/lib/mcp/writes.ts +++ b/src/lib/mcp/writes.ts @@ -563,7 +563,7 @@ export async function logMcpMood(input: { // Best-effort rollup refresh — a cache tier, never a write-path invariant. try { - await recomputeMoodBucketsForEntry(input.userId, moodLoggedAt); + await recomputeMoodBucketsForEntry(input.userId, date); } catch (rollupErr) { getEvent()?.addMeta( "mcp_mood_rollup_failed", diff --git a/src/lib/mood/create-from-telegram.ts b/src/lib/mood/create-from-telegram.ts index c475ffb89..b8512f798 100644 --- a/src/lib/mood/create-from-telegram.ts +++ b/src/lib/mood/create-from-telegram.ts @@ -101,7 +101,7 @@ export async function logTelegramMood(input: { // Best-effort rollup refresh — a cache tier, never a write-path invariant. try { - await recomputeMoodBucketsForEntry(input.userId, moodLoggedAt); + await recomputeMoodBucketsForEntry(input.userId, date); } catch (rollupErr) { getEvent()?.addMeta( "telegram_mood_rollup_failed", diff --git a/src/lib/rollups/__tests__/mood-rollups.test.ts b/src/lib/rollups/__tests__/mood-rollups.test.ts index d7f4d6e4f..a46ddeccc 100644 --- a/src/lib/rollups/__tests__/mood-rollups.test.ts +++ b/src/lib/rollups/__tests__/mood-rollups.test.ts @@ -83,7 +83,9 @@ import { enqueueMoodRollupRecompute, ensureUserMoodRollupsFresh, recomputeMoodBucketsForEntry, + recomputeUserMoodRollups, } from "../mood-rollups"; +import { moodDateKey } from "@/lib/mood/date-key"; beforeEach(() => { queryRaw.mockReset(); @@ -119,16 +121,14 @@ describe("recomputeMoodBucketsForEntry", () => { getGlobalBossMock.mockReturnValue({ send: bossSend }); bossSend.mockResolvedValue("job-id"); - await recomputeMoodBucketsForEntry( - "user-1", - new Date("2026-05-10T14:30:00.000Z"), - ); + await recomputeMoodBucketsForEntry("user-1", "2026-05-10"); expect(executeRaw).toHaveBeenCalledTimes(2); const insertBinds = bindValues(executeRaw.mock.calls[0]); expect(insertBinds).toContain("user-1"); - // bucket_start is bound twice (INSERT row + INSERT SELECT predicate - // are both inside the same CTE shape via the same parameter). + // v1.32.12 — the aggregate WHERE keys on the `date` label string, + // and `bucket_start` is that label's UTC-midnight instant. + expect(insertBinds).toContain("2026-05-10"); const dayStart = new Date("2026-05-10T00:00:00.000Z"); expect( (insertBinds.filter((b) => b instanceof Date) as Date[]).some( @@ -152,10 +152,7 @@ describe("recomputeMoodBucketsForEntry", () => { it("issues the paired DELETE so a fully-cleared day removes its row", async () => { getGlobalBossMock.mockReturnValue(null); - await recomputeMoodBucketsForEntry( - "user-1", - new Date("2026-05-10T14:30:00.000Z"), - ); + await recomputeMoodBucketsForEntry("user-1", "2026-05-10"); // The DELETE always fires; its NOT EXISTS predicate matches zero // rows on the populated-day branch and matches the row on the @@ -169,14 +166,8 @@ describe("recomputeMoodBucketsForEntry", () => { it("is idempotent under re-run for the same (user, day)", async () => { getGlobalBossMock.mockReturnValue(null); - await recomputeMoodBucketsForEntry( - "user-1", - new Date("2026-05-10T14:30:00.000Z"), - ); - await recomputeMoodBucketsForEntry( - "user-1", - new Date("2026-05-10T14:30:00.000Z"), - ); + await recomputeMoodBucketsForEntry("user-1", "2026-05-10"); + await recomputeMoodBucketsForEntry("user-1", "2026-05-10"); expect(executeRaw).toHaveBeenCalledTimes(4); // Bind values for the INSERT statement match across both runs. @@ -197,14 +188,8 @@ describe("recomputeMoodBucketsForEntry", () => { getGlobalBossMock.mockReturnValue(null); await Promise.all([ - recomputeMoodBucketsForEntry( - "user-1", - new Date("2026-05-10T14:30:00.000Z"), - ), - recomputeMoodBucketsForEntry( - "user-1", - new Date("2026-05-10T14:30:00.000Z"), - ), + recomputeMoodBucketsForEntry("user-1", "2026-05-10"), + recomputeMoodBucketsForEntry("user-1", "2026-05-10"), ]); // 2 callers × 2 statements (INSERT + DELETE) each. expect(executeRaw).toHaveBeenCalledTimes(4); @@ -226,10 +211,7 @@ describe("recomputeMoodBucketsForEntry", () => { }); getGlobalBossMock.mockReturnValue({ send: () => enqueueGate }); - await recomputeMoodBucketsForEntry( - "user-1", - new Date("2026-05-10T14:30:00.000Z"), - ); + await recomputeMoodBucketsForEntry("user-1", "2026-05-10"); // The helper has resolved even though the boss.send promise is // still pending. Resolve it after the assertion so the test does @@ -239,6 +221,202 @@ describe("recomputeMoodBucketsForEntry", () => { }); }); +describe("mood rollup keying — canonical MoodEntry.date label (v1.32.12)", () => { + // Each case is a boundary-straddling local mood. `moodDateKey` mints + // the label the WRITE path stores in `MoodEntry.date` and every live + // fallback keys on. The rollup writer must key its bucket on that SAME + // label — the UTC truncation of `moodLoggedAt` (the old bug) lands the + // entry on the wrong calendar day. All inputs are absolute instants + + // named zones, so the assertions hold under TZ=UTC. + const cases: Array<{ + name: string; + moodLoggedAt: string; + tz: string; + expectedLabel: string; + straddles: boolean; + }> = [ + { + name: "23:30 America/New_York straddles the UTC boundary forward", + moodLoggedAt: "2026-05-11T03:30:00.000Z", // 23:30 EDT on 2026-05-10 + tz: "America/New_York", + expectedLabel: "2026-05-10", + straddles: true, + }, + { + name: "00:30 Europe/Berlin straddles the UTC boundary back", + moodLoggedAt: "2026-05-09T22:30:00.000Z", // 00:30 CEST on 2026-05-10 + tz: "Europe/Berlin", + expectedLabel: "2026-05-10", + straddles: true, + }, + { + name: "legacy tz-null row anchors to Europe/Berlin", + moodLoggedAt: "2026-05-09T22:30:00.000Z", + tz: "", + expectedLabel: "2026-05-10", + straddles: true, + }, + { + name: "DST fall-back night (Europe/Berlin, 2025-10-26) straddles forward", + moodLoggedAt: "2025-10-25T23:30:00.000Z", // 01:30 CEST on 2025-10-26 + tz: "Europe/Berlin", + expectedLabel: "2025-10-26", + straddles: true, + }, + ]; + + function utcLabel(d: Date): string { + return d.toISOString().slice(0, 10); + } + + it.each(cases)( + "keys on the local date label, not the UTC day of moodLoggedAt — $name", + async ({ moodLoggedAt, tz, expectedLabel, straddles }) => { + getGlobalBossMock.mockReturnValue(null); + const at = new Date(moodLoggedAt); + + // Parity: the label the write path stored (fallback key) is exactly + // what the hook keys on. + const label = moodDateKey(at, tz); + expect(label).toBe(expectedLabel); + + await recomputeMoodBucketsForEntry("user-1", label); + + const insertBinds = executeRaw.mock.calls[0].slice(1); + // Bucket keyed on the label string... + expect(insertBinds).toContain(expectedLabel); + // ...and bucket_start is that label's UTC midnight, which reads + // back as exactly the stored label (byte-identical to the fallback). + const bucketStart = insertBinds.find((b) => b instanceof Date) as Date; + expect(bucketStart.toISOString()).toBe(`${expectedLabel}T00:00:00.000Z`); + expect(utcLabel(bucketStart)).toBe(label); + + // The OLD (buggy) UTC-truncation of moodLoggedAt lands on a + // different calendar day for every straddling entry. + const buggyUtcDay = new Date( + Date.UTC(at.getUTCFullYear(), at.getUTCMonth(), at.getUTCDate()), + ); + expect(utcLabel(buggyUtcDay) !== expectedLabel).toBe(straddles); + if (straddles) { + expect(bucketStart.getTime()).not.toBe(buggyUtcDay.getTime()); + } + }, + ); +}); + +describe("runMoodRollupAggregate keys on the date label (v1.32.12)", () => { + it('groups by m."date" (never mood_logged_at) and binds day labels', async () => { + // Mutation guard: reverting the GROUP BY / WHERE back to + // date_trunc(..., m."mood_logged_at") fails one of these assertions. + getGlobalBossMock.mockReturnValue(null); + queryRawUnsafe.mockResolvedValue([]); + + await recomputeUserMoodRollups("user-1", { + granularities: ["DAY"], + from: new Date("2021-05-10T00:00:00.000Z"), + to: new Date("2026-05-10T12:00:00.000Z"), + }); + + expect(queryRawUnsafe).toHaveBeenCalledTimes(1); + const [sql, ...binds] = queryRawUnsafe.mock.calls[0] as [ + string, + ...unknown[], + ]; + expect(sql).toContain('m."date"'); + expect(sql).not.toContain("mood_logged_at"); + // Window bounds are lexicographic YYYY-MM-DD labels; the upper bound + // rounds up past a mid-day `to` so today's label is still folded. + expect(binds).toEqual(["user-1", "2021-05-10", "2026-05-11"]); + }); + + it("folds WEEK/MONTH/YEAR by the local label's calendar bucket", async () => { + getGlobalBossMock.mockReturnValue(null); + queryRawUnsafe.mockResolvedValue([]); + + await recomputeUserMoodRollups("user-1", { + granularities: ["WEEK", "MONTH", "YEAR"], + from: new Date("2026-05-04T00:00:00.000Z"), + to: new Date("2026-05-11T00:00:00.000Z"), + }); + + for (const call of queryRawUnsafe.mock.calls) { + const sql = call[0] as string; + // The coarser tiers cast the label column to a date before + // truncating — never touch mood_logged_at. + expect(sql).toContain('m."date"::date'); + expect(sql).not.toContain("mood_logged_at"); + } + }); +}); + +describe("recomputeUserMoodRollups replace (delete-then-refold, v1.32.12)", () => { + it("empties the folded granularities before refolding when replace:true", async () => { + getGlobalBossMock.mockReturnValue(null); + queryRawUnsafe.mockResolvedValue([]); + deleteMany.mockResolvedValue({ count: 0 }); + + await recomputeUserMoodRollups("user-1", { + granularities: ["DAY", "WEEK"], + replace: true, + }); + + expect(deleteMany).toHaveBeenCalledTimes(1); + expect(deleteMany).toHaveBeenCalledWith({ + where: { userId: "user-1", granularity: { in: ["DAY", "WEEK"] } }, + }); + }); + + it("does NOT delete when replace is unset (per-bucket async fold)", async () => { + getGlobalBossMock.mockReturnValue(null); + queryRawUnsafe.mockResolvedValue([]); + + await recomputeUserMoodRollups("user-1", { granularities: ["WEEK"] }); + + expect(deleteMany).not.toHaveBeenCalled(); + }); + + it("is idempotent — refolding twice upserts the identical bucket key + stats", async () => { + getGlobalBossMock.mockReturnValue(null); + deleteMany.mockResolvedValue({ count: 0 }); + const aggRow = { + bucket_start: new Date("2026-05-10T00:00:00.000Z"), + count: BigInt(3), + mean: 4.2, + min_score: 3, + max_score: 5, + sd: 0.5, + }; + queryRawUnsafe.mockResolvedValue([aggRow]); + upsert.mockResolvedValue({}); + + await recomputeUserMoodRollups("user-1", { + granularities: ["DAY"], + replace: true, + }); + const first = upsert.mock.calls.map((c) => c[0] as Record); + upsert.mockClear(); + await recomputeUserMoodRollups("user-1", { + granularities: ["DAY"], + replace: true, + }); + const second = upsert.mock.calls.map( + (c) => c[0] as Record, + ); + + expect(first).toHaveLength(1); + expect(second).toHaveLength(1); + // Same bucket key + same folded stats across both rebuilds (only the + // computedAt wall-clock differs, which we don't compare). + expect(first[0].where).toEqual(second[0].where); + expect((first[0].create as { count: number }).count).toBe( + (second[0].create as { count: number }).count, + ); + expect((first[0].create as { mean: number }).mean).toBe( + (second[0].create as { mean: number }).mean, + ); + }); +}); + describe("enqueueMoodRollupRecompute", () => { it("is a silent no-op when no boss is attached", async () => { getGlobalBossMock.mockReturnValue(null); diff --git a/src/lib/rollups/mood-rollups.ts b/src/lib/rollups/mood-rollups.ts index c840db4b7..78596eea9 100644 --- a/src/lib/rollups/mood-rollups.ts +++ b/src/lib/rollups/mood-rollups.ts @@ -16,11 +16,19 @@ * to a bounded `prisma.moodEntryRollup.findMany` with the daily mean * already materialised. * + * Keying (v1.32.12): every bucket is keyed on the canonical per-row + * `MoodEntry.date` label (minted at write time by `moodDateKey` under + * the row's own `tz`), NOT the UTC truncation of `mood_logged_at`. The + * rollup is therefore byte-identical to every live `entry.date` + * fallback, and a late-evening / after-midnight local mood lands on the + * calendar day the user logged it in their timezone. `bucket_start` + * stores that label as its UTC-midnight instant. + * * Write hooks: * - Every mood-entry create/update/delete fires - * `recomputeMoodBucketsForEntry`. The DAY pass runs inline - * (cheap — single `(user, day)` aggregate) so the next read sees - * the fresh row without waiting on pg-boss. + * `recomputeMoodBucketsForEntry(userId, dateLabel)`. The DAY pass + * runs inline (cheap — single `(user, day-label)` aggregate) so the + * next read sees the fresh row without waiting on pg-boss. * - WEEK / MONTH / YEAR are enqueued onto `mood-rollup-recompute` * for the worker to fold. No current reader consumes them; the * buckets exist so future cross-granularity readers ship without @@ -70,6 +78,36 @@ export const MOOD_ROLLUP_FULL_BACKFILL_QUEUE = "mood-rollup-full-backfill"; /** Boot-fold worker concurrency. Serial so we don't crowd the pool. */ export const MOOD_ROLLUP_FULL_BACKFILL_CONCURRENCY = 1; +/** + * Format a `Date` as a `YYYY-MM-DD` label from its UTC calendar parts. + * Used to convert a bucket-window bound (always a UTC-midnight instant, + * or `now` for the full fold) into the lexicographic day label the + * `mood_entries."date"` column carries. + */ +function utcDateLabel(d: Date): string { + const yyyy = d.getUTCFullYear(); + const mm = String(d.getUTCMonth() + 1).padStart(2, "0"); + const dd = String(d.getUTCDate()).padStart(2, "0"); + return `${yyyy}-${mm}-${dd}`; +} + +/** + * Exclusive upper day-label for a window `to` bound. A `to` that lands + * exactly on UTC midnight already IS the exclusive boundary (e.g. a + * `bucketSpan` week's next-Monday). A `to` mid-day (the full-fold's + * `new Date()`) rounds up to the following midnight so the day + * containing `to` is still folded — a plain UTC-slice of `now` would + * drop today's label under the half-open `< toLabel` filter. + */ +function exclusiveUpperLabel(to: Date): string { + const midnight = startOfUtcDay(to); + const boundary = + to.getTime() === midnight.getTime() + ? midnight + : new Date(midnight.getTime() + 24 * 60 * 60 * 1000); + return utcDateLabel(boundary); +} + /** Payload the worker queue carries. */ export interface MoodRollupRecomputePayload { userId: string; @@ -135,6 +173,16 @@ export async function recomputeUserMoodRollups( granularities?: RollupGranularity[]; from?: Date; to?: Date; + /** + * Delete the user's existing rollup rows for the folded + * granularities before refolding. Used by the boot-time + * full-backfill worker (§ rebuild): it makes the fold + * self-cleaning against any row minted under an older keying + * (e.g. a rolling-deploy old-container write) rather than merely + * upserting over it. Left off for the per-bucket async fold, which + * targets a narrow window and must not wipe neighbouring buckets. + */ + replace?: boolean; } = {}, ): Promise<{ rowsUpserted: number; durationMs: number }> { const startedAt = Date.now(); @@ -143,6 +191,16 @@ export async function recomputeUserMoodRollups( const from = opts.from ?? fiveYearsAgo; const to = opts.to ?? new Date(); + if (opts.replace) { + // Empty the folded granularities first so the refold below is the + // only source of truth. With zero rows in place every read for this + // user falls back to the live `date`-keyed walk (correct data) for + // the brief window until the fold lands. + await prisma.moodEntryRollup.deleteMany({ + where: { userId, granularity: { in: granularities } }, + }); + } + let rowsUpserted = 0; for (const granularity of granularities) { @@ -174,7 +232,7 @@ export async function recomputeUserMoodRollups( */ export async function recomputeMoodBucketsForEntry( userId: string, - moodLoggedAt: Date, + dateLabel: string, ): Promise { const client = prisma; // DAY pass — synchronous. v1.4.39 hotfix (QA F-H-02): one atomic SQL @@ -183,8 +241,15 @@ export async function recomputeMoodBucketsForEntry( // could interleave A-SELECT → B-SELECT → B-UPSERT (correct) → // A-UPSERT (stale N-1). The trailing DELETE handles the "all entries // for the day were removed" case in the same shot. - const dayStart = startOfUtcDay(moodLoggedAt); - const dayEnd = new Date(dayStart.getTime() + 24 * 60 * 60 * 1000); + // + // v1.32.12: the bucket is keyed on the canonical per-row `date` label + // (`MoodEntry.date`, minted at write time via `moodDateKey` under the + // row's own `tz`), NOT the UTC truncation of `mood_logged_at`. This + // makes the rollup byte-identical to every live fallback (which keys + // on `entry.date`) and puts a late-evening / after-midnight local + // mood on the calendar day the user logged it in their own timezone. + // `bucket_start` encodes the label as its UTC midnight instant. + const bucketStart = new Date(`${dateLabel}T00:00:00.000Z`); await client.$executeRaw` WITH aggregate AS ( SELECT @@ -194,17 +259,16 @@ export async function recomputeMoodBucketsForEntry( MAX(m."score")::integer AS max_score, STDDEV_POP(m."score")::double precision AS sd FROM "mood_entries" m - WHERE m."user_id" = ${userId} - AND m."deleted_at" IS NULL - AND m."mood_logged_at" >= ${dayStart} - AND m."mood_logged_at" < ${dayEnd} + WHERE m."user_id" = ${userId} + AND m."deleted_at" IS NULL + AND m."date" = ${dateLabel} ) INSERT INTO "mood_entry_rollups" ("user_id", "granularity", "bucket_start", "count", "mean", "min_score", "max_score", "sd", "computed_at") SELECT ${userId}, 'DAY', - ${dayStart}, + ${bucketStart}, aggregate.count, aggregate.mean, aggregate.min_score, @@ -225,14 +289,13 @@ export async function recomputeMoodBucketsForEntry( DELETE FROM "mood_entry_rollups" WHERE "user_id" = ${userId} AND "granularity" = 'DAY' - AND "bucket_start" = ${dayStart} + AND "bucket_start" = ${bucketStart} AND NOT EXISTS ( SELECT 1 FROM "mood_entries" m - WHERE m."user_id" = ${userId} - AND m."deleted_at" IS NULL - AND m."mood_logged_at" >= ${dayStart} - AND m."mood_logged_at" < ${dayEnd} + WHERE m."user_id" = ${userId} + AND m."deleted_at" IS NULL + AND m."date" = ${dateLabel} ) `; @@ -246,7 +309,7 @@ export async function recomputeMoodBucketsForEntry( // captures any pg-boss failure. void Promise.all( ASYNC_GRANULARITIES.map((granularity) => { - const { from, to } = bucketSpan(moodLoggedAt, granularity); + const { from, to } = bucketSpan(dateLabel, granularity); return enqueueMoodRollupRecompute({ userId, granularity, @@ -527,13 +590,21 @@ export async function enqueueBootTimeMoodRollupBackfill( // ─── internals ──────────────────────────────────────────── /** - * Pull the raw aggregates from Postgres. Groups by - * `date_trunc(, mood_logged_at)` and folds the stats - * with `STDDEV_POP`. The `date_trunc` unit literal must be inlined - * because Postgres rejects a parameterised expression in a GROUP BY; - * the value comes from a closed enum (DATE_TRUNC_UNIT) so there's no - * injection surface — we still assert against the whitelist before - * splicing. + * Pull the raw aggregates from Postgres. Groups by the local calendar + * bucket of the canonical `MoodEntry.date` label — + * `date_trunc(, m."date"::date)` — so a day belongs to the + * week / month / year of its *local* label, not the UTC instant of its + * `mood_logged_at`. `bucket_start` is the label's UTC-midnight encoding + * (`::timestamptz` under the UTC session), byte-identical to the DAY + * hook and to every live `entry.date` fallback (v1.32.12). + * + * The `date_trunc` unit literal must be inlined because Postgres rejects + * a parameterised expression in a GROUP BY; the value comes from a + * closed enum (DATE_TRUNC_UNIT) so there's no injection surface — we + * still assert against the whitelist before splicing. The window bounds + * are the `date` column's lexicographic day labels (YYYY-MM-DD sorts as + * the calendar day), so a boundary-straddling entry can never fall out + * of its own recompute window. */ async function runMoodRollupAggregate( client: PrismaTxOrClient, @@ -548,28 +619,28 @@ async function runMoodRollupAggregate( if (!["day", "week", "month", "year"].includes(truncUnit)) { throw new Error(`unexpected granularity: ${input.granularity}`); } - const dateTrunc = `date_trunc('${truncUnit}', m."mood_logged_at")`; + const bucketExpr = `date_trunc('${truncUnit}', m."date"::date)::timestamptz`; const sql = ` SELECT - ${dateTrunc} AS bucket_start, + ${bucketExpr} AS bucket_start, COUNT(*) AS count, AVG(m."score")::double precision AS mean, MIN(m."score")::integer AS min_score, MAX(m."score")::integer AS max_score, STDDEV_POP(m."score")::double precision AS sd FROM mood_entries m - WHERE m."user_id" = $1 - AND m."deleted_at" IS NULL - AND m."mood_logged_at" >= $2 - AND m."mood_logged_at" < $3 - GROUP BY ${dateTrunc} + WHERE m."user_id" = $1 + AND m."deleted_at" IS NULL + AND m."date" >= $2 + AND m."date" < $3 + GROUP BY ${bucketExpr} `; return client.$queryRawUnsafe( sql, input.userId, - input.from, - input.to, + utcDateLabel(input.from), + exclusiveUpperLabel(input.to), ); } @@ -627,44 +698,49 @@ async function persistMoodRollupRows( } /** - * Span of the bucket containing `moodLoggedAt` at the given - * granularity. Mirrors `measurement-rollups` so the WEEK / MONTH / - * YEAR fold queries select the same set of rows the read-side - * `date_trunc` would group on. + * Span of the bucket containing the calendar day `dateLabel` + * (`YYYY-MM-DD`, a `MoodEntry.date` value) at the given granularity. + * The window bounds are UTC-midnight instants of the local label + * boundaries; `runMoodRollupAggregate` converts them back to day labels + * for its `m."date"` range filter, so the fold selects exactly the rows + * whose label falls in this bucket — the local calendar week / month / + * year, not the UTC span of a `mood_logged_at` instant (v1.32.12). */ function bucketSpan( - moodLoggedAt: Date, + dateLabel: string, granularity: RollupGranularity, ): { from: Date; to: Date } { + // Anchor at the label's UTC midnight; its UTC calendar parts ARE the + // label's parts, so the existing UTC date math below is exact. + const anchor = new Date(`${dateLabel}T00:00:00.000Z`); switch (granularity) { case "DAY": { - const from = startOfUtcDay(moodLoggedAt); - return { from, to: new Date(from.getTime() + 24 * 60 * 60 * 1000) }; + return { + from: anchor, + to: new Date(anchor.getTime() + 24 * 60 * 60 * 1000), + }; } case "WEEK": { - const day = startOfUtcDay(moodLoggedAt); // Postgres ISO week: Monday is day 1. JS getUTCDay(): Sunday=0. - const dayOfWeek = day.getUTCDay(); + const dayOfWeek = anchor.getUTCDay(); const mondayOffset = (dayOfWeek + 6) % 7; - const from = new Date(day.getTime() - mondayOffset * 24 * 60 * 60 * 1000); + const from = new Date( + anchor.getTime() - mondayOffset * 24 * 60 * 60 * 1000, + ); return { from, to: new Date(from.getTime() + 7 * 24 * 60 * 60 * 1000) }; } case "MONTH": { const from = new Date( - Date.UTC(moodLoggedAt.getUTCFullYear(), moodLoggedAt.getUTCMonth(), 1), + Date.UTC(anchor.getUTCFullYear(), anchor.getUTCMonth(), 1), ); const to = new Date( - Date.UTC( - moodLoggedAt.getUTCFullYear(), - moodLoggedAt.getUTCMonth() + 1, - 1, - ), + Date.UTC(anchor.getUTCFullYear(), anchor.getUTCMonth() + 1, 1), ); return { from, to }; } case "YEAR": { - const from = new Date(Date.UTC(moodLoggedAt.getUTCFullYear(), 0, 1)); - const to = new Date(Date.UTC(moodLoggedAt.getUTCFullYear() + 1, 0, 1)); + const from = new Date(Date.UTC(anchor.getUTCFullYear(), 0, 1)); + const to = new Date(Date.UTC(anchor.getUTCFullYear() + 1, 0, 1)); return { from, to }; } } diff --git a/src/lib/tz/start-of-utc-day.ts b/src/lib/tz/start-of-utc-day.ts index eb9cc53be..f5b1306a4 100644 --- a/src/lib/tz/start-of-utc-day.ts +++ b/src/lib/tz/start-of-utc-day.ts @@ -1,17 +1,19 @@ /** - * UTC-anchored start-of-day truncation shared by the measurement + - * mood rollup writers. + * UTC-anchored start-of-day truncation used by the measurement rollup + * writer. * - * Both rollup tables store TIMESTAMPTZ bucket starts. Anchoring on the - * UTC calendar day (rather than the user's local day) keeps the bucket - * key deterministic across timezone-shifting consumers — the same - * convention `date_trunc('day', measured_at AT TIME ZONE 'UTC')` would - * produce on the read side. The display surfaces re-bucket into the - * user's timezone via `userDayKey()` from `./format`. + * The measurement rollup table stores TIMESTAMPTZ bucket starts. + * Anchoring on the UTC calendar day (rather than the user's local day) + * keeps the bucket key deterministic across timezone-shifting consumers + * — the same convention `date_trunc('day', measured_at AT TIME ZONE + * 'UTC')` would produce on the read side. The display surfaces re-bucket + * into the user's timezone via `userDayKey()` from `./format`. * - * v1.4.40 W-GHOSTS consolidated two byte-identical local copies - * (`src/lib/measurements/rollups.ts` + `src/lib/mood/rollups.ts`) into - * this module so future writers cannot drift independently. + * v1.4.40 W-GHOSTS consolidated two byte-identical local copies into + * this module so future writers cannot drift independently. Note the + * mood rollup writer no longer anchors here: since v1.32.12 it keys on + * the canonical per-row `MoodEntry.date` label, so a mood lands on the + * user's own local day rather than the UTC day of `mood_logged_at`. */ export function startOfUtcDay(d: Date): Date { return new Date(