mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-14 06:40:50 +00:00
fix(dashboard,rollups): the score survives new data types and folds stop eating buckets
The snapshot's thick phase gated on every type the account had ever logged having rollup coverage — one fresh reading from a new integration nulled the health score and the blood-pressure extras while the insights page showed a score for the same account. The gate now checks only the three types the phase reads, per the established per-type pattern. Underneath sat the destabiliser: the large rollup fold deleted the window's buckets and re-inserted without a transaction or duplicate tolerance, so a racing sync write aborted the insert and left the delete committed — whole types lost their buckets until the next clean fold, and accounts with data older than the five-year fold window re-ran the full backfill on every boot because discovery scanned unbounded history. Delete and insert now commit atomically with duplicate tolerance, discovery shares the fold's bound, the provisional ring says the score is being computed when the inputs exist and reserves the no-data wording for accounts that have none, and the greeting leads the band typographically.
This commit is contained in:
1 parent
7d3557a3fd
commit
d142a6ed31
6 files changed
+448
-37
No files matched your search
@@ -494,6 +494,77 @@ describe("<DashboardHero> — score column", () => {
|
||||
// collapses (the 120 px sm geometry rides an inline style).
|
||||
expect(html).toContain("width:120px");
|
||||
});
|
||||
|
||||
// A null score on an account that already carries score inputs is a
|
||||
// warm-up state (the rollup tier is mid-fold), not a data gap — the
|
||||
// copy must say "computing", never "not enough data".
|
||||
it("null score WITH existing score inputs renders the computing label, not the data-gap one", () => {
|
||||
const html = render(
|
||||
baseSnapshot({
|
||||
healthScore: null,
|
||||
tiles: {
|
||||
summaries: { WEIGHT: summary({ count: 12, latest: 80 }) },
|
||||
lastSeenByType: {
|
||||
WEIGHT: { lastSeenAt: isoHoursFromNow(-2), daysAgo: 0 },
|
||||
},
|
||||
mood: { summary: null, entries: [] },
|
||||
},
|
||||
}),
|
||||
);
|
||||
expect(html).toContain('data-provisional="true"');
|
||||
expect(html).toContain("Score wird berechnet");
|
||||
expect(html).not.toContain("Noch nicht genug Daten");
|
||||
});
|
||||
|
||||
it("active medications alone count as a score input (compliance pillar)", () => {
|
||||
const html = render(
|
||||
baseSnapshot({
|
||||
healthScore: null,
|
||||
medsToday: medsToday({ activeCount: 1 }),
|
||||
}),
|
||||
);
|
||||
expect(html).toContain("Score wird berechnet");
|
||||
expect(html).not.toContain("Noch nicht genug Daten");
|
||||
});
|
||||
|
||||
it("keeps the genuine data-gap label when no score input exists at all", () => {
|
||||
// Empty summaries, no mood entries, no active medications — the
|
||||
// baseSnapshot. A non-score-pillar summary (steps) must not flip
|
||||
// the copy either.
|
||||
const html = render(
|
||||
baseSnapshot({
|
||||
healthScore: null,
|
||||
tiles: {
|
||||
summaries: { ACTIVITY_STEPS: summary({ count: 30, latest: 9000 }) },
|
||||
lastSeenByType: {},
|
||||
mood: { summary: null, entries: [] },
|
||||
},
|
||||
}),
|
||||
);
|
||||
expect(html).toContain("Noch nicht genug Daten");
|
||||
expect(html).not.toContain("Score wird berechnet");
|
||||
});
|
||||
});
|
||||
|
||||
describe("<DashboardHero> — typographic hierarchy", () => {
|
||||
it("the greeting leads: larger + heavier than the verdict line", () => {
|
||||
const html = render(baseSnapshot());
|
||||
const greeting = html.match(
|
||||
/<p[^>]*data-slot="dashboard-hero-greeting"[^>]*>/,
|
||||
);
|
||||
expect(greeting).not.toBeNull();
|
||||
expect(greeting![0]).toContain("text-lg");
|
||||
expect(greeting![0]).toContain("font-semibold");
|
||||
expect(greeting![0]).not.toContain("text-muted-foreground");
|
||||
const verdict = html.match(
|
||||
/<p[^>]*data-slot="dashboard-hero-verdict"[^>]*>/,
|
||||
);
|
||||
expect(verdict).not.toBeNull();
|
||||
// The verdict stays one step down — base size, medium weight.
|
||||
expect(verdict![0]).toContain("text-base");
|
||||
expect(verdict![0]).toContain("font-medium");
|
||||
expect(verdict![0]).not.toContain("font-semibold");
|
||||
});
|
||||
});
|
||||
|
||||
describe("<DashboardHero> — source pins", () => {
|
||||
|
||||
@@ -11,11 +11,18 @@
|
||||
*
|
||||
* - left column: greeting line (absorbed from the old header
|
||||
* paragraph — the snapshot's server-computed `greetingHour` keeps
|
||||
* the daypart free of any client `Intl` work), the verdict sentence
|
||||
* with its single CTA button, and the dose row;
|
||||
* the daypart free of any client `Intl` work; typographically the
|
||||
* band's anchor, larger and heavier than the verdict so the
|
||||
* personalised line is not drowned by the content below it), the
|
||||
* verdict sentence with its single CTA button, and the dose row;
|
||||
* - right column: the shared `<ScoreRing>` at `sm` geometry. A null
|
||||
* score renders the ring's provisional state at the identical
|
||||
* 120 px footprint, so the column never collapses.
|
||||
* 120 px footprint, so the column never collapses. The provisional
|
||||
* label is honest about WHY the score is null: when the snapshot
|
||||
* already carries score inputs (weight / BP summaries, mood
|
||||
* entries, active medications) the rollup tier is merely mid-fold
|
||||
* and the label reads "computing"; only an account with no score
|
||||
* inputs at all gets "not enough data".
|
||||
*
|
||||
* Defensive contract (cached snapshot): `medsToday.nextDueAt` in the
|
||||
* past with `nextDueOverdue: false` means the slot's anchor passed
|
||||
@@ -167,6 +174,22 @@ export function DashboardHero({
|
||||
const cta = verdict.cta;
|
||||
const ctaLabelKey = CTA_LABEL_KEY[verdict.variant];
|
||||
|
||||
// ── Provisional score copy ──────────────────────────────────────────
|
||||
// The score's four pillars are weight, BP, mood, and medication
|
||||
// compliance. When ANY of them already carries data, a null score is
|
||||
// a warm-up state (the rollup tier hasn't folded the buckets the
|
||||
// warm-phase score rides on yet) — telling an account with weeks of
|
||||
// readings "not enough data" would be a lie. Only an account with no
|
||||
// score inputs at all keeps the genuine empty-state copy. Cheap
|
||||
// heuristic over fields the snapshot already carries; no extra reads.
|
||||
const summaries = snapshot.tiles.summaries;
|
||||
const hasScoreInputs =
|
||||
(summaries.WEIGHT?.count ?? 0) > 0 ||
|
||||
(summaries.BLOOD_PRESSURE_SYS?.count ?? 0) > 0 ||
|
||||
(summaries.BLOOD_PRESSURE_DIA?.count ?? 0) > 0 ||
|
||||
snapshot.tiles.mood.entries.length > 0 ||
|
||||
snapshot.medsToday.activeCount > 0;
|
||||
|
||||
// ── Dose row ────────────────────────────────────────────────────────
|
||||
const meds = snapshot.medsToday;
|
||||
const now = new Date();
|
||||
@@ -206,9 +229,15 @@ export function DashboardHero({
|
||||
>
|
||||
<div className="flex h-full flex-col gap-4 md:flex-row md:items-center md:justify-between">
|
||||
<div className="min-w-0 flex-1 space-y-3">
|
||||
{/* Greeting leads the typographic hierarchy: larger + heavier
|
||||
than the verdict so the personalised line reads first.
|
||||
The verdict keeps its medium weight one step down — still
|
||||
the content lead, clearly subordinate to the greeting.
|
||||
Both stay on the existing foreground token; the band adds
|
||||
no new colour. */}
|
||||
<p
|
||||
data-slot="dashboard-hero-greeting"
|
||||
className="text-muted-foreground text-sm"
|
||||
className="text-foreground text-lg font-semibold tracking-tight sm:text-xl"
|
||||
>
|
||||
{welcomeText}
|
||||
</p>
|
||||
@@ -216,7 +245,7 @@ export function DashboardHero({
|
||||
<p
|
||||
data-slot="dashboard-hero-verdict"
|
||||
data-verdict-variant={verdict.variant}
|
||||
className="text-foreground text-base font-medium"
|
||||
className="text-foreground/90 text-base font-medium"
|
||||
>
|
||||
{sentence}
|
||||
</p>
|
||||
@@ -276,7 +305,9 @@ export function DashboardHero({
|
||||
label={
|
||||
snapshot.healthScore
|
||||
? t("dashboard.hero.scoreLabel")
|
||||
: t("dashboard.hero.scoreProvisional")
|
||||
: hasScoreInputs
|
||||
? t("dashboard.hero.scoreComputing")
|
||||
: t("dashboard.hero.scoreProvisional")
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
||||
Reference in new issue
Block a user