mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 21:10:59 +00:00
Drop the just-in line from the Today hero
Under a number, 'Just in' did not say what had arrived - the score? a reading? - and the clock face beside it changed nothing a person would then do. The line is gone, and with it the only wall clock this surface rendered. What it was there for survives: the day still flips in place when last night's sleep lands, and the pending-night note still says the number is reading an incomplete day. An arrival on its own is no longer a reason to paint a hero at all, so an account whose only news was the arrival now gets nothing rather than a bordered empty card. The digest still carries the arrival, which the morning line and the wide event both read.
This commit is contained in:
1 parent
ee63b09e59
commit
4e934a191d
9 files changed
+91
-211
No files matched your search
+19
-18
@@ -12,15 +12,21 @@ import { E2E_USER, STORAGE_STATE_PATH } from "./setup/global-setup";
|
||||
*
|
||||
* The spec drives that end-to-end against the real read path:
|
||||
*
|
||||
* 1. load the dashboard with nothing fresh — no chip, day is provisional;
|
||||
* 2. land last night's sleep through the REAL batch endpoint, and the
|
||||
* arrival marker the spine's worker would have written;
|
||||
* 1. load the dashboard with last night's sleep still missing;
|
||||
* 2. land it through the REAL batch endpoint, and the arrival marker the
|
||||
* spine's worker would have written;
|
||||
* 3. surface the tab's real visibility-change signal — the browser event
|
||||
* TanStack Query's focus manager consumes for
|
||||
* `refetchOnWindowFocus: "always"` (alongside the 120 s foreground poll);
|
||||
* using that existing trigger keeps the test inside the real cadence
|
||||
* while staying well under the poll interval;
|
||||
* 4. assert the chip appeared and the phase flipped — in place, no reload.
|
||||
* 4. assert the phase flipped and the pending note cleared — in place, no
|
||||
* reload.
|
||||
*
|
||||
* The hero used to carry a "just in" chip here; it is gone, because under a
|
||||
* number "just in" never said what had arrived. What the milestone was
|
||||
* always about survives it: the open page reads differently on its own
|
||||
* cadence.
|
||||
*
|
||||
* The marker is seeded directly rather than waited for from pg-boss: whether
|
||||
* the queue drains inside the e2e web server is the SPINE's contract and has
|
||||
@@ -43,9 +49,7 @@ function localDayKey(at: Date, timeZone: string): string {
|
||||
}).format(at);
|
||||
}
|
||||
|
||||
test("a fresh arrival surfaces the just-in chip and flips the day in place", async ({
|
||||
page,
|
||||
}) => {
|
||||
test("a fresh arrival flips the day in place", async ({ page }) => {
|
||||
const dbUrl = process.env.DATABASE_URL;
|
||||
test.skip(!dbUrl, "DATABASE_URL is required to seed the arrival marker");
|
||||
|
||||
@@ -85,10 +89,10 @@ test("a fresh arrival surfaces the just-in chip and flips the day in place", asy
|
||||
);
|
||||
|
||||
// ── BEFORE ────────────────────────────────────────────────────────────
|
||||
// Wait until the digest settles, then prove there is no stale arrival
|
||||
// reaction. Other browser specs deliberately reuse this account and may
|
||||
// have recorded older health data already, so an ordinary briefing Hero
|
||||
// is a valid baseline; the invariant here is that it has no just-in chip.
|
||||
// Wait until the digest settles. Other browser specs deliberately reuse
|
||||
// this account and may have recorded older health data already, so an
|
||||
// ordinary briefing hero is a valid baseline. Nothing on it may name an
|
||||
// arrival: that chip was removed.
|
||||
await page.goto("/");
|
||||
await expect(page.locator('[data-slot="today-hero-skeleton"]')).toHaveCount(
|
||||
0,
|
||||
@@ -149,9 +153,6 @@ test("a fresh arrival surfaces the just-in chip and flips the day in place", asy
|
||||
window.dispatchEvent(new Event("visibilitychange")),
|
||||
);
|
||||
|
||||
await expect(hero.locator('[data-slot="today-hero-just-in"]')).toBeVisible({
|
||||
timeout: 20_000,
|
||||
});
|
||||
await expect(hero).toHaveAttribute("data-phase", "final", {
|
||||
timeout: 20_000,
|
||||
});
|
||||
@@ -159,10 +160,10 @@ test("a fresh arrival surfaces the just-in chip and flips the day in place", asy
|
||||
await expect(
|
||||
hero.locator('[data-slot="today-hero-sleep-pending"]'),
|
||||
).toHaveCount(0);
|
||||
// The chip names the arrival kind — the stable attribute, not the copy.
|
||||
await expect(
|
||||
hero.locator('[data-slot="today-hero-just-in"]'),
|
||||
).toHaveAttribute("data-just-in-kind", "sleep_night");
|
||||
// And the arrival still raises no chip of its own, on either pass.
|
||||
await expect(page.locator('[data-slot="today-hero-just-in"]')).toHaveCount(
|
||||
0,
|
||||
);
|
||||
} finally {
|
||||
client.release();
|
||||
await pool.end();
|
||||
|
||||
@@ -9288,8 +9288,6 @@
|
||||
"deltaVsBaseline": "{delta} ggü. deinem Ausgangswert",
|
||||
"deltaFlat": "Im Bereich deines Ausgangswerts",
|
||||
"sleepPending": "Der Schlaf von letzter Nacht fehlt noch – der heutige Wert ist vorläufig.",
|
||||
"justIn": "Gerade eingetroffen",
|
||||
"justInAt": "Gerade eingetroffen · {time}",
|
||||
"worthALook": "Einen Blick wert",
|
||||
"allClear": "Heute braucht nichts deine Aufmerksamkeit.",
|
||||
"ringLink": "{metric}-Details öffnen"
|
||||
|
||||
@@ -9288,8 +9288,6 @@
|
||||
"deltaVsBaseline": "{delta} vs your baseline",
|
||||
"deltaFlat": "In line with your baseline",
|
||||
"sleepPending": "Last night's sleep isn't in yet — today's read is provisional.",
|
||||
"justIn": "Just in",
|
||||
"justInAt": "Just in · {time}",
|
||||
"worthALook": "Worth a look",
|
||||
"allClear": "Nothing needs your attention today.",
|
||||
"ringLink": "Open {metric} details"
|
||||
|
||||
@@ -9288,8 +9288,6 @@
|
||||
"deltaVsBaseline": "{delta} frente a tu valor de referencia",
|
||||
"deltaFlat": "En línea con tu valor de referencia",
|
||||
"sleepPending": "El sueño de anoche aún no ha llegado; la lectura de hoy es provisional.",
|
||||
"justIn": "Recién llegado",
|
||||
"justInAt": "Recién llegado · {time}",
|
||||
"worthALook": "Merece un vistazo",
|
||||
"allClear": "Hoy nada necesita tu atención.",
|
||||
"ringLink": "Abrir detalles de {metric}"
|
||||
|
||||
@@ -9288,8 +9288,6 @@
|
||||
"deltaVsBaseline": "{delta} par rapport à ta référence",
|
||||
"deltaFlat": "Conforme à ta référence",
|
||||
"sleepPending": "Le sommeil de la nuit dernière n'est pas encore arrivé — la lecture du jour est provisoire.",
|
||||
"justIn": "Vient d'arriver",
|
||||
"justInAt": "Vient d'arriver · {time}",
|
||||
"worthALook": "À regarder",
|
||||
"allClear": "Rien ne requiert ton attention aujourd'hui.",
|
||||
"ringLink": "Ouvrir les détails de {metric}"
|
||||
|
||||
@@ -9288,8 +9288,6 @@
|
||||
"deltaVsBaseline": "{delta} rispetto al tuo valore di riferimento",
|
||||
"deltaFlat": "In linea con il tuo valore di riferimento",
|
||||
"sleepPending": "Il sonno di stanotte non è ancora arrivato: la lettura di oggi è provvisoria.",
|
||||
"justIn": "Appena arrivato",
|
||||
"justInAt": "Appena arrivato · {time}",
|
||||
"worthALook": "Da tenere d'occhio",
|
||||
"allClear": "Oggi nulla richiede la tua attenzione.",
|
||||
"ringLink": "Apri i dettagli di {metric}"
|
||||
|
||||
@@ -9288,8 +9288,6 @@
|
||||
"deltaVsBaseline": "{delta} wzgl. Twojej wartości bazowej",
|
||||
"deltaFlat": "Zgodnie z Twoją wartością bazową",
|
||||
"sleepPending": "Sen z ostatniej nocy jeszcze nie dotarł — dzisiejszy odczyt jest wstępny.",
|
||||
"justIn": "Właśnie dotarło",
|
||||
"justInAt": "Właśnie dotarło · {time}",
|
||||
"worthALook": "Warto sprawdzić",
|
||||
"allClear": "Dziś nic nie wymaga Twojej uwagi.",
|
||||
"ringLink": "Otwórz szczegóły: {metric}"
|
||||
|
||||
@@ -1,22 +1,17 @@
|
||||
/**
|
||||
* The Today hero's "just in" chip and the reaction line's lead replacement.
|
||||
* The Today hero's arrival handling and the reaction line's lead
|
||||
* replacement.
|
||||
*
|
||||
* Two invariants, both of which have a specific failure this project has
|
||||
* already paid for:
|
||||
* The hero used to carry a "just in" chip under the score, with the
|
||||
* arrival's local time on it. It is gone: under a number, "just in" did
|
||||
* not say what had arrived — the score? a reading? — and the clock face
|
||||
* changed nothing a person would then do. What is left is the invariant
|
||||
* that an arrival alone is not a reason to paint anything, and the one
|
||||
* the reaction line has always had.
|
||||
*
|
||||
* 1. HYDRATION. The hero is deliberately NOT mount-gated — it paints from the
|
||||
* server-dehydrated digest on the SSR pass because it is the LCP element
|
||||
* (v1.30.9). So anything inside it that differs between the server and the
|
||||
* browser is a React #418 mismatch. A wall-clock time is exactly that: the
|
||||
* server's locale and timezone are not the reader's. The chip therefore
|
||||
* renders its SLOT on both passes and fills the time in only after mount.
|
||||
* These tests render through `renderToStaticMarkup`, which takes the SERVER
|
||||
* snapshot of `useSyncExternalStore` — i.e. precisely the pass that must
|
||||
* carry no formatted time.
|
||||
*
|
||||
* 2. NO LAYOUT SHIFT. The reaction line REPLACES the lead; it is never a second
|
||||
* paragraph. The hero stays one lead line tall, so nothing below it moves
|
||||
* when the line arrives on a poll.
|
||||
* NO LAYOUT SHIFT. The reaction line REPLACES the lead; it is never a
|
||||
* second paragraph. The hero stays one lead line tall, so nothing below
|
||||
* it moves when the line arrives on a poll.
|
||||
*/
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
@@ -53,30 +48,56 @@ function digest(over: Partial<DailyDigest> = {}): DailyDigest {
|
||||
};
|
||||
}
|
||||
|
||||
/** Any hh:mm clock face — the thing the server must never emit. */
|
||||
/** Any hh:mm clock face — the thing the hero must never print here. */
|
||||
const CLOCK_FACE = /\d{1,2}:\d{2}/;
|
||||
|
||||
describe("TodayHero — the just-in chip", () => {
|
||||
it("renders no chip when nothing landed", () => {
|
||||
const html = render(<TodayHero digest={digest()} />);
|
||||
expect(html).not.toContain('data-slot="today-hero-just-in"');
|
||||
});
|
||||
|
||||
it("renders the chip slot on the server pass, carrying the kind", () => {
|
||||
describe("TodayHero — an arrival is not a chip", () => {
|
||||
it("paints no arrival marker when something has landed", () => {
|
||||
const html = render(
|
||||
<TodayHero
|
||||
digest={digest({ justIn: { kind: "sleep_night", at: ARRIVED_AT } })}
|
||||
/>,
|
||||
);
|
||||
|
||||
// The slot is present on the SSR pass, so the hydration render — which
|
||||
// takes the same server snapshot — produces identical HTML.
|
||||
expect(html).toContain('data-slot="today-hero-just-in"');
|
||||
expect(html).toContain('data-just-in-kind="sleep_night"');
|
||||
expect(html).toContain("Just in");
|
||||
expect(html).not.toContain('data-slot="today-hero-just-in"');
|
||||
expect(html).not.toContain("Just in");
|
||||
expect(html).not.toContain(ARRIVED_AT);
|
||||
});
|
||||
|
||||
it("renders for a bare account when the arrival has no generated line", () => {
|
||||
it("prints no clock face beside the score", () => {
|
||||
// The removed chip was the only wall clock on this surface, and a
|
||||
// wall clock rendered on the server pass is a hydration mismatch as
|
||||
// well as noise. Neither is possible once nothing prints one.
|
||||
const html = render(
|
||||
<TodayHero
|
||||
digest={digest({ justIn: { kind: "weight", at: ARRIVED_AT } })}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(html).not.toMatch(CLOCK_FACE);
|
||||
});
|
||||
|
||||
it("still shows a pending night, which is a different sentence", () => {
|
||||
// The freshness note says the day is not complete yet, which changes
|
||||
// how the number should be read. That one stays.
|
||||
const html = render(
|
||||
<TodayHero
|
||||
digest={digest({
|
||||
phase: "provisional",
|
||||
sleepPending: true,
|
||||
justIn: { kind: "weight", at: ARRIVED_AT },
|
||||
})}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(html).toContain('data-slot="today-hero-sleep-pending"');
|
||||
expect(html).not.toContain('data-slot="today-hero-just-in"');
|
||||
});
|
||||
|
||||
it("renders nothing at all for an account whose only news was the arrival", () => {
|
||||
// With no chip there is nothing left for such a digest to say, and a
|
||||
// bordered empty hero above the tile strip would be worse than
|
||||
// absence.
|
||||
const html = render(
|
||||
<TodayHero
|
||||
digest={digest({
|
||||
@@ -89,68 +110,7 @@ describe("TodayHero — the just-in chip", () => {
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(html).toContain('data-slot="today-hero"');
|
||||
expect(html).toContain('data-slot="today-hero-just-in"');
|
||||
});
|
||||
|
||||
it("emits NO formatted time and no raw ISO instant on the server pass", () => {
|
||||
const html = render(
|
||||
<TodayHero
|
||||
digest={digest({ justIn: { kind: "weight", at: ARRIVED_AT } })}
|
||||
/>,
|
||||
);
|
||||
|
||||
const chip = html.slice(html.indexOf('data-slot="today-hero-just-in"'));
|
||||
// The mismatch itself: a wall clock the browser would render differently.
|
||||
expect(chip).not.toMatch(CLOCK_FACE);
|
||||
// And the raw instant must not leak either — it is machine data, not copy.
|
||||
expect(html).not.toContain(ARRIVED_AT);
|
||||
});
|
||||
|
||||
it("keeps the chip muted meta — never an accent, never an opacity modifier", () => {
|
||||
const html = render(
|
||||
<TodayHero
|
||||
digest={digest({ justIn: { kind: "workout", at: ARRIVED_AT } })}
|
||||
/>,
|
||||
);
|
||||
|
||||
const row = html.slice(
|
||||
0,
|
||||
html.indexOf('data-slot="today-hero-just-in"') + 400,
|
||||
);
|
||||
expect(row).toContain("text-muted-foreground");
|
||||
expect(row).toContain("text-xs");
|
||||
// UI-STANDARDS §text: alpha on muted text drops below AA contrast.
|
||||
expect(html).not.toContain("text-muted-foreground/");
|
||||
expect(html).not.toContain("text-primary");
|
||||
});
|
||||
|
||||
it("shows the chip alongside a still-pending night", () => {
|
||||
// Legitimately co-occurring: a weight landed this morning while last
|
||||
// night's sleep has not arrived yet.
|
||||
const html = render(
|
||||
<TodayHero
|
||||
digest={digest({
|
||||
phase: "provisional",
|
||||
sleepPending: true,
|
||||
justIn: { kind: "weight", at: ARRIVED_AT },
|
||||
})}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(html).toContain('data-slot="today-hero-sleep-pending"');
|
||||
expect(html).toContain('data-slot="today-hero-just-in"');
|
||||
});
|
||||
|
||||
it("localises the chip", () => {
|
||||
const html = render(
|
||||
<TodayHero
|
||||
digest={digest({ justIn: { kind: "sleep_night", at: ARRIVED_AT } })}
|
||||
/>,
|
||||
"de",
|
||||
);
|
||||
expect(html).toContain("Gerade eingetroffen");
|
||||
expect(html).not.toContain("Just in");
|
||||
expect(html).not.toContain('data-slot="today-hero"');
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -37,8 +37,6 @@
|
||||
import Link from "next/link";
|
||||
import { Moon } from "lucide-react";
|
||||
|
||||
import { useMounted } from "@/hooks/use-mounted";
|
||||
import { formatTime } from "@/lib/format";
|
||||
import { ScoreRing } from "@/components/insights/derived/score-ring";
|
||||
import type { ScoreBand } from "@/components/insights/derived/band-tokens";
|
||||
import { ProseBlocks } from "@/components/insights/prose-blocks";
|
||||
@@ -86,32 +84,6 @@ function withoutRepeatedScore(
|
||||
return result.length > 0 ? result : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* The "just in" chip's clock face.
|
||||
*
|
||||
* Formatted CLIENT-side, and only after mount. `justIn.at` crosses the wire as
|
||||
* an ISO instant precisely so this stays a client concern: the server has no
|
||||
* business rendering a local wall-clock time it would format under its OWN
|
||||
* locale and timezone, and any such string would differ from the browser's on
|
||||
* the hydration render — React #418, a class of bug this project has already
|
||||
* paid for once.
|
||||
*
|
||||
* Goes through the shared `formatTime`, which reads the profile's timezone and
|
||||
* clock preference. The bare `toLocaleTimeString(undefined, …)` this replaces
|
||||
* asked the BROWSER instead, which is a different question: someone whose
|
||||
* profile says Europe/Berlin while their laptop sits in Manila saw a Manila
|
||||
* time here and a Berlin time on every other surface, for the same instant.
|
||||
*/
|
||||
function formatJustInTime(iso: string): string | null {
|
||||
const at = new Date(iso);
|
||||
if (Number.isNaN(at.getTime())) return null;
|
||||
try {
|
||||
return formatTime(at);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function TodayHero({
|
||||
digest,
|
||||
renderFilteredAllClear = false,
|
||||
@@ -125,7 +97,6 @@ export function TodayHero({
|
||||
// paints from the server-dehydrated digest on both the SSR and the hydration
|
||||
// render). So the slot renders on both passes and the formatted time fills in
|
||||
// on the first client re-render, keeping those two passes byte-identical.
|
||||
const mounted = useMounted();
|
||||
const { keep, letGo } = useCoachCheckinAction();
|
||||
const dismissItem = usePriorityItemDismiss();
|
||||
|
||||
@@ -166,7 +137,6 @@ export function TodayHero({
|
||||
const signalFallbackLead =
|
||||
hasScore && !lead ? topSignal?.headline?.trim() || null : null;
|
||||
const displayLead = lead ?? signalFallbackLead;
|
||||
const justInTime = digest.justIn ? formatJustInTime(digest.justIn.at) : null;
|
||||
// A score-only all-clear digest has no narrative content for the leading
|
||||
// column. Keeping the full md ring in that two-column shell left a blank
|
||||
// column beside a 168 px dial and pushed the actual all-clear read below it.
|
||||
@@ -191,8 +161,7 @@ export function TodayHero({
|
||||
!hasScore &&
|
||||
!hasItems &&
|
||||
!digest.briefingLead &&
|
||||
!digest.reactionLine &&
|
||||
!digest.justIn
|
||||
!digest.reactionLine
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
@@ -267,28 +236,15 @@ export function TodayHero({
|
||||
{/* In the compact fallback the quiet freshness tier belongs next
|
||||
to the smaller ring. That uses the otherwise-empty leading
|
||||
space and avoids adding a second row below the dial. */}
|
||||
{compactAllClear && (digest.sleepPending || digest.justIn) ? (
|
||||
{compactAllClear && digest.sleepPending ? (
|
||||
<div className="text-muted-foreground flex flex-wrap items-center gap-x-3 gap-y-1 text-xs">
|
||||
{digest.sleepPending ? (
|
||||
<p
|
||||
data-slot="today-hero-sleep-pending"
|
||||
className="flex items-center gap-1.5"
|
||||
>
|
||||
<Moon className="size-3.5 shrink-0" aria-hidden="true" />
|
||||
{t("daily.today.sleepPending")}
|
||||
</p>
|
||||
) : null}
|
||||
{digest.justIn ? (
|
||||
<p
|
||||
data-slot="today-hero-just-in"
|
||||
data-just-in-kind={digest.justIn.kind}
|
||||
className="flex items-center gap-1.5"
|
||||
>
|
||||
{mounted && justInTime
|
||||
? t("daily.today.justInAt", { time: justInTime })
|
||||
: t("daily.today.justIn")}
|
||||
</p>
|
||||
) : null}
|
||||
<p
|
||||
data-slot="today-hero-sleep-pending"
|
||||
className="flex items-center gap-1.5"
|
||||
>
|
||||
<Moon className="size-3.5 shrink-0" aria-hidden="true" />
|
||||
{t("daily.today.sleepPending")}
|
||||
</p>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
@@ -334,43 +290,18 @@ export function TodayHero({
|
||||
|
||||
{/* Meta row — the hero's quiet tier (UI-STANDARDS §text: `text-xs
|
||||
text-muted-foreground` is the meta floor; never an accent, never an
|
||||
opacity modifier). Holds the freshness note and the "just in" chip;
|
||||
they can legitimately co-occur (a weight landed while last night's
|
||||
sleep is still pending), so they share one wrapping row rather than
|
||||
competing for the same slot. */}
|
||||
{!compactAllClear && (digest.sleepPending || digest.justIn) ? (
|
||||
opacity modifier). Freshness note (plan §2.4) — provisional day,
|
||||
last night's sleep not yet folded in. Muted, non-blocking,
|
||||
refreshes in place when the morning job lands. */}
|
||||
{!compactAllClear && digest.sleepPending ? (
|
||||
<div className="text-muted-foreground flex flex-wrap items-center gap-x-3 gap-y-1 text-xs">
|
||||
{/* Freshness note (plan §2.4) — provisional day, last night's
|
||||
sleep not yet folded in. Muted, non-blocking, refreshes in
|
||||
place when the morning job lands. */}
|
||||
{digest.sleepPending ? (
|
||||
<p
|
||||
data-slot="today-hero-sleep-pending"
|
||||
className="flex items-center gap-1.5"
|
||||
>
|
||||
<Moon className="size-3.5 shrink-0" aria-hidden="true" />
|
||||
{t("daily.today.sleepPending")}
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
{/* The "just in" chip — a state change, not a notification. No
|
||||
badge count, no accent, no animation: the record simply reads
|
||||
differently on the next refresh, and this names why. It is
|
||||
present for as long as the arrival is news (the server applies
|
||||
the window, so the chip expires on a poll rather than on a
|
||||
client timer) and carries the arrival's local time once the
|
||||
client has mounted and can format it honestly. */}
|
||||
{digest.justIn ? (
|
||||
<p
|
||||
data-slot="today-hero-just-in"
|
||||
data-just-in-kind={digest.justIn.kind}
|
||||
className="flex items-center gap-1.5"
|
||||
>
|
||||
{mounted && justInTime
|
||||
? t("daily.today.justInAt", { time: justInTime })
|
||||
: t("daily.today.justIn")}
|
||||
</p>
|
||||
) : null}
|
||||
<p
|
||||
data-slot="today-hero-sleep-pending"
|
||||
className="flex items-center gap-1.5"
|
||||
>
|
||||
<Moon className="size-3.5 shrink-0" aria-hidden="true" />
|
||||
{t("daily.today.sleepPending")}
|
||||
</p>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
|
||||
Reference in new issue
Block a user