mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-13 22:32:04 +00:00
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:
1 parent
3d95ace218
commit
a2a995e429
25 files changed
+770
-97
No files matched your search
@@ -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)}
|
||||
|
||||
Reference in new issue
Block a user