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:
Marc-André Bombeck committed 2026-06-06 20:47:47 +02:00
1 parent 2b9d2370e9
commit a9f57af602
12 files changed
+363 -11

No files matched your search

+19
View File
@@ -167,6 +167,19 @@ const CycleInsightSummaryCard = dynamic(
{ ssr: false },
);
// v1.15.3 — the compact cycle RING, dropped into the wellness-score strip as a
// gated sibling tile (NOT the summary teaser — that stays further down). Mounted
// only for a cycle-tracking account, so its calendar read never fires otherwise.
// Deferred behind `next/dynamic`; it renders nothing while resolving / on error /
// when there is no active cycle, so it carries no loading placeholder.
const CycleRingTile = dynamic(
() =>
import("@/components/cycle/cycle-ring-tile").then((mod) => ({
default: mod.CycleRingTile,
})),
{ ssr: false },
);
/**
* v1.4.25 W4d — Insights mother page.
*
@@ -376,6 +389,12 @@ export default function InsightsPage() {
isLoading={dashboardDerived.isLoading}
isError={dashboardDerived.isError}
refetch={dashboardDerived.refetch}
// v1.15.3 — the cycle ring rides the scores strip as a gated sibling
// tile, only for a cycle-tracking account, so its calendar read never
// fires otherwise (the same `/api/auth/me` gate the sidebar nav uses).
extraTile={
user?.cycleTrackingEnabled ? <CycleRingTile /> : undefined
}
/>
{flags.briefing && (
+20 -5
View File
@@ -24,6 +24,14 @@ const GAP = 3;
// below the touch-friendly 14 px square on narrow viewports. The
// stretch branch computes an adaptive size; both branches clamp here.
const CELL_FLOOR_PX = 14;
// v1.15.3 — cell-ceiling for the stretch branch. With a short window (a
// single medication / few covered weeks → only ~5 columns) the adaptive
// `containerWidth / weeks` cell ballooned to fill a wide card, and since the
// grid is square the SVG height (`7 * cellSize + 6 * GAP`) blew up to ~3× the
// neighbouring tiles. Capping the cell keeps the grid dense + left-aligned
// (extra width is whitespace) so the heatmap holds the tile-height rhythm:
// height ≈ 18 + 7·22 + 6·3 = 190 px, in line with the surrounding tiles.
const CELL_CEIL_PX = 22;
function getColor(data: DailyData): string {
if (data.expected === 0) return "var(--secondary)";
@@ -229,10 +237,13 @@ export function ComplianceHeatmap({
// narrows. Static (non-stretch) instances use the canonical 18 px.
const cellSize =
stretch && containerWidth > 0
? Math.max(
CELL_FLOOR_PX,
(containerWidth - labelWidth - Math.max(0, weeks - 1) * GAP) /
Math.max(weeks, 1),
? Math.min(
CELL_CEIL_PX,
Math.max(
CELL_FLOOR_PX,
(containerWidth - labelWidth - Math.max(0, weeks - 1) * GAP) /
Math.max(weeks, 1),
),
)
: CELL_SIZE;
const step = cellSize + GAP;
@@ -253,7 +264,11 @@ export function ComplianceHeatmap({
<svg
width={svgWidth}
height={svgHeight}
className={stretch ? "block w-full" : "block"}
// v1.15.3 — `max-w-full` (not `w-full`) so a short window keeps its
// natural, square, left-aligned grid (the capped cells already size
// off `containerWidth`, so a full window still fills the row) rather
// than CSS-stretching ~5 columns of capped cells into wide rectangles.
className={stretch ? "block max-w-full" : "block"}
onMouseLeave={() => setTooltip((prev) => (prev?.pinned ? prev : null))}
>
{/* Month labels */}
+17 -5
View File
@@ -35,6 +35,12 @@ interface MoodHeatmapProps {
const CELL_SIZE = 18;
const GAP = 3;
const CELL_FLOOR_PX = 14;
// v1.15.3 — cell-ceiling for the stretch branch. A short window (few covered
// weeks → only ~5 columns) drove the adaptive `containerWidth / weeks` cell to
// fill a wide card; since the grid is square the SVG height blew up to ~3× the
// neighbouring tiles. Capping the cell keeps the grid dense + left-aligned so
// the calendar holds the tile-height rhythm. Mirrors `compliance-heatmap.tsx`.
const CELL_CEIL_PX = 22;
/**
* Mood score band → Dracula colour. Mirrors `mood-chart.tsx` VALUE_BANDS
@@ -183,10 +189,13 @@ export function MoodHeatmap({
const headerHeight = 18;
const cellSize =
stretch && containerWidth > 0
? Math.max(
CELL_FLOOR_PX,
(containerWidth - labelWidth - Math.max(0, weeks - 1) * GAP) /
Math.max(weeks, 1),
? Math.min(
CELL_CEIL_PX,
Math.max(
CELL_FLOOR_PX,
(containerWidth - labelWidth - Math.max(0, weeks - 1) * GAP) /
Math.max(weeks, 1),
),
)
: CELL_SIZE;
const step = cellSize + GAP;
@@ -205,7 +214,10 @@ export function MoodHeatmap({
<svg
width={svgWidth}
height={svgHeight}
className={stretch ? "block w-full" : "block"}
// v1.15.3 — `max-w-full` (not `w-full`) so a short window keeps its
// natural, square, left-aligned grid rather than CSS-stretching a few
// capped columns into wide rectangles. Mirrors `compliance-heatmap`.
className={stretch ? "block max-w-full" : "block"}
onMouseLeave={() => setTooltip((prev) => (prev?.pinned ? prev : null))}
>
{monthMarkers.map((m, i) => (
@@ -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("");
});
});
+127
View File
@@ -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>
);
}
@@ -47,6 +47,16 @@ interface ScoreStripProps {
isError?: boolean;
/** Refetch the one shared batch query. Recovers both strips at once. */
refetch?: () => void;
/**
* An optional extra tile rendered as the LAST sibling inside the same scores
* grid (e.g. the gated cycle ring). It participates in the column-count math
* so it never leaves a dangling empty cell, and it keeps the strip mounted
* even when no wellness score has data (a cycle-only account still sees its
* dial). The caller owns the gating — the strip just slots whatever it gets.
* The element is expected to render `null` on its own when it has nothing to
* show, so the strip subtracts it from the count in that case.
*/
extraTile?: React.ReactNode;
className?: string;
}
@@ -149,6 +159,7 @@ export function WellnessScores({
isLoading,
isError = false,
refetch,
extraTile,
className,
}: ScoreStripProps) {
const { t } = useTranslations();
@@ -310,7 +321,23 @@ export function WellnessScores({
);
}
// Whole strip un-mounts when no score has data — never an empty section.
// The optional caller-supplied tile (e.g. the gated cycle ring) slots in as
// the last sibling. It is counted toward the grid column math so it never
// leaves a dangling cell, and its presence keeps the strip mounted even when
// no wellness score has data (a cycle-only account still sees its dial). The
// tile renders `null` on its own when it has nothing to show; the grid has
// no background, so a transient empty trailing cell shows nothing — never a
// "missing tile" frame.
if (extraTile != null) {
tiles.push(
<div key="EXTRA" data-slot="wellness-extra-tile-slot" className="contents">
{extraTile}
</div>,
);
}
// Whole strip un-mounts when no score has data AND no extra tile — never an
// empty section.
if (tiles.length === 0) return null;
// v1.12.4 — the strip used a fixed `lg:grid-cols-5`, so the common case of