Files
HealthLog/src/components/cycle/cycle-ring-tile.tsx
T
Marc-André Bombeck 1f4ba5939d style: apply prettier across the tree
One-shot prettier pass so the new format:check gate starts from a clean
baseline. Formatting only — no behaviour change. Generator-owned and
vendored artifacts stay untouched via .prettierignore.
2026-06-19 22:20:31 +02:00

143 lines
5.9 KiB
TypeScript

"use client";
import { useMemo, useState } from "react";
import Link from "next/link";
import { Droplets } from "lucide-react";
import { cn } from "@/lib/utils";
import { useTranslations } from "@/lib/i18n/context";
import { CycleRing } from "./cycle-ring";
import { PHASE_HUE } from "./phase-tokens";
import { deriveWheelState } from "./wheel-state";
import { localYmd, useCycleCalendar, useCycleProfile } 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 (`<WellnessScores>`), so the cycle phase reads as part of the
* premium scores strip rather than bolted-on below it. It reuses the SAME
* `<CycleRing>` 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 `<CycleInsightSummaryCard>` — that teaser (phase finding +
* deep-link) already lives further down the overview. This is the RING only,
* as a wellness dial. Both surfaces derive day-of-cycle + phase from the SAME
* `useCycleCalendar` read + `deriveWheelState`, so they can never disagree.
*
* 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, on a hard error, and when there is no
* active cycle phase today — never an empty-framed or half-painted dial in the
* strip (no gap, no placeholder).
*/
/** 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);
}
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 today = useMemo(() => shiftToday(0), []);
const from = useMemo(() => shiftToday(-90), []);
const to = useMemo(() => shiftToday(180), []);
const calendar = useCycleCalendar(from, to);
// Shared 5-min-cached profile read (no extra request when the cycle page or
// summary card already loaded it): its typical lengths let a low-data tracker
// draw the canonical four-phase ring instead of one dominant arc.
const profile = useCycleProfile();
const wheel = useMemo(
() =>
deriveWheelState(calendar.data?.days ?? [], today, {
typicalCycleLength: profile.data?.typicalCycleLength,
typicalPeriodLength: profile.data?.typicalPeriodLength,
lutealPhaseLength: profile.data?.lutealPhaseLength,
}),
[calendar.data, today, profile.data],
);
// 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 `<CycleRing>`.
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, on a hard error, 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 null;
if (wheel.phase == null || wheel.dayOfCycle == null) return null;
const hue = PHASE_HUE[wheel.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.
<div data-revealed={play ? "true" : undefined} className="contents">
<Link
href="/cycle"
data-slot="wellness-cycle-tile"
data-metric="CYCLE"
data-phase={wheel.phase}
// Match the score `RingTile` chrome: the phase's `--tile-hue`
// low-opacity mix over `--card` + the faint film grain (the
// `.wellness-tile` family), with a calm once-per-session rise. The
// per-phase hue arc inside the ring is the only saturated thing.
style={{ "--tile-hue": hue } as React.CSSProperties}
className={cn(
"wellness-tile focus-visible:ring-ring flex flex-col gap-4 rounded-xl p-5 focus-visible:ring-2 focus-visible:outline-none",
play && "wellness-tile-rise",
className,
)}
>
<div className="text-foreground flex items-center gap-2">
<Droplets className="h-5 w-5 shrink-0" aria-hidden="true" />
<span className="truncate text-base leading-none font-semibold">
{t("cycle.wellnessRing.label")}
</span>
</div>
<div className="flex flex-col items-center gap-1.5">
<CycleRing
dayOfCycle={wheel.dayOfCycle}
cycleLength={wheel.cycleLength}
phase={wheel.phase}
spans={wheel.spans}
animate={play}
size={120}
/>
<span
data-slot="wellness-cycle-band-word"
className="text-muted-foreground text-center text-xs"
>
{t(`cycle.phase.${wheel.phase}`)}
</span>
</div>
</Link>
</div>
);
}