Merge pull request #607 from MBombeck/release/v1.32.12

release/v1.32.12 — mood rollups key by local day, not UTC day
This commit is contained in:
Marc-André Bombeck authored and GitHub committed 2026-07-24 04:04:18 +02:00
commit 4f95afa33c
20 files changed
+452 -153

No files matched your search

+19
View File
@@ -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
+1 -1
View File
@@ -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.
+1 -1
View File
@@ -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",
@@ -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";
+1 -1
View File
@@ -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/*`
@@ -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: " +
+9 -6
View File
@@ -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<number>([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<string>([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: {
@@ -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,
+10 -12
View File
@@ -75,7 +75,7 @@ async function postBulkDelete(request: NextRequest): Promise<Response> {
// 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<Response> {
// 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<number>();
// 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<string>();
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) {
+7 -10
View File
@@ -432,23 +432,20 @@ async function postBulk(request: NextRequest): Promise<Response> {
// 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<number>();
// 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<string>();
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) {
@@ -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,
+8 -10
View File
@@ -76,7 +76,7 @@ async function postRestore(request: NextRequest): Promise<Response> {
// 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<Response> {
// 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<number>();
const touchedLabels = new Set<string>();
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) {
+1 -1
View File
@@ -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: {
+8 -6
View File
@@ -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();
+10 -2
View File
@@ -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`,
+1 -1
View File
@@ -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",
+1 -1
View File
@@ -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",
+208 -30
View File
@@ -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<string, unknown>);
upsert.mockClear();
await recomputeUserMoodRollups("user-1", {
granularities: ["DAY"],
replace: true,
});
const second = upsert.mock.calls.map(
(c) => c[0] as Record<string, unknown>,
);
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);
+127 -51
View File
@@ -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<void> {
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(<granularity>, 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(<granularity>, 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<MoodRollupRow[]>(
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 };
}
}
+13 -11
View File
@@ -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(