Files
HealthLog/src/components/cycle/cycle-view.tsx
T
Marc-André Bombeck f3388ceb70 feat(cycle): redesign the log-day sheet — phase header, field info, wider dialog
Anchor the capture surface to the ring and lower its cognitive load:

- A hue-tinted phase-context strip (day-of-cycle + phase) at the top of the
  sheet, on the wellness-tile surface, honesty-gated the same way the phase-
  education card is — no phase claim until prediction is on, not raw-chart,
  and at least three cycles observed; otherwise a neutral day header.
- Inline field explainers (FieldInfo) on the clinical fields: BBT, cervical
  mucus, ovulation test, spotting, pregnancy/progesterone test, intercourse,
  protection — factual, descriptive-only, reachable by keyboard and screen
  reader.
- BBT is now the shadcn Input rather than a bare element, with a morning-
  timing hint, so it matches every other control.
- The symptothermal section (Temperature & ovulation) auto-expands for
  fertility goals and for an edited day that already holds a fertility sign,
  so a pre-filled value is never hidden behind a collapsed section.
- Desktop dialog widened to lg so the form breathes.

The payload builders and the form-hydration reconciliation are unchanged, so
the wire contract and their unit tests are untouched.
2026-06-16 21:32:58 +02:00

440 lines
17 KiB
TypeScript

