diff --git a/CHANGELOG.md b/CHANGELOG.md index 1b53da442..c218c7d8f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,24 @@ ## [Unreleased] +## [1.15.10] — 2026-06-07 — Insights overview, medication follow-ups, health score + +### Fixed + +- **Medication compliance now pairs doses to the right slot.** For a twice-daily medication, a dose logged off its usual time could be matched to the wrong slot — so a deliberately-skipped dose might read as taken, or a late evening dose as missed. Doses now snap to their own scheduled slot first, so the percentage reflects what actually happened. +- **The card advances to your next real dose.** After you log a dose, the "next dose" line moves to the next upcoming, unresolved slot instead of lingering on one that's already taken or past. +- **A single dose time reads cleanly.** A dose with one target time shows "07:00" rather than "07:00 to 07:00". +- **Your weight trend counts toward the Health Score even without a target.** The weight pillar used to need a height/target to score; it now scores your weight trend on its own when no target is set, so it stops dropping out of the score. + +### Changed + +- **A consistent Insights overview.** Every section now has its title above its card with a matching icon and even spacing throughout — the daily-briefing icon matches the rest, Trends has an icon, and the period-in-review and cycle sections carry proper headings. +- **A clearer cycle ring for new trackers.** With only a little cycle data the ring now shows the four phases in sensible proportions instead of collapsing to a single arc. +- **Mobile polish on Insights and Cycle** — the cycle ring fits narrow screens, the cycle tabs no longer crowd, and calendar days meet the tap-target size. +- **Your photo in the top bar.** If you've uploaded a profile photo it now shows in the top-right avatar; otherwise the icon remains. +- **The notifications area is just "Notifications"** (shorter), and admins get a link to the admin area from the mobile menu too. +- **Dark theme by default** for a fresh visitor; an explicit theme choice is still remembered. + ## [1.15.9] — 2026-06-07 — medication compliance and cards ### Fixed diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index abcab09a0..197c9f6b1 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.9 + version: 1.15.10 description: >- Self-hosted personal-health-tracking PWA — public API surface for the iOS native client and external ingest. diff --git a/e2e/cycle-insights-mobile.spec.ts b/e2e/cycle-insights-mobile.spec.ts new file mode 100644 index 000000000..cb278f5d0 --- /dev/null +++ b/e2e/cycle-insights-mobile.spec.ts @@ -0,0 +1,214 @@ +import { expect, test } from "@playwright/test"; + +import { STORAGE_STATE_PATH } from "./setup/global-setup"; + +/** + * Pixel-5 mobile smoke for the Insights overview + the cycle vertical + * (v1.15.10). Mirrors `mobile-viewport.spec.ts`. Asserts: + * + * 1. `/insights` has no horizontal page scroll on the Pixel-5 width — the + * core guarantee behind the cycle-ring-width fix (the 120 px ring tile + * used to overflow narrow grid cells). + * 2. `/cycle` has no horizontal page scroll on the Pixel-5 width — covers + * the tab-strip-overflow fix (the four long German labels used to force + * a horizontal scroll at 375 px) and the responsive cycle ring. + * 3. The cycle-calendar day cells clear the WCAG 2.5.5 mobile tap-target + * height floor (44 CSS px) — the `min-h-10` → `min-h-11` bump. + * + * The cycle feature gate is enabled by rewriting the real `/api/auth/me` + * response with `cycleTrackingEnabled: true` (every other field stays + * authentic), and the `/api/cycle/*` reads are mocked so the wheel, tab strip, + * and calendar render deterministically without seeding cycle data. + * + * Runs only on the `chromium-mobile` project (Pixel 5). The desktop project + * skips this whole describe block. + */ + +function ymd(d: Date): string { + return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`; +} + +/** A 90-day-back → 180-day-forward calendar with a recent labelled cycle run + * so the wheel resolves a current phase + day and the calendar paints cells. */ +function buildCalendarDays(): Array> { + const days: Array> = []; + const start = new Date(); + start.setDate(start.getDate() - 12); + // 5 menstrual, 6 follicular, 1 ovulatory, then luteal through today. + const plan: string[] = [ + ...Array(5).fill("MENSTRUAL"), + ...Array(6).fill("FOLLICULAR"), + "OVULATORY", + "LUTEAL", + "LUTEAL", + ]; + for (let i = 0; i < plan.length; i++) { + const d = new Date(start); + d.setDate(start.getDate() + i); + days.push({ + date: ymd(d), + phase: plan[i], + isPredictedPeriod: false, + isFertileWindow: plan[i] === "OVULATORY", + isPredictedOvulation: false, + isPeriodLogged: plan[i] === "MENSTRUAL", + flow: plan[i] === "MENSTRUAL" ? "MEDIUM" : null, + hasSymptoms: false, + confidence: 1, + basalBodyTempC: null, + ovulationTest: null, + cervicalMucus: null, + }); + } + return days; +} + +const CALENDAR_BODY = { + data: { + profile: { + goal: "GENERAL_HEALTH", + rawChartMode: false, + predictionEnabled: true, + cyclesObserved: 1, + }, + prediction: null, + days: buildCalendarDays(), + meta: { generatedAt: new Date().toISOString() }, + }, + error: null, +}; + +const PROFILE_BODY = { + data: { + goal: "GENERAL_HEALTH", + cycleTrackingEnabled: true, + rawChartMode: false, + predictionEnabled: true, + discreetNotifications: false, + sensitiveCategoryEncryption: false, + typicalCycleLength: 28, + typicalPeriodLength: 5, + lutealPhaseLength: 14, + updatedAt: new Date().toISOString(), + }, + error: null, +}; + +const HISTORY_BODY = { + data: { + cycles: [], + stats: { + avgLengthDays: null, + lengthVariabilityDays: null, + avgPeriodLengthDays: null, + regularity: "LEARNING", + }, + }, + error: null, +}; + +const INSIGHTS_BODY = { + data: { rows: [], headline: null, symptomPatterns: [] }, + error: null, +}; + +async function assertNoHorizontalScroll(page: import("@playwright/test").Page) { + const dims = await page.evaluate(() => ({ + scrollWidth: document.documentElement.scrollWidth, + innerWidth: window.innerWidth, + })); + expect( + dims.scrollWidth, + `scrollWidth=${dims.scrollWidth}, innerWidth=${dims.innerWidth}`, + ).toBeLessThanOrEqual(dims.innerWidth + 1); +} + +test.describe("cycle + insights mobile smoke", () => { + test.use({ storageState: STORAGE_STATE_PATH }); + + test.beforeEach(async ({ page }, testInfo) => { + test.skip(testInfo.project.name !== "chromium-mobile", "mobile-only spec"); + + // Flip the cycle feature gate ON by rewriting the real /api/auth/me + // response — every other field stays authentic so the auth shell behaves + // exactly as in production. + await page.route("**/api/auth/me", async (route) => { + const res = await route.fetch(); + const json = await res.json().catch(() => null); + if (!json?.data) return route.fulfill({ response: res }); + json.data.cycleTrackingEnabled = true; + return route.fulfill({ + response: res, + body: JSON.stringify(json), + headers: { ...res.headers(), "content-type": "application/json" }, + }); + }); + + // Deterministic cycle reads. + await page.route(/\/api\/cycle\/calendar(\?|$)/, (route) => + route.fulfill({ + status: 200, + contentType: "application/json", + body: JSON.stringify(CALENDAR_BODY), + }), + ); + await page.route("**/api/cycle/profile", (route) => + route.fulfill({ + status: 200, + contentType: "application/json", + body: JSON.stringify(PROFILE_BODY), + }), + ); + await page.route(/\/api\/cycle\/cycles(\?|$)/, (route) => + route.fulfill({ + status: 200, + contentType: "application/json", + body: JSON.stringify(HISTORY_BODY), + }), + ); + await page.route("**/api/cycle/insights", (route) => + route.fulfill({ + status: 200, + contentType: "application/json", + body: JSON.stringify(INSIGHTS_BODY), + }), + ); + }); + + test("/insights has no horizontal scroll on Pixel-5", async ({ page }) => { + await page.goto("/insights", { waitUntil: "domcontentloaded" }); + await page.waitForLoadState("networkidle"); + await assertNoHorizontalScroll(page); + }); + + test("/cycle has no horizontal scroll and the calendar cells clear the 44 px tap floor", async ({ + page, + }) => { + await page.goto("/cycle", { waitUntil: "domcontentloaded" }); + await page.waitForLoadState("networkidle"); + + // The cycle vertical mounted (the page redirects home if the gate is off). + const wheel = page.locator('[data-slot="cycle-wheel-tile"]'); + await expect(wheel).toBeVisible(); + + // 1) No horizontal page scroll at the Pixel-5 width. + await assertNoHorizontalScroll(page); + + // 2) Calendar day cells clear the 44 px tap-target height floor. + const cells = await page.locator('[role="gridcell"]').all(); + const failures: string[] = []; + for (const cell of cells) { + if (!(await cell.isVisible().catch(() => false))) continue; + const box = await cell.boundingBox(); + if (!box) continue; + if (box.height < 44) { + const label = (await cell.getAttribute("aria-label")) ?? "(cell)"; + failures.push(`${label.slice(0, 30)} → ${box.height.toFixed(1)}px`); + } + } + expect( + failures, + `Calendar cells below 44 px tall:\n ${failures.join("\n ")}`, + ).toEqual([]); + }); +}); diff --git a/messages/de.json b/messages/de.json index 5aa7eb089..431fa1863 100644 --- a/messages/de.json +++ b/messages/de.json @@ -205,7 +205,7 @@ "logout": "Abmelden", "login": "Anmelden", "about": "Über HealthLog", - "notifications": "Benachrichtigungs-Center", + "notifications": "Benachrichtigungen", "theme": "Darstellung", "themeSystem": "System", "themeDark": "Dunkel", @@ -4692,8 +4692,8 @@ "disabledBody": "Der Administrator hat Bug-Reports auf diesem Server deaktiviert." }, "notifications": { - "title": "Benachrichtigungs-Center", - "breadcrumbInbox": "Benachrichtigungs-Center", + "title": "Benachrichtigungen", + "breadcrumbInbox": "Benachrichtigungen", "breadcrumbChannels": "Benachrichtigungs-Kanäle", "breadcrumbSettings": "Einstellungen", "eventColumn": "Ereignis", diff --git a/messages/en.json b/messages/en.json index bcfa155bf..b83e09a1d 100644 --- a/messages/en.json +++ b/messages/en.json @@ -205,7 +205,7 @@ "logout": "Sign out", "login": "Sign in", "about": "About HealthLog", - "notifications": "Notification Center", + "notifications": "Notifications", "theme": "Appearance", "themeSystem": "System", "themeDark": "Dark", @@ -4692,8 +4692,8 @@ "disabledBody": "The administrator has disabled bug reports on this server." }, "notifications": { - "title": "Notification Center", - "breadcrumbInbox": "Notification Center", + "title": "Notifications", + "breadcrumbInbox": "Notifications", "breadcrumbChannels": "Notification channels", "breadcrumbSettings": "Settings", "eventColumn": "Event", diff --git a/messages/es.json b/messages/es.json index 6e5146b72..13e215365 100644 --- a/messages/es.json +++ b/messages/es.json @@ -205,7 +205,7 @@ "logout": "Cerrar sesión", "login": "Iniciar sesión", "about": "Acerca de HealthLog", - "notifications": "Centro de notificaciones", + "notifications": "Notificaciones", "theme": "Apariencia", "themeSystem": "Sistema", "themeDark": "Oscuro", @@ -4692,8 +4692,8 @@ "disabledBody": "El administrador ha desactivado los informes de errores en este servidor." }, "notifications": { - "title": "Centro de notificaciones", - "breadcrumbInbox": "Centro de notificaciones", + "title": "Notificaciones", + "breadcrumbInbox": "Notificaciones", "breadcrumbChannels": "Canales de notificación", "breadcrumbSettings": "Ajustes", "eventColumn": "Evento", diff --git a/messages/fr.json b/messages/fr.json index c422e5303..4481bde38 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -205,7 +205,7 @@ "logout": "Se déconnecter", "login": "Se connecter", "about": "À propos de HealthLog", - "notifications": "Centre de notifications", + "notifications": "Notifications", "theme": "Apparence", "themeSystem": "Système", "themeDark": "Sombre", @@ -4692,8 +4692,8 @@ "disabledBody": "L'administrateur a désactivé les rapports de bug sur ce serveur." }, "notifications": { - "title": "Centre de notifications", - "breadcrumbInbox": "Centre de notifications", + "title": "Notifications", + "breadcrumbInbox": "Notifications", "breadcrumbChannels": "Canaux de notification", "breadcrumbSettings": "Paramètres", "eventColumn": "Événement", diff --git a/messages/it.json b/messages/it.json index 112f8c9ba..dc804f209 100644 --- a/messages/it.json +++ b/messages/it.json @@ -205,7 +205,7 @@ "logout": "Esci", "login": "Accedi", "about": "Informazioni su HealthLog", - "notifications": "Centro notifiche", + "notifications": "Notifiche", "theme": "Aspetto", "themeSystem": "Sistema", "themeDark": "Scuro", @@ -4692,8 +4692,8 @@ "disabledBody": "L'amministratore ha disattivato le segnalazioni di bug su questo server." }, "notifications": { - "title": "Centro notifiche", - "breadcrumbInbox": "Centro notifiche", + "title": "Notifiche", + "breadcrumbInbox": "Notifiche", "breadcrumbChannels": "Canali di notifica", "breadcrumbSettings": "Impostazioni", "eventColumn": "Evento", diff --git a/messages/pl.json b/messages/pl.json index fa0e5dead..36aab26fc 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -205,7 +205,7 @@ "logout": "Wyloguj", "login": "Zaloguj", "about": "O HealthLog", - "notifications": "Centrum powiadomień", + "notifications": "Powiadomienia", "theme": "Wygląd", "themeSystem": "Systemowy", "themeDark": "Ciemny", @@ -4692,8 +4692,8 @@ "disabledBody": "Administrator wyłączył zgłoszenia błędów na tym serwerze." }, "notifications": { - "title": "Centrum powiadomień", - "breadcrumbInbox": "Centrum powiadomień", + "title": "Powiadomienia", + "breadcrumbInbox": "Powiadomienia", "breadcrumbChannels": "Kanały powiadomień", "breadcrumbSettings": "Ustawienia", "eventColumn": "Zdarzenie", diff --git a/package.json b/package.json index e52e9c029..f3113ca91 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "healthlog", - "version": "1.15.9", + "version": "1.15.10", "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/medications/[id]/route.ts b/src/app/api/medications/[id]/route.ts index ae5837a94..8c33a61fb 100644 --- a/src/app/api/medications/[id]/route.ts +++ b/src/app/api/medications/[id]/route.ts @@ -64,6 +64,27 @@ export const GET = apiHandler( select: { takenAt: true }, }) : null; + // v1.15.10 — slots the user has already acted on near now, so the + // next-due search skips them and advances to the next genuinely-open + // slot. Bound the read to [now-1d, now+2d] — the lookahead only needs the + // slots adjacent to now. + const now = new Date(); + const resolvedFrom = new Date(now.getTime() - 24 * 60 * 60 * 1000); + const resolvedTo = new Date(now.getTime() + 2 * 24 * 60 * 60 * 1000); + const resolvedRows = await prisma.medicationIntakeEvent.findMany({ + where: { + userId: user.id, + medicationId: id, + deletedAt: null, + scheduledFor: { gte: resolvedFrom, lte: resolvedTo }, + OR: [ + { takenAt: { not: null } }, + { skipped: true }, + { autoMissed: true }, + ], + }, + select: { scheduledFor: true }, + }); const nextDue = computeNextDueAt({ medication: { id: medication.id, @@ -73,9 +94,10 @@ export const GET = apiHandler( createdAt: medication.createdAt, }, schedules: medication.schedules, - now: new Date(), + now, userTz: user.timezone || "Europe/Berlin", lastIntakeAt: lastIntake?.takenAt ?? null, + resolvedSlots: resolvedRows.map((r) => r.scheduledFor), }); annotate({ diff --git a/src/app/api/medications/route.ts b/src/app/api/medications/route.ts index 844535a7b..4a470f920 100644 --- a/src/app/api/medications/route.ts +++ b/src/app/api/medications/route.ts @@ -32,29 +32,63 @@ async function buildMedicationsList( userTz, ); - const [medications, latestIntakes, todayEvents] = await Promise.all([ - prisma.medication.findMany({ - where: { userId }, - include: { schedules: true }, - orderBy: { createdAt: "desc" }, - }), - prisma.medicationIntakeEvent.groupBy({ - by: ["medicationId"], - // v1.7.0 sync — exclude tombstoned rows from the last-taken map. - where: { userId, deletedAt: null, skipped: false, takenAt: { not: null } }, - _max: { takenAt: true }, - }), - prisma.medicationIntakeEvent.groupBy({ - by: ["medicationId"], - // v1.7.0 sync — exclude tombstoned rows from the today-count map. - where: { - userId, - deletedAt: null, - scheduledFor: { gte: todayStartUtc, lte: todayEndUtc }, - }, - _count: { id: true }, - }), - ]); + // v1.15.10 — the slots the user has already acted on near "now", so the + // next-due search can skip them. A resolved slot is a taken, deliberately + // skipped, or cron-auto-missed row. Bound the window to [today-start, + // today-end + 2d] — the next-due lookahead only needs the slots adjacent to + // now, and a tight window keeps the read cheap. The 60s list cache covers + // the rest. + const resolvedWindowEnd = new Date(todayEndUtc.getTime() + 2 * 24 * 60 * 60 * 1000); + + const [medications, latestIntakes, todayEvents, resolvedEvents] = + await Promise.all([ + prisma.medication.findMany({ + where: { userId }, + include: { schedules: true }, + orderBy: { createdAt: "desc" }, + }), + prisma.medicationIntakeEvent.groupBy({ + by: ["medicationId"], + // v1.7.0 sync — exclude tombstoned rows from the last-taken map. + where: { + userId, + deletedAt: null, + skipped: false, + takenAt: { not: null }, + }, + _max: { takenAt: true }, + }), + prisma.medicationIntakeEvent.groupBy({ + by: ["medicationId"], + // v1.7.0 sync — exclude tombstoned rows from the today-count map. + where: { + userId, + deletedAt: null, + scheduledFor: { gte: todayStartUtc, lte: todayEndUtc }, + }, + _count: { id: true }, + }), + prisma.medicationIntakeEvent.findMany({ + where: { + userId, + deletedAt: null, + scheduledFor: { gte: todayStartUtc, lte: resolvedWindowEnd }, + OR: [ + { takenAt: { not: null } }, + { skipped: true }, + { autoMissed: true }, + ], + }, + select: { medicationId: true, scheduledFor: true }, + }), + ]); + + const resolvedSlotsByMedId = new Map(); + for (const e of resolvedEvents) { + const list = resolvedSlotsByMedId.get(e.medicationId); + if (list) list.push(e.scheduledFor); + else resolvedSlotsByMedId.set(e.medicationId, [e.scheduledFor]); + } const lastTakenAtByMedicationId = Object.fromEntries( latestIntakes.map((entry) => [ @@ -101,6 +135,7 @@ async function buildMedicationsList( now, userTz, lastIntakeAt: lastTakenAtDateByMedicationId[m.id] ?? null, + resolvedSlots: resolvedSlotsByMedId.get(m.id) ?? [], }); return { ...m, diff --git a/src/app/insights/page.tsx b/src/app/insights/page.tsx index 71b67c6f2..70487d82b 100644 --- a/src/app/insights/page.tsx +++ b/src/app/insights/page.tsx @@ -366,6 +366,13 @@ export default function InsightsPage() { // wrap used `space-y-6` (24 px), so the overview read as two tiers. Unify // to `space-y-6` — it matches the vitals wrap and tightens the overview in // line with the "Insights gives away too much space" direction. + // + // v1.15.10 — `space-y-6` is now the SINGLE inter-section gap, top to + // bottom. Every section renders as a real `
` (or `null` — never a + // `display: contents` wrapper, which used to collapse the cycle-summary box + // so `space-y` skipped its margin and the cycle→signals seam read as + // zero-gap). Each section owns its OWN `SectionHeading` + `space-y-3` to its + // card, so the rhythm is even regardless of which sections are present.
coachLaunch.askCoach(prompt) - : undefined + coachLaunch ? (prompt) => coachLaunch.askCoach(prompt) : undefined } healthScore={analytics?.healthScore ?? undefined} /> @@ -392,9 +397,7 @@ export default function InsightsPage() { // 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 - } + extraTile={user?.cycleTrackingEnabled ? : undefined} // v1.15.5 — when the cycle ring is shown it TAKES the Strain slot: // hide Strain so the strip stays compact instead of growing a sixth // tile. Strain stays visible for non-cycle accounts. diff --git a/src/app/layout.tsx b/src/app/layout.tsx index 9bae20a19..422457f1e 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -90,7 +90,7 @@ export const viewport: Viewport = { }; // Inline script to apply theme before first paint (prevents FOUC) -const themeScript = `(function(){try{var t=localStorage.getItem("healthlog-theme");var c=(t==="light"||t==="dark")?t:(window.matchMedia("(prefers-color-scheme:dark)").matches?"dark":"light");document.documentElement.classList.add(c)}catch(e){document.documentElement.classList.add("dark")}})()`; +const themeScript = `(function(){try{var t=localStorage.getItem("healthlog-theme");var c=(t==="light"||t==="dark")?t:(t==="system"?(window.matchMedia("(prefers-color-scheme:dark)").matches?"dark":"light"):"dark");document.documentElement.classList.add(c)}catch(e){document.documentElement.classList.add("dark")}})()`; export default async function RootLayout({ children, diff --git a/src/components/cycle/__tests__/cycle-insight-summary-card.test.tsx b/src/components/cycle/__tests__/cycle-insight-summary-card.test.tsx index 60b10499c..f7a72f259 100644 --- a/src/components/cycle/__tests__/cycle-insight-summary-card.test.tsx +++ b/src/components/cycle/__tests__/cycle-insight-summary-card.test.tsx @@ -57,6 +57,8 @@ vi.mock("../use-cycle", async () => { ...actual, useCycleCalendar: () => calendarState.current, useCycleInsights: () => insightsState.current, + // The card now reads the profile lengths for the low-data idealized ring. + useCycleProfile: () => ({ data: undefined }), }; }); @@ -173,7 +175,11 @@ describe("", () => { }); it("renders nothing while the calendar read is still resolving", () => { - calendarState.current = { data: undefined, isLoading: true, isError: false }; + calendarState.current = { + data: undefined, + isLoading: true, + isError: false, + }; const html = render(); expect(html).toBe(""); }); diff --git a/src/components/cycle/__tests__/cycle-ring-tile.test.tsx b/src/components/cycle/__tests__/cycle-ring-tile.test.tsx index 42d7d1d12..59a3b8ee5 100644 --- a/src/components/cycle/__tests__/cycle-ring-tile.test.tsx +++ b/src/components/cycle/__tests__/cycle-ring-tile.test.tsx @@ -49,6 +49,9 @@ vi.mock("../use-cycle", async () => { return { ...actual, useCycleCalendar: () => calendarState.current, + // The tile now reads the profile lengths for the low-data idealized ring. + // Mock it at the hook boundary too so the SSR snapshot needs no QueryClient. + useCycleProfile: () => ({ data: undefined }), }; }); @@ -112,12 +115,20 @@ describe("", () => { }); it("renders nothing while the calendar read is still resolving", () => { - calendarState.current = { data: undefined, isLoading: true, isError: false }; + 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 }; + calendarState.current = { + data: undefined, + isLoading: false, + isError: true, + }; expect(render()).toBe(""); }); diff --git a/src/components/cycle/__tests__/wheel-state.test.ts b/src/components/cycle/__tests__/wheel-state.test.ts index e700f8f43..5d8ce6253 100644 --- a/src/components/cycle/__tests__/wheel-state.test.ts +++ b/src/components/cycle/__tests__/wheel-state.test.ts @@ -45,18 +45,79 @@ describe("deriveWheelState", () => { }); it("builds proportional phase spans summing to ~1", () => { + // A well-populated run (>= 7 labelled days spanning all four phases) keeps + // the OBSERVED-share spans. Eight forward-labelled days exercise the + // observed path (above the sparse threshold). const days = [ day("2026-06-01", "MENSTRUAL"), day("2026-06-02", "MENSTRUAL"), - day("2026-06-03", "FOLLICULAR"), - day("2026-06-04", "OVULATORY"), - day("2026-06-05", "LUTEAL"), + day("2026-06-03", "MENSTRUAL"), + day("2026-06-04", "FOLLICULAR"), + day("2026-06-05", "FOLLICULAR"), + day("2026-06-06", "OVULATORY"), + day("2026-06-07", "LUTEAL"), + day("2026-06-08", "LUTEAL"), ]; - const w = deriveWheelState(days, "2026-06-03"); + const w = deriveWheelState(days, "2026-06-05"); const total = w.spans.reduce((s, x) => s + x.fraction, 0); expect(total).toBeCloseTo(1, 5); - // Only phases present in the run are emitted. - expect(w.spans.map((s) => s.phase)).toContain("MENSTRUAL"); + // All four observed phases are emitted, and the observed cycle length + // matches the labelled run (8 days) — NOT the idealized fallback length. + expect(w.spans.map((s) => s.phase).sort()).toEqual([ + "FOLLICULAR", + "LUTEAL", + "MENSTRUAL", + "OVULATORY", + ]); + expect(w.cycleLength).toBe(8); + }); + + it("falls back to canonical four-phase spans for a low-data tracker", () => { + // Only the first few days of a first-ever cycle are logged — a 2-day + // menstrual run. The observed-share path would normalise this so MENSTRUAL + // filled (nearly) the whole ring. The low-data fallback instead draws the + // canonical four phases from the profile lengths. + const days = [ + day("2026-06-01", "MENSTRUAL"), + day("2026-06-02", "MENSTRUAL"), + ]; + const w = deriveWheelState(days, "2026-06-02", { + typicalCycleLength: 28, + typicalPeriodLength: 5, + lutealPhaseLength: 14, + }); + + // The marker read stays driven by the observed labels. + expect(w.dayOfCycle).toBe(2); + expect(w.phase).toBe("MENSTRUAL"); + + // FOUR sensible arcs, not one dominant sliver. The idealized cycle length + // (5 + follicular + 2 + 14 = 28) is represented, and no single phase + // swallows the ring. + expect(w.spans).toHaveLength(4); + expect(w.spans.map((s) => s.phase)).toEqual([ + "MENSTRUAL", + "FOLLICULAR", + "OVULATORY", + "LUTEAL", + ]); + const total = w.spans.reduce((s, x) => s + x.fraction, 0); + expect(total).toBeCloseTo(1, 5); + for (const span of w.spans) { + expect(span.fraction).toBeGreaterThan(0); + expect(span.fraction).toBeLessThan(0.95); + } + expect(w.cycleLength).toBe(28); + }); + + it("uses textbook defaults for the low-data ring when no profile is given", () => { + const days = [day("2026-06-01", "MENSTRUAL")]; + const w = deriveWheelState(days, "2026-06-01"); + expect(w.spans).toHaveLength(4); + // Defaults: 28-day cycle. + expect(w.cycleLength).toBe(28); + expect(w.phase).toBe("MENSTRUAL"); + expect(w.dayOfCycle).toBe(1); }); }); diff --git a/src/components/cycle/cycle-calendar.tsx b/src/components/cycle/cycle-calendar.tsx index 5712e2cec..7573ee4e1 100644 --- a/src/components/cycle/cycle-calendar.tsx +++ b/src/components/cycle/cycle-calendar.tsx @@ -221,7 +221,7 @@ export function CycleCalendar({ } onClick={() => onSelectDay(date)} className={cn( - "relative flex aspect-square min-h-10 flex-col items-center justify-center rounded-lg text-sm transition-colors", + "relative flex aspect-square min-h-11 flex-col items-center justify-center rounded-lg text-sm transition-colors", "hover:bg-accent focus-visible:ring-ring/50 focus-visible:ring-2 focus-visible:outline-none", isToday && "font-semibold", )} diff --git a/src/components/cycle/cycle-insight-summary-card.tsx b/src/components/cycle/cycle-insight-summary-card.tsx index 1641c6419..a143755b4 100644 --- a/src/components/cycle/cycle-insight-summary-card.tsx +++ b/src/components/cycle/cycle-insight-summary-card.tsx @@ -2,13 +2,19 @@ import { useMemo } from "react"; import Link from "next/link"; -import { ArrowRight, Sparkles } from "lucide-react"; +import { ArrowRight, Droplets } from "lucide-react"; import { useTranslations } from "@/lib/i18n/context"; +import { SectionHeading } from "@/components/insights/section-heading"; import { CyclePhaseHeadline } from "./cycle-phase-crosstab"; import { PHASE_HUE } from "./phase-tokens"; import { deriveWheelState } from "./wheel-state"; -import { localYmd, useCycleCalendar, useCycleInsights } from "./use-cycle"; +import { + localYmd, + useCycleCalendar, + useCycleInsights, + useCycleProfile, +} from "./use-cycle"; /** * v1.15.2 — the gated cycle-insights summary card for the main Insights page. @@ -54,10 +60,18 @@ export function CycleInsightSummaryCard() { const calendar = useCycleCalendar(from, to); const insights = useCycleInsights(); + // Shared 5-min-cached profile read so a low-data tracker still gets the + // canonical four-phase ring (no extra request — shared cache key). + const profile = useCycleProfile(); const wheel = useMemo( - () => deriveWheelState(calendar.data?.days ?? [], today), - [calendar.data, today], + () => + deriveWheelState(calendar.data?.days ?? [], today, { + typicalCycleLength: profile.data?.typicalCycleLength, + typicalPeriodLength: profile.data?.typicalPeriodLength, + lutealPhaseLength: profile.data?.lutealPhaseLength, + }), + [calendar.data, today, profile.data], ); // Stay silent until the calendar read resolves, and on a hard error: the @@ -83,30 +97,29 @@ export function CycleInsightSummaryCard() { // node never matched, so the tile stayed stuck at the rise rule's // `opacity: 0` — invisible but still occupying its box, which read as an // oversized gap on the overview. The wrapper restores the entrance. -
-
-
- {/* Zone 1 — eyebrow + the current phase / cycle-day read. */} +
+ + {/* `data-revealed` must sit on an ANCESTOR of the `.wellness-tile-rise` + element — the keyframe selector is `[data-revealed="true"] + .wellness-tile-rise` (descendant combinator). */} +
+
+ {/* Zone 1 — 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 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")} -
-
+ {/* Zone 3 — the deep-link into the single source of truth: the cycle + insights tab. The cycle page reads `?tab=insights` and opens it. */} + + {t("cycle.insightsSummary.viewDetails")} +
+
+
); } diff --git a/src/components/cycle/cycle-ring-tile.tsx b/src/components/cycle/cycle-ring-tile.tsx index 0285f192f..2e20b4761 100644 --- a/src/components/cycle/cycle-ring-tile.tsx +++ b/src/components/cycle/cycle-ring-tile.tsx @@ -9,7 +9,7 @@ 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"; +import { localYmd, useCycleCalendar, useCycleProfile } from "./use-cycle"; /** * v1.15.3 — the compact cycle ring as a WELLNESS-STRIP element. @@ -53,10 +53,19 @@ export function CycleRingTile({ className }: { className?: string }) { const to = useMemo(() => shiftToday(180), []); const calendar = useCycleCalendar(from, to); + // Shared 5-min-cached profile read (no extra request when the cycle page or + // summary card already loaded it): its typical lengths let a low-data tracker + // draw the canonical four-phase ring instead of one dominant arc. + const profile = useCycleProfile(); const wheel = useMemo( - () => deriveWheelState(calendar.data?.days ?? [], today), - [calendar.data, today], + () => + deriveWheelState(calendar.data?.days ?? [], today, { + typicalCycleLength: profile.data?.typicalCycleLength, + typicalPeriodLength: profile.data?.typicalPeriodLength, + lutealPhaseLength: profile.data?.lutealPhaseLength, + }), + [calendar.data, today, profile.data], ); // The signature reveal plays ONCE per browser session, gated on its own key diff --git a/src/components/cycle/cycle-ring.tsx b/src/components/cycle/cycle-ring.tsx index 21e94632d..5474f19e2 100644 --- a/src/components/cycle/cycle-ring.tsx +++ b/src/components/cycle/cycle-ring.tsx @@ -160,11 +160,16 @@ export function CycleRing({ data-phase={phase ?? "none"} role="img" aria-label={ariaLabel} - className={cn("relative shrink-0", className)} + // v1.15.10 — responsive width. The ring used a fixed `width: size` + + // `shrink-0`, so at the 120 px tile size it overflowed grid cells on + // ≤360 px Android widths. It now caps at `size` (`maxWidth`) but is free + // to shrink to fill its cell (`w-full`, square via `aspect-square`), so it + // never pushes the page wider than the viewport. The day number + phase + // caption scale with the box; the SVG already paints `h-full w-full`. + className={cn("relative aspect-square w-full", className)} style={ { - width: size, - height: size, + maxWidth: size, // The bloom on the active arc is tinted to the live phase hue. "--ring-glow": markerHue, } as React.CSSProperties diff --git a/src/components/cycle/cycle-view.tsx b/src/components/cycle/cycle-view.tsx index 131d21dae..26bf834e7 100644 --- a/src/components/cycle/cycle-view.tsx +++ b/src/components/cycle/cycle-view.tsx @@ -98,9 +98,16 @@ export function CycleView() { } }); + // Pass the profile's typical lengths so a low-data tracker (few logged + // cycles) draws the canonical four-phase ring instead of one dominant arc. const wheel = useMemo( - () => deriveWheelState(calendar.data?.days ?? [], today), - [calendar.data, today], + () => + deriveWheelState(calendar.data?.days ?? [], today, { + typicalCycleLength: profileQuery.data?.typicalCycleLength, + typicalPeriodLength: profileQuery.data?.typicalPeriodLength, + lutealPhaseLength: profileQuery.data?.lutealPhaseLength, + }), + [calendar.data, today, profileQuery.data], ); function openSheet(date: string) { @@ -149,64 +156,64 @@ export function CycleView() {
{/* Wheel + compact phase card — grouped so they stick together. */}
- {/* Wheel — the signature ring on the premium wellness-tile surface. */} -
- {loading ? ( -
- -
- ) : calendarError ? ( -
-

- {t("cycle.loadError")} + {/* Wheel — the signature ring on the premium wellness-tile surface. */} +

+ {loading ? ( +
+ +
+ ) : calendarError ? ( +
+

+ {t("cycle.loadError")} +

+ +
+ ) : ( + + )} + {!calendarError ? ( +

+ {t("cycle.ring.caption")}

+ ) : null} + {/* First-period CTA — only when no cycle is active yet. */} + {!loading && !calendarError && wheel.dayOfCycle == null ? ( -
- ) : ( - - )} - {!calendarError ? ( -

- {t("cycle.ring.caption")} -

- ) : null} - {/* First-period CTA — only when no cycle is active yet. */} - {!loading && !calendarError && wheel.dayOfCycle == null ? ( - - ) : null} -
+ ) : null} +
{/* Phase-education card beneath the wheel — the single instance, including the "you often notice this here" chip row sourced from @@ -224,17 +231,35 @@ export function CycleView() {
- - + {/* v1.15.10 — the four German labels (Kalender/Prognosen/ + Erkenntnisse/Einstellungen) overflowed the `flex-1` equal-width + cells at 375 px and forced a horizontal scroll. Below `sm` the + triggers size to their content (`text-xs`, no `flex-1`) and the + strip scrolls cleanly if needed; from `sm` up they reclaim the + equal-width `flex-1` layout. */} + + {t("cycle.tabs.calendar")} - + {t("cycle.tabs.predictions")} - + {t("cycle.tabs.insights")} - + {t("cycle.tabs.settings")} diff --git a/src/components/cycle/wheel-state.ts b/src/components/cycle/wheel-state.ts index e86e17e36..4494fc447 100644 --- a/src/components/cycle/wheel-state.ts +++ b/src/components/cycle/wheel-state.ts @@ -24,6 +24,71 @@ export interface WheelState { spans: { phase: CyclePhase; fraction: number }[]; } +/** + * The profile-derived canonical phase lengths used to draw an idealized ring + * for a low-data tracker (see `idealizedSpans`). All optional — every field + * falls back to the textbook default when the profile has not set it. + */ +export interface CycleProfileLengths { + /** Typical full cycle length in days (default 28). */ + typicalCycleLength?: number | null; + /** Typical period (menstrual) length in days (default 5). */ + typicalPeriodLength?: number | null; + /** Typical luteal-phase length in days (default 14). */ + lutealPhaseLength?: number | null; +} + +/** Below this many labelled days in the current cycle run, the ring would + * normalise a 1-3 day partial run so a single phase filled the whole circle. + * For such low-data trackers we draw the canonical four-phase proportions from + * the profile instead, so the dial always reads as a real cycle wheel. A run + * of 7+ labelled days already spans more than the menstrual phase, so the + * observed-share path takes over from there. */ +const SPARSE_RUN_THRESHOLD = 7; + +/** + * The canonical four-phase span set for a low-data tracker, derived from the + * profile's typical lengths (textbook defaults when unset): a 28-day cycle + * with a 5-day period, a 2-day ovulatory window, a 14-day luteal phase, and + * the follicular phase filling the remainder. Proportions, not day counts, so + * the ring renders the familiar MENSTRUAL/FOLLICULAR/OVULATORY/LUTEAL arcs + * instead of one dominant sliver. + */ +function idealizedSpans(profile?: CycleProfileLengths): { + spans: { phase: CyclePhase; fraction: number }[]; + cycleLength: number; +} { + const cycle = Math.max( + Math.round(profile?.typicalCycleLength ?? 28) || 28, + 7, + ); + const period = Math.min( + Math.max(Math.round(profile?.typicalPeriodLength ?? 5) || 5, 1), + cycle - 3, + ); + const luteal = Math.min( + Math.max(Math.round(profile?.lutealPhaseLength ?? 14) || 14, 1), + cycle - period - 1, + ); + // A short ovulatory window sits at the fertile peak; the follicular phase + // fills whatever remains between the period and the ovulatory window. + const ovulatory = Math.min(2, Math.max(cycle - period - luteal - 1, 1)); + const follicular = Math.max(cycle - period - ovulatory - luteal, 1); + + const days: Record = { + MENSTRUAL: period, + FOLLICULAR: follicular, + OVULATORY: ovulatory, + LUTEAL: luteal, + }; + const total = days.MENSTRUAL + days.FOLLICULAR + days.OVULATORY + days.LUTEAL; + const spans = PHASE_ORDER.map((phase) => ({ + phase, + fraction: days[phase] / total, + })); + return { spans, cycleLength: total }; +} + /** * Walk back from `todayIdx` to the start of the current MENSTRUAL-anchored run: * the first day of the most recent MENSTRUAL block at/before today with no phase @@ -66,6 +131,7 @@ export function currentCycleStartDate( export function deriveWheelState( days: CalendarDay[], today: string, + profile?: CycleProfileLengths, ): WheelState { const byDate = new Map(days.map((d) => [d.date, d])); const todayDay = byDate.get(today); @@ -104,6 +170,24 @@ export function deriveWheelState( for (const d of fullRun) { if (d.phase) counts[d.phase] += 1; } + + // Low-data tracker: too few labelled days to span a real cycle. The + // observed-share math would normalise a 1-3 day partial run so a single + // phase filled the whole ring (the "one dominant arc" bug). Fall back to the + // canonical four-phase proportions derived from the profile's typical + // lengths so the dial reads as a real cycle wheel. The current phase + the + // day-of-cycle marker stay driven by the observed labels; only the arc + // proportions and the ring's represented length come from the ideal. + if (fullRun.length < SPARSE_RUN_THRESHOLD) { + const ideal = idealizedSpans(profile); + return { + dayOfCycle, + cycleLength: ideal.cycleLength, + phase: todayDay.phase, + spans: ideal.spans, + }; + } + const total = fullRun.length || 1; const spans = PHASE_ORDER.filter((p) => counts[p] > 0).map((phase) => ({ phase, diff --git a/src/components/insights/__tests__/coincident-deviation-card.test.tsx b/src/components/insights/__tests__/coincident-deviation-card.test.tsx index f964d32ad..9b64d26e0 100644 --- a/src/components/insights/__tests__/coincident-deviation-card.test.tsx +++ b/src/components/insights/__tests__/coincident-deviation-card.test.tsx @@ -48,7 +48,12 @@ function ok( metric: "COINCIDENT_DEVIATION", status: "ok", value, - coverage: { requiredInputs: 5, presentInputs: 5, historyDays: 30, missing: [] }, + coverage: { + requiredInputs: 5, + presentInputs: 5, + historyDays: 30, + missing: [], + }, confidence: { score: 90, band }, provenance: { inputs: ["RESTING_HEART_RATE", "RESPIRATORY_RATE"], @@ -65,7 +70,12 @@ function insufficient(): Resp { metric: "COINCIDENT_DEVIATION", status: "insufficient", value: null, - coverage: { requiredInputs: 2, presentInputs: 1, historyDays: 0, missing: [] }, + coverage: { + requiredInputs: 2, + presentInputs: 1, + historyDays: 0, + missing: [], + }, confidence: null, provenance: { inputs: ["RESTING_HEART_RATE"], @@ -90,7 +100,9 @@ describe("", () => { expect(html).toContain('data-slot="coincident-deviation-card-skeleton"'); expect(html).not.toContain('data-slot="coincident-deviation-card"'); // The skeleton reserves the resolved card's footprint (no downward shift). - expect(html).toContain("min-h-48"); + // The footprint shrank to `min-h-32` in v1.15.10: the in-card title row + // moved out to the section heading above the card. + expect(html).toContain("min-h-32"); }); it("the skeleton and a resolved card share the same min-height footprint", () => { @@ -111,8 +123,8 @@ describe("", () => { }), ); const fired = render(); - expect(skeleton).toContain("min-h-48"); - expect(fired).toContain("min-h-48"); + expect(skeleton).toContain("min-h-32"); + expect(fired).toContain("min-h-32"); }); it("renders nothing in the insufficient (building baselines) state", () => { @@ -127,7 +139,10 @@ describe("", () => { ok({ fired: false, day: "2026-06-03", - vitals: [deviation("RESTING_HEART_RATE", false), deviation("WEIGHT", false)], + vitals: [ + deviation("RESTING_HEART_RATE", false), + deviation("WEIGHT", false), + ], contributing: [], }), ); @@ -141,7 +156,10 @@ describe("", () => { ok({ fired: false, day: "2026-06-03", - vitals: [deviation("RESTING_HEART_RATE", true), deviation("WEIGHT", false)], + vitals: [ + deviation("RESTING_HEART_RATE", true), + deviation("WEIGHT", false), + ], contributing: [deviation("RESTING_HEART_RATE", true)], }), ); diff --git a/src/components/insights/coincident-deviation-card.tsx b/src/components/insights/coincident-deviation-card.tsx index e7b4ff005..dea43da1d 100644 --- a/src/components/insights/coincident-deviation-card.tsx +++ b/src/components/insights/coincident-deviation-card.tsx @@ -4,6 +4,7 @@ import { AlertTriangle, Activity } from "lucide-react"; import { useTranslations } from "@/lib/i18n/context"; import { cn } from "@/lib/utils"; +import { SectionHeading } from "@/components/insights/section-heading"; import { MEASUREMENT_TYPE_LABEL_KEYS } from "@/components/measurements/measurement-list-meta"; import { CoverageMeter } from "@/components/insights/derived/coverage-meter"; import { ProvenanceExplainer } from "@/components/insights/derived/provenance-explainer"; @@ -78,48 +79,61 @@ function CoincidentProvenance({ } /** - * The card shell — uppercase label + the provenance affordance, with the - * state-specific body as children. The tall `fired` state keeps a `min-h` floor; - * the calmer everyday states size to their (short) content so the card carries - * no empty tail. + * The card shell — the "Signals of the day" heading lives ABOVE the card now + * (via `SectionHeading`, mirroring every other overview section), with the + * provenance ⓘ as the heading's trailing action. The card body carries the + * state-specific content. The tall `fired` state keeps a `min-h` floor; the + * calmer everyday states size to their (short) content so the card carries no + * empty tail. */ function CardShell({ state, provenance, + className, children, }: { state: SignalState; provenance?: DerivedProvenance; + className?: string; children: React.ReactNode; }) { const { t } = useTranslations(); return ( -
-
- - {t("insights.derived.coincident.cardTitle")} - - {provenance ? : null} + + ) : undefined + } + /> +
+ {children}
- {children} -
+ ); } @@ -129,20 +143,27 @@ function CardShell({ * mirroring the provenance trigger the resolved header carries — so the card * does not shift when the real content drops in. */ -function CardSkeleton() { +function CardSkeleton({ className }: { className?: string }) { + const { t } = useTranslations(); return ( -