From c1d8ff9db910e0f8d81a354a3b41f69a98502eaa Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sat, 6 Jun 2026 19:44:01 +0200 Subject: [PATCH 1/2] feat(cycle): surface a gated cycle summary card on the insights page MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add a compact cycle teaser to the main Insights overview for accounts with cycle tracking enabled. The card shows the current phase, cycle day, and the single headline phase finding, and deep-links into the full cycle insights tab so the cycle tab stays the single source of truth. The card mounts only when cycleTrackingEnabled resolves true from /api/auth/me — the same gate the sidebar cycle entry uses — and renders nothing for every other account. Phase and cycle day derive from the same calendar read plus deriveWheelState the cycle wheel uses, and the headline reuses the shared CyclePhaseHeadline, including its honest empty line when nothing has cleared the significance check. The cycle view now opens the insights tab when the URL carries ?tab=insights, validated against the known tab values; CycleView is wrapped in a Suspense boundary for the search-params read. --- messages/de.json | 6 + messages/en.json | 6 + messages/es.json | 6 + messages/fr.json | 6 + messages/it.json | 6 + messages/pl.json | 6 + src/app/cycle/page.tsx | 17 +- src/app/insights/page.tsx | 19 ++ .../cycle-insight-summary-card.test.tsx | 180 ++++++++++++++++++ .../cycle/cycle-insight-summary-card.tsx | 141 ++++++++++++++ src/components/cycle/cycle-view.tsx | 20 +- 11 files changed, 410 insertions(+), 3 deletions(-) create mode 100644 src/components/cycle/__tests__/cycle-insight-summary-card.test.tsx create mode 100644 src/components/cycle/cycle-insight-summary-card.tsx diff --git a/messages/de.json b/messages/de.json index ee1ad4f77..b4de1c4bb 100644 --- a/messages/de.json +++ b/messages/de.json @@ -4993,6 +4993,12 @@ "trackNudge": "Heute eintragen", "stillLearning": "Lerne deinen Zyklus noch kennen. Trage weiter ein, dann füllt sich das mit Mustern aus deinen eigenen Tagen." }, + "insightsSummary": { + "title": "Dein Zyklus", + "currentDay": "Tag {day} deines Zyklus", + "noActiveCycle": "Noch kein aktiver Zyklus", + "viewDetails": "Zyklus-Erkenntnisse ansehen" + }, "disclaimer": "Diese Vorhersagen sind beschreibende Schätzungen aus deinen erfassten Daten, keine Verhütungsmethode und keine medizinische Beratung. Sie zeigen nie an, dass ungeschützter Geschlechtsverkehr sicher ist. Wende dich für Verhütung oder medizinische Entscheidungen an eine medizinische Fachperson.", "title": "Dein Zyklus", "subtitle": "Erfasse deine Periode und Symptome und sieh, was als Nächstes kommt.", diff --git a/messages/en.json b/messages/en.json index 09f86e5f7..65956ba5c 100644 --- a/messages/en.json +++ b/messages/en.json @@ -4993,6 +4993,12 @@ "trackNudge": "Log today", "stillLearning": "Still learning your cycle. Keep logging and this fills in with patterns from your own days." }, + "insightsSummary": { + "title": "Your cycle", + "currentDay": "Day {day} of your cycle", + "noActiveCycle": "No active cycle yet", + "viewDetails": "View cycle insights" + }, "disclaimer": "These predictions are descriptive estimates from your logged data, not a contraceptive method and not medical advice. They never indicate that it is safe to have unprotected sex. For contraception or any medical decision, consult a healthcare professional.", "title": "Your cycle", "subtitle": "Log your period and symptoms, see what comes next.", diff --git a/messages/es.json b/messages/es.json index f2dcad374..55eeb130d 100644 --- a/messages/es.json +++ b/messages/es.json @@ -4993,6 +4993,12 @@ "trackNudge": "Registrar hoy", "stillLearning": "Aún estamos conociendo tu ciclo. Sigue registrando y esto se irá completando con patrones de tus propios días." }, + "insightsSummary": { + "title": "Tu ciclo", + "currentDay": "Día {day} de tu ciclo", + "noActiveCycle": "Aún no hay un ciclo activo", + "viewDetails": "Ver análisis del ciclo" + }, "disclaimer": "Estas predicciones son estimaciones descriptivas a partir de tus datos registrados, no un método anticonceptivo ni un consejo médico. Nunca indican que sea seguro tener relaciones sin protección. Para anticoncepción o cualquier decisión médica, consulta a un profesional sanitario.", "title": "Tu ciclo", "subtitle": "Registra tu periodo y síntomas, y mira qué viene después.", diff --git a/messages/fr.json b/messages/fr.json index fbbd9cdb3..ee4f7309b 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -4993,6 +4993,12 @@ "trackNudge": "Noter aujourd'hui", "stillLearning": "On apprend encore ton cycle. Continue à noter et cela se remplira avec les tendances de tes propres journées." }, + "insightsSummary": { + "title": "Ton cycle", + "currentDay": "Jour {day} de ton cycle", + "noActiveCycle": "Aucun cycle actif pour l'instant", + "viewDetails": "Voir les analyses du cycle" + }, "disclaimer": "Ces prévisions sont des estimations descriptives issues de vos données enregistrées, ni une méthode de contraception ni un avis médical. Elles n'indiquent jamais qu'il est sûr d'avoir des rapports non protégés. Pour la contraception ou toute décision médicale, consultez un professionnel de santé.", "title": "Ton cycle", "subtitle": "Enregistre tes règles et symptômes, et vois ce qui arrive ensuite.", diff --git a/messages/it.json b/messages/it.json index 992a58d51..367a129d5 100644 --- a/messages/it.json +++ b/messages/it.json @@ -4993,6 +4993,12 @@ "trackNudge": "Registra oggi", "stillLearning": "Stiamo ancora imparando il tuo ciclo. Continua a registrare e questo si completerà con gli schemi delle tue giornate." }, + "insightsSummary": { + "title": "Il tuo ciclo", + "currentDay": "Giorno {day} del tuo ciclo", + "noActiveCycle": "Nessun ciclo attivo ancora", + "viewDetails": "Vedi le analisi del ciclo" + }, "disclaimer": "Queste previsioni sono stime descrittive basate sui tuoi dati registrati, non un metodo contraccettivo né un parere medico. Non indicano mai che sia sicuro avere rapporti non protetti. Per la contraccezione o qualsiasi decisione medica, consulta un professionista sanitario.", "title": "Il tuo ciclo", "subtitle": "Registra il ciclo e i sintomi e scopri cosa arriva dopo.", diff --git a/messages/pl.json b/messages/pl.json index 54edaf4ba..6e5754dce 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -4993,6 +4993,12 @@ "trackNudge": "Zapisz dziś", "stillLearning": "Wciąż poznajemy Twój cykl. Zapisuj dalej, a to wypełni się wzorcami z Twoich własnych dni." }, + "insightsSummary": { + "title": "Twój cykl", + "currentDay": "Dzień {day} Twojego cyklu", + "noActiveCycle": "Brak aktywnego cyklu", + "viewDetails": "Zobacz analizy cyklu" + }, "disclaimer": "Te prognozy to opisowe szacunki na podstawie zapisanych danych, a nie metoda antykoncepcji ani porada medyczna. Nigdy nie oznaczają, że współżycie bez zabezpieczenia jest bezpieczne. W sprawach antykoncepcji lub decyzji medycznych skonsultuj się z pracownikiem ochrony zdrowia.", "title": "Twój cykl", "subtitle": "Zapisuj miesiączkę i objawy oraz sprawdzaj, co dalej.", diff --git a/src/app/cycle/page.tsx b/src/app/cycle/page.tsx index 54034d4a5..dbdbe72d1 100644 --- a/src/app/cycle/page.tsx +++ b/src/app/cycle/page.tsx @@ -1,6 +1,6 @@ "use client"; -import { useEffect } from "react"; +import { Suspense, useEffect } from "react"; import { useRouter } from "next/navigation"; import { Loader2 } from "lucide-react"; @@ -40,5 +40,18 @@ export default function CyclePage() { ); } - return ; + // `` reads `useSearchParams` for the `?tab=` deep-link; wrap it in + // a Suspense boundary so the client-search-params bailout never de-opts the + // build (Next's `missing-suspense-with-csr-bailout`). + return ( + + + + } + > + + + ); } diff --git a/src/app/insights/page.tsx b/src/app/insights/page.tsx index be7a0aaeb..db3a1a1ad 100644 --- a/src/app/insights/page.tsx +++ b/src/app/insights/page.tsx @@ -153,6 +153,20 @@ const PeriodNarrativeCard = dynamic( }, ); +// v1.15.2 — the gated cycle-insights summary teaser. Mounted ONLY when +// `user.cycleTrackingEnabled` is true (the same /api/auth/me signal the +// sidebar nav entry gates on), so the cycle reads never fire for an account +// without the feature. Deferred behind `next/dynamic` like the other +// below-the-hero blocks; it owns its own calendar + insights reads and renders +// nothing while resolving / on error, so it carries no loading placeholder. +const CycleInsightSummaryCard = dynamic( + () => + import("@/components/cycle/cycle-insight-summary-card").then((mod) => ({ + default: mod.CycleInsightSummaryCard, + })), + { ssr: false }, +); + /** * v1.4.25 W4d — Insights mother page. * @@ -384,6 +398,11 @@ export default function InsightsPage() { {flags.briefing && } + {/* v1.15.2 — gated cycle teaser. Render only for a cycle-tracking + account; for everyone else this is nothing (no card, no layout gap). + The card itself stays silent until its reads resolve. */} + {user?.cycleTrackingEnabled ? : null} + diff --git a/src/components/cycle/__tests__/cycle-insight-summary-card.test.tsx b/src/components/cycle/__tests__/cycle-insight-summary-card.test.tsx new file mode 100644 index 000000000..c24d38226 --- /dev/null +++ b/src/components/cycle/__tests__/cycle-insight-summary-card.test.tsx @@ -0,0 +1,180 @@ +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"; +import type { CycleInsightsResponse } from "../use-cycle"; +import type { CyclePhaseCrosstabRow } from "../cycle-phase-crosstab"; + +/** + * v1.15.2 — the gated cycle-insights summary teaser on the main Insights page. + * + * The card itself is mounted ONLY when `user.cycleTrackingEnabled` is true (a + * page-level gate); these tests cover the card's own behaviour given that gate: + * it sources the phase + cycle day from the SAME calendar read the wheel uses, + * renders the shared FDR-gated headline, deep-links to the cycle insights tab, + * and stays silent while resolving / on a hard error so the overview never + * shows a broken cycle teaser. + * + * The cycle reads are 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; + }) => ( + + {children} + + ), +})); + +const calendarState = vi.hoisted(() => ({ + current: { + data: undefined as CalendarResponse | undefined, + isLoading: false, + isError: false, + }, +})); +const insightsState = vi.hoisted(() => ({ + current: { + data: undefined as CycleInsightsResponse | undefined, + isError: false, + }, +})); + +vi.mock("../use-cycle", async () => { + const actual = + await vi.importActual("../use-cycle"); + return { + ...actual, + useCycleCalendar: () => calendarState.current, + useCycleInsights: () => insightsState.current, + }; +}); + +import { CycleInsightSummaryCard } from "../cycle-insight-summary-card"; + +function render(node: React.ReactNode) { + return renderToStaticMarkup( + {node}, + ); +} + +/** Build a calendar read where `today` sits on a LUTEAL day at day-of-cycle 3 + * (a short MENSTRUAL run → LUTEAL today, so `deriveWheelState` resolves a + * phase + day). */ +function lutealCalendar(today: string): CalendarResponse { + // Three consecutive days ending today: MENSTRUAL, FOLLICULAR, LUTEAL. + 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")}`; +} + +const headlineRow: CyclePhaseCrosstabRow = { + metricKey: "restingHeartRate", + display: "bpm", + lutealDays: 12, + follicularDays: 11, + lutealAvg: 64, + follicularAvg: 60, + delta: 4, + pValue: 0.01, + qValue: 0.03, + confidence: "high", +}; + +beforeEach(() => { + calendarState.current = { + data: undefined, + isLoading: false, + isError: false, + }; + insightsState.current = { data: undefined, isError: false }; +}); + +describe("", () => { + it("renders the current phase, cycle day, headline, and the deep-link", () => { + calendarState.current = { + data: lutealCalendar(todayYmd()), + isLoading: false, + isError: false, + }; + insightsState.current = { + data: { rows: [headlineRow], headline: headlineRow, symptomPatterns: [] }, + isError: false, + }; + + const html = render(); + + // Phase name + cycle-day read (day 3 of the 3-day run). + expect(html).toContain("Luteal"); + expect(html).toContain("Day 3 of your cycle"); + // The shared FDR-gated headline (resting-heart-rate phrasing). + expect(html).toContain("resting heart rate"); + // Deep-link into the single source of truth (cycle insights tab). + expect(html).toContain('href="/cycle?tab=insights"'); + expect(html).toContain("View cycle insights"); + expect(html).toContain('data-phase="LUTEAL"'); + }); + + it("shows the honest empty headline line when nothing cleared the FDR gate", () => { + calendarState.current = { + data: lutealCalendar(todayYmd()), + isLoading: false, + isError: false, + }; + insightsState.current = { + data: { rows: [], headline: null, symptomPatterns: [] }, + isError: false, + }; + + const html = render(); + // Calm "keep logging" line from the shared . + expect(html).toContain("Not enough cycles"); + // Still a teaser with a working deep-link. + expect(html).toContain('href="/cycle?tab=insights"'); + }); + + it("renders nothing while the calendar read is still resolving", () => { + calendarState.current = { data: undefined, isLoading: true, isError: false }; + const html = render(); + expect(html).toBe(""); + }); + + it("renders nothing on a hard calendar read error", () => { + calendarState.current = { + data: undefined, + isLoading: false, + isError: true, + }; + const html = render(); + expect(html).toBe(""); + }); +}); diff --git a/src/components/cycle/cycle-insight-summary-card.tsx b/src/components/cycle/cycle-insight-summary-card.tsx new file mode 100644 index 000000000..c0a3136ca --- /dev/null +++ b/src/components/cycle/cycle-insight-summary-card.tsx @@ -0,0 +1,141 @@ +"use client"; + +import { useMemo } from "react"; +import Link from "next/link"; +import { ArrowRight, Sparkles } from "lucide-react"; + +import { useTranslations } from "@/lib/i18n/context"; +import { CyclePhaseHeadline } from "./cycle-phase-crosstab"; +import { PHASE_HUE } from "./phase-tokens"; +import { deriveWheelState } from "./wheel-state"; +import { localYmd, useCycleCalendar, useCycleInsights } from "./use-cycle"; + +/** + * v1.15.2 — the gated cycle-insights summary card for the main Insights page. + * + * A compact TEASER + entry point, never a duplicate of the full cycle insights + * tab. It surfaces three things and links into the source of truth: + * 1. the current phase (name + hue dot) and cycle day, derived from the + * SAME calendar read + `deriveWheelState` the cycle wheel uses, so the + * two surfaces can never disagree on the day-of-cycle or phase; + * 2. the one headline phase finding, rendered through the SHARED + * `` (which already owns the FDR-gated copy + the + * honest "not enough cycles yet" empty line) — no re-implementation; + * 3. a "view details" deep-link into `/cycle?tab=insights`. + * + * Visual idiom mirrors `phase-education-card.tsx`: a hue-tinted `wellness-tile` + * keyed to the ACTIVE phase via `--tile-hue`, with one calm entrance that the + * global `prefers-reduced-motion` block already zeroes. The phase semantic is + * never colour-only — the phase word + an aria-label always accompany the hue. + * + * GATING IS THE CALLER'S JOB: the page only mounts this when + * `user.cycleTrackingEnabled` is true (the same `/api/auth/me` signal the + * sidebar nav entry uses), so the two cycle reads never fire for an account + * without the feature. The component additionally renders nothing while the + * calendar read is still resolving and on a hard read error, so it never shows + * a broken or empty-framed state on the overview. + */ + +/** 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 CycleInsightSummaryCard() { + const { t } = useTranslations(); + + // Mirror the cycle-view window so the cache key is shared (no extra request + // when the user later 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 insights = useCycleInsights(); + + const wheel = useMemo( + () => deriveWheelState(calendar.data?.days ?? [], today), + [calendar.data, today], + ); + + // Stay silent until the calendar read resolves, and on a hard error: the + // overview must never show a half-painted or error-framed cycle teaser. The + // full cycle page owns the retry affordance. + if (calendar.isLoading && !calendar.data) return null; + if (calendar.isError && !calendar.data) return null; + + const phase = wheel.phase; + const hue = phase ? PHASE_HUE[phase] : PHASE_HUE.LUTEAL; + const phaseName = phase ? t(`cycle.phase.${phase}`) : t("cycle.phase.none"); + const dayOfCycle = wheel.dayOfCycle; + + const ariaLabel = + phase && dayOfCycle != null + ? t("cycle.ring.ariaPhase", { day: dayOfCycle, phase: phaseName }) + : t("cycle.ring.ariaUnknown"); + + return ( +
+
+ {/* Zone 1 — eyebrow + the current phase / cycle-day read. */} +
+
+
+ +

