mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-13 06:32:03 +00:00
When only a few cycle days are logged, the labelled forward run was short enough that normalising it filled the whole ring with a single phase. For a tracker below seven labelled days, derive the four-phase proportions from the profile's typical lengths instead (period / luteal / cycle length, textbook 28/5/14 defaults), so the dial shows the familiar MENSTRUAL/FOLLICULAR/OVULATORY/LUTEAL arcs. The current phase and the day-of-cycle marker stay driven by the observed labels. The ring tile reads the shared, 5-min-cached cycle profile to source the typical lengths (no extra request). Well-populated cycles keep the observed-share spans unchanged.
137 lines
5.5 KiB
TypeScript
137 lines
5.5 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 (
|
|
<Link
|
|
href="/cycle"
|
|
data-slot="wellness-cycle-tile"
|
|
data-metric="CYCLE"
|
|
data-phase={wheel.phase}
|
|
data-revealed={play ? "true" : undefined}
|
|
// 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 on the tile.
|
|
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>
|
|
);
|
|
}
|