diff --git a/CHANGELOG.md b/CHANGELOG.md index 0193bbbe4..89707836e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/e2e/today-just-in.spec.ts b/e2e/today-just-in.spec.ts index 49e00ba81..5cb431938 100644 --- a/e2e/today-just-in.spec.ts +++ b/e2e/today-just-in.spec.ts @@ -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, diff --git a/src/components/daily/__tests__/today-hero-just-in.test.tsx b/src/components/daily/__tests__/today-hero-just-in.test.tsx index afb978cb1..4f39b0c7f 100644 --- a/src/components/daily/__tests__/today-hero-just-in.test.tsx +++ b/src/components/daily/__tests__/today-hero-just-in.test.tsx @@ -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( + , + ); + + 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( "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 +> = vi.fn(async () => "job-id"); const annotateMock = vi.fn(); let moduleMap: Record = {}; let timezone = "Europe/Berlin"; diff --git a/src/lib/jobs/__tests__/data-arrival-worker.test.ts b/src/lib/jobs/__tests__/data-arrival-worker.test.ts index e5e6bcedd..a66389156 100644 --- a/src/lib/jobs/__tests__/data-arrival-worker.test.ts +++ b/src/lib/jobs/__tests__/data-arrival-worker.test.ts @@ -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"); diff --git a/src/lib/queries/use-daily-digest.ts b/src/lib/queries/use-daily-digest.ts index dfbbb55d0..d6384d3bf 100644 --- a/src/lib/queries/use-daily-digest.ts +++ b/src/lib/queries/use-daily-digest.ts @@ -35,12 +35,14 @@ export function useDailyDigest(enabled = true) { queryFn: () => apiGet("/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