diff --git a/CHANGELOG.md b/CHANGELOG.md index 1cdff5f88..88175601c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,7 +2,21 @@ ## [Unreleased] -## [1.15.3] — 2026-06-06 — cycle summary on the insights page +## [1.15.4] — 2026-06-06 — cycle and insights polish + +### Added + +- **Your cycle ring in the health-scores strip.** With cycle tracking on, the Insights health-scores row now includes your cycle ring — current phase and day — alongside the other scores. It shows only when cycle tracking is enabled. + +### Changed + +- **A calmer cycle calendar and cards.** Predicted-period days are now a soft filled marker instead of a thin underline; the "what's happening now" card sits only under the ring (no duplicate above the calendar) and drops its own log button since logging is one tap away at the top; and the predictions and insights tabs lost their repeated "estimates / associations" footers (the caveat is stated once, where it matters). +- **Tidier settings.** Section descriptions line up under their headings; the web-push enable action sits in the card header; and the standalone cycle-data export/delete actions are gone from the cycle settings (cycle data is already in the full export). +- **Calendar tiles keep their size.** The mood and medication calendar tiles no longer balloon to several times the height of their neighbours when only a short window or a single medication is shown. + +### Fixed + +- **Per-score explanations load faster.** The dashboard's batch read now carries each score's deterministic explanation, so the score sheets show their "why" without an extra request. ### Added diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index bf6491b75..36661381f 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.3 + version: 1.15.4 description: >- Self-hosted personal-health-tracking PWA — public API surface for the iOS native client and external ingest. diff --git a/messages/de.json b/messages/de.json index b4de1c4bb..bed6128bc 100644 --- a/messages/de.json +++ b/messages/de.json @@ -4999,6 +4999,9 @@ "noActiveCycle": "Noch kein aktiver Zyklus", "viewDetails": "Zyklus-Erkenntnisse ansehen" }, + "wellnessRing": { + "label": "Zyklus" + }, "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 65956ba5c..d9d9a08ba 100644 --- a/messages/en.json +++ b/messages/en.json @@ -4999,6 +4999,9 @@ "noActiveCycle": "No active cycle yet", "viewDetails": "View cycle insights" }, + "wellnessRing": { + "label": "Cycle" + }, "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 55eeb130d..2629b9c90 100644 --- a/messages/es.json +++ b/messages/es.json @@ -4999,6 +4999,9 @@ "noActiveCycle": "Aún no hay un ciclo activo", "viewDetails": "Ver análisis del ciclo" }, + "wellnessRing": { + "label": "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 ee4f7309b..f352bd636 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -4999,6 +4999,9 @@ "noActiveCycle": "Aucun cycle actif pour l'instant", "viewDetails": "Voir les analyses du cycle" }, + "wellnessRing": { + "label": "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 367a129d5..a9325f736 100644 --- a/messages/it.json +++ b/messages/it.json @@ -4999,6 +4999,9 @@ "noActiveCycle": "Nessun ciclo attivo ancora", "viewDetails": "Vedi le analisi del ciclo" }, + "wellnessRing": { + "label": "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 6e5754dce..1e6fe6c4a 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -4999,6 +4999,9 @@ "noActiveCycle": "Brak aktywnego cyklu", "viewDetails": "Zobacz analizy cyklu" }, + "wellnessRing": { + "label": "Cykl" + }, "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/package.json b/package.json index 9d3adb397..af54c63f9 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "healthlog", - "version": "1.15.3", + "version": "1.15.4", "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", diff --git a/src/app/api/insights/derived/batch/route.ts b/src/app/api/insights/derived/batch/route.ts index 51d5802b9..b9330ff29 100644 --- a/src/app/api/insights/derived/batch/route.ts +++ b/src/app/api/insights/derived/batch/route.ts @@ -34,6 +34,8 @@ import { isDerivedMetricId, type DerivedMetricId, } from "@/lib/insights/derived"; +import { resolveDeterministicAssessment } from "@/lib/insights/derived/derived-assessment"; +import { resolveServerLocale } from "@/lib/i18n/server-locale"; export const dynamic = "force-dynamic"; @@ -146,6 +148,35 @@ export const GET = apiHandler(async (request: NextRequest) => { // four computes at a time against the shared pool, not seventeen. const limit = pLimit(BATCH_CONCURRENCY); const now = new Date(); + + // Attach the cheap DETERMINISTIC per-score assessment to the grid read so + // iOS paints the "Einschätzung" straight off the overview without a second + // per-id round-trip (the AI-warm prose stays lazy, served by the single + // route). `resolveDeterministicAssessment` is pure — no DB, no LLM — and + // null for non-assessable ids / non-`ok` status, so a non-score item still + // gets `assessment: null`. A best-effort grid extra must NEVER fail the + // whole batch, so each call is guarded → null on any unexpected shape. + const locale = await resolveServerLocale({ + request, + userLocale: user.locale ?? null, + override: request.nextUrl.searchParams.get("locale"), + }); + const assessmentLocale = locale === "de" ? "de" : "en"; + const safeAssessment = ( + metric: DerivedMetricId, + derived: Parameters[1], + ) => { + try { + return resolveDeterministicAssessment( + metric, + derived, + assessmentLocale, + now, + ); + } catch { + return null; + } + }; const results = await Promise.all( items.map((item) => limit(async () => { @@ -167,11 +198,10 @@ export const GET = apiHandler(async (request: NextRequest) => { provenance: derived.provenance, reason: derived.status === "insufficient" ? derived.reason : null, - // The batch backs the dashboard GRID; per-score assessment text is - // a detail-sheet concern served by the single-metric route. Always - // null here so the wire shape matches `DerivedMetricResponse` - // without paying the assessment cost on a 17-item grid read. - assessment: null, + // Deterministic assessment only (cheap, pure). The AI-warm prose + // stays a single-route concern; here every assessable `ok` score + // carries its template "why" text, everything else stays null. + assessment: safeAssessment(item.metric, derived), }, }; }), diff --git a/src/app/insights/page.tsx b/src/app/insights/page.tsx index db3a1a1ad..5018a9035 100644 --- a/src/app/insights/page.tsx +++ b/src/app/insights/page.tsx @@ -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 ? : undefined + } /> {flags.briefing && ( diff --git a/src/components/charts/compliance-heatmap.tsx b/src/components/charts/compliance-heatmap.tsx index 2cf347f37..c6a23e071 100644 --- a/src/components/charts/compliance-heatmap.tsx +++ b/src/components/charts/compliance-heatmap.tsx @@ -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({ setTooltip((prev) => (prev?.pinned ? prev : null))} > {/* Month labels */} diff --git a/src/components/charts/mood-heatmap.tsx b/src/components/charts/mood-heatmap.tsx index 35bf3fc28..c5fd04019 100644 --- a/src/components/charts/mood-heatmap.tsx +++ b/src/components/charts/mood-heatmap.tsx @@ -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({ setTooltip((prev) => (prev?.pinned ? prev : null))} > {monthMarkers.map((m, i) => ( diff --git a/src/components/cycle/__tests__/cycle-calendar.test.tsx b/src/components/cycle/__tests__/cycle-calendar.test.tsx index 439afa4af..e24b107cd 100644 --- a/src/components/cycle/__tests__/cycle-calendar.test.tsx +++ b/src/components/cycle/__tests__/cycle-calendar.test.tsx @@ -49,14 +49,16 @@ describe("", () => { expect(html).toContain("Logged period"); }); - it("renders the predicted period as a band, not a logged pip", () => { + it("renders the predicted period as a dashed soft fill, distinct from a logged pip", () => { const days = [{ ...dayBase("2026-06-20"), isPredictedPeriod: true }]; const html = render( {}} />, ); - // The predicted band uses a dashed gradient underline + the aria marker. + // Predicted days are a soft FILL with a dashed outline (not the old + // underline), tagged data-predicted; the solid logged pip has no dashes. expect(html).toContain("Predicted period"); - expect(html).toContain("repeating-linear-gradient"); + expect(html).toContain('data-predicted="true"'); + expect(html).toContain("border-dashed"); }); it("labels a fertile-window day (only present when goal-gated server allows it)", () => { diff --git a/src/components/cycle/__tests__/cycle-ring-tile.test.tsx b/src/components/cycle/__tests__/cycle-ring-tile.test.tsx new file mode 100644 index 000000000..42d7d1d12 --- /dev/null +++ b/src/components/cycle/__tests__/cycle-ring-tile.test.tsx @@ -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; + }) => ( + + {children} + + ), +})); + +const calendarState = vi.hoisted(() => ({ + current: { + data: undefined as CalendarResponse | undefined, + isLoading: false, + isError: false, + }, +})); + +vi.mock("../use-cycle", async () => { + const actual = + await vi.importActual("../use-cycle"); + return { + ...actual, + useCycleCalendar: () => calendarState.current, + }; +}); + +import { CycleRingTile } from "../cycle-ring-tile"; + +function render(node: React.ReactNode) { + return renderToStaticMarkup( + {node}, + ); +} + +/** 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("", () => { + 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(); + + // 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()).toBe(""); + }); + + it("renders nothing on a hard calendar read error", () => { + calendarState.current = { data: undefined, isLoading: false, isError: true }; + expect(render()).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()).toBe(""); + }); +}); diff --git a/src/components/cycle/__tests__/phase-education-card.test.tsx b/src/components/cycle/__tests__/phase-education-card.test.tsx index 7e18e0ae1..d8769bbb9 100644 --- a/src/components/cycle/__tests__/phase-education-card.test.tsx +++ b/src/components/cycle/__tests__/phase-education-card.test.tsx @@ -30,11 +30,10 @@ const okGate = { predictionEnabled: true, rawChartMode: false, cyclesObserved: 4, - onLogToday: () => {}, }; describe("", () => { - it("renders phase name + descriptive line + the log nudge when the gate passes", () => { + it("renders phase name + descriptive line when the gate passes", () => { const html = render( ", () => { ); expect(html).toContain("Luteal"); expect(html).toContain("progesterone"); // curated whatsHappening line - expect(html).toContain("Log today"); expect(html).toContain('data-phase="LUTEAL"'); }); @@ -69,7 +67,6 @@ describe("", () => { predictionEnabled rawChartMode={false} cyclesObserved={2} - onLogToday={() => {}} />, ); expect(html).toContain("Still learning your cycle"); @@ -85,7 +82,6 @@ describe("", () => { predictionEnabled rawChartMode cyclesObserved={9} - onLogToday={() => {}} />, ); expect(raw).toContain("Still learning your cycle"); @@ -97,24 +93,21 @@ describe("", () => { predictionEnabled={false} rawChartMode={false} cyclesObserved={9} - onLogToday={() => {}} />, ); expect(off).toContain("Still learning your cycle"); }); - it("omits the chip row in the compact variant", () => { + it("renders the chip row when the user has symptoms clustering in this phase", () => { const html = render( , ); expect(html).toContain("progesterone"); - expect(html).not.toContain("cycle-phase-education-chips"); - // The compact variant still carries the log nudge. - expect(html).toContain("Log today"); + expect(html).toContain("cycle-phase-education-chips"); + expect(html).toContain("Cramps"); }); }); diff --git a/src/components/cycle/__tests__/predictions-panel.test.tsx b/src/components/cycle/__tests__/predictions-panel.test.tsx index af2b832d4..c1b91adb1 100644 --- a/src/components/cycle/__tests__/predictions-panel.test.tsx +++ b/src/components/cycle/__tests__/predictions-panel.test.tsx @@ -37,12 +37,13 @@ const emptyHistory: CycleHistoryResponse = { }; describe("", () => { - it("renders the next-period range + the fixed disclaimer", () => { + it("renders the next-period range and shows the disclaimer under the AVOID_PREGNANCY goal", () => { const html = render( , ); expect(html).toContain('data-slot="cycle-disclaimer"'); @@ -51,6 +52,21 @@ describe("", () => { expect(html).toContain("–"); }); + it("suppresses the generic estimate disclaimer for non-AVOID_PREGNANCY goals", () => { + const html = render( + , + ); + expect(html).not.toContain('data-slot="cycle-disclaimer"'); + expect(html).not.toContain("Estimates only, not medical advice."); + // The range still renders. + expect(html).toContain("–"); + }); + it("renders the fertile window only when the prediction carries one", () => { const withFertile: CyclePrediction = { ...basePrediction, diff --git a/src/components/cycle/cycle-calendar.tsx b/src/components/cycle/cycle-calendar.tsx index f9b46304a..5712e2cec 100644 --- a/src/components/cycle/cycle-calendar.tsx +++ b/src/components/cycle/cycle-calendar.tsx @@ -269,14 +269,18 @@ export function CycleCalendar({ {cell.getDate()} - {/* Predicted-period band: a soft hatched underline, never a dot. */} + {/* Predicted-period day: a soft FILL behind the number (like a + logged period day, but lighter) with a dashed outline so it + still reads as predicted, not confirmed — replaces the old + hatched underline, which looked unfinished. */} {info?.isPredictedPeriod && !info?.isPeriodLogged ? (