mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 12:32:43 +00:00
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:
20 files changed
+452
-153
No files matched your search
@@ -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,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
@@ -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
@@ -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: " +
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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`,
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
@@ -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 };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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(
|
||||
|
||||
Reference in new issue
Block a user