mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-11 22:02:16 +00:00
The Today digest went stale after logging a dose: nothing invalidated the `["daily","digest"]` query, and it reads meds from the same server snapshot the intake routes already hard-evict — so the "dose due" rail item lingered until a hard reload. Add the digest key to the medication-dependent bundle and force the inactive digest (unmounted while the user is on the medication card) to refetch alongside the dashboard snapshot; the server snapshot eviction seam is reused, so the refetch returns post-write dose state immediately. Remove the score-ring cluster from the Today hero — it read as uneven and wasted tile space. The health-score ring is the hero's only ring now; the score-ring selection contract stays server-side for the native client. Tighten the hero's vertical rhythm so the worth-a-look rail sits close under the lead instead of floating below a half-empty column. Wire the remaining sleep transports into the morning-refresh trigger: Google Health, Fitbit, Oura, and Polar now enqueue the debounced morning refresh on a last-night segment landing, matching the Withings / WHOOP / Apple seams — those sources had no trigger at all. Make the trigger observable: each early-return gate now annotates a distinct `daily.morning_refresh.skipped` reason (the not-last-night gate also carries the samples' resolved local dates, today's key and the tz) so a real landing shows exactly which gate fired. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018V8V9qapgHi7fhgtMJ8SkM
152 lines
5.9 KiB
TypeScript
152 lines
5.9 KiB
TypeScript
/**
|
|
* S4 — the sleep-arrival trigger for the morning digest refresh.
|
|
*
|
|
* The nightly insight pre-pass (`insight-pregenerate`, 04:30) runs before last
|
|
* night's sleep has synced, so the morning digest + health score reference the
|
|
* day WITHOUT the current sleep. This trigger closes that gap the event-driven
|
|
* way: when a completed sleep segment for LAST NIGHT lands from any sleep
|
|
* transport (Withings sync-sleep / the WHOOP sync / the Apple-Health
|
|
* measurement batch / Google Health / Fitbit / Oura / Polar), it enqueues a debounced
|
|
* `morning-digest-refresh` job that re-runs the sleep-dependent generation and
|
|
* flips the day `provisional → final`.
|
|
*
|
|
* There is no single canonical sleep-persist function in the tree — each
|
|
* transport upserts through its own helper — so every write seam calls
|
|
* `maybeEnqueueMorningRefresh` with the measuredAt of every sleep row it just
|
|
* wrote. The debounce (queue `singletonKey` + the `User.morningDigestRefreshedOn`
|
|
* marker) collapses the many samples of one night, and across all sources,
|
|
* into ONE refresh per user per local morning.
|
|
*
|
|
* Timezone correctness is load-bearing: "last night" is judged in the user's
|
|
* PROFILE timezone, never UTC, so a 30-day backfill re-sync never re-triggers
|
|
* for historical nights and a sleep that is "last night" locally but a
|
|
* different UTC date still triggers.
|
|
*/
|
|
import { prisma } from "@/lib/db";
|
|
import { annotate } from "@/lib/logging/context";
|
|
import { resolveModuleMap } from "@/lib/modules/gate";
|
|
import { userDayKey } from "@/lib/tz/format";
|
|
import { resolveUserTimezone } from "@/lib/tz/resolver";
|
|
import { enqueueMorningDigestRefresh } from "@/lib/jobs/morning-digest-refresh-shared";
|
|
|
|
const MS_PER_DAY = 86_400_000;
|
|
|
|
/**
|
|
* True when `measuredAt` belongs to "last night" for an observer in `tz`: its
|
|
* local calendar date is today or yesterday. An older backfilled segment (local
|
|
* date ≥ 2 days ago) returns false, so a full WHOOP / Apple / Withings re-sync
|
|
* that replays a month of nights never re-triggers the morning refresh for a
|
|
* historical night. A future-dated sample (clock skew) is rejected too.
|
|
*
|
|
* Pure + timezone-explicit so the tz-boundary cases are unit-testable without
|
|
* touching the DB or the queue.
|
|
*/
|
|
export function isLastNightLocal(
|
|
measuredAt: Date,
|
|
now: Date,
|
|
tz: string,
|
|
): boolean {
|
|
if (measuredAt.getTime() > now.getTime()) return false;
|
|
const todayKey = userDayKey(now, tz);
|
|
const yesterdayKey = userDayKey(new Date(now.getTime() - MS_PER_DAY), tz);
|
|
const segKey = userDayKey(measuredAt, tz);
|
|
return segKey === todayKey || segKey === yesterdayKey;
|
|
}
|
|
|
|
/**
|
|
* Enqueue the debounced morning refresh when at least one of the just-written
|
|
* sleep segments belongs to last night in the user's profile timezone.
|
|
*
|
|
* Best-effort and self-contained: every read is wrapped so a failure can never
|
|
* fail the sleep sync that called it. Callers still `void … .catch(() => {})`
|
|
* for defence in depth.
|
|
*
|
|
* Gates, in order:
|
|
* 1. No sleep rows → nothing to do.
|
|
* 2. `sleep` module off → the digest is always `final` (no sleep to wait
|
|
* for), so a refresh would be pointless; no-op.
|
|
* 3. No segment is "last night" locally → an old backfill; no-op.
|
|
* 4. `User.morningDigestRefreshedOn` already equals today's local date → the
|
|
* refresh already ran this morning; skip the enqueue (the handler
|
|
* re-checks the same marker, so this only saves queue churn).
|
|
*/
|
|
export async function maybeEnqueueMorningRefresh(
|
|
userId: string,
|
|
sleepMeasuredAts: Date[],
|
|
now: Date = new Date(),
|
|
): Promise<void> {
|
|
try {
|
|
// Gate 1 — nothing to refresh on. Every early return annotates a distinct
|
|
// `reason` so a real landing shows exactly which gate fired (the gates were
|
|
// silent before, so a "insight stuck on the nightly stamp" report could not
|
|
// be traced to a gate without a code change).
|
|
if (sleepMeasuredAts.length === 0) {
|
|
annotate({
|
|
action: { name: "daily.morning_refresh.skipped" },
|
|
meta: { reason: "no_sleep_rows" },
|
|
});
|
|
return;
|
|
}
|
|
|
|
const modules = await resolveModuleMap(userId);
|
|
if (modules.sleep === false) {
|
|
annotate({
|
|
action: { name: "daily.morning_refresh.skipped" },
|
|
meta: { reason: "sleep_module_off" },
|
|
});
|
|
return;
|
|
}
|
|
|
|
const tz = await resolveUserTimezone(userId);
|
|
const todayKey = userDayKey(now, tz);
|
|
const hasLastNight = sleepMeasuredAts.some((at) =>
|
|
isLastNightLocal(at, now, tz),
|
|
);
|
|
if (!hasLastNight) {
|
|
// The most diagnostic gate: carry the samples' resolved LOCAL dates (up
|
|
// to 5) alongside today's key + tz, so the next real landing tells us
|
|
// exactly why `isLastNightLocal` rejected every segment (a tz mismatch, a
|
|
// backfill of only-old nights, or a clock-skewed future sample).
|
|
annotate({
|
|
action: { name: "daily.morning_refresh.skipped" },
|
|
meta: {
|
|
reason: "not_last_night",
|
|
today: todayKey,
|
|
tz,
|
|
sample_local_dates: sleepMeasuredAts
|
|
.slice(0, 5)
|
|
.map((at) => userDayKey(at, tz)),
|
|
},
|
|
});
|
|
return;
|
|
}
|
|
|
|
const localDate = todayKey;
|
|
|
|
const row = await prisma.user.findUnique({
|
|
where: { id: userId },
|
|
select: { morningDigestRefreshedOn: true },
|
|
});
|
|
if (row?.morningDigestRefreshedOn === localDate) {
|
|
annotate({
|
|
action: { name: "daily.morning_refresh.skipped" },
|
|
meta: { reason: "already_refreshed", local_date: localDate },
|
|
});
|
|
return;
|
|
}
|
|
|
|
await enqueueMorningDigestRefresh({ userId, localDate });
|
|
annotate({
|
|
action: { name: "daily.morning_refresh.triggered" },
|
|
meta: {
|
|
local_date: localDate,
|
|
sleep_samples: sleepMeasuredAts.length,
|
|
},
|
|
});
|
|
} catch {
|
|
// Never let a freshness trigger fail an ingest. The next sleep landing and
|
|
// the nightly cron are the catch-net; the digest stays honestly
|
|
// provisional until one of them refreshes it.
|
|
}
|
|
}
|