mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-11 13:52:20 +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
@@ -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 && (
|
||||
|
||||
@@ -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 */}
|
||||
|
||||
@@ -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("");
|
||||
});
|
||||
});
|
||||
@@ -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
|
||||
|
||||
Reference in new issue
Block a user