fix(ui): draw no empty frame and say when a read failed

"Since the last visit" was wrapped in a card by the visits section, so
the card stayed on screen empty while the window loaded, when it held
nothing and when the read failed. The component now owns its card and
draws it only with a row; a failed read shows a compact error row.

The same shapes elsewhere: the caffeine card and the mood forecast no
longer open a loading card that most often collapses, and a failed read
on them, the mood tag catalogue, the usual-range strip, the cycle ring
and the measurement drill-down now says so with a retry instead of
passing for no data. The scores strip keeps the ring's cell on a failed
calendar read. The mental-health card no longer draws an empty action
slot for a viewer without the right to start.

The frame guard now also flags a frame whose only content sits behind a
condition.

Refs #1241
This commit is contained in:
Marc-André Bombeck committed 2026-10-19 16:43:52 +02:00
1 parent d00281f35a
commit 38c106e358
23 files changed
+816 -193

No files matched your search

@@ -152,13 +152,19 @@ describe("<CycleRingTile>", () => {
expect(render(<CycleRingTile />)).toBe("");
});
it("renders nothing on a hard calendar read error", () => {
// A failed read is not a day without a cycle: the tile says it failed and
// offers a retry, instead of passing the failure off as nothing to show.
it("says a hard calendar read error failed, with a retry", () => {
calendarState.current = {
data: undefined,
isLoading: false,
isError: true,
};
expect(render(<CycleRingTile />)).toBe("");
const html = render(<CycleRingTile />);
expect(html).toContain('data-slot="wellness-cycle-tile-error"');
expect(html).toContain('role="alert"');
expect(html).toContain('data-slot="query-error-row-retry"');
expect(html).not.toContain('data-slot="wellness-cycle-tile"');
});
it("renders nothing when there is no active cycle today", () => {
@@ -26,16 +26,35 @@ function window(): [string, string] {
}
function Probe({ enabled }: { enabled: boolean }) {
const { dial, pending } = useCycleRingDial(enabled);
return <span>{`${dial}:${pending}`}</span>;
const { dial, pending, failed } = useCycleRingDial(enabled);
return <span>{`${dial}:${pending}${failed ? ":failed" : ""}`}</span>;
}
function probe(verdict: unknown | undefined, enabled = true): string {
/** Marks the calendar read as failed, with no data behind it. */
const FAILED = Symbol("failed");
function probe(
verdict: unknown | undefined | typeof FAILED,
enabled = true,
): string {
const client = new QueryClient({
defaultOptions: { queries: { staleTime: Infinity, retry: 0 } },
// `retryOnMount: false` keeps a seeded failure a failure on mount, the
// state the strip sees once the real read has given up.
defaultOptions: {
queries: { staleTime: Infinity, retry: 0, retryOnMount: false },
},
});
const [from, to] = window();
if (verdict !== undefined) {
if (verdict === FAILED) {
client
.getQueryCache()
.build(client, { queryKey: queryKeys.cycleCalendar(from, to) })
.setState({
status: "error",
error: new Error("calendar read failed"),
fetchStatus: "idle",
});
} else if (verdict !== undefined) {
client.setQueryData(queryKeys.cycleCalendar(from, to), { verdict });
}
const html = renderToStaticMarkup(
@@ -66,6 +85,29 @@ describe("useCycleRingDial", () => {
it("is pending until the calendar read lands", () => {
expect(probe(undefined)).toBe("false:true");
});
// A failed read is not a day without a cycle: the strip keeps the ring's
// cell so the tile can say the read failed.
it("reports a failed read instead of settling it to no dial", () => {
expect(probe(FAILED)).toBe("false:false:failed");
expect(probe(FAILED, false)).toBe("false:false");
});
});
describe("the scores strip keeps the ring's cell on a failed read", () => {
it("mounts the tile on a dial or a failure, and only then drops Strain", () => {
const source = readFileSync(
join(process.cwd(), "src/app/insights/page-client.tsx"),
"utf8",
);
expect(source).toMatch(
/cycleRingCell = cycleRing\.dial \|\| cycleRing\.failed/,
);
expect(source).toMatch(
/extraTile=\{cycleRingCell \? <CycleRingTile \/> : undefined\}/,
);
expect(source).toMatch(/hideStrain=\{cycleRingCell\}/);
});
});
describe("the scores strip waits for the ring's decision", () => {
+20 -7
View File
@@ -4,6 +4,7 @@ import { useState } from "react";
import Link from "next/link";
import { Droplets } from "lucide-react";
import { QueryErrorRow } from "@/components/ui/query-error-row";
import { cn } from "@/lib/utils";
import { useTranslations } from "@/lib/i18n/context";
import { CycleRing } from "./cycle-ring";
@@ -31,9 +32,12 @@ import { useCycleRingCalendar } from "./use-cycle";
* `user.cycleTrackingEnabled` is true (the same `/api/auth/me` signal the
* sidebar-nav entry + the summary card gate on), so the cycle read never fires
* for an account without the feature. The tile additionally renders nothing
* while the calendar read resolves, on a hard error, and when there is no
* active cycle phase today — never an empty-framed or half-painted dial in the
* strip (no gap, no placeholder).
* while the calendar read resolves and when there is no active cycle phase
* today — never an empty-framed or half-painted dial in the strip (no gap, no
* placeholder). A failed read takes the tile's place with a notice and a
* retry: silence there read as "no cycle phase today", and the summary card
* further down stays out on the same failure, so the tile is the one place
* the page says it.
*/
export function CycleRingTile({ className }: { className?: string }) {
@@ -61,11 +65,20 @@ export function CycleRingTile({ className }: { className?: string }) {
}
});
// Stay silent until the read resolves, on a hard error, and when today has
// no phase (no active cycle) — the strip must never show a half-painted or
// empty-framed cycle dial. The full cycle page owns the empty-state CTA.
// Stay silent until the read resolves and when today has no phase (no
// active cycle) — the strip must never show a half-painted or empty-framed
// cycle dial. The full cycle page owns the empty-state CTA.
if (calendar.isLoading && !calendar.data) return null;
if (calendar.isError && !calendar.data) return null;
if (calendar.isError && !calendar.data) {
return (
<QueryErrorRow
slot="wellness-cycle-tile-error"
message={t("cycle.wellnessRing.loadError")}
onRetry={() => void calendar.refetch()}
className={className}
/>
);
}
if (verdict?.phase == null || verdict.dayOfCycle == null) return null;
const hue = PHASE_HUE[verdict.phase];
+6 -1
View File
@@ -136,17 +136,22 @@ export function useCycleRingCalendar(enabled = true) {
* takes Strain's place for it): the ring renders nothing without an active
* cycle, and a cell reserved for it would sit empty in the row. While the
* calendar read is `pending` the strip holds its skeleton, so it never paints
* Strain and then swaps it for the ring. A failed read settles to no dial.
* Strain and then swaps it for the ring. A failed read draws no dial but is
* `failed`: the strip still gives the ring its cell, where the tile says the
* read failed and offers a retry. Settling it to "no dial" passed a failure
* off as a day without an active cycle.
*/
export function useCycleRingDial(enabled: boolean): {
dial: boolean;
pending: boolean;
failed: boolean;
} {
const calendar = useCycleRingCalendar(enabled);
const verdict = calendar.data?.verdict;
return {
dial: enabled && verdict?.phase != null && verdict.dayOfCycle != null,
pending: enabled && calendar.isPending,
failed: enabled && calendar.isError && !calendar.data,
};
}