mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-11 05:42:19 +00:00
With signals of the day resting on today's readings only, the briefing had to be written after those readings, and it was only written at the 04:30 Berlin warm, the post-sleep morning refresh, or a page open on a day-old cache. A morning blood pressure or weigh-in never became a signal, and since an earlier day's briefing is no longer served, a reader in Los Angeles or Seoul had none for most of the day: the Berlin warm lands the previous evening there, or at noon. The snapshot read every surface goes through (dashboard, iOS, digest, morning push) now asks for a briefing for today at two moments: the first read of the reader's local day with no briefing written today, and the first read after a signal metric's first reading of the day that the briefing has not read. Each reason is claimed once per user per local day in a Postgres bucket and remembered in-process, so later reads cost no query; no write path needs a hook. A claim enqueues one today warm: immediately for a new day, after a five-minute window for readings, so a burst (weigh-in, then blood pressure) folds into one job. The warm writes the comprehensive briefing only, past the one-hour freshness gate but under the failure backoff, the daily forced-warm cap (two per 24 h) and the content-hash gate. Worst case per user per local day: one enqueue per reason, at most eight (the day plus seven signal reading types, measured over a day of reads every two minutes); at most two forced comprehensive attempts under the existing cap, each at most three completions (the answer, a JSON retry, a grounding retry), so six provider calls at the ceiling. A warm whose data has not changed costs at most the existing once-a-day paragraph re-roll, inside the same cap.
204 lines
8.4 KiB
TypeScript
204 lines
8.4 KiB
TypeScript
/**
|
|
* Shared cached-snapshot read for the two dashboard entry points:
|
|
*
|
|
* - `GET /api/dashboard/snapshot` (the client cell's endpoint), and
|
|
* - the dashboard RSC wrapper (`src/app/page.tsx`), which server-
|
|
* prefetches the same payload into a dehydrated TanStack cache so the
|
|
* first HTML paints real tiles instead of skeletons-until-JS.
|
|
*
|
|
* Both ride the SAME `caches.analytics` SWR cell, keyed by
|
|
* `dashboardSnapshotCacheKey(userId)` plus the resolved locale, so an RSC
|
|
* prefetch warms the API path and vice versa — the builder never runs twice
|
|
* for one user within the TTL, and the write-invalidation semantics
|
|
* (stale-sweep on measurement writes, hard evict on widget reorder) cover
|
|
* both readers.
|
|
*
|
|
* "The SAME cell" rests on `caches` being pinned to `globalThis` in
|
|
* `src/lib/cache/server-cache.ts`. These two entry points are bundled into
|
|
* different layers, and while the registry sat in plain module state they
|
|
* held one Map each: a write evicted the route handlers' copy and the home
|
|
* page kept rendering the pre-write snapshot until it aged out. Anything
|
|
* that moves the registry back off the global breaks this paragraph, not
|
|
* just a performance assumption.
|
|
*/
|
|
import type { User } from "@/generated/prisma/client";
|
|
|
|
import { prisma } from "@/lib/db";
|
|
import { annotate } from "@/lib/logging/context";
|
|
import { cachedSwr, caches, type ServerCache } from "@/lib/cache/server-cache";
|
|
import { dashboardSnapshotCacheKey } from "@/lib/cache/invalidate";
|
|
import { DASHBOARD_REFETCH_INTERVAL_MS } from "@/lib/queries/refetch-interval";
|
|
import {
|
|
applyBriefingCapability,
|
|
buildDashboardSnapshot,
|
|
type DashboardSnapshot,
|
|
type SnapshotUserInput,
|
|
} from "@/lib/dashboard/snapshot";
|
|
import { aiCapabilityToServe } from "@/lib/ai/capabilities/gate";
|
|
import { resolveServerLocale } from "@/lib/i18n/server-locale";
|
|
import type { Locale } from "@/lib/i18n/config";
|
|
import { briefingForToday } from "@/lib/daily/briefing-today";
|
|
import { requestTodayBriefingWarm } from "@/lib/daily/briefing-today-warm";
|
|
import { readBriefingGeneratedAt } from "@/lib/insights/briefing-generated-at";
|
|
import { userDayKey } from "@/lib/tz/format";
|
|
|
|
/**
|
|
* Per-key TTL for the snapshot cache entry. Strictly greater than the
|
|
* client's 120 s refetch interval so a scheduled poll lands on a warm
|
|
* entry instead of a guaranteed miss that re-runs the full builder. The
|
|
* 60 s headroom absorbs interval jitter / a poll firing a touch late.
|
|
* The analytics bucket's 60 s default still governs the slim / thick /
|
|
* mood cells; only this key is lengthened. Eviction on writes is
|
|
* unchanged (the `${userId}|` prefix sweep / point-delete both ignore
|
|
* the TTL).
|
|
*/
|
|
export const SNAPSHOT_CACHE_TTL_MS = DASHBOARD_REFETCH_INTERVAL_MS + 60_000;
|
|
|
|
/**
|
|
* Resolve the snapshot's briefing for today, on every read.
|
|
*
|
|
* The same rule the digest applies, here so the dashboard, the RSC prefetch,
|
|
* the iOS snapshot read and the digest all serve one answer: text generated
|
|
* on an earlier calendar day (or at an unknown time) is not served, a
|
|
* reading-backed signal whose metric has no reading today is dropped, and
|
|
* every delta reads at its metric's precision. Applied after the cache like
|
|
* the capability, so a body cached at 23:58 does not serve yesterday's text
|
|
* at 00:01. A withheld briefing reads "preparing" while a warm is due, never
|
|
* "ready" with nothing in it.
|
|
*/
|
|
export function applyBriefingForToday(
|
|
body: DashboardSnapshot,
|
|
ctx: {
|
|
generatedAt: string | null;
|
|
timezone: string;
|
|
language: Locale;
|
|
now: Date;
|
|
},
|
|
): DashboardSnapshot {
|
|
if (!body.briefing) return body;
|
|
const lastSeen = body.tiles?.lastSeenByType ?? {};
|
|
const briefing = briefingForToday(body.briefing, {
|
|
generatedAt: ctx.generatedAt,
|
|
lastSeenAt: (type) => lastSeen[type]?.lastSeenAt ?? null,
|
|
timezone: ctx.timezone,
|
|
todayLocalDate: userDayKey(ctx.now, ctx.timezone),
|
|
language: ctx.language,
|
|
});
|
|
if (briefing) return { ...body, briefing };
|
|
return {
|
|
...body,
|
|
briefing: null,
|
|
briefingStale: false,
|
|
briefingState:
|
|
body.briefingState === "ready" ? "preparing" : body.briefingState,
|
|
};
|
|
}
|
|
|
|
export interface SnapshotReadResult {
|
|
body: DashboardSnapshot;
|
|
/** The locale the snapshot was resolved (and cache-keyed) under. */
|
|
locale: Locale;
|
|
}
|
|
|
|
export interface SnapshotReadOptions {
|
|
/**
|
|
* A locale already resolved by the caller. Background paths (the morning
|
|
* briefing push) have no request to resolve one from and pass the job-side
|
|
* resolution here so the digest and the push copy agree on a language.
|
|
*/
|
|
locale?: Locale;
|
|
/** The read's clock; the digest passes its own so both agree on today. */
|
|
now?: Date;
|
|
}
|
|
|
|
/**
|
|
* Resolve locale + read the snapshot through the SWR cache for an already
|
|
* authenticated user row. `time` lets the API route keep its per-sub-query
|
|
* timing surface; the RSC prefetch passes none.
|
|
*/
|
|
export async function readDashboardSnapshotCached(
|
|
user: User,
|
|
time?: <T>(label: string, builder: () => Promise<T>) => Promise<T>,
|
|
options: SnapshotReadOptions = {},
|
|
): Promise<SnapshotReadResult> {
|
|
// `User` row is already in hand at both call sites — no extra round-trip.
|
|
const snapshotUser: SnapshotUserInput = {
|
|
id: user.id,
|
|
username: user.username,
|
|
displayName: user.displayName,
|
|
timezone: user.timezone,
|
|
heightCm: user.heightCm,
|
|
dateOfBirth: user.dateOfBirth,
|
|
gender: user.gender,
|
|
glucoseUnit: user.glucoseUnit,
|
|
onboardingTourCompleted: user.onboardingTourCompleted,
|
|
insightsCachedText: user.insightsCachedText,
|
|
insightsCachedAt: user.insightsCachedAt,
|
|
insightsCachedLocale: user.insightsCachedLocale,
|
|
dashboardWidgetsJson: user.dashboardWidgetsJson,
|
|
sourcePriorityJson: user.sourcePriorityJson,
|
|
thresholdsJson: user.thresholdsJson,
|
|
healthScoreConfigJson: user.healthScoreConfigJson,
|
|
};
|
|
|
|
// v1.21.2 (A4) — the briefing recall + forward-look is locale-specific
|
|
// prose, so the snapshot cache key carries the resolved locale: an EN and
|
|
// a DE session never share a snapshot cell (and never see each other's
|
|
// memory wording). The `${user.id}|` prefix still covers the key under
|
|
// the measurement-write stale-sweep.
|
|
const locale = await resolveServerLocale({
|
|
userLocale: user.locale,
|
|
override: options.locale ?? null,
|
|
});
|
|
|
|
// Stale-while-revalidate: a measurement write marks the analytics
|
|
// bucket stale rather than hard-evicting the snapshot, so a busy iOS
|
|
// sync serves the prior snapshot immediately (within the bucket's
|
|
// stale window) while a single background recompute warms a fresh one
|
|
// — the foreground request never pays the cold rebuild. Hard-evicting
|
|
// writes (widget reorder) still drop the key outright, forcing a clean
|
|
// miss + synchronous rebuild here.
|
|
const [cached, briefingAi] = await Promise.all([
|
|
cachedSwr(
|
|
caches.analytics as ServerCache<DashboardSnapshot>,
|
|
// The invalidators sweep by `dashboardSnapshotCacheKey(userId)` prefix, so
|
|
// the read has to build its key from that same function. Spelling the
|
|
// string out here worked only for as long as nobody edited one side.
|
|
`${dashboardSnapshotCacheKey(user.id)}|${locale}`,
|
|
() => buildDashboardSnapshot(prisma, snapshotUser, { time, locale }),
|
|
annotate,
|
|
SNAPSHOT_CACHE_TTL_MS,
|
|
),
|
|
aiCapabilityToServe(user.id, "briefing"),
|
|
]);
|
|
|
|
// The briefing is model text: shown only while the record's `briefing`
|
|
// capability is available, decided on every read rather than baked into
|
|
// the cached body. The record's own state decides, whoever is reading, so a
|
|
// delegate sees the owner's briefing exactly when the owner would.
|
|
const now = options.now ?? new Date();
|
|
const generatedAt = readBriefingGeneratedAt(user.insightsCachedText);
|
|
const body = applyBriefingForToday(
|
|
applyBriefingCapability(cached, briefingAi),
|
|
{ generatedAt, timezone: user.timezone, language: locale, now },
|
|
);
|
|
|
|
// The day's first read with no briefing written today, or the first read
|
|
// after a signal metric's first reading of the day, asks for a briefing
|
|
// for today (`briefing-today-warm.ts`). Never awaited by the read beyond
|
|
// its claim, never thrown.
|
|
if (briefingAi.available) {
|
|
const lastSeen = cached.tiles?.lastSeenByType ?? {};
|
|
void requestTodayBriefingWarm({
|
|
userId: user.id,
|
|
locale,
|
|
timezone: user.timezone,
|
|
generatedAt,
|
|
lastSeenAt: (type) => lastSeen[type]?.lastSeenAt ?? null,
|
|
now,
|
|
}).catch(() => undefined);
|
|
}
|
|
|
|
return { body, locale };
|
|
}
|