mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-14 14:40:52 +00:00
feat(insights): cycle ring in the wellness strip, bound mood/medication calendar height
Surface the cycle phase ring in the main Insights health-scores strip as a gated sibling tile. It reuses the cycle wheel SVG, sized to a score-ring tile, and sources day-of-cycle + phase from the same calendar read + deriveWheelState the cycle page wheel uses, so the two surfaces never disagree. Mounted only for a cycle-tracking account (the same /api/auth/me gate the sidebar nav and the cycle summary card use), so its calendar read never fires otherwise; it renders nothing while resolving, on error, and when there is no active cycle — no gap, no placeholder. Cap the adaptive cell size in the mood and medication compliance heatmaps. A short window (a single medication, a 30-day mood calendar) left only a few columns, so the container-width cell sizing ballooned and the square grid blew the tile to roughly three times its neighbours' height. The cell ceiling keeps the grid dense and left-aligned within the tile-height rhythm at mobile and desktop.
This commit is contained in:
1 parent
2b9d2370e9
commit
a9f57af602
12 files changed
+363
-11
No files matched your search
@@ -0,0 +1,134 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
|
||||
import { I18nProvider } from "@/lib/i18n/context";
|
||||
import type { CalendarResponse } from "../types";
|
||||
|
||||
/**
|
||||
* v1.15.3 — the compact cycle ring as a wellness-strip tile.
|
||||
*
|
||||
* The tile is mounted ONLY when `user.cycleTrackingEnabled` is true (a
|
||||
* page-level gate the Insights page owns, mirroring the summary card); these
|
||||
* tests cover the tile's own behaviour given that gate: it sources the phase +
|
||||
* cycle day from the SAME calendar read + `deriveWheelState` the wheel uses,
|
||||
* renders the cycle ring labelled, deep-links to the cycle page, and stays
|
||||
* silent while resolving / on a hard error / when there is no active cycle so
|
||||
* the wellness strip never shows a half-painted dial (no gap, no placeholder).
|
||||
*
|
||||
* The calendar read is mocked at the hook boundary so the SSR snapshot is
|
||||
* deterministic without a TanStack-Query client / network round-trip.
|
||||
*/
|
||||
|
||||
// `next/link` → a plain anchor in the static snapshot.
|
||||
vi.mock("next/link", () => ({
|
||||
default: ({
|
||||
href,
|
||||
children,
|
||||
...rest
|
||||
}: {
|
||||
href: string;
|
||||
children: React.ReactNode;
|
||||
}) => (
|
||||
<a href={href} {...rest}>
|
||||
{children}
|
||||
</a>
|
||||
),
|
||||
}));
|
||||
|
||||
const calendarState = vi.hoisted(() => ({
|
||||
current: {
|
||||
data: undefined as CalendarResponse | undefined,
|
||||
isLoading: false,
|
||||
isError: false,
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("../use-cycle", async () => {
|
||||
const actual =
|
||||
await vi.importActual<typeof import("../use-cycle")>("../use-cycle");
|
||||
return {
|
||||
...actual,
|
||||
useCycleCalendar: () => calendarState.current,
|
||||
};
|
||||
});
|
||||
|
||||
import { CycleRingTile } from "../cycle-ring-tile";
|
||||
|
||||
function render(node: React.ReactNode) {
|
||||
return renderToStaticMarkup(
|
||||
<I18nProvider initialLocale="en">{node}</I18nProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
/** A calendar read where `today` sits on a LUTEAL day at day-of-cycle 3. */
|
||||
function lutealCalendar(today: string): CalendarResponse {
|
||||
const days = [
|
||||
{ date: shift(today, -2), phase: "MENSTRUAL" as const },
|
||||
{ date: shift(today, -1), phase: "FOLLICULAR" as const },
|
||||
{ date: today, phase: "LUTEAL" as const },
|
||||
];
|
||||
return {
|
||||
days: days as unknown as CalendarResponse["days"],
|
||||
profile: {} as CalendarResponse["profile"],
|
||||
prediction: null,
|
||||
} as CalendarResponse;
|
||||
}
|
||||
|
||||
function shift(ymd: string, delta: number): string {
|
||||
const [y, m, d] = ymd.split("-").map(Number);
|
||||
const dt = new Date(y, m - 1, d);
|
||||
dt.setDate(dt.getDate() + delta);
|
||||
return `${dt.getFullYear()}-${String(dt.getMonth() + 1).padStart(2, "0")}-${String(dt.getDate()).padStart(2, "0")}`;
|
||||
}
|
||||
|
||||
function todayYmd(): string {
|
||||
const d = new Date();
|
||||
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`;
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
calendarState.current = { data: undefined, isLoading: false, isError: false };
|
||||
});
|
||||
|
||||
describe("<CycleRingTile>", () => {
|
||||
it("renders the cycle ring, phase label, and deep-link for an active cycle", () => {
|
||||
calendarState.current = {
|
||||
data: lutealCalendar(todayYmd()),
|
||||
isLoading: false,
|
||||
isError: false,
|
||||
};
|
||||
|
||||
const html = render(<CycleRingTile />);
|
||||
|
||||
// The wellness-strip tile chrome + the compact cycle ring.
|
||||
expect(html).toContain('data-slot="wellness-cycle-tile"');
|
||||
expect(html).toContain('data-slot="cycle-ring"');
|
||||
expect(html).toContain('data-phase="LUTEAL"');
|
||||
// The phase band word + the strip-tile label.
|
||||
expect(html).toContain("Luteal");
|
||||
expect(html).toContain("Cycle");
|
||||
// Deep-link into the cycle page.
|
||||
expect(html).toContain('href="/cycle"');
|
||||
});
|
||||
|
||||
it("renders nothing while the calendar read is still resolving", () => {
|
||||
calendarState.current = { data: undefined, isLoading: true, isError: false };
|
||||
expect(render(<CycleRingTile />)).toBe("");
|
||||
});
|
||||
|
||||
it("renders nothing on a hard calendar read error", () => {
|
||||
calendarState.current = { data: undefined, isLoading: false, isError: true };
|
||||
expect(render(<CycleRingTile />)).toBe("");
|
||||
});
|
||||
|
||||
it("renders nothing when there is no active cycle today", () => {
|
||||
// A resolved calendar with no phase on today → no active cycle.
|
||||
const empty: CalendarResponse = {
|
||||
days: [] as unknown as CalendarResponse["days"],
|
||||
profile: {} as CalendarResponse["profile"],
|
||||
prediction: null,
|
||||
} as CalendarResponse;
|
||||
calendarState.current = { data: empty, isLoading: false, isError: false };
|
||||
expect(render(<CycleRingTile />)).toBe("");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,127 @@
|
||||
"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 } 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);
|
||||
|
||||
const wheel = useMemo(
|
||||
() => deriveWheelState(calendar.data?.days ?? [], today),
|
||||
[calendar.data, today],
|
||||
);
|
||||
|
||||
// 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>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user