"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: () => (
), }, ); 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(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 (
{t("cycle.title")}} 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 ? ( ) : null } /> {/* Desktop: ring (left) + tabs (right). Single column below lg. */}
{/* 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. */}
{/* 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. */}
{loading ? (
) : calendarError ? (

{t("cycle.loadError")}

) : ( )} {!calendarError ? (

{/* 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")}

) : 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 ? ( ) : null}
{/* 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 ? ( ) : null}
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. */} {t("cycle.tabs.calendar")} {t("cycle.tabs.predictions")} {t("cycle.tabs.insights")} {canManageCycle && ( {t("cycle.tabs.settings")} )} {loading ? (

{t("cycle.calendar.loading")}

) : calendarError ? ( // Inside the calendar card already — a borderless row keeps // it in line with the plain loading line above it (§3/§6). 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 ? (
) : null} )}
{calendarError ? ( void calendar.refetch()} /> ) : ( <> )} {insights.isError ? ( void insights.refetch()} /> ) : ( <> )} {profileQuery.isError ? ( void profileQuery.refetch()} /> ) : profileQuery.isLoading ? (
) : profileQuery.data && canManageCycle ? ( ) : null}
); } /** A compact in-place error + Retry for a failed tab read. */