fix(release): harden Today arrival rendering

This commit is contained in:
Marc-André Bombeck committed 2026-07-19 19:55:40 +02:00
1 parent d40da09558
commit c97ad3ed55
7 files changed
+81 -38

No files matched your search

@@ -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
+14 -9
View File
@@ -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");
+6 -4
View File
@@ -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