"use client"; 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"; import { PHASE_HUE } from "./phase-tokens"; import { useCycleRingCalendar } from "./use-cycle"; /** * v1.15.3 — the compact cycle ring as a WELLNESS-STRIP element. * * A same-size sibling tile that drops into the main Insights "Your health * scores" grid (``), so the cycle phase reads as part of the * premium scores strip rather than bolted-on below it. It reuses the SAME * `` SVG the cycle page wheel uses, sized down to match a * `ScoreRing size="sm"` (≈120 px) so the dial sits in the strip's tile * rhythm; the tile chrome (hue-tinted `wellness-tile`, icon header, once-per- * session reveal) mirrors the score `RingTile` exactly. * * It is NOT a second `` — that teaser (phase finding + * deep-link) already lives further down the overview. This is the RING only, * as a wellness dial. Both surfaces read day-of-cycle + phase from the SAME * server-resolved `verdict` on the calendar read, so they can never disagree — * and neither of them computes it. * * GATING IS THE CALLER'S JOB. The page mounts this only when * `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 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 }) { const { t } = useTranslations(); // Mirror the cycle-view + summary-card window so the cache key is shared — // no extra request when the user opens the cycle page in the same session. const calendar = useCycleRingCalendar(); // The server already resolved the ring: cycle day, phase, and the arc // proportions (including the profile-derived idealized ring a low-data // tracker gets). The tile renders it. const verdict = calendar.data?.verdict; // The signature reveal plays ONCE per browser session, gated on its own key // so a background calendar refetch never re-triggers the moment. The // reduced-motion zeroing lives inside ``. const [play] = useState(() => { if (typeof window === "undefined") return false; try { if (sessionStorage.getItem("cycle-strip-ring-revealed")) return false; sessionStorage.setItem("cycle-strip-ring-revealed", "1"); return true; } catch { return true; } }); // 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 ( void calendar.refetch()} className={className} /> ); } if (verdict?.phase == null || verdict.dayOfCycle == null) return null; const hue = PHASE_HUE[verdict.phase]; return ( // `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`. The `contents` wrapper carries // the flag without adding a layout box.
{t(`cycle.phase.${verdict.phase}`)}
); }