fix(insights,medications): close the staleness escape hatches and harden the review findings

Staleness can re-enter a generated text: measurement age is hashed as a
bucketed tier, so the fingerprint stays stable day to day but flips
exactly when a reading crosses the boundary the prompt caveat cares
about. The ingest debounce becomes a one-hour minimum gap with the
hash gate carrying the budget, so a notable morning reading is
narrated the same morning instead of after the next nightly run. A
manual regenerate now refills the status cards through the same
hash-gated pipeline — changed cards regenerate, unchanged cards
refresh free, no eviction touches the hash baselines. The user's
self-context joins the comprehensive fingerprint so a newly remembered
fact reaches the briefing, the sleep writer collapse covers every
caller that reads nights, the batched compliance build fans out
bounded instead of sequentially, and a failed compliance read renders
a quiet retry state in the exact skeleton footprint instead of
swallowing the error.
This commit is contained in:
Marc-André Bombeck committed 2026-06-11 22:49:42 +02:00
1 parent 3d95ace218
commit a2a995e429
25 files changed
+770 -97

No files matched your search

+1
View File
@@ -735,6 +735,7 @@
"compliance7d": "Therapietreue (7 Tage)",
"compliance30d": "Therapietreue (30 Tage)",
"complianceWindow": "Therapietreue ({days} Tage)",
"complianceUnavailable": "Therapietreue konnte nicht geladen werden",
"complianceInfo": "Anteil der erfassten an den erwarteten Dosen. Wöchentliche Pläne zählen wochenweise.",
"complianceInfoLabel": "Was bedeutet Therapietreue?",
"complianceDoses": "{count} Dosen",
+1
View File
@@ -735,6 +735,7 @@
"compliance7d": "Adherence (7 days)",
"compliance30d": "Adherence (30 days)",
"complianceWindow": "Adherence ({days} days)",
"complianceUnavailable": "Adherence could not be loaded",
"complianceInfo": "Share of expected doses you logged. Weekly schedules count week by week.",
"complianceInfoLabel": "What does adherence mean?",
"complianceDoses": "{count} doses",
+1
View File
@@ -735,6 +735,7 @@
"compliance7d": "Cumplimiento 7 días",
"compliance30d": "Cumplimiento 30 días",
"complianceWindow": "Cumplimiento {days} días",
"complianceUnavailable": "No se pudo cargar el cumplimiento",
"complianceInfo": "Proporción de dosis registradas respecto a las esperadas. Las pautas semanales se cuentan semana a semana.",
"complianceInfoLabel": "¿Qué significa el cumplimiento?",
"complianceDoses": "{count} dosis",
+1
View File
@@ -735,6 +735,7 @@
"compliance7d": "Observance 7 jours",
"compliance30d": "Observance 30 jours",
"complianceWindow": "Observance {days} jours",
"complianceUnavailable": "Impossible de charger l'observance",
"complianceInfo": "Part des doses attendues que vous avez enregistrées. Les plans hebdomadaires comptent semaine par semaine.",
"complianceInfoLabel": "Que signifie l'observance ?",
"complianceDoses": "{count} doses",
+1
View File
@@ -735,6 +735,7 @@
"compliance7d": "Aderenza 7 giorni",
"compliance30d": "Aderenza 30 giorni",
"complianceWindow": "Aderenza {days} giorni",
"complianceUnavailable": "Impossibile caricare l'aderenza",
"complianceInfo": "Quota delle dosi attese che hai registrato. I piani settimanali contano settimana per settimana.",
"complianceInfoLabel": "Cosa significa aderenza?",
"complianceDoses": "{count} dosi",
+1
View File
@@ -735,6 +735,7 @@
"compliance7d": "Przestrzeganie 7 dni",
"compliance30d": "Przestrzeganie 30 dni",
"complianceWindow": "Przestrzeganie {days} dni",
"complianceUnavailable": "Nie udało się wczytać danych o przestrzeganiu",
"complianceInfo": "Udział zarejestrowanych dawek względem oczekiwanych. Plany tygodniowe liczone są tydzień po tygodniu.",
"complianceInfoLabel": "Co oznacza przestrzeganie zaleceń?",
"complianceDoses": "{count} dawek",
+9 -1
View File
@@ -570,7 +570,15 @@ async function buildDashboardSummary(
measuredAt: { gte: streakWindowStart },
},
orderBy: { measuredAt: "asc" },
select: { value: true, measuredAt: true, sleepStage: true, source: true },
// Writer-level collapse: two HealthKit apps behind one source
// (watch stages vs phone in-bed) must not blend into one night.
select: {
value: true,
measuredAt: true,
sleepStage: true,
source: true,
deviceType: true,
},
}),
),
]);
@@ -94,6 +94,13 @@ vi.mock("@/lib/jobs/insight-pregenerate-shared", () => ({
enqueueForceWarm: vi.fn(async () => undefined),
}));
// The POST follows a successful generation with a hash-gated refill of the
// per-status / generic-metric cards. Mocked at the module boundary — the
// enqueue pipeline itself is covered by the comprehensive-generate tests.
vi.mock("@/lib/insights/comprehensive-generate", () => ({
enqueueStatusRefillForUser: vi.fn(async () => 7),
}));
vi.mock("@/lib/logging/context", () => ({
annotate: vi.fn(),
}));
@@ -117,6 +124,7 @@ vi.mock("@/lib/i18n/server-locale", () => ({
}));
import { GET, POST, resolveInsightsRateLimit } from "../route";
import { enqueueStatusRefillForUser } from "@/lib/insights/comprehensive-generate";
import { resolveProvider } from "@/lib/ai/provider";
import { checkRateLimit } from "@/lib/rate-limit";
import { prisma } from "@/lib/db";
@@ -389,6 +397,51 @@ describe("POST /api/insights/generate — cache write (v1.16.8)", () => {
expect(prisma.user.update).not.toHaveBeenCalled();
expect(prisma.auditLog.deleteMany).not.toHaveBeenCalled();
});
it("enqueues the hash-gated card refill after a successful generation", async () => {
makeWorkingProvider();
const res = await POST(jsonRequest({ force: true }) as never);
expect(res.status).toBe(200);
// The manual regenerate takes the cards along: one refill enqueue for
// the caller's resolved locale, annotated for the wide-event pipeline.
expect(enqueueStatusRefillForUser).toHaveBeenCalledTimes(1);
expect(enqueueStatusRefillForUser).toHaveBeenCalledWith("u-1", "en");
const refillAnnotate = vi
.mocked(annotate)
.mock.calls.find(
(call) =>
(call[0] as { action?: { name?: string } })?.action?.name ===
"insights.generate.cards_refill",
);
expect(refillAnnotate, "cards_refill annotate event").toBeTruthy();
});
it("does NOT enqueue the card refill when serving from the 24h DB cache", async () => {
vi.mocked(prisma.user.findUnique).mockResolvedValueOnce({
insightsPrivacyMode: "aggregated",
insightsCachedAt: new Date(),
insightsCachedText: JSON.stringify({ changed: "still fresh" }),
locale: "en",
} as never);
const res = await POST(jsonRequest({}) as never);
expect(res.status).toBe(200);
expect(enqueueStatusRefillForUser).not.toHaveBeenCalled();
});
it("does NOT enqueue the card refill when the provider fails", async () => {
makeProviderThatThrows(
Object.assign(new Error("OpenAI request failed (500)"), {
httpStatus: 500,
}),
);
const res = await POST(jsonRequest({ force: true }) as never);
expect(res.status).toBe(503);
expect(enqueueStatusRefillForUser).not.toHaveBeenCalled();
});
});
// v1.4.36 QA H1 — when both the raw-mode extraction AND the aggregated
+32 -6
View File
@@ -61,6 +61,7 @@ import { resolveServerLocale } from "@/lib/i18n/server-locale";
import { hasUsableStatusProvider } from "@/lib/insights/status-provider";
import { hashInsightSnapshot } from "@/lib/insights/snapshot-hash";
import { enqueueForceWarm } from "@/lib/jobs/insight-pregenerate-shared";
import { enqueueStatusRefillForUser } from "@/lib/insights/comprehensive-generate";
export const dynamic = "force-dynamic";
@@ -159,6 +160,9 @@ async function buildComparisonSnapshotForUser(
value: true,
sleepStage: true,
source: true,
// Writer-level collapse: two HealthKit apps behind one
// source (watch stages vs phone in-bed) must not blend.
deviceType: true,
},
}),
resolveUserTimezone(userId),
@@ -718,15 +722,37 @@ export const POST = apiHandler(async (request: NextRequest) => {
// regeneration paths can detect "nothing changed" and skip the
// provider call. The user-initiated POST itself stays un-gated: an
// explicit regenerate request is honoured even on unchanged data
// (it is already bounded by the hourly rate limit above).
insightsSnapshotHash: hashInsightSnapshot(compactFeatures),
// (it is already bounded by the hourly rate limit above). The
// about-me text joins the fingerprint — it feeds the prompt and can
// change with no data change (Coach remember, Settings → AI edit) —
// and the shape MUST match the gate in `comprehensive-generate.ts`,
// or every off-request warm after a manual regenerate re-pays a
// full generation on unchanged data.
insightsSnapshotHash: hashInsightSnapshot({
features: compactFeatures,
aboutMe: aboutMe ?? null,
}),
},
});
// v1.16.8 — no blanket per-status eviction here any more. The cards
// track their own data through the ingest invalidator and their own
// content-hash gates; nuking ~45 cache rows per manual regenerate was
// the source of the post-regenerate card-regeneration storm.
// v1.16.8 — no blanket per-status eviction here any more (nuking ~45
// cache rows per manual regenerate deleted every hash baseline and was
// the source of the post-regenerate card-regeneration storm). Instead,
// the manual regenerate takes the cards along through the hash-gated
// queue: every scope the user has data for is enqueued, the worker
// forces each generator past its same-day cache read, and the content-
// hash gate regenerates exactly the cards whose data changed while
// refreshing the unchanged ones for free. This is the user's escape
// hatch for a card they can SEE is stale — without it the cards had no
// refresh path until the next nightly warm.
const refillScopes = await enqueueStatusRefillForUser(
userId,
locale === "de" ? "de" : "en",
);
annotate({
action: { name: "insights.generate.cards_refill" },
meta: { scopes: refillScopes },
});
// v1.7.0 W6 — the dashboard snapshot embeds the pre-generated daily
// briefing read-only; drop it so the next snapshot carries the fresh
+9 -1
View File
@@ -277,7 +277,15 @@ async function buildTargetsResponse(user: AuthedUser) {
deletedAt: null,
},
orderBy: { measuredAt: "asc" },
select: { value: true, measuredAt: true, sleepStage: true, source: true },
// Writer-level collapse: two HealthKit apps behind one source (watch
// stages vs phone in-bed) must not blend into one night.
select: {
value: true,
measuredAt: true,
sleepStage: true,
source: true,
deviceType: true,
},
}),
// Active medications + their 30-day intake events (section 8). The
// intake read depends on the medication ids, so the pair chains
+28 -19
View File
@@ -1,3 +1,5 @@
import pLimit from "p-limit";
import { prisma } from "@/lib/db";
import { apiHandler, requireAuth } from "@/lib/api-handler";
import { annotate } from "@/lib/logging/context";
@@ -53,25 +55,32 @@ export const GET = apiHandler(async () => {
orderBy: { createdAt: "desc" },
});
// Sequential on purpose: each cold cell costs one bounded intake read +
// one band-expansion pass, and a sequential walk keeps a many-meds
// account from stampeding the pool. Warm / stale cells return without
// touching the database at all.
const items = [];
for (const medication of medications) {
const payload = await cachedSwr(
caches.medicationCompliance as ServerCache<CompliancePayload>,
complianceCacheKey(user.id, medication.id, userTz),
() => buildCompliancePayload(medication, user.id, userTz),
annotate,
);
items.push({
medicationId: medication.id,
compliance7: payload.compliance7,
compliance30: payload.compliance30,
complianceDisplay: payload.complianceDisplay,
});
}
// Bounded fan-out: each cold cell costs one bounded intake read + one
// band-expansion pass. Three at a time keeps a many-meds account from
// stampeding the pool while bounding the cold wall-clock — the client
// reads this through the shared fetch wrapper's 15 s default timeout,
// and a strictly sequential walk over a large cabinet could outlive it
// on a cold cache. Warm / stale cells return without touching the
// database at all; `Promise.all` keeps the response in list order.
const limit = pLimit(3);
const items = await Promise.all(
medications.map((medication) =>
limit(async () => {
const payload = await cachedSwr(
caches.medicationCompliance as ServerCache<CompliancePayload>,
complianceCacheKey(user.id, medication.id, userTz),
() => buildCompliancePayload(medication, user.id, userTz),
annotate,
);
return {
medicationId: medication.id,
compliance7: payload.compliance7,
compliance30: payload.compliance30,
complianceDisplay: payload.complianceDisplay,
};
}),
),
);
annotate({
action: { name: "medication.compliance_summary.read" },
@@ -8,7 +8,10 @@ import {
Glp1MedicationCard,
type Glp1Medication,
} from "@/components/medications/glp1-medication-card";
import { MedicationComplianceBars } from "@/components/medications/card-parts/medication-compliance-bars";
import {
MedicationComplianceBars,
MedicationComplianceError,
} from "@/components/medications/card-parts/medication-compliance-bars";
import { MedicationStatusPill } from "@/components/medications/card-parts/medication-status-pill";
import { MedicationIntakeActions } from "@/components/medications/card-parts/medication-intake-actions";
import { MedicationStateBadges } from "@/components/medications/card-parts/medication-state-badges";
@@ -550,3 +553,87 @@ describe("compliance bars — fixed geometry (v1.16.6)", () => {
expect(skeleton(a)).toBe(skeleton(b));
});
});
/**
* v1.16.8 — when the batched compliance query FAILS (post-retry), the
* card swaps the loading skeleton for a quiet error fallback: same
* two-row footprint (no layout jump), no bars (a failed read must not
* paint a 0 % adherence), one muted notice line + a small retry that
* refetches the shared query. Without it the swallowed-error path left
* every card on a permanent skeleton with no recovery affordance.
*/
describe("compliance error fallback (v1.16.8)", () => {
function renderBodyCompliance(args: {
compliance: boolean;
complianceError?: boolean;
}) {
return render(
<MedicationCardBody
name="Ramipril"
dose="5 mg"
categoryLabel="Blood Pressure"
active
href="/medications/m1"
linkLabel="Open"
stateBadges={null}
headerActions={null}
windowStatus={null}
doseStatus="upcoming"
nextLine="Tomorrow, 08:00"
lastLine="Today, 07:30"
compliance={
args.compliance
? { rate7: 90, rate30: 88, streak: 0, shortDays: 7, longDays: 30 }
: null
}
complianceError={args.complianceError}
onRetryCompliance={() => {}}
currentCycle={null}
intakeLoading={null}
onRecordIntake={() => {}}
/>,
);
}
it("renders the notice line and the retry affordance, no progress bars", () => {
const html = render(<MedicationComplianceError onRetry={() => {}} />);
expect(html).toContain("Adherence could not be loaded");
expect(html).toContain("Retry");
expect(html).not.toContain('data-slot="progress"');
});
it("mirrors the skeleton's two-row footprint so the card height holds", () => {
const error = render(<MedicationComplianceError onRetry={() => {}} />);
// Two h-5 label lines + two h-2 bar-height slots — exactly the
// skeleton's reserved geometry, so failed/loading/loaded states all
// occupy the same space.
expect((error.match(/flex h-5 items-center/g) ?? []).length).toBe(2);
expect((error.match(/h-2 rounded/g) ?? []).length).toBe(2);
});
it("the body shows the error fallback instead of the skeleton on a failed query", () => {
const html = renderBodyCompliance({
compliance: false,
complianceError: true,
});
expect(html).toContain("Adherence could not be loaded");
expect(html).toContain("Retry");
// Not the aria-hidden loading skeleton.
expect(html).not.toContain('aria-hidden="true"><div class="space-y-1.5"');
});
it("the body keeps the skeleton while the query is merely in flight", () => {
const html = renderBodyCompliance({ compliance: false });
expect(html).not.toContain("Adherence could not be loaded");
});
it("resolved data still renders the bars even when a stale error flag lingers", () => {
// A successful refetch clears the failure visually: data wins.
const html = renderBodyCompliance({
compliance: true,
complianceError: true,
});
expect(html).toContain('data-slot="progress"');
expect(html).not.toContain("Adherence could not be loaded");
});
});
@@ -9,6 +9,7 @@ import { MedicationCardHeader } from "@/components/medications/MedicationCardHea
import { MedicationStatusPill } from "@/components/medications/card-parts/medication-status-pill";
import {
MedicationComplianceBars,
MedicationComplianceError,
MedicationComplianceSkeleton,
} from "@/components/medications/card-parts/medication-compliance-bars";
import { MedicationCycleStatus } from "@/components/medications/card-parts/medication-cycle-status";
@@ -95,6 +96,16 @@ export interface MedicationCardBodyProps {
longDays: number;
} | null;
/**
* True when the batched compliance query FAILED (post-retry). The body
* then renders the quiet same-footprint error fallback instead of the
* skeleton — a permanent skeleton reads as "still loading" forever,
* while a failed read needs a visible (but calm) retry affordance.
*/
complianceError?: boolean;
/** Refetch the shared compliance query (the error fallback's retry). */
onRetryCompliance?: () => void;
/** The open-cycle descriptor, or null when the display block is absent. */
currentCycle: CurrentCycle | null;
@@ -121,6 +132,8 @@ export function MedicationCardBody({
nextLine,
lastLine,
compliance,
complianceError = false,
onRetryCompliance,
currentCycle,
intakeLoading,
onRecordIntake,
@@ -183,7 +196,10 @@ export function MedicationCardBody({
<MedicationNextLastSlot next={nextLine} last={lastLine} />
{/* Compliance bars — always two rows; constant-height skeleton holds
the slot while the query is in flight so the grid row stays even. */}
the slot while the query is in flight so the grid row stays even.
A FAILED query swaps the skeleton for the same-footprint quiet
error fallback (no bars, one notice line + retry) instead of
sitting on the skeleton forever. */}
{active &&
(compliance ? (
<MedicationComplianceBars
@@ -193,6 +209,8 @@ export function MedicationCardBody({
shortDays={compliance.shortDays}
longDays={compliance.longDays}
/>
) : complianceError && onRetryCompliance ? (
<MedicationComplianceError onRetry={onRetryCompliance} />
) : (
<MedicationComplianceSkeleton />
))}
@@ -1,4 +1,4 @@
import { Flame } from "lucide-react";
import { Flame, RotateCw } from "lucide-react";
import { ComplianceInfoTip } from "@/components/medications/card-parts/compliance-info-tip";
import { Progress } from "@/components/ui/progress";
@@ -145,3 +145,45 @@ export function MedicationComplianceSkeleton() {
</div>
);
}
/**
* Quiet error fallback for the compliance block, shown when the batched
* `/api/medications/compliance` query failed after its retries. Mirrors
* the skeleton's two-row footprint exactly (same `h-5` label lines, same
* `h-2` bar slots, same gaps) so the card height never jumps between the
* loading, loaded, and failed states. No bars are drawn — a failed read
* must not paint a 0 % adherence — just one muted notice line and a small
* retry affordance that refetches the shared query for every card at once.
*/
export function MedicationComplianceError({
onRetry,
}: {
onRetry: () => void;
}) {
const { t } = useTranslations();
return (
<div className="space-y-2.5">
<div className="space-y-1.5">
<div className="flex h-5 items-center text-sm">
<span className="text-muted-foreground min-w-0 truncate">
{t("medications.complianceUnavailable")}
</span>
</div>
<div className="bg-muted/40 h-2 rounded" />
</div>
<div className="space-y-1.5">
<div className="flex h-5 items-center">
<button
type="button"
onClick={onRetry}
className="text-muted-foreground hover:text-foreground flex items-center gap-1 text-xs underline-offset-2 hover:underline"
>
<RotateCw className="h-3 w-3" aria-hidden="true" />
{t("common.retry")}
</button>
</div>
<div className="bg-muted/40 h-2 rounded" />
</div>
</div>
);
}
@@ -192,7 +192,13 @@ export function Glp1MedicationCard({
// v1.16.8 — same batched compliance source as the generic card: one
// `GET /api/medications/compliance` round trip shared by every card on
// the page (the per-id endpoint stays for the detail page's heatmap).
const { data: compliance } = useMedicationComplianceSummary(medication.id);
// `isError` swaps the compliance slot to the quiet retry fallback so a
// failed batch read never leaves the card on a permanent skeleton.
const {
data: compliance,
isError: complianceError,
refetch: refetchCompliance,
} = useMedicationComplianceSummary(medication.id);
// v1.4.37 W4b — same reminder-thresholds source as the generic
// medication card so the take-now / overdue / very-overdue pill
@@ -442,6 +448,8 @@ export function Glp1MedicationCard({
compliance={
compliance ? { rate7, rate30, streak, shortDays, longDays } : null
}
complianceError={complianceError}
onRetryCompliance={refetchCompliance}
currentCycle={display?.currentCycle ?? null}
intakeLoading={intakeLoading}
onRecordIntake={(skipped) => recordIntake(skipped, displayedSlot)}
@@ -131,7 +131,13 @@ export function MedicationCard({
// v1.16.8 — the per-card compliance read rides the ONE batched
// `GET /api/medications/compliance` round trip every card on the page
// shares (the per-id endpoint stays for the detail page's heatmap).
const { data: compliance } = useMedicationComplianceSummary(medication.id);
// `isError` swaps the compliance slot to the quiet retry fallback so a
// failed batch read never leaves the card on a permanent skeleton.
const {
data: compliance,
isError: complianceError,
refetch: refetchCompliance,
} = useMedicationComplianceSummary(medication.id);
const [, forceUpdate] = useReducer((x: number) => x + 1, 0);
@@ -386,6 +392,8 @@ export function MedicationCard({
compliance={
compliance ? { rate7, rate30, streak, shortDays, longDays } : null
}
complianceError={complianceError}
onRetryCompliance={refetchCompliance}
currentCycle={display?.currentCycle ?? null}
intakeLoading={intakeLoading}
onRecordIntake={(skipped) => recordIntake(skipped, displayedSlot)}
+9 -1
View File
@@ -770,7 +770,15 @@ async function buildCoachSnapshotImpl(
deletedAt: null,
},
orderBy: { measuredAt: "asc" },
select: { value: true, measuredAt: true, sleepStage: true, source: true },
// Writer-level collapse: two HealthKit apps behind one source
// (watch stages vs phone in-bed) must not blend into one night.
select: {
value: true,
measuredAt: true,
sleepStage: true,
source: true,
deviceType: true,
},
})
: null;
const workoutRowsPromise = sources.has("workouts")
@@ -70,9 +70,13 @@ import { compactSections } from "@/lib/ai/prompts/compact-sections";
const FEATURES = {
weight: { count: 12, latest: 81.4, mean30: 82.1 },
};
const FEATURES_HASH = hashInsightSnapshot(
compactSections(FEATURES as unknown as Record<string, unknown>),
);
// The fingerprint covers the compacted features AND the about-me text
// (null here — the about-me module is mocked to no text), matching the
// composite shape both the gate and the POST route hash.
const FEATURES_HASH = hashInsightSnapshot({
features: compactSections(FEATURES as unknown as Record<string, unknown>),
aboutMe: null,
});
beforeEach(() => {
vi.clearAllMocks();
@@ -18,6 +18,7 @@ const deleteMany = vi.fn();
const auditFindMany = vi.fn();
const enqueueStatusGeneration = vi.fn();
const userFindUnique = vi.fn();
const measurementFindMany = vi.fn();
vi.mock("@/lib/db", () => ({
prisma: {
@@ -26,6 +27,7 @@ vi.mock("@/lib/db", () => ({
findMany: (...a: unknown[]) => auditFindMany(...a),
},
user: { findUnique: (...a: unknown[]) => userFindUnique(...a) },
measurement: { findMany: (...a: unknown[]) => measurementFindMany(...a) },
},
}));
@@ -33,7 +35,10 @@ vi.mock("@/lib/jobs/insight-status-generate-shared", () => ({
enqueueStatusGeneration: (...a: unknown[]) => enqueueStatusGeneration(...a),
}));
import { invalidateStatusInsightsForTypes } from "../comprehensive-generate";
import {
enqueueStatusRefillForUser,
invalidateStatusInsightsForTypes,
} from "../comprehensive-generate";
beforeEach(() => {
vi.clearAllMocks();
@@ -43,6 +48,7 @@ beforeEach(() => {
auditFindMany.mockResolvedValue([]);
enqueueStatusGeneration.mockResolvedValue(undefined);
userFindUnique.mockResolvedValue({ locale: "de" });
measurementFindMany.mockResolvedValue([]);
});
/** Distinct scopes the invalidator enqueued a regenerate for. */
@@ -189,7 +195,7 @@ describe("invalidateStatusInsightsForTypes", () => {
).toBe(false);
});
describe("ingest-invalidation debounce (v1.9.0, 6 h window since v1.16.8)", () => {
describe("ingest-invalidation debounce (v1.9.0; a 1 h minimum gap since v1.16.8)", () => {
/** Build a recent (within-window) real assessment cache row. */
function freshRow(scope: string, locale = "de") {
return {
@@ -198,14 +204,26 @@ describe("invalidateStatusInsightsForTypes", () => {
};
}
it("skips a scope whose assessment was warmed within the window", async () => {
// `general` was regenerated within the window; a fresh WEIGHT sample
it("skips a scope whose assessment was warmed within the gap", async () => {
// `general` was regenerated within the gap; a fresh WEIGHT sample
// must not re-enqueue it. weight + bmi are still stale, so they refresh.
auditFindMany.mockResolvedValue([freshRow("general")]);
await invalidateStatusInsightsForTypes("u1", ["WEIGHT"]);
expect(enqueuedScopes()).toEqual(["bmi", "weight"]);
});
it("re-enqueues a scope warmed longer than the gap ago (the hash gate meters cost)", async () => {
// The DB probe is cutoff-filtered, so a scope last warmed before the
// gap simply does not come back as fresh — it re-enqueues even though
// a cache row exists. The worker's forced run lands on the content-
// hash gate, which makes a no-change run a free timestamp refresh —
// this is what lets same-day data be narrated same-day without
// reopening the per-batch regeneration storm.
auditFindMany.mockResolvedValue([]);
await invalidateStatusInsightsForTypes("u1", ["WEIGHT"]);
expect(enqueuedScopes()).toEqual(["bmi", "general", "weight"]);
});
it("is a complete no-op (no enqueue) when every scope is fresh", async () => {
auditFindMany.mockResolvedValue([
freshRow("weight"),
@@ -229,7 +247,7 @@ describe("invalidateStatusInsightsForTypes", () => {
expect(enqueuedScopes()).toEqual(["bmi", "general", "weight"]);
});
it("scopes the freshness probe to the user's resolved locale and a 6 h window cutoff", async () => {
it("scopes the freshness probe to the user's resolved locale and a 1 h gap cutoff", async () => {
userFindUnique.mockResolvedValue({ locale: "en" });
const before = Date.now();
await invalidateStatusInsightsForTypes("u1", ["WEIGHT"]);
@@ -243,14 +261,86 @@ describe("invalidateStatusInsightsForTypes", () => {
"insights.general-status.en",
]),
);
// The recency floor is six hours in the past (v1.16.8 — widened
// from 30 min so an all-day syncer refreshes a scope at most a few
// times, with the hash gate dropping unchanged refreshes to zero
// LLM calls).
// The recency floor is ONE hour in the past. The 6 h wall this
// started as kept same-day data from being narrated same-day (the
// nightly warm restarted the window, so a morning reading stayed
// un-narrated until tomorrow); the 1 h gap only bounds worker-run
// frequency, while the worker's content-hash gate keeps an
// unchanged re-run at zero LLM calls.
const cutoff = (where.createdAt.gte as Date).getTime();
const sixHours = 6 * 60 * 60 * 1000;
expect(before - cutoff).toBeGreaterThanOrEqual(sixHours - 5_000);
expect(before - cutoff).toBeLessThanOrEqual(sixHours + 60_000);
const oneHour = 60 * 60 * 1000;
expect(before - cutoff).toBeGreaterThanOrEqual(oneHour - 5_000);
expect(before - cutoff).toBeLessThanOrEqual(oneHour + 60_000);
});
});
});
// v1.16.8 — the manual comprehensive regenerate enqueues a hash-gated
// refill of the assessment cards instead of blanket-evicting them. The
// worker forces each enqueued scope past its same-day cache read, so the
// content-hash gate regenerates changed cards and refreshes unchanged
// ones for free — the hash baseline rows are never deleted.
describe("enqueueStatusRefillForUser", () => {
it("enqueues the seven specialised scopes plus the user's data-bearing generic scopes", async () => {
measurementFindMany.mockResolvedValue([
{ type: "WEIGHT" }, // specialised — no generic registry entry
{ type: "BLOOD_GLUCOSE" }, // generic card
{ type: "ACTIVE_ENERGY_BURNED" }, // generic card under a remapped id
]);
const count = await enqueueStatusRefillForUser("u1", "de");
expect(enqueuedScopes()).toEqual([
"blood-pressure",
"bmi",
"general",
"medication-compliance",
"metric:ACTIVE_ENERGY",
"metric:BLOOD_GLUCOSE",
"mood",
"pulse",
"weight",
]);
expect(count).toBe(9);
const locales = new Set(
enqueueStatusGeneration.mock.calls.map(
(c) => (c[0] as { locale: string }).locale,
),
);
expect([...locales]).toEqual(["de"]);
});
it("never deletes cache rows (the hash baselines survive the refill)", async () => {
await enqueueStatusRefillForUser("u1", "en");
expect(deleteMany).not.toHaveBeenCalled();
});
it("bypasses the ingest debounce — an explicit regenerate refills even freshly-warmed scopes", async () => {
// Every scope reads as freshly warmed; the refill must NOT consult the
// freshness probe at all (the user is explicitly asking).
auditFindMany.mockResolvedValue([
{
action: "insights.general-status.en",
details: JSON.stringify({ text: "fresh assessment", model: "gpt" }),
},
]);
await enqueueStatusRefillForUser("u1", "en");
expect(auditFindMany).not.toHaveBeenCalled();
expect(enqueuedScopes()).toContain("general");
});
it("still refills the specialised scopes when the generic-scope discovery read fails", async () => {
measurementFindMany.mockRejectedValue(new Error("pool exhausted"));
const count = await enqueueStatusRefillForUser("u1", "de");
expect(count).toBe(7);
expect(enqueuedScopes()).toEqual([
"blood-pressure",
"bmi",
"general",
"medication-compliance",
"mood",
"pulse",
"weight",
]);
});
});
@@ -4,14 +4,16 @@
* The hash must be: deterministic across key order (so two structurally
* equal snapshots always match), locale-agnostic and day-label-agnostic
* (so a midnight rollover or a locale switch alone never forces a
* regeneration), clock-offset-agnostic (dayOffset / *DaysAgo shift with
* `now`, not with the data), and sensitive to every actual data value
* the prompt narrates. All assertions use fixed inputs — no clocks, no
* timezones.
* regeneration), clock-offset-agnostic for positional offsets (dayOffset
* shifts with `now`, not with the data), tier-bucketed for the `*DaysAgo`
* recency keys (stable within a staleness tier, flipping exactly at the
* 1/2, 7/8 and 30/31 boundaries so the staleness caveat can re-enter the
* text), and sensitive to every actual data value the prompt narrates.
* All assertions use fixed inputs — no clocks, no timezones.
*/
import { describe, it, expect } from "vitest";
import { hashInsightSnapshot } from "../snapshot-hash";
import { hashInsightSnapshot, stalenessTier } from "../snapshot-hash";
describe("hashInsightSnapshot", () => {
it("is a sha256 hex digest", () => {
@@ -50,7 +52,7 @@ describe("hashInsightSnapshot", () => {
expect(de).toBe(en);
});
it("ignores clock-relative offsets (dayOffset, *DaysAgo) but not the values", () => {
it("ignores positional offsets (dayOffset) but not the values", () => {
const yesterday = hashInsightSnapshot({
dataCoverage: { totalMeasurements: 12, newestMeasurementDaysAgo: 0 },
daily: [
@@ -65,7 +67,8 @@ describe("hashInsightSnapshot", () => {
{ dayOffset: 2, value: 80.4, n: 2 },
],
});
// Same readings, one day later, nothing new logged → same fingerprint.
// Same readings, one day later, nothing new logged, recency still in
// the fresh tier (0 → 1 days) → same fingerprint.
expect(yesterday).toBe(today);
const newReading = hashInsightSnapshot({
@@ -79,6 +82,72 @@ describe("hashInsightSnapshot", () => {
expect(newReading).not.toBe(today);
});
describe("staleness tiers for *DaysAgo keys", () => {
/** Snapshot with identical data values and a varying recency. */
function snap(newestMeasurementDaysAgo: number | null) {
return {
dataCoverage: { totalMeasurements: 12, newestMeasurementDaysAgo },
weight: { mean: 80.2, n: 12 },
};
}
it("maps day counts onto the 0-1 / 2-7 / 8-30 / 30+ tiers", () => {
expect(stalenessTier(0)).toBe("0-1d");
expect(stalenessTier(1)).toBe("0-1d");
expect(stalenessTier(2)).toBe("2-7d");
expect(stalenessTier(7)).toBe("2-7d");
expect(stalenessTier(8)).toBe("8-30d");
expect(stalenessTier(30)).toBe("8-30d");
expect(stalenessTier(31)).toBe("30d+");
expect(stalenessTier(365)).toBe("30d+");
});
it("is stable while the recency stays within one tier", () => {
expect(hashInsightSnapshot(snap(0))).toBe(hashInsightSnapshot(snap(1)));
expect(hashInsightSnapshot(snap(2))).toBe(hashInsightSnapshot(snap(7)));
expect(hashInsightSnapshot(snap(8))).toBe(hashInsightSnapshot(snap(30)));
expect(hashInsightSnapshot(snap(31))).toBe(
hashInsightSnapshot(snap(120)),
);
});
it("flips exactly when the recency crosses a tier boundary", () => {
// The day a user's data goes from "fresh" to "going stale" to
// "stale" the fingerprint must change, so the cached text picks up
// (or escalates) the out-of-date caveat instead of being re-stamped
// as current forever.
expect(hashInsightSnapshot(snap(1))).not.toBe(
hashInsightSnapshot(snap(2)),
);
expect(hashInsightSnapshot(snap(7))).not.toBe(
hashInsightSnapshot(snap(8)),
);
expect(hashInsightSnapshot(snap(30))).not.toBe(
hashInsightSnapshot(snap(31)),
);
});
it("keeps a null recency (no dated reading) distinct from every tier", () => {
const noReading = hashInsightSnapshot(snap(null));
expect(noReading).not.toBe(hashInsightSnapshot(snap(0)));
expect(noReading).not.toBe(hashInsightSnapshot(snap(31)));
});
it("buckets nested *DaysAgo keys the same way (per-series, signal block)", () => {
const fresh = hashInsightSnapshot({
signal: { metric: "Pulse", newestDaysAgo: 3, current: 61 },
});
const sameTier = hashInsightSnapshot({
signal: { metric: "Pulse", newestDaysAgo: 6, current: 61 },
});
const staleTier = hashInsightSnapshot({
signal: { metric: "Pulse", newestDaysAgo: 9, current: 61 },
});
expect(fresh).toBe(sameTier);
expect(fresh).not.toBe(staleTier);
});
});
it("keeps absolute day keys in the fingerprint", () => {
const a = hashInsightSnapshot({ recent: [{ date: "2026-06-09", mean: 80 }] });
const b = hashInsightSnapshot({ recent: [{ date: "2026-06-10", mean: 80 }] });
+90 -18
View File
@@ -81,9 +81,10 @@ import { annotate } from "@/lib/logging/context";
const CACHE_TTL_MS = 24 * 60 * 60 * 1000;
/**
* Debounce window for ingest-driven status invalidation. A scope whose
* cached assessment was (re)generated within this window is left intact on
* a fresh measurement ingest — not re-enqueued.
* Minimum gap between a scope's last cached (re)generation and the next
* ingest-driven re-enqueue. A scope whose cached assessment was
* (re)generated within this window is left intact on a fresh measurement
* ingest — not re-enqueued.
*
* A constantly-syncing client (Apple Health drips batches every few
* minutes) used to delete + re-enqueue every dirtied scope on every batch,
@@ -95,15 +96,24 @@ const CACHE_TTL_MS = 24 * 60 * 60 * 1000;
* still refreshes immediately, but a scope refreshed inside the window is
* skipped, so a fresh assessment survives the day's sync drip.
*
* v1.16.8 — widened from 30 min to 6 h. An assessment is a daily prose
* summary, not a live chart; a card that re-narrates the same day's data
* every half hour burns provider budget with no informational gain. Six
* hours bounds an all-day syncer to at most ~3 ingest-driven refreshes per
* scope, and the content-hash gate in each generator drops even those to
* zero LLM calls when the snapshot did not actually change. The 24 h TTL
* and the nightly warm still guarantee a fresh narration every morning.
* v1.16.8 — first widened from 30 min to 6 h to bound provider spend, but
* a 6 h wall meant same-day data was not narrated same-day: the nightly
* warm at 04:30 restarted the window, so a notable 08:00 reading never
* re-enqueued its scopes and the card showed the pre-reading text until
* the next nightly tick. The window is now a ONE-HOUR minimum gap, and
* the budget role the 6 h wall carried moves to the worker's content-hash
* gate: every ingest that lands past the gap re-enqueues the dirtied
* scopes, the worker's forced run re-gathers the snapshot, and the gate
* (`refreshUnchangedStatusInsight`) turns an unchanged snapshot into a
* timestamp refresh with zero provider cost. So this clock only bounds
* the SQL-gather frequency — at most one worker run per scope per hour
* under a constant sync drip — while provider spend tracks actual data
* change, which is what the gate exists to meter. Comparing the hash
* inline at invalidation time was rejected: it would run the full
* per-scope data gather inside the ingest path, which is exactly the
* work the queue exists to keep off that path.
*/
const INGEST_INVALIDATE_DEBOUNCE_MS = 6 * 60 * 60 * 1000;
const INGEST_INVALIDATE_MIN_GAP_MS = 60 * 60 * 1000;
const MAX_DOWNGRADE_TOKENS: ReadonlyArray<string> = [
"anthropometrics",
@@ -181,6 +191,9 @@ export async function buildComparisonSnapshotForUser(
value: true,
sleepStage: true,
source: true,
// Writer-level collapse: two HealthKit apps behind one
// source (watch stages vs phone in-bed) must not blend.
deviceType: true,
},
}),
resolveUserTimezone(userId),
@@ -332,10 +345,12 @@ export async function invalidateStatusInsightsForTypes(
// scope on every batch regenerated the same card several times an hour
// (the per-(user,scope,locale) enqueue singleton is only 120 s, so it
// could not coalesce across batches arriving minutes apart). Skip any
// scope whose cached assessment was (re)generated within the debounce
// window — leave its row intact and do NOT re-enqueue. A genuinely stale
// scope whose cached assessment was (re)generated within the minimum
// gap — leave its row intact and do NOT re-enqueue. A genuinely stale
// or missing scope still refreshes immediately, so correctness holds;
// only the redundant churn is removed.
// only the redundant churn is removed. Past the gap the enqueue always
// goes through — the worker's content-hash gate decides whether the
// batch actually changed anything (see the constant's doc).
const freshScopes = await findRecentlyWarmedScopes(userId, locale, scopes);
const staleScopes = Array.from(scopes).filter(
(scope) => !freshScopes.has(scope),
@@ -366,9 +381,54 @@ export async function invalidateStatusInsightsForTypes(
});
}
/**
* v1.16.8 — enqueue a hash-gated refill of every assessment card one user
* actually has: the seven specialised scopes plus the generic
* `metric:<ID>` scope of every measurement type with live rows. The
* worker regenerates each enqueued scope with `force: true`, so every
* card skips its same-day cache read, re-gathers its snapshot, and lands
* on the content-hash gate — a card whose data changed regenerates, an
* unchanged card gets a free timestamp refresh.
*
* This is the manual-regenerate path's card story: the POST regenerate
* used to blanket-evict every per-status row, which deleted the hash
* baselines and force-paid ~45 regenerations per click. Enqueuing through
* the gate keeps the baseline rows intact, so a user who noticed a stale
* card gets exactly the changed cards re-narrated — and nothing else.
* Deliberately NOT routed through the ingest debounce: an explicit
* regenerate is a user action, already bounded by the route's hourly
* rate limit and the queue's per-(user,scope,locale) singleton.
*
* Returns the number of scopes enqueued (best-effort — the generic-scope
* discovery read failing still refills the seven specialised cards).
*/
export async function enqueueStatusRefillForUser(
userId: string,
locale: "de" | "en",
): Promise<number> {
const scopes = new Set<InsightStatusScope>(PER_STATUS_SCOPES);
try {
const rows = await prisma.measurement.findMany({
where: { userId, deletedAt: null },
distinct: ["type"],
select: { type: true },
});
for (const row of rows) {
const metricId = metricIdForMeasurementType(row.type);
if (metricId) scopes.add(metricStatusScope(metricId));
}
} catch {
// Discovery is best-effort; the specialised scopes still refill.
}
for (const scope of scopes) {
void enqueueStatusGeneration({ userId, metric: scope, locale });
}
return scopes.size;
}
/**
* v1.9.0 — return the subset of `scopes` whose cached assessment for
* `locale` was generated within `INGEST_INVALIDATE_DEBOUNCE_MS` and is a
* `locale` was generated within `INGEST_INVALIDATE_MIN_GAP_MS` and is a
* real (non-stub) assessment. Those scopes are skipped by the ingest
* invalidator so a fresh assessment survives the sync drip.
*
@@ -383,7 +443,7 @@ async function findRecentlyWarmedScopes(
locale: "de" | "en",
scopes: ReadonlySet<InsightStatusScope>,
): Promise<Set<InsightStatusScope>> {
const cutoff = new Date(Date.now() - INGEST_INVALIDATE_DEBOUNCE_MS);
const cutoff = new Date(Date.now() - INGEST_INVALIDATE_MIN_GAP_MS);
// Match exactly the cache actions for the candidate scopes in this locale.
const candidateActions = Array.from(scopes, (scope) =>
statusCacheAction(scope, locale),
@@ -546,7 +606,19 @@ export async function generateComprehensiveInsight(
// sees has changed — refresh the cache timestamp and skip the provider
// call. This runs on the forced paths too (nightly tick, on-demand
// warm), which is exactly where the same-data regeneration waste lived.
const snapshotHash = hashInsightSnapshot(compactFeatures);
//
// The user-authored "about me" self-description joins the fingerprint:
// it is the one prompt input that can change with NO data change (the
// Coach remember action, a Settings → AI edit), and excluding it would
// pin the briefing to the pre-edit self-context until a measurement
// happens to move. The comparison + plateau blocks stay out — both are
// derived from the same measurement rows the feature snapshot already
// fingerprints. Fetched once here and reused for the prompt below.
const aboutMe = await getSelfContextTextForUser(userId, locale);
const snapshotHash = hashInsightSnapshot({
features: compactFeatures,
aboutMe: aboutMe ?? null,
});
if (
dbUser?.insightsCachedText &&
dbUser.insightsSnapshotHash === snapshotHash
@@ -576,7 +648,7 @@ export async function generateComprehensiveInsight(
// v1.15.20 — fold the user-authored "about me" self-description
// (Settings → AI) into the nightly briefing exactly like the
// on-demand route. Null (no text / undecryptable) costs nothing.
const aboutMe = await getSelfContextTextForUser(userId, locale);
// (Fetched above the hash gate — it is part of the fingerprint.)
if (aboutMe) {
userPrompt += buildAboutMeInsightBlock(aboutMe, locale);
}
+9 -1
View File
@@ -245,7 +245,15 @@ export async function generateMetricStatus(args: {
},
orderBy: { measuredAt: "desc" },
take: 365 * 6, // ≈ 5 stage rows/night × source-count for a year
select: { value: true, measuredAt: true, sleepStage: true, source: true },
// Writer-level collapse: two HealthKit apps behind one source
// (watch stages vs phone in-bed) must not blend into one night.
select: {
value: true,
measuredAt: true,
sleepStage: true,
source: true,
deviceType: true,
},
}),
]);
const nights = reconstructSleepNights(
+61 -10
View File
@@ -21,14 +21,27 @@
* the underlying data is locale-independent and the calendar label
* changes at midnight without any data change. Excluding them keeps
* the gate locale-agnostic and stable across the day rollover.
* - clock-relative offsets (`dayOffset`, keys ending in `DaysAgo`):
* these shift with `now`, not with the data. The bucket VALUES (and
* the absolute `date` / `weekISO` / `month` keys the graded series
* carry) stay in the hash, so a genuinely new or changed reading
* still changes the fingerprint. Sliding windows still move at the
* Berlin day boundary, so an active account converges to roughly one
* content change per scope per day — which is the regeneration
* budget the gate is meant to enforce.
* - clock-relative offsets (`dayOffset`): these shift with `now`, not
* with the data. The bucket VALUES (and the absolute `date` /
* `weekISO` / `month` keys the graded series carry) stay in the
* hash, so a genuinely new or changed reading still changes the
* fingerprint. Sliding windows still move at the Berlin day
* boundary, so an active account converges to roughly one content
* change per scope per day — which is the regeneration budget the
* gate is meant to enforce.
*
* Keys ending in `DaysAgo` (`newestMeasurementDaysAgo`, the per-series
* `newestDaysAgo` / `oldestDaysAgo`, the signal block's recency) get a
* third treatment: they are hashed as a coarse STALENESS TIER, not as the
* raw day count and not excluded. The raw number shifts with `now` every
* midnight, but the prompt's staleness caveat ("values may be out of
* date" past ~7 days) is driven by exactly these keys — excluding them
* wholesale meant an account that stopped logging kept an unchanged
* fingerprint forever, so the daily refresh re-stamped a text that
* narrated week-old readings as current. Bucketing keeps the fingerprint
* stable across ordinary day rollovers within a tier and flips it
* precisely when the data crosses a staleness boundary — i.e. when the
* narrative should change.
*
* Serialisation is canonical: object keys are sorted, arrays keep their
* order, `Date` collapses to ISO-8601, `undefined` members are dropped
@@ -48,7 +61,36 @@ const VOLATILE_KEYS = new Set([
]);
function isVolatileKey(key: string): boolean {
return VOLATILE_KEYS.has(key) || key.endsWith("DaysAgo");
return VOLATILE_KEYS.has(key);
}
/**
* Staleness tiers for `*DaysAgo` keys. The boundaries follow the prompt
* contract: 0-1 days is "current" (the prompts phrase today / 1 day ago
* specially), 2-7 days is recent (no caveat yet — the data-strength line
* flags staleness strictly past 7 days), 8-30 days carries the
* "values may be out of date" caveat, and past 30 days the picture is
* long stale. Crossing a boundary flips the fingerprint so the cached
* text regenerates with the right framing; movement within a tier keeps
* it stable so an ordinary midnight rollover stays free.
*/
export type StalenessTier = "0-1d" | "2-7d" | "8-30d" | "30d+";
/**
* Map a days-ago count to its staleness tier. Pure integer bucketing —
* the caller computed the day count, so no clock and no timezone enter
* here and the tier is stable wherever the hash is computed.
*/
export function stalenessTier(daysAgo: number): StalenessTier {
if (daysAgo <= 1) return "0-1d";
if (daysAgo <= 7) return "2-7d";
if (daysAgo <= 30) return "8-30d";
return "30d+";
}
/** True for the day-count recency keys that hash as a staleness tier. */
function isStalenessKey(key: string): boolean {
return key.endsWith("DaysAgo");
}
/** Canonical, key-sorted serialisation with volatile keys dropped. */
@@ -64,7 +106,16 @@ function canonicalise(value: unknown): string {
const entries = Object.entries(value as Record<string, unknown>)
.filter(([key, v]) => v !== undefined && !isVolatileKey(key))
.sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0))
.map(([key, v]) => `${JSON.stringify(key)}:${canonicalise(v)}`);
.map(([key, v]) => {
// A days-ago recency hashes as its coarse tier (see module doc);
// a null / non-numeric value falls through to plain canonical
// form so "no dated reading" stays distinct from every tier.
const canonical =
isStalenessKey(key) && typeof v === "number" && Number.isFinite(v)
? JSON.stringify(stalenessTier(v))
: canonicalise(v);
return `${JSON.stringify(key)}:${canonical}`;
});
return `{${entries.join(",")}}`;
}
// string | number | boolean — JSON.stringify is canonical for these.
@@ -0,0 +1,95 @@
/**
* v1.16.8 — batched per-card compliance read, error contract.
*
* The hook's queryFn used to swallow every failure to `null`. TanStack
* caches that `null` as a SUCCESS, so a failed / aborted batch read
* neither retried nor refetched and every medication card sat on the
* loading skeleton until a full invalidation. The contract now: the
* queryFn lets the rejection propagate (default retry/backoff applies),
* `isError` + `refetch` surface to the cards so the compliance slot can
* swap to the quiet retry fallback, and the per-card `select` keeps the
* one-request / many-cards collapse intact.
*/
import { describe, it, expect, vi, beforeEach } from "vitest";
const useQueryMock = vi.fn<(opts: Record<string, unknown>) => unknown>();
vi.mock("@tanstack/react-query", () => ({
useQuery: (opts: Record<string, unknown>) => useQueryMock(opts),
}));
const apiGetMock = vi.fn();
vi.mock("@/lib/api/api-fetch", () => ({
apiGet: (...a: unknown[]) => apiGetMock(...a),
}));
import { useMedicationComplianceSummary } from "../use-medication-compliance-summary";
import { queryKeys } from "@/lib/query-keys";
interface CapturedOptions {
queryKey: unknown;
queryFn: () => Promise<unknown>;
select: (rows: Array<{ medicationId: string }>) => unknown;
retry?: unknown;
}
function lastOpts(): CapturedOptions {
expect(useQueryMock).toHaveBeenCalledTimes(1);
return useQueryMock.mock.calls[0][0] as unknown as CapturedOptions;
}
beforeEach(() => {
vi.clearAllMocks();
useQueryMock.mockReturnValue({
data: undefined,
isError: false,
refetch: vi.fn(),
});
});
describe("useMedicationComplianceSummary", () => {
it("registers under the centralised factory key", () => {
useMedicationComplianceSummary("med-1");
expect(lastOpts().queryKey).toEqual(
queryKeys.medicationComplianceSummary(),
);
});
it("lets a failed batch read REJECT instead of resolving to null", async () => {
useMedicationComplianceSummary("med-1");
const failure = new Error("Request failed (500)");
apiGetMock.mockRejectedValue(failure);
// No catch in the queryFn: TanStack sees the rejection, applies its
// default retry, and lands the query in error state — the previous
// swallowed-`null` version cached a fake success forever.
await expect(lastOpts().queryFn()).rejects.toBe(failure);
});
it("does not disable the default retry behaviour", () => {
useMedicationComplianceSummary("med-1");
expect(lastOpts().retry).toBeUndefined();
});
it("selects the card's own row from the shared batch (null when absent)", () => {
useMedicationComplianceSummary("med-1");
const opts = lastOpts();
const rows = [
{ medicationId: "med-0" },
{ medicationId: "med-1" },
{ medicationId: "med-2" },
];
expect(opts.select(rows)).toEqual({ medicationId: "med-1" });
expect(opts.select([{ medicationId: "med-9" }])).toBeNull();
});
it("surfaces isError and refetch to the card", () => {
const refetch = vi.fn();
useQueryMock.mockReturnValue({ data: undefined, isError: true, refetch });
const result = useMedicationComplianceSummary("med-1");
expect(result.isError).toBe(true);
result.refetch();
expect(refetch).toHaveBeenCalledTimes(1);
});
});
@@ -34,28 +34,31 @@ export interface MedicationComplianceSummaryEntry {
* Lives in one shared hook (not per-card `useQuery` calls) so the two
* card variants can never register the same key with diverging fetch
* shapes.
*
* Errors propagate — the queryFn deliberately does NOT catch. An earlier
* revision swallowed failures to `null`, which froze every card on the
* loading skeleton with no retry: the cached `null` looked like a
* resolved success, so TanStack neither retried nor refetched. Letting
* the rejection through restores the default retry/backoff, and `isError`
* lets the cards swap the skeleton for a quiet same-footprint fallback
* with a manual retry.
*/
export function useMedicationComplianceSummary(medicationId: string): {
data: MedicationComplianceSummaryEntry | null | undefined;
isError: boolean;
refetch: () => void;
} {
const { data } = useQuery({
const { data, isError, refetch } = useQuery({
queryKey: queryKeys.medicationComplianceSummary(),
queryFn: async () => {
try {
return await apiGet<MedicationComplianceSummaryEntry[]>(
"/api/medications/compliance",
);
} catch {
return null;
}
},
queryFn: () =>
apiGet<MedicationComplianceSummaryEntry[]>("/api/medications/compliance"),
// Dose actions invalidate the key explicitly through
// `medicationDependentKeys`; a shorter window would only re-fire the
// batch on every list visit. Matches the reminder-thresholds query
// the cards mount alongside.
staleTime: 5 * 60 * 1000,
select: (rows: MedicationComplianceSummaryEntry[] | null) =>
rows?.find((row) => row.medicationId === medicationId) ?? null,
select: (rows: MedicationComplianceSummaryEntry[]) =>
rows.find((row) => row.medicationId === medicationId) ?? null,
});
return { data };
return { data, isError, refetch };
}