Files
HealthLog/src/components/cycle/cycle-view.tsx
T
Marc-André Bombeck 3cf67d05f9 fix(ui): let the read-failure primitives decide by the error
About forty compact rows and seventy cards still offered a retry on a
spent read budget and drew an error card on a refusal, while a third
primitive (ReadFailureRow) did the right thing at eight places.

QueryErrorRow and QueryErrorCard now take the query's `error` and read
it through readFailureKind, which moves to src/lib/api next to
ApiError: a refusal (403) draws the surface's refused state or nothing,
a spent budget (429) says so without a retry, an ended session (401)
says so and offers sign-in instead of going blank, and only a real
failure offers the retry. ReadFailureRow is gone and every site passes
its error; a guard test fails on a site that does not.

The coach read strip keeps its card in the error state and holds one
body height across loading, failure and a one-line read.
2026-10-20 03:38:57 +02:00

497 lines
21 KiB
TypeScript

"use client";
import { useMemo, useState } from "react";
import dynamic from "next/dynamic";
import { useSearchParams } from "next/navigation";
import { Loader2, Plus, RefreshCw, Sparkles } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Card, CardContent } from "@/components/ui/card";
import { PageHeader } from "@/components/ui/page-header";
import { QueryErrorCard } from "@/components/ui/query-error-card";
import { QueryErrorRow } from "@/components/ui/query-error-row";
import { Skeleton } from "@/components/ui/skeleton";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { useRecordCapabilities } from "@/hooks/use-record-capabilities";
import { useTranslations } from "@/lib/i18n/context";
import { cn } from "@/lib/utils";
import { CycleRing } from "./cycle-ring";
import { CycleCalendar } from "./cycle-calendar";
import { CycleHistoryChart } from "./cycle-history-chart";
import { LogDaySheet, sheetDayContext } from "./log-day-sheet";
import { PhaseEducationCard } from "./phase-education-card";
import { PredictionsPanel } from "./predictions-panel";
import { CyclePhaseHeadline, CyclePhaseCrosstab } from "./cycle-phase-crosstab";
import { CycleSymptomPatterns } from "./cycle-symptom-patterns";
import { CycleSettings } from "./cycle-settings";
import { PHASE_HUE } from "./phase-tokens";
import { monthOf, monthWindowOutside } from "./calendar-window";
import {
localYmd,
useCycleCalendar,
useCycleHistory,
useCycleInsights,
useCycleProfile,
} from "./use-cycle";
/**
* v1.15.0 — the cycle vertical's client orchestrator.
*
* Holds the four tabs (Calendar · Predictions · Insights · Settings), the
* wheel above the tab strip, and the log-day sheet. The calendar read drives
* the ring (day-of-cycle + phase), the grid, and the BBT chart; the
* predictions read powers the panel; history powers the stats. The page-level
* RSC has already gated on `cycleTrackingEnabled`.
*
* The wheel earns the once-per-session "signature reveal" (sweep + glow +
* sheen + count-up): a `data-revealed` flag set on first visit drives the CSS
* keyframes, gated by sessionStorage so a background calendar refetch never
* re-triggers the moment.
*/
/** YYYY-MM-DD for `n` days from now in the local tz (shares `localYmd`). */
function shiftToday(days: number): string {
const d = new Date();
d.setDate(d.getDate() + days);
return localYmd(d);
}
/** The four cycle tabs, in render order. The deep-link value is validated
* against this set so a junk `?tab=` falls back to the calendar tab. */
// The BBT chart is the only recharts consumer on /cycle — load it through
// the shared chart-runtime boundary so the route stops carrying its own
// copy of the recharts module graph. The loading shell mirrors the card
// the chart paints (header row + caption + 210 px chart band inside the
// standard card chrome) so the tab layout holds still while the shared
// chunk streams.
const BbtChart = dynamic(
() =>
import("@/components/charts/chart-runtime").then((mod) => ({
default: mod.BbtChart,
})),
{
ssr: false,
loading: () => (
<div className="bg-card border-border rounded-xl border py-6">
<div className="px-6">
<Skeleton className="h-5 w-44" />
</div>
<div className="mt-6 px-6">
<Skeleton className="mb-2 h-4 w-64" />
<Skeleton className="h-[210px] w-full rounded-md" />
</div>
</div>
),
},
);
const CYCLE_TABS = ["calendar", "predictions", "insights", "settings"] as const;
type CycleTab = (typeof CYCLE_TABS)[number];
export function CycleView() {
const { t } = useTranslations();
// v1.36.x — no grant level admits a cycle write. Inside somebody else's
// record the page stays readable and every path into the log-day sheet and
// the profile settings is absent, rather than opening onto a refusal.
const { canManageDomain } = useRecordCapabilities();
const canManageCycle = canManageDomain("cycle");
// Deep-link support: `/cycle?tab=insights` opens the insights tab. The value
// is read ONCE for the initial `defaultValue` (uncontrolled tabs — so normal
// clicking still works), validated against the known set, and falls back to
// the calendar tab on anything else.
const searchParams = useSearchParams();
const initialTab: CycleTab = useMemo(() => {
const raw = searchParams?.get("tab");
return raw && (CYCLE_TABS as readonly string[]).includes(raw)
? (raw as CycleTab)
: "calendar";
}, [searchParams]);
// Controlled tabs, initialised from the `?tab=` deep-link and matching the
// prior uncontrolled `defaultValue` behaviour (the initial param is honoured;
// a later param change does not force-switch a tab the user is reading).
const [tab, setTab] = useState<CycleTab>(initialTab);
const today = useMemo(() => shiftToday(0), []);
const from = useMemo(() => shiftToday(-90), []);
const to = useMemo(() => shiftToday(180), []);
const calendar = useCycleCalendar(from, to);
const history = useCycleHistory();
const profileQuery = useCycleProfile();
const insights = useCycleInsights();
// The month grid navigates freely; the anchored read above does not follow
// it, because every other surface on this page (the ring, the forecast panel,
// the temperature chart) is about today and the route caps a span at 400
// days. A month outside the anchor therefore gets its own bounded read, and
// the two grids merge by date. Without it a back-dated entry landed on the
// server and had nothing on screen to land in.
const [visibleMonth, setVisibleMonth] = useState(() => monthOf(today));
const monthWindow = useMemo(
() => monthWindowOutside(visibleMonth, { from, to }),
[visibleMonth, from, to],
);
const monthCalendar = useCycleCalendar(
monthWindow?.from ?? from,
monthWindow?.to ?? to,
monthWindow !== null,
);
// Month days last: on the edge months the two reads overlap and the grid
// keys by date, so the later entry wins and both carry the same answer.
const calendarDays = useMemo(
() => [...(calendar.data?.days ?? []), ...(monthCalendar.data?.days ?? [])],
[calendar.data?.days, monthCalendar.data?.days],
);
const [sheetOpen, setSheetOpen] = useState(false);
const [selectedDate, setSelectedDate] = useState(today);
// The signature reveal plays ONCE per browser session (mirrors the wellness
// strip): a background calendar refetch after a quick-log would otherwise
// replay the sweep/sheen and read as janky.
const [play] = useState(() => {
if (typeof window === "undefined") return false;
try {
if (sessionStorage.getItem("cycle-wheel-revealed")) return false;
sessionStorage.setItem("cycle-wheel-revealed", "1");
return true;
} catch {
return true;
}
});
// The server's resolved verdict — cycle day, the ring's arcs, the overdue
// judgement and its day count. Nothing on this page recomputes any of it.
const verdict = calendar.data?.verdict;
function openSheet(date: string) {
setSelectedDate(date);
setSheetOpen(true);
}
const loading = calendar.isLoading && !calendar.data;
const calendarError = calendar.isError && !calendar.data;
const tileHue = verdict?.phase ? PHASE_HUE[verdict.phase] : undefined;
const goal = calendar.data?.profile.goal;
// Honesty-gate inputs for the phase-education card (Clue precedent): the
// predictive phase framing only shows when prediction is on, not in
// raw-chart mode, and at least three cycles are observed.
const calProfile = calendar.data?.profile;
return (
<div className="space-y-6">
<PageHeader
title={<span data-tour-id="cycle-hero">{t("cycle.title")}</span>}
description={t("cycle.subtitle")}
actions={
// One action. The settings glyph that used to sit beside it was a
// second door to the tab directly below, and no other page in the
// app carries a gear in that corner.
canManageCycle ? (
<Button
onClick={() => openSheet(today)}
className="min-h-11 sm:min-h-9"
>
<Plus className="h-4 w-4" />
{t("cycle.logToday")}
</Button>
) : null
}
/>
{/* Desktop: ring (left) + tabs (right). Single column below lg. */}
<div className="grid gap-6 lg:grid-cols-[minmax(0,22rem)_minmax(0,1fr)] lg:items-start">
{/* Wheel + compact phase card — grouped so they stick together. The
`lg:mt-15` (3.75rem) drops the column so the wheel tile's top edge
lines up with the calendar / predictions content rather than the
tab strip: TabsList `h-9` (2.25rem) + the Tabs root `gap-2` (0.5rem)
+ the content's `mt-4` (1rem). Below `lg` the column stacks first and
the offset collapses to zero. */}
<div className="flex flex-col gap-4 lg:sticky lg:top-6 lg:mt-15">
{/* Wheel — the signature ring on the premium wellness-tile surface.
`data-revealed` must sit on an ANCESTOR of `.wellness-tile-rise`
— the keyframe selector is `[data-revealed="true"]
.wellness-tile-rise` (descendant combinator). Both on the same
node never matched, leaving the tile stuck at the rise rule's
`opacity: 0` (invisible but still boxed). The `contents` wrapper
carries the flag without adding a layout box. */}
<div data-revealed={play ? "true" : undefined} className="contents">
<div
data-slot="cycle-wheel-tile"
style={
tileHue
? ({ "--tile-hue": tileHue } as React.CSSProperties)
: undefined
}
className={cn(
"wellness-tile flex flex-col items-center gap-3 rounded-xl p-4 md:p-6",
play && "wellness-tile-rise",
)}
>
{loading ? (
<div className="flex h-[220px] items-center justify-center">
<Loader2 className="text-muted-foreground h-8 w-8 animate-spin motion-reduce:animate-none" />
</div>
) : calendarError ? (
<div className="flex h-[220px] flex-col items-center justify-center gap-3 text-center">
<p className="text-muted-foreground text-sm">
{t("cycle.loadError")}
</p>
<Button
variant="outline"
size="sm"
className="gap-1.5"
onClick={() => void calendar.refetch()}
>
<RefreshCw className="h-3.5 w-3.5" aria-hidden="true" />
{t("common.retry")}
</Button>
</div>
) : (
<CycleRing
dayOfCycle={verdict?.dayOfCycle ?? null}
cycleLength={verdict?.cycleLength ?? null}
phase={verdict?.phase ?? null}
spans={verdict?.spans ?? []}
animate={play}
/>
)}
{!calendarError ? (
<p className="text-muted-foreground max-w-56 text-center text-xs">
{/* Beyond the typical length plus the server's grace window
the count stops being an observed fact, so the server
resolves OVERDUE and says how late; this caption reports
that instead of the "of your cycle" tagline under an
empty ring. */}
{verdict?.state === "OVERDUE"
? verdict.overdueDays != null
? t("cycle.ring.periodOverdueDays", {
days: verdict.overdueDays,
})
: t("cycle.ring.periodOverdue")
: // The ring shows the bare number; the caption under it
// reads as a whole phrase ("Tag 9 deines Zyklus"), not
// a fragment hanging off the ring.
verdict?.dayOfCycle != null
? t("cycle.insightsSummary.currentDay", {
day: verdict.dayOfCycle,
})
: t("cycle.ring.caption")}
</p>
) : null}
{/* Log CTA — first period when no cycle exists yet, next period
when the count paused on an overdue cycle. */}
{canManageCycle &&
!loading &&
!calendarError &&
verdict?.dayOfCycle == null ? (
<Button
variant="outline"
className="mt-1 w-full"
onClick={() => openSheet(today)}
>
{verdict?.state === "OVERDUE"
? t("cycle.ring.logPeriodCta")
: t("cycle.ring.firstPeriodCta")}
</Button>
) : null}
</div>
</div>
{/* Phase-education card beneath the wheel — the single instance,
including the "you often notice this here" chip row sourced from
the user's own symptom-by-phase history. */}
{!loading && !calendarError ? (
<PhaseEducationCard
animate={play}
phase={verdict?.phase ?? null}
symptomPatterns={insights.data?.symptomPatterns ?? []}
predictionEnabled={calProfile?.predictionEnabled ?? false}
rawChartMode={calProfile?.rawChartMode ?? false}
cyclesObserved={calProfile?.cyclesObserved ?? 0}
/>
) : null}
</div>
<Tabs value={tab} onValueChange={(v) => setTab(v as CycleTab)}>
{/* v1.15.10 — the four German labels (Kalender/Prognosen/
Erkenntnisse/Einstellungen) overflowed the `flex-1` equal-width
cells at 375 px and forced a horizontal scroll. Below `sm` the
triggers size to their content (`text-xs`, no `flex-1`) and the
strip scrolls cleanly if needed; from `sm` up they reclaim the
equal-width `flex-1` layout. */}
<TabsList className="scroll-touch w-full max-w-full overflow-x-auto">
<TabsTrigger
value="calendar"
className="text-xs sm:flex-1 sm:text-sm"
>
{t("cycle.tabs.calendar")}
</TabsTrigger>
<TabsTrigger
value="predictions"
className="text-xs sm:flex-1 sm:text-sm"
>
{t("cycle.tabs.predictions")}
</TabsTrigger>
<TabsTrigger
value="insights"
className="text-xs sm:flex-1 sm:text-sm"
>
{t("cycle.tabs.insights")}
</TabsTrigger>
{canManageCycle && (
<TabsTrigger
value="settings"
className="text-xs sm:flex-1 sm:text-sm"
>
{t("cycle.tabs.settings")}
</TabsTrigger>
)}
</TabsList>
<TabsContent value="calendar" className="mt-4 space-y-4">
<Card>
<CardContent className="py-4">
{loading ? (
<p className="text-muted-foreground text-sm">
{t("cycle.calendar.loading")}
</p>
) : calendarError ? (
// Inside the calendar card already — a borderless row keeps
// it in line with the plain loading line above it (§3/§6).
<QueryErrorRow
error={calendar.error}
message={t("cycle.loadError")}
onRetry={() => void calendar.refetch()}
className="border-0 bg-transparent p-0"
/>
) : (
<>
{/* Cold start: the grid carries no fertile/ovulation/phase
overlays yet (the server suppresses them while learning),
so name the calm state rather than letting a bare grid
imply a confident forecast. */}
{calendar.data?.stillLearning ? (
<div
data-slot="cycle-calendar-learning"
className="text-muted-foreground bg-muted/40 mb-3 flex items-start gap-2 rounded-lg p-3 text-sm"
>
<Sparkles
className="text-foreground mt-0.5 h-4 w-4 shrink-0"
aria-hidden="true"
/>
<span>{t("cycle.calendar.learning")}</span>
</div>
) : null}
<CycleCalendar
days={calendarDays}
today={today}
confirmedOvulation={
calendar.data?.prediction?.ovulationConfirmed
? (calendar.data.prediction.predictedOvulation ??
null)
: null
}
onSelectDay={canManageCycle ? openSheet : undefined}
onVisibleMonthChange={setVisibleMonth}
/>
</>
)}
</CardContent>
</Card>
</TabsContent>
<TabsContent value="predictions" className="mt-4 space-y-4">
{calendarError ? (
<QueryErrorCard
error={calendar.error}
title={t("cycle.loadError")}
onRetry={() => void calendar.refetch()}
/>
) : (
<>
<PredictionsPanel
prediction={calendar.data?.prediction ?? null}
rawChartMode={calendar.data?.profile.rawChartMode ?? false}
history={history.data}
/>
<CycleHistoryChart history={history.data} animate={play} />
<BbtChart
days={calendar.data?.days ?? []}
today={today}
cycleStartDate={verdict?.cycleStartDate ?? null}
predictedOvulation={
calendar.data?.prediction?.predictedOvulation ?? null
}
ovulationConfirmed={
calendar.data?.prediction?.ovulationConfirmed ?? false
}
rawChartMode={calendar.data?.profile.rawChartMode ?? false}
/>
</>
)}
</TabsContent>
<TabsContent value="insights" className="mt-4 space-y-4">
{insights.isError ? (
<QueryErrorCard
error={insights.error}
title={t("cycle.loadError")}
onRetry={() => void insights.refetch()}
/>
) : (
<>
<CyclePhaseHeadline
headline={insights.data?.headline ?? null}
/>
<CyclePhaseCrosstab rows={insights.data?.rows ?? []} />
<CycleSymptomPatterns
rows={insights.data?.symptomPatterns ?? []}
/>
</>
)}
</TabsContent>
<TabsContent value="settings" className="mt-4">
{profileQuery.isError ? (
<QueryErrorCard
error={profileQuery.error}
title={t("cycle.loadError")}
onRetry={() => void profileQuery.refetch()}
/>
) : profileQuery.isLoading ? (
<div className="flex h-32 items-center justify-center">
<Loader2 className="text-muted-foreground h-6 w-6 animate-spin motion-reduce:animate-none" />
</div>
) : profileQuery.data && canManageCycle ? (
<CycleSettings
key={profileQuery.data.updatedAt}
profile={profileQuery.data}
/>
) : null}
</TabsContent>
</Tabs>
</div>
<LogDaySheet
open={sheetOpen}
onOpenChange={setSheetOpen}
date={selectedDate}
today={today}
// Everything the sheet says about the day is about THAT day: its own
// cycle day, its own phase, whether a period end can land on it. The
// verdict answers for today and only ever labelled today correctly.
{...sheetDayContext(selectedDate, calendarDays)}
goal={goal}
predictionEnabled={calProfile?.predictionEnabled ?? false}
rawChartMode={calProfile?.rawChartMode ?? false}
cyclesObserved={calProfile?.cyclesObserved ?? 0}
/>
</div>
);
}
/** A compact in-place error + Retry for a failed tab read. */