mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 04:32:42 +00:00
fix(release): harden Today arrival rendering
This commit is contained in:
1 parent
d40da09558
commit
c97ad3ed55
7 files changed
+81
-38
No files matched your search
+4
-2
@@ -11,8 +11,10 @@ instead of waiting for a daily rollup to notice.
|
||||
first weigh-in of the day — the home screen now carries a calm "just in"
|
||||
note for a few hours after each one, and at most one written line a day per
|
||||
kind, grounded in your own numbers. Nothing changes if you run without an
|
||||
AI provider: the note is the garnish, not the feature. The underlying
|
||||
change that makes this possible without new polling or a flood of
|
||||
AI provider: the sentence is the garnish, not the feature — the arrival
|
||||
itself still appears, including on an otherwise-empty record, and an open
|
||||
dashboard refreshes it as soon as the tab becomes visible again. The
|
||||
underlying change that makes this possible without new polling or a flood of
|
||||
background work: every write path that can produce a genuinely new reading
|
||||
now emits a single typed event, day-scoped and de-duplicated, that a mass
|
||||
import or a provider re-sync produces none of — a ten-year import or a
|
||||
|
||||
+23
-18
@@ -15,11 +15,11 @@ import { E2E_USER, STORAGE_STATE_PATH } from "./setup/global-setup";
|
||||
* 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;
|
||||
* 3. bring the tab back to focus — which is one of the three refetch
|
||||
* triggers the digest hook ALREADY declares (`refetchOnWindowFocus`,
|
||||
* alongside `staleTime` and the 120 s foreground poll). Using the focus
|
||||
* trigger keeps the test inside the real cadence while staying well
|
||||
* under the poll interval, which no test timeout would survive;
|
||||
* 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.
|
||||
*
|
||||
* The marker is seeded directly rather than waited for from pg-boss: whether
|
||||
@@ -71,29 +71,33 @@ test("a fresh arrival surfaces the just-in chip and flips the day in place", asy
|
||||
);
|
||||
|
||||
// ── BEFORE ────────────────────────────────────────────────────────────
|
||||
// The globally-seeded account is intentionally empty. Wait until the
|
||||
// digest settles, then prove there is no stale hero or arrival chip.
|
||||
await page.goto("/");
|
||||
const hero = page.locator('[data-slot="today-hero"]');
|
||||
await expect(hero).toBeVisible({ timeout: 20_000 });
|
||||
await expect(hero.locator('[data-slot="today-hero-just-in"]')).toHaveCount(
|
||||
await expect(page.locator('[data-slot="today-hero-skeleton"]')).toHaveCount(
|
||||
0,
|
||||
);
|
||||
const hero = page.locator('[data-slot="today-hero"]');
|
||||
await expect(hero).toHaveCount(0);
|
||||
|
||||
// ── NEW DATA LANDS ────────────────────────────────────────────────────
|
||||
// Last night's sleep through the real ingest route, on the session the
|
||||
// storage state already carries.
|
||||
const now = new Date();
|
||||
const wokeAt = new Date(now.getTime() - 60 * 60_000);
|
||||
const fellAsleepAt = new Date(wokeAt.getTime() - 7 * 60 * 60_000);
|
||||
|
||||
const batch = await page.request.post("/api/measurements/batch", {
|
||||
data: {
|
||||
measurements: [
|
||||
entries: [
|
||||
{
|
||||
type: "SLEEP_DURATION",
|
||||
value: 7,
|
||||
unit: "h",
|
||||
measuredAt: wokeAt.toISOString(),
|
||||
hkIdentifier: "HKCategoryTypeIdentifierSleepAnalysis",
|
||||
value: 420,
|
||||
unit: "min",
|
||||
startDate: fellAsleepAt.toISOString(),
|
||||
endDate: wokeAt.toISOString(),
|
||||
externalId: `e2e-just-in-${now.getTime()}`,
|
||||
source: "manual",
|
||||
sleepStage: 3,
|
||||
},
|
||||
],
|
||||
},
|
||||
@@ -120,10 +124,11 @@ test("a fresh arrival surfaces the just-in chip and flips the day in place", asy
|
||||
[userId, localDate],
|
||||
);
|
||||
|
||||
// ── THE MOMENT ────────────────────────────────────────────────────────
|
||||
// The tab regains focus — one of the digest hook's existing refetch
|
||||
// triggers. Nothing new is introduced here and the page never reloads.
|
||||
await page.evaluate(() => window.dispatchEvent(new Event("focus")));
|
||||
// The tab becomes visible — the browser signal TanStack Query's focus
|
||||
// manager consumes. The digest refetches in place; the page never reloads.
|
||||
await page.evaluate(() =>
|
||||
window.dispatchEvent(new Event("visibilitychange")),
|
||||
);
|
||||
|
||||
await expect(hero.locator('[data-slot="today-hero-just-in"]')).toBeVisible({
|
||||
timeout: 20_000,
|
||||
|
||||
@@ -76,6 +76,23 @@ describe("TodayHero — the just-in chip", () => {
|
||||
expect(html).toContain("Just in");
|
||||
});
|
||||
|
||||
it("renders for a bare account when the arrival has no generated line", () => {
|
||||
const html = render(
|
||||
<TodayHero
|
||||
digest={digest({
|
||||
score: null,
|
||||
briefingLead: null,
|
||||
worthALook: [],
|
||||
reactionLine: null,
|
||||
justIn: { kind: "sleep_night", at: ARRIVED_AT },
|
||||
})}
|
||||
/>,
|
||||
);
|
||||
|
||||
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
|
||||
|
||||
@@ -126,15 +126,20 @@ export function TodayHero({ digest }: { digest: DailyDigest }) {
|
||||
const justInTime = digest.justIn ? formatJustInTime(digest.justIn.at) : null;
|
||||
|
||||
// Calm degrade (plan §3): a genuinely empty account — no score, no rail
|
||||
// items, and no cached briefing lead — surfaces nothing here. The tile
|
||||
// strip below carries its own "add your first reading" empty state, so a
|
||||
// second alarming empty card on the hero would be noise. The all-clear
|
||||
// state (score present, nothing needs attention) is handled inline below.
|
||||
// A freshly-generated reaction line counts as content in its own right: an
|
||||
// otherwise-bare account whose first reading just landed has something
|
||||
// honest to say, and swallowing it here would hide the one moment this
|
||||
// surface exists for.
|
||||
if (!hasScore && !hasItems && !digest.briefingLead && !digest.reactionLine) {
|
||||
// items, no cached briefing lead, and no fresh arrival — surfaces nothing
|
||||
// here. The tile strip below carries its own "add your first reading" empty
|
||||
// state, so a second alarming empty card on the hero would be noise. The
|
||||
// all-clear state (score present, nothing needs attention) is handled inline
|
||||
// below. A fresh marker counts as content even when line generation was
|
||||
// unavailable: the deterministic digest line still gives the hero an honest
|
||||
// lead, and hiding the marker would lose the arrival moment entirely.
|
||||
if (
|
||||
!hasScore &&
|
||||
!hasItems &&
|
||||
!digest.briefingLead &&
|
||||
!digest.reactionLine &&
|
||||
!digest.justIn
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
|
||||
@@ -23,9 +23,14 @@
|
||||
* classifier's return value. Testing the classifier alone would prove the
|
||||
* predicate correct while saying nothing about whether the seam consults it.
|
||||
*/
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { beforeEach, describe, expect, it, vi, type Mock } from "vitest";
|
||||
|
||||
const sendMock = vi.fn(async () => "job-id");
|
||||
// Typed explicitly (rather than via named implementation params) so
|
||||
// `.mock.calls[N][idx]` below keeps its three-argument shape without any
|
||||
// parameter sitting unused in the implementation itself.
|
||||
const sendMock: Mock<
|
||||
(queue: string, payload: unknown, opts: unknown) => Promise<string>
|
||||
> = vi.fn(async () => "job-id");
|
||||
const annotateMock = vi.fn();
|
||||
let moduleMap: Record<string, boolean> = {};
|
||||
let timezone = "Europe/Berlin";
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
* job, and retrying against a ceiling that will not move until the local
|
||||
* day rolls over is a loop.
|
||||
*/
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { beforeEach, describe, expect, it, vi, type Mock } from "vitest";
|
||||
|
||||
vi.mock("@/lib/logging/context", () => ({ annotate: vi.fn() }));
|
||||
vi.mock("@/lib/logging/background", () => ({
|
||||
@@ -35,8 +35,15 @@ vi.mock("@/lib/jobs/workout-insight-generate-shared", () => ({
|
||||
enqueueWorkoutInsight: vi.fn(async () => ({ enqueued: true })),
|
||||
}));
|
||||
|
||||
const createMany = vi.fn(async () => ({ count: 1 }));
|
||||
const updateMany = vi.fn(async () => ({ count: 1 }));
|
||||
// Typed explicitly (rather than via a named implementation param) so
|
||||
// `.mock.calls[N][0]` below keeps its one-argument shape without a param
|
||||
// sitting unused in the implementation itself.
|
||||
const createMany: Mock<(args: unknown) => Promise<{ count: number }>> = vi.fn(
|
||||
async () => ({ count: 1 }),
|
||||
);
|
||||
const updateMany: Mock<(args: unknown) => Promise<{ count: number }>> = vi.fn(
|
||||
async () => ({ count: 1 }),
|
||||
);
|
||||
const fakePrisma = { arrivalReaction: { createMany, updateMany } };
|
||||
|
||||
const { runDataArrival } = await import("../data-arrival");
|
||||
|
||||
@@ -35,12 +35,14 @@ export function useDailyDigest(enabled = true) {
|
||||
queryFn: () => apiGet<DailyDigest>("/api/daily/digest"),
|
||||
enabled,
|
||||
// Match the dashboard snapshot's freshness so the hero and the tile
|
||||
// strip below it share one refresh cadence: a warm remount inside the
|
||||
// minute is free; a background sync surfaces on focus; an open tab
|
||||
// polls in the foreground only.
|
||||
// strip share one refresh cadence. Every real window-focus transition
|
||||
// refetches even inside the minute freshness window: arrival markers are
|
||||
// written out-of-band by sync workers, so the browser has no mutation it
|
||||
// could use to invalidate this cache. The 120 s foreground poll remains the
|
||||
// fallback while the tab stays open.
|
||||
staleTime: 60_000,
|
||||
refetchOnMount: false,
|
||||
refetchOnWindowFocus: true,
|
||||
refetchOnWindowFocus: "always",
|
||||
refetchInterval: DASHBOARD_REFETCH_INTERVAL_MS,
|
||||
refetchIntervalInBackground: false,
|
||||
// One retry on transient network / 5xx (never 401/403) so a single
|
||||
|
||||
Reference in new issue
Block a user