"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 ? (