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