"use client";
import { useMemo, useState } from "react";
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 { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { useTranslations } from "@/lib/i18n/context";
import { cn } from "@/lib/utils";
import { CycleRing } from "./cycle-ring";
import { BbtChart } from "./bbt-chart";
import { CycleCalendar } from "./cycle-calendar";
import { CycleDisclaimer } from "./cycle-disclaimer";
import { CycleHistoryChart } from "./cycle-history-chart";
import { LogDaySheet } 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 { deriveWheelState } from "./wheel-state";
import { PHASE_HUE } from "./phase-tokens";
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. */
const CYCLE_TABS = ["calendar", "predictions", "insights", "settings"] as const;
type CycleTab = (typeof CYCLE_TABS)[number];
export function CycleView() {
const { t } = useTranslations();
// 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]);
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();
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;
}
});
// Pass the profile's typical lengths so a low-data tracker (few logged
// cycles) draws the canonical four-phase ring instead of one dominant arc.
const wheel = useMemo(
() =>
deriveWheelState(calendar.data?.days ?? [], today, {
typicalCycleLength: profileQuery.data?.typicalCycleLength,
typicalPeriodLength: profileQuery.data?.typicalPeriodLength,
lutealPhaseLength: profileQuery.data?.lutealPhaseLength,
}),
[calendar.data, today, profileQuery.data],
);
function openSheet(date: string) {
setSelectedDate(date);
setSheetOpen(true);
}
const loading = calendar.isLoading && !calendar.data;
const calendarError = calendar.isError && !calendar.data;
const tileHue = wheel.phase ? PHASE_HUE[wheel.phase] : undefined;
// AVOID_PREGNANCY surfaces the fertile window, so it must show the stronger
// "not a contraceptive method" caveat. Prefer the server-resolved
// prediction.disclaimer (already goal-correct); fall back to the goal-derived
// key for the no-prediction calendar tab (QA H-1).
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;
const disclaimerText =
calendar.data?.prediction?.disclaimer ??
t(
goal === "AVOID_PREGNANCY"
? "cycle.disclaimer"
: "cycle.prediction.disclaimer",
);
return (
<div className="space-y-6">
<div className="flex items-start justify-between gap-3">
<div className="min-w-0">
<h1 className="text-2xl font-bold tracking-tight">
{t("cycle.title")}
</h1>
<p className="text-muted-foreground text-xs sm:text-sm">
{t("cycle.subtitle")}
</p>
</div>
<Button
onClick={() => openSheet(today)}
className="min-h-11 shrink-0 sm:min-h-9"
>
<Plus className="h-4 w-4" />
{t("cycle.logToday")}
</Button>
</div>
{/* 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. */}
<div className="flex flex-col gap-4 lg:sticky lg:top-6">
{/* 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 px-6 py-6",
play && "wellness-tile-rise",
)}
>
{loading ? (
<div className="flex h-[220px] items-center justify-center">
<Loader2 className="text-primary 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={wheel.dayOfCycle}
cycleLength={wheel.cycleLength}
phase={wheel.phase}
spans={wheel.spans}
animate={play}
/>
)}
{!calendarError ? (
<p className="text-muted-foreground text-xs">
{t("cycle.ring.caption")}
</p>
) : null}
{/* First-period CTA — only when no cycle is active yet. */}
{!loading && !calendarError && wheel.dayOfCycle == null ? (
<Button
variant="outline"
className="mt-1 w-full"
onClick={() => openSheet(today)}
>
{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={wheel.phase}
symptomPatterns={insights.data?.symptomPatterns ?? []}
predictionEnabled={calProfile?.predictionEnabled ?? false}
rawChartMode={calProfile?.rawChartMode ?? false}
cyclesObserved={calProfile?.cyclesObserved ?? 0}
/>
) : null}
</div>
<Tabs defaultValue={initialTab}>
{/* 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="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>
<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 ? (
<TabError onRetry={() => void calendar.refetch()} />
) : (
<>
{/* 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-primary mt-0.5 h-4 w-4 shrink-0"
aria-hidden="true"
/>
<span>{t("cycle.calendar.learning")}</span>
</div>
) : null}
<CycleCalendar
days={calendar.data?.days ?? []}
today={today}
confirmedOvulation={
calendar.data?.prediction?.ovulationConfirmed
? (calendar.data.prediction.predictedOvulation ??
null)
: null
}
onSelectDay={openSheet}
/>
</>
)}
</CardContent>
</Card>
{!loading && !calendarError ? (
<CycleDisclaimer text={disclaimerText} />
) : null}
</TabsContent>
<TabsContent value="predictions" className="mt-4 space-y-4">
{calendarError ? (
<Card>
<CardContent className="py-4">
<TabError onRetry={() => void calendar.refetch()} />
</CardContent>
</Card>
) : (
<>
<PredictionsPanel
prediction={calendar.data?.prediction ?? null}
rawChartMode={calendar.data?.profile.rawChartMode ?? false}
history={history.data}
fallbackDisclaimer={disclaimerText}
goal={goal}
/>
<CycleHistoryChart history={history.data} animate={play} />
<BbtChart
days={calendar.data?.days ?? []}
today={today}
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 ? (
<Card>
<CardContent className="py-4">
<TabError onRetry={() => void insights.refetch()} />
</CardContent>
</Card>
) : (
<>
<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 ? (
<Card>
<CardContent className="py-4">
<TabError onRetry={() => void profileQuery.refetch()} />
</CardContent>
</Card>
) : profileQuery.isLoading ? (
<div className="flex h-32 items-center justify-center">
<Loader2 className="text-primary h-6 w-6 animate-spin motion-reduce:animate-none" />
</div>
) : profileQuery.data ? (
<CycleSettings
key={profileQuery.data.updatedAt}
profile={profileQuery.data}
/>
) : null}
</TabsContent>
</Tabs>
</div>
<LogDaySheet
open={sheetOpen}
onOpenChange={setSheetOpen}
date={selectedDate}
today={today}
activePeriod={wheel.phase === "MENSTRUAL"}
phase={wheel.phase}
dayOfCycle={wheel.dayOfCycle}
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. */
function TabError({ onRetry }: { onRetry: () => void }) {
const { t } = useTranslations();
return (
<div
role="alert"
data-slot="cycle-tab-error"
className="text-muted-foreground flex flex-col items-start gap-3 text-sm sm:flex-row sm:items-center sm:justify-between"
>
<span>{t("cycle.loadError")}</span>
<Button
type="button"
size="sm"
variant="outline"
onClick={onRetry}
className="gap-1.5"
>
<RefreshCw className="h-3.5 w-3.5" aria-hidden="true" />
<span>{t("common.retry")}</span>
</Button>
</div>
);
}