mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 21:10:59 +00:00
About forty compact rows and seventy cards still offered a retry on a spent read budget and drew an error card on a refusal, while a third primitive (ReadFailureRow) did the right thing at eight places. QueryErrorRow and QueryErrorCard now take the query's `error` and read it through readFailureKind, which moves to src/lib/api next to ApiError: a refusal (403) draws the surface's refused state or nothing, a spent budget (429) says so without a retry, an ended session (401) says so and offers sign-in instead of going blank, and only a real failure offers the retry. ReadFailureRow is gone and every site passes its error; a guard test fails on a site that does not. The coach read strip keeps its card in the error state and holds one body height across loading, failure and a one-line read.
136 lines
5.7 KiB
TypeScript
136 lines
5.7 KiB
TypeScript
"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 (`<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 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 `<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 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 (
|
|
<QueryErrorRow
|
|
error={calendar.error}
|
|
slot="wellness-cycle-tile-error"
|
|
message={t("cycle.wellnessRing.loadError")}
|
|
onRetry={() => 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.
|
|
<div data-revealed={play ? "true" : undefined} className="contents">
|
|
<Link
|
|
href="/cycle"
|
|
data-slot="wellness-cycle-tile"
|
|
data-metric="CYCLE"
|
|
data-phase={verdict.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-4 focus-visible:ring-2 focus-visible:outline-none md:p-6",
|
|
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={verdict.dayOfCycle}
|
|
cycleLength={verdict.cycleLength}
|
|
phase={verdict.phase}
|
|
spans={verdict.spans}
|
|
animate={play}
|
|
size={120}
|
|
/>
|
|
<span
|
|
data-slot="wellness-cycle-band-word"
|
|
className="text-muted-foreground text-center text-xs"
|
|
>
|
|
{t(`cycle.phase.${verdict.phase}`)}
|
|
</span>
|
|
</div>
|
|
</Link>
|
|
</div>
|
|
);
|
|
}
|