+

+ + +
+
+ + {/* Zone 2 — the one headline phase finding (shared component owns the + FDR-gated copy AND the honest "not enough cycles yet" empty line, so + this can never show a broken state). */} +
+ +
+ + {/* Zone 3 — the deep-link into the single source of truth: the cycle + insights tab. The cycle page reads `?tab=insights` and opens that tab. */} + + {t("cycle.insightsSummary.viewDetails")} +
+ ); +} diff --git a/src/components/cycle/cycle-view.tsx b/src/components/cycle/cycle-view.tsx index b64c292e5..6e14876fc 100644 --- a/src/components/cycle/cycle-view.tsx +++ b/src/components/cycle/cycle-view.tsx @@ -1,6 +1,7 @@ "use client"; import { useMemo, useState } from "react"; +import { useSearchParams } from "next/navigation"; import { Loader2, Plus, RefreshCw } from "lucide-react"; import { Button } from "@/components/ui/button"; @@ -51,9 +52,26 @@ function shiftToday(days: number): string { return localYmd(d); } +/** The four cycle tabs, in render order. The deep-link value is validated + * against this set so a junk `?tab=` falls back to the calendar tab. */ +const CYCLE_TABS = ["calendar", "predictions", "insights", "settings"] as const; +type CycleTab = (typeof CYCLE_TABS)[number]; + export function CycleView() { const { t } = useTranslations(); + // Deep-link support: `/cycle?tab=insights` opens the insights tab. The value + // is read ONCE for the initial `defaultValue` (uncontrolled tabs — so normal + // clicking still works), validated against the known set, and falls back to + // the calendar tab on anything else. + const searchParams = useSearchParams(); + const initialTab: CycleTab = useMemo(() => { + const raw = searchParams?.get("tab"); + return raw && (CYCLE_TABS as readonly string[]).includes(raw) + ? (raw as CycleTab) + : "calendar"; + }, [searchParams]); + const today = useMemo(() => shiftToday(0), []); const from = useMemo(() => shiftToday(-90), []); const to = useMemo(() => shiftToday(180), []); @@ -206,7 +224,7 @@ export function CycleView() { ) : null} - + {t("cycle.tabs.calendar")} From 6dc241ccb1a1a7b0b0b2653163362891085d6bf0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sat, 6 Jun 2026 19:44:53 +0200 Subject: [PATCH 2/2] =?UTF-8?q?chore(release):=20v1.15.3=20=E2=80=94=20cyc?= =?UTF-8?q?le=20summary=20on=20the=20insights=20page?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- CHANGELOG.md | 6 ++++++ docs/api/openapi.yaml | 2 +- package.json | 2 +- 3 files changed, 8 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index c57e682cb..1cdff5f88 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,12 @@ ## [Unreleased] +## [1.15.3] — 2026-06-06 — cycle summary on the insights page + +### Added + +- **Your cycle at a glance on Insights.** If you track your cycle, the Insights page now shows a compact card with your current phase and cycle day plus the one standout phase relation, with a tap-through to the full cycle insights. It appears only when cycle tracking is on, and the detailed phase analysis stays in the cycle section. + ## [1.15.2] — 2026-06-06 — cycle insights crash fix ### Fixed diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index 8d4fbc6e9..bf6491b75 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -1,7 +1,7 @@ openapi: 3.1.0 info: title: HealthLog API - version: 1.15.2 + version: 1.15.3 description: >- Self-hosted personal-health-tracking PWA — public API surface for the iOS native client and external ingest. diff --git a/package.json b/package.json index f14b91675..9d3adb397 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "healthlog", - "version": "1.15.2", + "version": "1.15.3", "description": "Self-hosted personal-health-tracking PWA with Withings integration, AI insights, and doctor-report PDF export.", "license": "AGPL-3.0-only", "homepage": "https://healthlog.dev",