Merge pull request #287 from MBombeck/release/v1.15.10

release/v1.15.10 — Insights overview, medication follow-ups, health score
This commit is contained in:
Marc-André Bombeck authored and GitHub committed 2026-06-07 14:58:23 +02:00
commit 399c0f5536
46 files changed
+1802 -492

No files matched your search

+18
View File
@@ -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
+1 -1
View File
@@ -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.
+214
View File
@@ -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<Record<string, unknown>> {
const days: Array<Record<string, unknown>> = [];
const start = new Date();
start.setDate(start.getDate() - 12);
// 5 menstrual, 6 follicular, 1 ovulatory, then luteal through today.
const plan: string[] = [
...Array<string>(5).fill("MENSTRUAL"),
...Array<string>(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([]);
});
});
+3 -3
View File
@@ -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",
+3 -3
View File
@@ -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",
+3 -3
View File
@@ -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",
+3 -3
View File
@@ -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",
+3 -3
View File
@@ -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",
+3 -3
View File
@@ -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",
+1 -1
View File
@@ -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",
+23 -1
View File
@@ -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({
+58 -23
View File
@@ -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<string, Date[]>();
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,
+9 -6
View File
@@ -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 `<section>` (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.
<div className="space-y-6">
<HeroStrip
briefing={briefingPayload}
@@ -377,9 +384,7 @@ export default function InsightsPage() {
: undefined
}
onPickPrompt={
coachLaunch
? (prompt) => 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 ? <CycleRingTile /> : undefined
}
extraTile={user?.cycleTrackingEnabled ? <CycleRingTile /> : 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.
+1 -1
View File
@@ -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,
@@ -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("<CycleInsightSummaryCard>", () => {
});
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(<CycleInsightSummaryCard />);
expect(html).toBe("");
});
@@ -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("<CycleRingTile>", () => {
});
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(<CycleRingTile />)).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(<CycleRingTile />)).toBe("");
});
@@ -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);
});
});
+1 -1
View File
@@ -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",
)}
@@ -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.
<div data-revealed="true" className="contents">
<section
data-slot="cycle-insight-summary"
data-phase={phase ?? "none"}
aria-label={t("cycle.insightsSummary.title")}
style={{ "--tile-hue": hue } as React.CSSProperties}
className="wellness-tile wellness-tile-rise rounded-xl px-5 py-5"
>
<div className="flex items-start justify-between gap-3">
{/* Zone 1 — eyebrow + the current phase / cycle-day read. */}
<section
data-slot="cycle-insight-summary-section"
aria-label={t("cycle.insightsSummary.title")}
className="space-y-3"
>
<SectionHeading
icon={Droplets}
title={t("cycle.insightsSummary.title")}
/>
{/* `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). */}
<div data-revealed="true" className="contents">
<div
data-slot="cycle-insight-summary"
data-phase={phase ?? "none"}
style={{ "--tile-hue": hue } as React.CSSProperties}
className="wellness-tile wellness-tile-rise rounded-xl px-5 py-5"
>
{/* Zone 1 — the current phase / cycle-day read. */}
<div className="min-w-0">
<div className="flex items-center gap-2">
<Sparkles
className="h-4 w-4 shrink-0"
style={{ color: hue }}
aria-hidden="true"
/>
<span className="text-muted-foreground text-xs font-medium tracking-wide uppercase">
{t("cycle.insightsSummary.title")}
</span>
</div>
<h3
className="text-foreground mt-2 flex items-center gap-2 text-base font-semibold"
className="text-foreground flex items-center gap-2 text-base font-semibold"
aria-label={ariaLabel}
>
<span
@@ -126,26 +139,26 @@ export function CycleInsightSummaryCard() {
: t("cycle.insightsSummary.noActiveCycle")}
</p>
</div>
</div>
{/* 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). */}
<div className="mt-3">
<CyclePhaseHeadline headline={insights.data?.headline ?? null} />
</div>
{/* 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). */}
<div className="mt-3">
<CyclePhaseHeadline headline={insights.data?.headline ?? null} />
</div>
{/* 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. */}
<Link
href="/cycle?tab=insights"
data-slot="cycle-insight-summary-link"
className="text-foreground bg-background/55 border-foreground/15 hover:bg-background/80 focus-visible:ring-ring/50 mt-4 inline-flex items-center gap-1.5 rounded-full border px-3 py-1.5 text-sm font-medium transition-colors focus:outline-none focus-visible:ring-[3px]"
>
{t("cycle.insightsSummary.viewDetails")}
<ArrowRight className="h-3.5 w-3.5" aria-hidden="true" />
</Link>
</section>
</div>
{/* Zone 3 — the deep-link into the single source of truth: the cycle
insights tab. The cycle page reads `?tab=insights` and opens it. */}
<Link
href="/cycle?tab=insights"
data-slot="cycle-insight-summary-link"
className="text-foreground bg-background/55 border-foreground/15 hover:bg-background/80 focus-visible:ring-ring/50 mt-4 inline-flex items-center gap-1.5 rounded-full border px-3 py-1.5 text-sm font-medium transition-colors focus:outline-none focus-visible:ring-[3px]"
>
{t("cycle.insightsSummary.viewDetails")}
<ArrowRight className="h-3.5 w-3.5" aria-hidden="true" />
</Link>
</div>
</div>
</section>
);
}
+12 -3
View File
@@ -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
+8 -3
View File
@@ -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
+85 -60
View File
@@ -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() {
<div className="grid gap-6 lg:grid-cols-[minmax(0,22rem)_minmax(0,1fr)] lg:items-start">
{/* Wheel + compact phase card — grouped so they stick together. */}
<div className="flex flex-col gap-4 lg:sticky lg:top-6">
{/* Wheel — the signature ring on the premium wellness-tile surface. */}
<div
data-slot="cycle-wheel-tile"
data-revealed={play ? "true" : undefined}
style={
tileHue
? ({ "--tile-hue": tileHue } as React.CSSProperties)
: undefined
}
className={cn(
"wellness-tile flex flex-col items-center gap-3 rounded-xl px-6 py-6",
play && "wellness-tile-rise",
)}
>
{loading ? (
<div className="flex h-[220px] items-center justify-center">
<Loader2 className="text-primary h-8 w-8 animate-spin motion-reduce:animate-none" />
</div>
) : calendarError ? (
<div className="flex h-[220px] flex-col items-center justify-center gap-3 text-center">
<p className="text-muted-foreground text-sm">
{t("cycle.loadError")}
{/* Wheel — the signature ring on the premium wellness-tile surface. */}
<div
data-slot="cycle-wheel-tile"
data-revealed={play ? "true" : undefined}
style={
tileHue
? ({ "--tile-hue": tileHue } as React.CSSProperties)
: undefined
}
className={cn(
"wellness-tile flex flex-col items-center gap-3 rounded-xl px-6 py-6",
play && "wellness-tile-rise",
)}
>
{loading ? (
<div className="flex h-[220px] items-center justify-center">
<Loader2 className="text-primary h-8 w-8 animate-spin motion-reduce:animate-none" />
</div>
) : calendarError ? (
<div className="flex h-[220px] flex-col items-center justify-center gap-3 text-center">
<p className="text-muted-foreground text-sm">
{t("cycle.loadError")}
</p>
<Button
variant="outline"
size="sm"
className="gap-1.5"
onClick={() => void calendar.refetch()}
>
<RefreshCw className="h-3.5 w-3.5" aria-hidden="true" />
{t("common.retry")}
</Button>
</div>
) : (
<CycleRing
dayOfCycle={wheel.dayOfCycle}
cycleLength={wheel.cycleLength}
phase={wheel.phase}
spans={wheel.spans}
animate={play}
/>
)}
{!calendarError ? (
<p className="text-muted-foreground text-xs">
{t("cycle.ring.caption")}
</p>
) : null}
{/* First-period CTA — only when no cycle is active yet. */}
{!loading && !calendarError && wheel.dayOfCycle == null ? (
<Button
variant="outline"
size="sm"
className="gap-1.5"
onClick={() => void calendar.refetch()}
className="mt-1 w-full"
onClick={() => openSheet(today)}
>
<RefreshCw className="h-3.5 w-3.5" aria-hidden="true" />
{t("common.retry")}
{t("cycle.ring.firstPeriodCta")}
</Button>
</div>
) : (
<CycleRing
dayOfCycle={wheel.dayOfCycle}
cycleLength={wheel.cycleLength}
phase={wheel.phase}
spans={wheel.spans}
animate={play}
/>
)}
{!calendarError ? (
<p className="text-muted-foreground text-xs">
{t("cycle.ring.caption")}
</p>
) : null}
{/* First-period CTA — only when no cycle is active yet. */}
{!loading && !calendarError && wheel.dayOfCycle == null ? (
<Button
variant="outline"
className="mt-1 w-full"
onClick={() => openSheet(today)}
>
{t("cycle.ring.firstPeriodCta")}
</Button>
) : null}
</div>
) : null}
</div>
{/* 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() {
</div>
<Tabs defaultValue={initialTab}>
<TabsList className="w-full">
<TabsTrigger value="calendar" className="flex-1">
{/* 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. */}
<TabsList className="w-full max-w-full overflow-x-auto">
<TabsTrigger
value="calendar"
className="text-xs sm:flex-1 sm:text-sm"
>
{t("cycle.tabs.calendar")}
</TabsTrigger>
<TabsTrigger value="predictions" className="flex-1">
<TabsTrigger
value="predictions"
className="text-xs sm:flex-1 sm:text-sm"
>
{t("cycle.tabs.predictions")}
</TabsTrigger>
<TabsTrigger value="insights" className="flex-1">
<TabsTrigger
value="insights"
className="text-xs sm:flex-1 sm:text-sm"
>
{t("cycle.tabs.insights")}
</TabsTrigger>
<TabsTrigger value="settings" className="flex-1">
<TabsTrigger
value="settings"
className="text-xs sm:flex-1 sm:text-sm"
>
{t("cycle.tabs.settings")}
</TabsTrigger>
</TabsList>
+84
View File
@@ -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<CyclePhase, number> = {
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,
@@ -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("<CoincidentDeviationCard>", () => {
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("<CoincidentDeviationCard>", () => {
}),
);
const fired = render(<CoincidentDeviationCard />);
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("<CoincidentDeviationCard>", () => {
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("<CoincidentDeviationCard>", () => {
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)],
}),
);
@@ -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 (
<div
data-slot="coincident-deviation-card"
data-state={state}
className={cn(
"bg-card flex w-full min-w-0 flex-col gap-2 rounded-xl border p-4 md:p-6",
// `min-h` floor only on the tallest state (fired: 44px provenance header
// + the named-vitals body + the mandatory factors line). The calmer
// everyday states (all-clear / watch / insufficient) carry a single
// short body, so flooring them to the fired height left a large empty
// tail; they now size to their content. The in-flight `CardSkeleton`
// keeps the `min-h-48` footprint so the initial paint still reserves the
// row and does not shift as the read resolves.
state === "fired" && "min-h-48",
// At most amber — never destructive/red. The fired state borders
// warning; every calmer state keeps the neutral card border.
state === "fired" ? "border-warning/40" : "border-border",
)}
<section
data-slot="coincident-deviation-section"
aria-label={t("insights.derived.coincident.cardTitle")}
className={cn("space-y-3", className)}
>
<div className="flex items-center justify-between gap-2">
<span className="text-muted-foreground truncate text-xs font-medium tracking-wide uppercase">
{t("insights.derived.coincident.cardTitle")}
</span>
{provenance ? <CoincidentProvenance provenance={provenance} /> : null}
<SectionHeading
icon={Activity}
title={t("insights.derived.coincident.cardTitle")}
action={
provenance ? (
<CoincidentProvenance provenance={provenance} />
) : undefined
}
/>
<div
data-slot="coincident-deviation-card"
data-state={state}
className={cn(
"bg-card flex w-full min-w-0 flex-col gap-2 rounded-xl border p-4 md:p-6",
// `min-h` floor only on the tallest state (fired: the named-vitals
// body + the mandatory factors line). The calmer everyday states
// (all-clear / watch / insufficient) carry a single short body, so
// flooring them to the fired height left a large empty tail; they now
// size to their content. The in-flight `CardSkeleton` keeps the
// `min-h-48` footprint so the initial paint still reserves the row and
// does not shift as the read resolves.
state === "fired" && "min-h-32",
// At most amber — never destructive/red. The fired state borders
// warning; every calmer state keeps the neutral card border.
state === "fired" ? "border-warning/40" : "border-border",
)}
>
{children}
</div>
{children}
</div>
</section>
);
}
@@ -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 (
<div
data-slot="coincident-deviation-card-skeleton"
aria-hidden="true"
className="bg-card border-border flex min-h-48 w-full min-w-0 flex-col gap-2 rounded-xl border p-4 md:p-6"
<section
data-slot="coincident-deviation-section"
aria-label={t("insights.derived.coincident.cardTitle")}
className={cn("space-y-3", className)}
>
<div className="flex h-11 items-center justify-between gap-2">
<div className="bg-muted/40 h-3 w-28 rounded" />
<div className="bg-muted/40 h-5 w-5 rounded-full" />
<SectionHeading
icon={Activity}
title={t("insights.derived.coincident.cardTitle")}
/>
<div
data-slot="coincident-deviation-card-skeleton"
aria-hidden="true"
className="bg-card border-border flex min-h-32 w-full min-w-0 flex-col gap-2 rounded-xl border p-4 md:p-6"
>
<div className="bg-muted/40 h-4 w-3/4 rounded" />
<div className="bg-muted/40 h-3 w-1/2 rounded" />
</div>
<div className="bg-muted/40 h-4 w-3/4 rounded" />
<div className="bg-muted/40 h-3 w-1/2 rounded" />
</div>
</section>
);
}
@@ -158,11 +179,7 @@ export function CoincidentDeviationCard({
// CLS-safe placeholder while the single read is in flight.
if (!data) {
return (
<div className={className}>
<CardSkeleton />
</div>
);
return <CardSkeleton className={className} />;
}
// Building the baselines — fewer than two banded vitals. There is no signal
@@ -202,28 +219,30 @@ export function CoincidentDeviationCard({
if (state === "watch") {
return (
<div className={className}>
<CardShell state="watch" provenance={data.provenance}>
<div className="flex items-start gap-2">
<Activity
className="text-muted-foreground mt-0.5 h-4 w-4 shrink-0"
aria-hidden="true"
/>
<div className="min-w-0 space-y-1">
<p
className="text-foreground text-sm font-medium"
data-slot="coincident-headline"
>
{t("insights.derived.coincident.watch")}
</p>
<p className="text-muted-foreground text-xs leading-snug">
{t("insights.derived.coincident.watchVital", { vital: names })}
</p>
</div>
<CardShell
state="watch"
provenance={data.provenance}
className={className}
>
<div className="flex items-start gap-2">
<Activity
className="text-muted-foreground mt-0.5 h-4 w-4 shrink-0"
aria-hidden="true"
/>
<div className="min-w-0 space-y-1">
<p
className="text-foreground text-sm font-medium"
data-slot="coincident-headline"
>
{t("insights.derived.coincident.watch")}
</p>
<p className="text-muted-foreground text-xs leading-snug">
{t("insights.derived.coincident.watchVital", { vital: names })}
</p>
</div>
{thinHistory ? <CoverageMeter coverage={data.coverage} /> : null}
</CardShell>
</div>
</div>
{thinHistory ? <CoverageMeter coverage={data.coverage} /> : null}
</CardShell>
);
}
@@ -232,37 +251,35 @@ export function CoincidentDeviationCard({
// so a screen-reader user gets the day-to-day state change in the calm tone
// the card keeps visually — the one state whose meaning shifts vs all-clear.
return (
<div className={className}>
<CardShell state="fired" provenance={data.provenance}>
<div className="flex items-start gap-2" role="status">
<AlertTriangle
className="text-warning mt-0.5 h-4 w-4 shrink-0"
aria-hidden="true"
/>
<div className="min-w-0 space-y-1">
<p
className="text-foreground text-sm font-medium"
data-slot="coincident-headline"
>
{t("insights.derived.coincident.firedHeadline", {
count: contributing.length,
})}
</p>
<p
className="text-muted-foreground text-xs leading-snug"
data-slot="coincident-vitals"
>
{t("insights.derived.coincident.vitals", { list: names })}
</p>
</div>
<CardShell state="fired" provenance={data.provenance} className={className}>
<div className="flex items-start gap-2" role="status">
<AlertTriangle
className="text-warning mt-0.5 h-4 w-4 shrink-0"
aria-hidden="true"
/>
<div className="min-w-0 space-y-1">
<p
className="text-foreground text-sm font-medium"
data-slot="coincident-headline"
>
{t("insights.derived.coincident.firedHeadline", {
count: contributing.length,
})}
</p>
<p
className="text-muted-foreground text-xs leading-snug"
data-slot="coincident-vitals"
>
{t("insights.derived.coincident.vitals", { list: names })}
</p>
</div>
<p
className="text-muted-foreground text-xs leading-snug"
data-slot="coincident-factors"
>
{t("insights.derived.coincident.factors")}
</p>
</CardShell>
</div>
</div>
<p
className="text-muted-foreground text-xs leading-snug"
data-slot="coincident-factors"
>
{t("insights.derived.coincident.factors")}
</p>
</CardShell>
);
}
+10 -19
View File
@@ -22,6 +22,7 @@ import {
import { Button } from "@/components/ui/button";
import { Card, CardContent } from "@/components/ui/card";
import { EmptyState } from "@/components/ui/empty-state";
import { SectionHeading } from "@/components/insights/section-heading";
import { useTranslations } from "@/lib/i18n/context";
import { formatRelativeTime } from "@/lib/i18n/relative-time";
import { stripChartTokens } from "@/lib/insights/chart-tokens";
@@ -283,25 +284,15 @@ export function DailyBriefing({
aria-label={t("insights.dailyBriefing.title")}
className="space-y-3"
>
<div className="flex flex-wrap items-center justify-between gap-2">
<div className="flex items-center gap-2">
<Sparkles
className="text-dose-accent h-4 w-4 shrink-0"
aria-hidden="true"
/>
<h2 className="text-lg font-semibold">
{t("insights.dailyBriefing.title")}
</h2>
</div>
{metaSlot && (
<div
data-slot="daily-briefing-meta-slot"
className="flex items-center gap-2"
>
{metaSlot}
</div>
)}
</div>
<SectionHeading
icon={Sparkles}
title={t("insights.dailyBriefing.title")}
action={
metaSlot ? (
<div data-slot="daily-briefing-meta-slot">{metaSlot}</div>
) : undefined
}
/>
<Card data-slot="daily-briefing" className="overflow-hidden">
<CardContent>
{loading ? (
@@ -12,6 +12,7 @@ import {
MEASUREMENT_TYPE_LABEL_KEYS,
} from "@/components/measurements/measurement-list-meta";
import { TileHeader } from "@/components/insights/tile-header";
import { SectionHeading } from "@/components/insights/section-heading";
import { SparklineDeltaTile } from "./sparkline-delta-tile";
import { CoverageMeter } from "./coverage-meter";
import { ProvenanceExplainer } from "./provenance-explainer";
@@ -114,7 +115,9 @@ function MetricProvenance({
const method = (
<>
{meta.caveatKey ? (
<span className="text-warning block font-medium">{t(meta.caveatKey)}</span>
<span className="text-warning block font-medium">
{t(meta.caveatKey)}
</span>
) : null}
{t(meta.methodKey)}
</>
@@ -161,7 +164,10 @@ function BaselineTile({
if (isLoading || !data) return null;
// Absent → don't render the tile at all.
if (data.status === "insufficient" && data.reason === "no_readings_in_window") {
if (
data.status === "insufficient" &&
data.reason === "no_readings_in_window"
) {
return null;
}
@@ -184,7 +190,10 @@ function BaselineTile({
className="bg-card border-border flex h-full w-full min-w-0 flex-col gap-3 rounded-xl border p-4 md:p-6"
>
<TileHeader icon={Icon} title={label} titleClassName="truncate" />
<p className="text-muted-foreground text-sm" data-slot="vitals-tile-building">
<p
className="text-muted-foreground text-sm"
data-slot="vitals-tile-building"
>
{t("insights.derived.vitals.building", {
count: data.coverage.historyDays,
target: 7,
@@ -211,7 +220,9 @@ function BaselineTile({
series={v.series}
framing={framing}
directionSentiment={vitalSentiment(type)}
provenance={<MetricProvenance metric={metric} provenance={data.provenance} />}
provenance={
<MetricProvenance metric={metric} provenance={data.provenance} />
}
/>
</div>
);
@@ -236,7 +247,11 @@ function SixMinuteWalkTile({ read, isLoading }: TileProps) {
})
: t("insights.derived.vitals.sixMinuteNoBand");
return (
<div data-slot="vitals-tile" data-metric="SIX_MINUTE_WALK_BAND" data-state="ok">
<div
data-slot="vitals-tile"
data-metric="SIX_MINUTE_WALK_BAND"
data-state="ok"
>
<SparklineDeltaTile
label={t("measurements.typeSixMinuteWalkDistance")}
value={v.distanceM}
@@ -248,7 +263,10 @@ function SixMinuteWalkTile({ read, isLoading }: TileProps) {
framing={framing}
precision={0}
provenance={
<MetricProvenance metric="SIX_MINUTE_WALK_BAND" provenance={data.provenance} />
<MetricProvenance
metric="SIX_MINUTE_WALK_BAND"
provenance={data.provenance}
/>
}
/>
</div>
@@ -311,7 +329,11 @@ function VascularAgeTile({ read, isLoading }: TileProps) {
: t("insights.derived.vitals.vascularMatch")
: t("insights.derived.vitals.vascularNoAge");
return (
<div data-slot="vitals-tile" data-metric="VASCULAR_AGE_DELTA" data-state="ok">
<div
data-slot="vitals-tile"
data-metric="VASCULAR_AGE_DELTA"
data-state="ok"
>
<SparklineDeltaTile
label={t("measurements.typeVascularAge")}
value={v.vascularAge}
@@ -337,7 +359,10 @@ function HrvBalanceTile({ read, isLoading }: TileProps) {
const { t } = useTranslations();
const data = read<HrvBalanceValue>({ metric: "HRV_BALANCE" });
if (isLoading || !data) return null;
if (data.status === "insufficient" && data.reason === "no_readings_in_window") {
if (
data.status === "insufficient" &&
data.reason === "no_readings_in_window"
) {
return null;
}
if (data.status === "insufficient") {
@@ -444,7 +469,9 @@ function hasRenderableVital(read: DerivedBatchRead): boolean {
const fitness = read<FitnessAgeValue>({ metric: "FITNESS_AGE" });
if (fitness?.status === "ok" && fitness.value) return true;
const vascular = read<VascularAgeDeltaValue>({ metric: "VASCULAR_AGE_DELTA" });
const vascular = read<VascularAgeDeltaValue>({
metric: "VASCULAR_AGE_DELTA",
});
if (vascular?.status === "ok" && vascular.value) return true;
const bmi = read<BmiValue>({ metric: "BMI" });
@@ -492,7 +519,10 @@ function hasRenderableMobility(read: DerivedBatchRead): boolean {
const band = read<VitalsBaselineValue>({ metric });
if (
band &&
!(band.status === "insufficient" && band.reason === "no_readings_in_window")
!(
band.status === "insufficient" &&
band.reason === "no_readings_in_window"
)
) {
return true;
}
@@ -526,7 +556,7 @@ export function VitalsDashboard({ batch, className }: DashboardProps) {
aria-label={t("insights.derived.vitals.sectionTitle")}
className="space-y-3"
>
<TileHeader
<SectionHeading
icon={HeartPulse}
title={t("insights.derived.vitals.sectionTitle")}
/>
@@ -561,14 +591,17 @@ export function VitalsDashboard({ batch, className }: DashboardProps) {
const showMobility = !isLoading && hasRenderableMobility(read);
return (
<div data-slot="vitals-dashboard-wrap" className={cn("space-y-6", className)}>
<div
data-slot="vitals-dashboard-wrap"
className={cn("space-y-6", className)}
>
{showSection && (
<section
data-slot="vitals-dashboard"
aria-label={t("insights.derived.vitals.sectionTitle")}
className="space-y-3"
>
<TileHeader
<SectionHeading
icon={HeartPulse}
title={t("insights.derived.vitals.sectionTitle")}
/>
@@ -612,7 +645,7 @@ export function VitalsDashboard({ batch, className }: DashboardProps) {
aria-label={t("insights.derived.vitals.mobilitySectionTitle")}
className="space-y-3"
>
<TileHeader
<SectionHeading
icon={Footprints}
title={t("insights.derived.vitals.mobilitySectionTitle")}
/>
@@ -2,11 +2,18 @@
import { useState, type ComponentType } from "react";
import Link from "next/link";
import { Activity, Flame, Gauge, HeartPulse, Moon, RefreshCw } from "lucide-react";
import {
Activity,
Flame,
Gauge,
HeartPulse,
Moon,
RefreshCw,
} from "lucide-react";
import { cn } from "@/lib/utils";
import { Button } from "@/components/ui/button";
import { useTranslations } from "@/lib/i18n/context";
import { TileHeader } from "@/components/insights/tile-header";
import { SectionHeading } from "@/components/insights/section-heading";
import { ScoreRing } from "./score-ring";
import { TILE_HUE, type RingHue } from "./ring-hues";
import type { DerivedBatchRead } from "./use-derived-metric";
@@ -201,7 +208,7 @@ export function WellnessScores({
aria-label={t("insights.derived.scores.sectionTitle")}
className={cn("space-y-3", className)}
>
<TileHeader
<SectionHeading
icon={Activity}
title={t("insights.derived.scores.sectionTitle")}
/>
@@ -341,7 +348,11 @@ export function WellnessScores({
// "missing tile" frame.
if (extraTile != null) {
tiles.push(
<div key="EXTRA" data-slot="wellness-extra-tile-slot" className="contents">
<div
key="EXTRA"
data-slot="wellness-extra-tile-slot"
className="contents"
>
{extraTile}
</div>,
);
@@ -380,7 +391,7 @@ export function WellnessScores({
aria-label={t("insights.derived.scores.sectionTitle")}
className={cn("space-y-3", className)}
>
<TileHeader
<SectionHeading
icon={Activity}
title={t("insights.derived.scores.sectionTitle")}
/>
@@ -2,11 +2,12 @@
import { useState } from "react";
import { useQuery } from "@tanstack/react-query";
import { Info } from "lucide-react";
import { CalendarRange, Info } from "lucide-react";
import { useTranslations } from "@/lib/i18n/context";
import { queryKeys } from "@/lib/query-keys";
import { cn } from "@/lib/utils";
import { SectionHeading } from "@/components/insights/section-heading";
import {
Popover,
PopoverContent,
@@ -122,22 +123,30 @@ export function PeriodNarrativeCard({
q.state.data?.revalidating && !q.state.data.narrative ? 5000 : false,
});
// Loading with nothing to show yet → matched skeleton (CLS-safe).
// Loading with nothing to show yet → matched skeleton (CLS-safe). The
// heading renders above the skeleton so the section reserves its full
// height (heading + card) before the prose lands.
if (query.isLoading) {
return (
<div
data-slot="period-narrative-card-skeleton"
aria-hidden="true"
className={cn(SKELETON_SHELL, className)}
<section
data-slot="period-narrative-section"
aria-label={t("insights.narrativeTitle")}
className={cn("space-y-3", className)}
>
<div className="flex h-7 items-center justify-between gap-2">
<div className="bg-muted/40 h-3 w-32 rounded" />
<div className="bg-muted/40 size-5 rounded-full" />
<SectionHeading
icon={CalendarRange}
title={t("insights.narrativeTitle")}
/>
<div
data-slot="period-narrative-card-skeleton"
aria-hidden="true"
className={SKELETON_SHELL}
>
<div className="bg-muted/40 h-4 w-full rounded" />
<div className="bg-muted/40 h-4 w-5/6 rounded" />
<div className="bg-muted/40 h-4 w-2/3 rounded" />
</div>
<div className="bg-muted/40 h-4 w-full rounded" />
<div className="bg-muted/40 h-4 w-5/6 rounded" />
<div className="bg-muted/40 h-4 w-2/3 rounded" />
</div>
</section>
);
}
@@ -153,67 +162,73 @@ export function PeriodNarrativeCard({
const preparing = !narrative && data?.revalidating === true;
return (
<div
data-slot="period-narrative-card"
data-period={period}
className={cn(SHELL, className)}
<section
data-slot="period-narrative-section"
aria-label={t("insights.narrativeTitle")}
className={cn("space-y-3", className)}
>
<div className="flex items-center justify-between gap-2">
<h2 className="text-foreground truncate text-base font-semibold">
{t("insights.narrativeTitle")}
</h2>
{narrative?.provenance ? (
<ProvenanceDisclosure provenance={narrative.provenance} />
<SectionHeading
icon={CalendarRange}
title={t("insights.narrativeTitle")}
action={
narrative?.provenance ? (
<ProvenanceDisclosure provenance={narrative.provenance} />
) : undefined
}
/>
<div
data-slot="period-narrative-card"
data-period={period}
className={SHELL}
>
{/* Week / month toggle. Calm segmented control, no chart. */}
<div className="flex gap-1 self-start">
{(["week", "month"] as const).map((p) => {
const active = period === p;
return (
<button
key={p}
type="button"
aria-pressed={active}
onClick={() => setPeriod(p)}
className={cn(
"focus-visible:ring-ring inline-flex min-h-11 items-center rounded-md px-2.5 py-1 text-xs font-medium transition-colors focus-visible:ring-2 focus-visible:outline-none",
active
? "bg-muted text-foreground"
: "text-muted-foreground hover:text-foreground",
)}
>
{p === "week"
? t("insights.narrativeWeek")
: t("insights.narrativeMonth")}
</button>
);
})}
</div>
{preparing ? (
<p className="text-muted-foreground text-sm">
{t("insights.narrativePreparing")}
</p>
) : (
// Plain text child — NO markdown renderer (XSS posture, CLAUDE.md).
<p className="text-foreground text-sm leading-relaxed whitespace-pre-line">
{narrative?.text}
</p>
)}
{narrative ? (
<p className="text-muted-foreground text-right text-[11px]">
{data?.revalidating
? t("insights.narrativeUpdating")
: t("insights.narrativeUpdated", {
time: new Date(narrative.updatedAt).toLocaleDateString(
locale === "de" ? "de-DE" : undefined,
),
})}
</p>
) : null}
</div>
{/* Week / month toggle. Calm segmented control, no chart. */}
<div className="flex gap-1 self-start">
{(["week", "month"] as const).map((p) => {
const active = period === p;
return (
<button
key={p}
type="button"
aria-pressed={active}
onClick={() => setPeriod(p)}
className={cn(
"focus-visible:ring-ring inline-flex min-h-11 items-center rounded-md px-2.5 py-1 text-xs font-medium transition-colors focus-visible:ring-2 focus-visible:outline-none",
active
? "bg-muted text-foreground"
: "text-muted-foreground hover:text-foreground",
)}
>
{p === "week"
? t("insights.narrativeWeek")
: t("insights.narrativeMonth")}
</button>
);
})}
</div>
{preparing ? (
<p className="text-muted-foreground text-sm">
{t("insights.narrativePreparing")}
</p>
) : (
// Plain text child — NO markdown renderer (XSS posture, CLAUDE.md).
<p className="text-foreground text-sm leading-relaxed whitespace-pre-line">
{narrative?.text}
</p>
)}
{narrative ? (
<p className="text-muted-foreground text-right text-[11px]">
{data?.revalidating
? t("insights.narrativeUpdating")
: t("insights.narrativeUpdated", {
time: new Date(narrative.updatedAt).toLocaleDateString(
locale === "de" ? "de-DE" : undefined,
),
})}
</p>
) : null}
</div>
</section>
);
}
+59 -62
View File
@@ -1,19 +1,14 @@
"use client";
import { useQuery } from "@tanstack/react-query";
import {
Activity,
HeartPulse,
Footprints,
Wind,
Info,
} from "lucide-react";
import { Activity, HeartPulse, Footprints, Wind, Info } from "lucide-react";
import type { LucideIcon } from "lucide-react";
import { useAuth } from "@/hooks/use-auth";
import { queryKeys } from "@/lib/query-keys";
import { useTranslations, useFormatters } from "@/lib/i18n/context";
import { cn } from "@/lib/utils";
import { SectionHeading } from "@/components/insights/section-heading";
/**
* v1.10.0 — device-flagged event awareness surface (categorical events,
@@ -62,7 +57,8 @@ const EVENT_LABEL_KEYS: Record<string, string> = {
HIGH_HEART_RATE_EVENT: "insights.rhythmEvents.event.highHeartRate",
LOW_HEART_RATE_EVENT: "insights.rhythmEvents.event.lowHeartRate",
WALKING_STEADINESS_EVENT: "insights.rhythmEvents.event.walkingSteadiness",
BREATHING_DISTURBANCE_EVENT: "insights.rhythmEvents.event.breathingDisturbance",
BREATHING_DISTURBANCE_EVENT:
"insights.rhythmEvents.event.breathingDisturbance",
};
/**
@@ -107,69 +103,70 @@ export function RhythmEventsCard({
return (
<section
data-slot="rhythm-events-card"
data-slot="rhythm-events-section"
aria-label={t("insights.rhythmEvents.sectionTitle")}
className={cn(
"bg-card border-border space-y-4 rounded-xl border p-4 md:p-6",
className,
)}
className={cn("space-y-3", className)}
>
<div className="space-y-1">
<h2 className="text-foreground text-sm font-semibold tracking-tight">
{t("insights.rhythmEvents.sectionTitle")}
</h2>
<SectionHeading
icon={Activity}
title={t("insights.rhythmEvents.sectionTitle")}
/>
<div
data-slot="rhythm-events-card"
className="bg-card border-border space-y-4 rounded-xl border p-4 md:p-6"
>
<p className="text-muted-foreground text-sm">
{t("insights.rhythmEvents.sectionIntro")}
</p>
</div>
<ol data-slot="rhythm-events-timeline" className="space-y-3">
{data.events.map((event) => {
const Icon = EVENT_ICONS[event.type] ?? Activity;
const labelKey = EVENT_LABEL_KEYS[event.type];
const label = labelKey ? t(labelKey) : event.type;
const verdictKey = event.classification
? CLASSIFICATION_LABEL_KEYS[event.classification]
: undefined;
const verdict = verdictKey ? t(verdictKey) : null;
return (
<li
key={event.id}
data-slot="rhythm-event-row"
data-event-type={event.type}
className="border-border/60 flex items-start gap-3 border-b pb-3 last:border-b-0 last:pb-0"
>
<span className="bg-muted text-muted-foreground mt-0.5 flex size-8 shrink-0 items-center justify-center rounded-full">
<Icon className="size-4" />
</span>
<div className="min-w-0 flex-1 space-y-0.5">
<p className="text-foreground text-sm font-medium">{label}</p>
{verdict && (
<p
data-slot="rhythm-event-verdict"
className="text-muted-foreground text-sm"
>
{verdict}
<ol data-slot="rhythm-events-timeline" className="space-y-3">
{data.events.map((event) => {
const Icon = EVENT_ICONS[event.type] ?? Activity;
const labelKey = EVENT_LABEL_KEYS[event.type];
const label = labelKey ? t(labelKey) : event.type;
const verdictKey = event.classification
? CLASSIFICATION_LABEL_KEYS[event.classification]
: undefined;
const verdict = verdictKey ? t(verdictKey) : null;
return (
<li
key={event.id}
data-slot="rhythm-event-row"
data-event-type={event.type}
className="border-border/60 flex items-start gap-3 border-b pb-3 last:border-b-0 last:pb-0"
>
<span className="bg-muted text-muted-foreground mt-0.5 flex size-8 shrink-0 items-center justify-center rounded-full">
<Icon className="size-4" />
</span>
<div className="min-w-0 flex-1 space-y-0.5">
<p className="text-foreground text-sm font-medium">{label}</p>
{verdict && (
<p
data-slot="rhythm-event-verdict"
className="text-muted-foreground text-sm"
>
{verdict}
</p>
)}
<p className="text-muted-foreground text-xs">
{fmt.dateTime(new Date(event.occurredAt))}
</p>
)}
<p className="text-muted-foreground text-xs">
{fmt.dateTime(new Date(event.occurredAt))}
</p>
</div>
</li>
);
})}
</ol>
</div>
</li>
);
})}
</ol>
{/* Load-bearing regulatory disclaimer — permanent, non-dismissible.
{/* Load-bearing regulatory disclaimer — permanent, non-dismissible.
Plain React text children (no markdown library — XSS rule). */}
<div
data-slot="rhythm-events-disclaimer"
role="note"
className="bg-muted/50 text-muted-foreground flex items-start gap-2 rounded-lg p-3 text-xs"
>
<Info className="mt-0.5 size-4 shrink-0" aria-hidden="true" />
<p>{t("insights.rhythmEvents.disclaimer")}</p>
<div
data-slot="rhythm-events-disclaimer"
role="note"
className="bg-muted/50 text-muted-foreground flex items-start gap-2 rounded-lg p-3 text-xs"
>
<Info className="mt-0.5 size-4 shrink-0" aria-hidden="true" />
<p>{t("insights.rhythmEvents.disclaimer")}</p>
</div>
</div>
</section>
);
@@ -0,0 +1,70 @@
import type { ComponentType, ReactNode } from "react";
import { cn } from "@/lib/utils";
/**
* v1.15.10 — the one section heading for the Insights overview.
*
* Every top-level overview section (health scores, daily briefing, vitals,
* trends, period-in-review, cycle summary, signals of the day) leads with
* this identical heading ABOVE its card: a leading Lucide icon and an `<h2>`
* title, both in the foreground colour (white in dark mode — never a per-
* section accent hue), at one size + weight. An optional `action` slot pins a
* trailing affordance (a subtitle, a meta control) to the right edge without
* breaking the row's alignment.
*
* The overview used to mix three heading dialects: a `text-lg` `<h2>` with a
* purple Sparkles glyph (briefing), a `text-lg` `<h2>` with NO icon (trends),
* a `text-base` `TileHeader` with a white icon (scores / vitals), and in-card
* titles buried inside the period-narrative, cycle-summary and signals cards.
* Side by side they read as four different surfaces. `SectionHeading` pins the
* single contract so the system can't drift: icon `h-5 w-5 shrink-0
* text-foreground`, title `<h2>` `text-base font-semibold`, `gap-2` between
* icon and title. The caller owns the `space-y-3` gap to the card below.
*
* RSC-safe: no hooks, no browser API.
*/
interface SectionHeadingProps {
/**
* Leading glyph. The caller passes the Lucide component itself
* (`icon={Sparkles}`), not a pre-sized node — `SectionHeading` owns the
* size and colour so every overview heading matches.
*/
icon: ComponentType<{ className?: string }>;
/** Heading text rendered inside the `<h2>`. */
title: ReactNode;
/** Optional trailing affordance pinned to the right edge of the row. */
action?: ReactNode;
/** Optional id on the `<h2>`, e.g. for an `aria-labelledby` link. */
id?: string;
className?: string;
}
export function SectionHeading({
icon: Icon,
title,
action,
id,
className,
}: SectionHeadingProps) {
return (
<div
data-slot="section-heading"
className={cn(
"flex flex-wrap items-center justify-between gap-2",
className,
)}
>
<div className="flex min-w-0 items-center gap-2">
<Icon className="text-foreground h-5 w-5 shrink-0" aria-hidden="true" />
<h2 id={id} className="text-base font-semibold">
{title}
</h2>
</div>
{action ? (
<div className="flex shrink-0 items-center gap-2">{action}</div>
) : null}
</div>
);
}
+18 -9
View File
@@ -1,9 +1,11 @@
"use client";
import dynamic from "next/dynamic";
import { TrendingUp } from "lucide-react";
import { useTranslations } from "@/lib/i18n/context";
import { useAuth } from "@/hooks/use-auth";
import { ChartSkeleton } from "@/components/charts/chart-skeleton";
import { SectionHeading } from "@/components/insights/section-heading";
import { HealthChartDynamicMini } from "@/components/charts/health-chart-dynamic";
import type { DailyBriefing } from "@/lib/ai/schema";
import {
@@ -99,7 +101,13 @@ interface TrendsRowProps {
loading?: boolean;
}
function MetricChart({ config, title }: { config: TrendChartConfig; title: string }) {
function MetricChart({
config,
title,
}: {
config: TrendChartConfig;
title: string;
}) {
const { user } = useAuth();
const userTimezone = user?.timezone;
if (config.kind === "mood") {
@@ -188,14 +196,15 @@ export function TrendsRow({
aria-label={t("insights.trendsRow.title")}
className="space-y-3"
>
<div className="flex items-baseline justify-between">
<h2 className="text-lg font-semibold">
{t("insights.trendsRow.title")}
</h2>
<p className="text-muted-foreground text-xs">
{t("insights.trendsRow.subtitle")}
</p>
</div>
<SectionHeading
icon={TrendingUp}
title={t("insights.trendsRow.title")}
action={
<p className="text-muted-foreground text-xs">
{t("insights.trendsRow.subtitle")}
</p>
}
/>
{/* v1.4.22 A4 — equal-height cards. The annotation prose
below each chart varies in length, which used to leave the
three cards on visibly different baselines. Each card is now
+28 -2
View File
@@ -8,10 +8,11 @@ import {
Monitor,
Moon,
Settings,
Shield,
Sun,
User,
} from "lucide-react";
import { Logo } from "@/components/ui/logo";
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
import Link from "next/link";
import { useAuth, useLogout } from "@/hooks/use-auth";
import { useTheme } from "@/components/providers";
@@ -27,11 +28,21 @@ import {
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
function getInitials(name: string): string {
return name
.split(/[\s._-]+/)
.slice(0, 2)
.map((s) => s[0]?.toUpperCase() ?? "")
.join("");
}
export function TopBar() {
const { user, isLoading } = useAuth();
const logout = useLogout();
const { theme, setTheme } = useTheme();
const { t } = useTranslations();
const avatarUrl = user?.avatarUrl ?? null;
const isAdmin = user?.role === "ADMIN";
const themeIcon =
theme === "system" ? (
@@ -77,7 +88,14 @@ export function TopBar() {
// the focus indicator without replacing it.
className="text-muted-foreground hover:text-foreground flex min-h-11 min-w-11 items-center gap-1.5 rounded-md px-2 py-1.5 text-sm transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring/50 focus-visible:ring-offset-2"
>
<User className="h-4 w-4" />
<Avatar className="size-7">
{avatarUrl && (
<AvatarImage src={avatarUrl} alt={user.username} />
)}
<AvatarFallback className="bg-primary/15 text-primary text-xs font-medium">
{getInitials(user.username)}
</AvatarFallback>
</Avatar>
<span className="hidden sm:inline">{user.username}</span>
<ChevronDown className="h-3 w-3 opacity-60" />
</DropdownMenuTrigger>
@@ -94,6 +112,14 @@ export function TopBar() {
{t("nav.notifications")}
</Link>
</DropdownMenuItem>
{isAdmin && (
<DropdownMenuItem asChild>
<Link href="/admin" className="cursor-pointer">
<Shield className="mr-2 h-4 w-4" />
{t("nav.admin")}
</Link>
</DropdownMenuItem>
)}
{/* v1.4.36 W4e — About moved into the Admin Console
(`/admin/about`). The dropdown entry was redundant
for the small audience that still reaches it (admins
+14 -6
View File
@@ -49,16 +49,21 @@ function applyTheme(resolved: "dark" | "light") {
function ThemeProvider({ children }: { children: ReactNode }) {
const [theme, setThemeState] = useState<Theme>(() => {
if (typeof window === "undefined") return "system";
if (typeof window === "undefined") return "dark";
const saved = localStorage.getItem("healthlog-theme") as Theme | null;
return saved === "light" || saved === "dark" ? saved : "system";
// A stored "light"/"dark"/"system" choice wins. With no stored
// preference the app defaults to dark rather than tracking the OS.
if (saved === "light" || saved === "dark") return saved;
if (saved === "system") return "system";
return "dark";
});
const [resolvedTheme, setResolvedTheme] = useState<"dark" | "light">(() => {
if (typeof window === "undefined") return "dark";
const saved = localStorage.getItem("healthlog-theme") as Theme | null;
const t = saved === "light" || saved === "dark" ? saved : "system";
return t === "system" ? getSystemTheme() : t;
if (saved === "light" || saved === "dark") return saved;
if (saved === "system") return getSystemTheme();
return "dark";
});
// Apply theme on mount (inline script handles FOUC, this ensures classes are in sync)
@@ -82,13 +87,16 @@ function ThemeProvider({ children }: { children: ReactNode }) {
const setTheme = useCallback((next: Theme) => {
setThemeState(next);
// Persist every explicit choice, including "system" — the absence of a
// stored value is reserved for a fresh visitor and now defaults to dark.
// Storing "system" verbatim keeps an explicit OS-tracking choice
// distinguishable from "never chose", so a reload honours it.
localStorage.setItem("healthlog-theme", next);
if (next === "system") {
localStorage.removeItem("healthlog-theme");
const resolved = getSystemTheme();
setResolvedTheme(resolved);
applyTheme(resolved);
} else {
localStorage.setItem("healthlog-theme", next);
setResolvedTheme(next);
applyTheme(next);
}
@@ -179,7 +179,7 @@ describe("settings sections — SSR smoke", () => {
const html = render(<NotificationsSection />);
// v1.4.33 IW7 — section renamed from "Notifications" to
// "Notification channels" so it doesn't collide with the inbox at
// `/notifications` ("Notification Center").
// `/notifications` ("Notifications").
expect(html).toContain("Notification channels");
});
@@ -147,7 +147,7 @@ describe("<SettingsShell>", () => {
expect(html).toContain("Integrations");
// v1.9.0 — the section label is back to the shorter "Notifications"
// (single-line; the longer "Notification channels" wrapped). The
// `/notifications` inbox is still "Notification Center" — distinct enough.
// `/notifications` inbox now shares the "Notifications" label.
expect(html).toContain("Notifications");
expect(html).toContain("Dashboard");
expect(html).toContain("Insights");
@@ -177,7 +177,7 @@ describe("<SettingsShell>", () => {
expect(html).toContain("Integrationen");
// v1.9.0 — back to the shorter "Benachrichtigungen" (single-line; the
// compound "Benachrichtigungs-Kanäle" wrapped). The `/notifications`
// inbox stays "Benachrichtigungs-Center" — distinct enough.
// inbox now shares the "Benachrichtigungen" label.
expect(html).toContain("Benachrichtigungen");
// v1.4.3: the Settings sub-section formerly labelled "Übersicht" is now
// "Dashboard" (matching the term users see in the main nav). The
@@ -37,4 +37,24 @@ describe("formatTimeWindowRange", () => {
// continue to render the historic German string.
expect(formatTimeWindowRange("08:00", "12:00")).toBe("08:00 bis 12:00 Uhr");
});
test("collapses an equal start/end to a single time (German keeps 'Uhr')", () => {
// BUG 1: a single target time (or a zero-span window) must not render the
// degenerate "07:00 bis 07:00 Uhr" range — the "bis" separator is reserved
// for a genuine range.
expect(formatTimeWindowRange("07:00", "07:00", "de")).toBe("07:00 Uhr");
});
test("collapses an equal start/end to a single time (English, no 'to')", () => {
expect(formatTimeWindowRange("07:00", "07:00", "en")).toBe("07:00");
});
test("collapses an equal start/end after zero-padding normalisation", () => {
// "7:00" and "07:00" are the same clock time once normalised.
expect(formatTimeWindowRange("7:00", "07:00", "de")).toBe("07:00 Uhr");
});
test("collapses an equal start/end with no locale (German fallback)", () => {
expect(formatTimeWindowRange("07:00", "07:00")).toBe("07:00 Uhr");
});
});
@@ -1667,6 +1667,57 @@ describe("buildComplianceDisplay — two rows, cadence-scaled windows", () => {
expect(missedDisplay.currentDose.status).toBe("missed");
});
it("currentDose — twice-daily, both today's slots resolved → next open is tomorrow 07:00 (BUG 2)", () => {
// v1.15.10 BUG 2 — afternoon, with today's 07:00 taken and today's 19:00
// taken early. The open-dose search must skip both resolved slots and
// surface tomorrow's 07:00 — the next genuinely-open slot — instead of
// sticking on today's resolved 19:00.
const schedules: ComplianceSchedule[] = [
{
windowStart: "07:00",
windowEnd: "19:00",
daysOfWeek: null,
timesOfDay: ["07:00", "19:00"],
},
];
// NOW = 2025-06-15T12:00:00Z (UTC ctx). Today's slots: 07:00Z + 19:00Z.
const slot0700 = new Date("2025-06-15T07:00:00Z");
const slot1900 = new Date("2025-06-15T19:00:00Z");
const events = [
// 07:00 dose logged on time.
{ scheduledFor: slot0700, takenAt: new Date("2025-06-15T07:05:00Z"), skipped: false },
// 19:00 dose logged EARLY (before its slot) — snapped onto the 19:00 row.
{ scheduledFor: slot1900, takenAt: new Date("2025-06-15T11:30:00Z"), skipped: false },
];
const display = buildComplianceDisplay(events, schedules, ctx(), { now: NOW });
expect(display.currentCycle.nextDueAt).not.toBeNull();
// Next open slot is tomorrow 07:00 UTC, not today's resolved 19:00.
expect(display.currentCycle.nextDueAt!.toISOString()).toBe(
"2025-06-16T07:00:00.000Z",
);
});
it("currentDose — twice-daily, only 07:00 resolved → next open is today 19:00 (BUG 2)", () => {
const schedules: ComplianceSchedule[] = [
{
windowStart: "07:00",
windowEnd: "19:00",
daysOfWeek: null,
timesOfDay: ["07:00", "19:00"],
},
];
const slot0700 = new Date("2025-06-15T07:00:00Z");
const events = [
{ scheduledFor: slot0700, takenAt: new Date("2025-06-15T07:05:00Z"), skipped: false },
];
const display = buildComplianceDisplay(events, schedules, ctx(), { now: NOW });
expect(display.currentCycle.nextDueAt).not.toBeNull();
// Today's 19:00 is still open.
expect(display.currentCycle.nextDueAt!.toISOString()).toBe(
"2025-06-15T19:00:00.000Z",
);
});
it("currentCycle — brand-new sparse med with zero closed cycles → hasClosedCycles false", () => {
// v1.13.x Fix 4 — a med created today with no logged intake has no
// closed dose cycle; the percentage rows are vacuous and the card should
@@ -2066,6 +2117,186 @@ describe("calculateCompliance — autoMissed forgotten doses (BUG #2 / #3)", ()
});
});
// ────────────────────────────────────────────────────────────────────
// v1.15.10 BUG 3 — verify the compliance % for an IRREGULAR twice-daily
// taker (07:00 + 19:00) who logs at off-times plus the odd ad-hoc dose.
// Pins the slot-pairing (an off-time morning take snaps to the 07:00 slot,
// an evening take to the 19:00 slot — ±6h half-gap for a 12h-gap med), the
// skip/missed/extra semantics, and the resulting rate = taken/(taken+missed).
// ────────────────────────────────────────────────────────────────────
describe("calculateCompliance — irregular twice-daily taker (BUG 3 verify)", () => {
// Pin `now` so the 7-day window is deterministic.
const NOW = new Date("2025-01-15T12:00:00Z");
beforeEach(() => {
vi.useFakeTimers();
vi.setSystemTime(NOW);
});
afterEach(() => {
vi.useRealTimers();
});
// Twice-daily: 07:00 + 19:00, every day. timesOfDay drives the two slots.
const twiceDaily: ComplianceSchedule[] = [
{
windowStart: "07:00",
windowEnd: "19:00",
daysOfWeek: null,
timesOfDay: ["07:00", "19:00"],
},
];
const ctxVal = buildComplianceMedicationContext(
{
startsOn: null,
endsOn: null,
oneShot: false,
// Created well before the window so age never clamps the denominator.
createdAt: new Date("2025-01-01T00:00:00Z"),
},
null,
"UTC",
);
function slot(day: number, hhmm: "07:00" | "19:00"): Date {
return new Date(`2025-01-${String(day).padStart(2, "0")}T${hhmm}:00Z`);
}
it("snaps two off-time same-day takes to BOTH slots → 2 taken, not 1 taken + 1 missed", () => {
// The crux of BUG 3: a day with a late-morning take (09:13) and an
// evening take (19:00) must read as two taken doses. The morning take
// pairs to the 07:00 slot (2h13m away, well inside the ±6h half-gap) and
// the evening to the 19:00 slot — NOT the morning take landing on 19:00
// and the 07:00 slot reading falsely missed.
//
// A 2-day window so BOTH of Jan-14's slots fall inside [NOW-2d, NOW]
// (a 1-day window starts at noon Jan-14, which is after the 07:00 slot).
const events = [
// 07:00 slot, logged at 09:13 (off-time, snapped onto the 07:00 row).
{
scheduledFor: slot(14, "07:00"),
takenAt: new Date(slot(14, "07:00").getTime() + (2 * 60 + 13) * 60 * 1000),
skipped: false,
},
// 19:00 slot, logged on time.
{ scheduledFor: slot(14, "19:00"), takenAt: slot(14, "19:00"), skipped: false },
// Jan-13's slots are covered too so the window has no stray miss.
{ scheduledFor: slot(13, "07:00"), takenAt: slot(13, "07:00"), skipped: false },
{ scheduledFor: slot(13, "19:00"), takenAt: slot(13, "19:00"), skipped: false },
];
const result = calculateCompliance(events, twiceDaily, 2, undefined, {
now: NOW,
medicationContext: ctxVal,
});
// Both of Jan-14's off-time/on-time takes pair to their own slot → the
// morning slot is NOT falsely missed. No missed slot anywhere → 100%.
expect(result.missed).toBe(0);
expect(result.rate).toBe(100);
// The morning take did not orphan the 07:00 slot: at least Jan-14's two
// doses both register as taken.
expect(result.taken).toBeGreaterThanOrEqual(3);
});
it("computes the rate as taken/(taken+missed) over a 7-day irregular pattern", () => {
// Window: NOW − 7d = Jan 8 12:00 .. Jan 15 12:00. The slots that fall in
// [start, now] (07:00 + 19:00 each day; Jan-8 19:00 only since 07:00 is
// before 12:00; Jan-15 07:00 only since 19:00 is after now):
// Jan 8: 19:00 → 1 slot
// Jan 9..14: 07:00 + 19:00 → 12 slots
// Jan 15: 07:00 → 1 slot
// = 14 expected slots.
//
// Pattern (mirrors the operator: irregular times + 2 real skips + 1
// uncovered morning slot + a couple of ad-hoc extras that pair to no
// slot):
// - Jan 8 19:00 : taken (logged 20:10, off-time) → taken
// - Jan 9 07:00 : taken 09:13 (off-time morning) → taken
// - Jan 9 19:00 : USER-SKIP → skipped (excluded)
// - Jan 10 07:00: taken 06:40 → taken
// - Jan 10 19:00: taken 21:30 (late) → taken
// - Jan 11 07:00: UNCOVERED (no intake, past cutoff) → missed
// - Jan 11 19:00: taken 18:50 → taken
// - Jan 12 07:00: taken 08:05 → taken
// - Jan 12 19:00: taken 19:20 → taken
// - Jan 13 07:00: USER-SKIP → skipped (excluded)
// - Jan 13 19:00: taken 19:00 → taken
// - Jan 14 07:00: taken 09:13 (off-time morning) → taken
// - Jan 14 19:00: taken 19:00 → taken
// - Jan 15 07:00: taken 07:30 → taken
// Plus two ad-hoc extras (no slot within ±6h) that must NOT touch the
// denominator:
// - Jan 10 13:00 (a midday top-up, midpoint between both slots)
// - Jan 12 03:00 (a small-hours dose, > 6h from either slot)
//
// Hand count over the 14 slots:
// taken = 11
// skipped = 2 (excluded)
// missed = 1
// denom = taken + missed = 12
// rate = round(11/12 * 100) = round(91.67) = 92
const mins = (h: number, m: number) => (h * 60 + m) * 60 * 1000;
const takeAt = (s: Date, h: number, m: number) =>
new Date(new Date(s).setUTCHours(0, 0, 0, 0) + mins(h, m));
const taken = (s: Date, h: number, m: number) => ({
scheduledFor: s,
takenAt: takeAt(s, h, m),
skipped: false,
});
const userSkip = (s: Date) => ({
scheduledFor: s,
takenAt: null,
skipped: true,
autoMissed: false,
});
const autoMiss = (s: Date) => ({
scheduledFor: s,
takenAt: null,
skipped: false,
autoMissed: true,
});
const extra = (s: Date) => ({ scheduledFor: s, takenAt: s, skipped: false });
const events = [
taken(slot(8, "19:00"), 20, 10),
taken(slot(9, "07:00"), 9, 13),
userSkip(slot(9, "19:00")),
taken(slot(10, "07:00"), 6, 40),
taken(slot(10, "19:00"), 21, 30),
autoMiss(slot(11, "07:00")),
taken(slot(11, "19:00"), 18, 50),
taken(slot(12, "07:00"), 8, 5),
taken(slot(12, "19:00"), 19, 20),
userSkip(slot(13, "07:00")),
taken(slot(13, "19:00"), 19, 0),
taken(slot(14, "07:00"), 9, 13),
taken(slot(14, "19:00"), 19, 0),
taken(slot(15, "07:00"), 7, 30),
// ad-hoc extras (must not enter the denominator)
extra(new Date("2025-01-10T13:00:00Z")),
extra(new Date("2025-01-12T03:00:00Z")),
];
const result = calculateCompliance(events, twiceDaily, 7, undefined, {
now: NOW,
medicationContext: ctxVal,
});
// The denominator counts only taken + missed (the two user-skips + the
// two ad-hoc extras are excluded). With the v1.15.10 exact-anchor pairing
// every off-time take lands on its OWN slot — the two user-skips read as
// skipped (not taken), the late evening take reads as taken (not missed).
expect(result.taken).toBe(11);
expect(result.skipped).toBe(2);
expect(result.missed).toBe(1);
// rate = taken / (taken + missed) = 11/12 = 92% — the honest arithmetic.
expect(result.rate).toBe(
Math.round((result.taken / (result.taken + result.missed)) * 100),
);
expect(result.rate).toBe(92);
});
});
// ────────────────────────────────────────────────────────────────────
// v1.15.9 — cadence-aware per-dose grace/miss window model. The card
// reads `deriveDoseStatus` to render on-time / overdue / missed states.
@@ -364,6 +364,59 @@ describe("computeUserHealthScoreFastPath", () => {
expect(weightCall.where.type).toBe("WEIGHT");
expect(ROLLUP_FIND_MANY).not.toHaveBeenCalled();
});
// Regression pin: a height-less user (no derivable weight target)
// who DOES have weight readings must still get a populated, weighted
// weight pillar. Before this fix the pillar came back
// `{value:null, weight:0, source:"none"}` because the only target
// source was `defaultWeightTargetFromHeight(null)` → null, and the
// pillar was gated on having a target. The trend-only scoring path
// now scores the bare weight trend instead.
it("populates the weight pillar from a trend-only score when height is null", async () => {
const coverage = new Map<string, boolean>([["WEIGHT", false]]);
FULLY_COVERED.mockReturnValue(false);
PROBE.mockResolvedValue(coverage);
const now = new Date("2026-05-17T12:00:00.000Z");
MEASUREMENT_FIND_MANY
// Weight (live) — multiple readings inside the window, gentle decline.
.mockResolvedValueOnce([
{
measuredAt: new Date("2026-05-04T08:00:00.000Z"),
value: 84.0,
source: "WITHINGS",
},
{
measuredAt: new Date("2026-05-09T08:00:00.000Z"),
value: 83.5,
source: "WITHINGS",
},
{
measuredAt: new Date("2026-05-14T08:00:00.000Z"),
value: 83.0,
source: "WITHINGS",
},
])
// BP-SYS source attribution.
.mockResolvedValueOnce([]);
const result = await computeUserHealthScoreFastPath({
userId: "user-no-height",
bpInTargetPct: null,
heightCm: null,
now,
coverage,
});
expect(result).not.toBeNull();
const weight = result?.components.weight;
// The pillar is populated and weighted even with no target.
expect(weight?.value).not.toBeNull();
expect(weight?.weight).toBeGreaterThan(0);
expect(weight?.source).not.toBe("none");
// The source reflects the contributing ingest path, not the empty state.
expect(weight?.source).toBe("withings");
});
});
describe("empty path", () => {
@@ -98,8 +98,34 @@ describe("weightTrendAlignment", () => {
expect(result!).toBeLessThan(50);
});
it("returns null when the target is missing", () => {
expect(weightTrendAlignment(weightSeries([80, 81]), null)).toBeNull();
it("scores the bare trend (non-null) when the target is missing but ≥2 readings exist", () => {
// No target → fall back to trend-only scoring instead of returning
// null, so a height-less user with weight data gets a populated pillar.
const result = weightTrendAlignment(weightSeries([80, 80.5, 81, 81.5]), null);
expect(result).not.toBeNull();
expect(result!).toBeGreaterThanOrEqual(0);
expect(result!).toBeLessThanOrEqual(100);
});
it("anchors a flat trend at 75 when there is no target", () => {
expect(weightTrendAlignment(weightSeries([80, 80, 80, 80]), null)).toBe(75);
});
it("rewards a gentle decline (>75) when there is no target", () => {
const result = weightTrendAlignment(weightSeries([84, 83.5, 83, 82.5]), null);
expect(result).not.toBeNull();
expect(result!).toBeGreaterThan(75);
});
it("penalises a sustained rise (<75) when there is no target", () => {
const result = weightTrendAlignment(weightSeries([80, 81, 82, 83, 84]), null);
expect(result).not.toBeNull();
expect(result!).toBeLessThan(75);
});
it("returns null with no target and fewer than two readings", () => {
expect(weightTrendAlignment(weightSeries([80]), null)).toBeNull();
expect(weightTrendAlignment([], null)).toBeNull();
});
it("returns null with fewer than two readings", () => {
+76 -7
View File
@@ -502,6 +502,21 @@ function toCanonicalSchedule(
export interface ComplianceIntakeInstant {
takenAt: Date | null;
skipped: boolean;
/**
* v1.15.10 — the dose slot this intake resolves (the canonical snapped
* instant, byte-identical to the engine occurrence's `.at`). Optional so
* every legacy caller / fixture that omits it keeps working; when present
* the open-dose selection in {@link buildCurrentCycle} uses it to skip a
* slot the user has already acted on (taken / skipped / auto-missed) so the
* card advances to the next genuinely-open slot rather than re-surfacing a
* resolved past dose.
*/
scheduledFor?: Date;
/**
* v1.15.10 — the cron-flagged forgotten miss. A resolved (auto-missed) slot
* is excluded from the open-dose search the same way a take / skip is.
*/
autoMissed?: boolean;
}
function intakeInstantsAtOrBefore(
@@ -845,6 +860,44 @@ export interface ComplianceDisplay {
* selection: a window holding zero expected (closed) doses means the
* percentage rows are vacuous and the card should show a neutral state.
*/
/**
* v1.15.10 — half-window an intake may sit from a slot's canonical instant
* and still count as "resolving" that slot. The intake write paths snap
* `scheduledFor` to the exact engine slot instant, so an exact (or
* sub-minute) match is the common case; the ±6h tolerance also catches an
* off-time take on a dense intraday cadence (e.g. a 07:00 dose logged 09:13)
* whose snapped slot is the 07:00 row but whose raw instant we compare
* defensively. It is the same ±half-gap radius the cadence pairer uses for a
* 12h-gap (twice-daily) med, floored so a single-dose-a-day cadence still
* matches its one slot.
*/
const OPEN_DOSE_RESOLVE_RADIUS_MS = 6 * 60 * 60 * 1000;
/**
* v1.15.10 — true when an intake event has already resolved the slot at
* `slotAt`. A resolved slot is one the user took, deliberately skipped, or
* the auto-miss cron flagged — any of which means the card must NOT keep
* surfacing that slot as the next dose. Matches on the snapped `scheduledFor`
* first (the canonical, exact path) and falls back to the take/skip instant
* within the resolve radius for rows that predate the snap or drifted.
*/
function slotIsResolved(
slotAt: Date,
intakes: ComplianceIntakeInstant[],
): boolean {
const slot = slotAt.getTime();
for (const e of intakes) {
const isResolved = e.skipped || e.autoMissed === true || e.takenAt !== null;
if (!isResolved) continue;
const ref = (e.scheduledFor ?? e.takenAt) as Date | undefined;
if (!ref) continue;
if (Math.abs(ref.getTime() - slot) <= OPEN_DOSE_RESOLVE_RADIUS_MS) {
return true;
}
}
return false;
}
export function buildCurrentCycle(
schedules: ComplianceSchedule[],
ctx: ComplianceMedicationContext,
@@ -854,8 +907,9 @@ export function buildCurrentCycle(
): CurrentCycle {
const recurrenceCtx = toRecurrenceCtx(ctx, "compliance-cycle");
const DAY_MS = 24 * 60 * 60 * 1000;
const allIntakes = intakes ?? [];
const lastIntakeAt =
lastNonSkippedTakenAt(intakes ?? []) ?? ctx.lastIntakeAt;
lastNonSkippedTakenAt(allIntakes) ?? ctx.lastIntakeAt;
// The open cycle's due instant + grace, picked as the SOONEST across every
// schedule. Two paths:
@@ -893,12 +947,27 @@ export function buildCurrentCycle(
consider(anchor, new Date(anchor.getTime() + graceMs));
continue;
}
const occ = nextOccurrenceAfter(
canonical,
new Date(now.getTime() - 1),
recurrenceCtx,
);
if (occ) consider(occ.at, occ.graceUntil);
// v1.15.10 — advance past slots the user has already resolved. The
// engine's `nextOccurrenceAfter` is purely time-anchored, so for a
// twice-daily med it surfaces today's 19:00 slot in the afternoon even
// after the user logged that dose early — the card then sticks on a
// resolved past/present slot ("träge"). Walk occurrences forward and pick
// the first one no intake has resolved (taken / skipped / auto-missed), so
// a med whose remaining slots today are all logged advances to tomorrow's
// first open slot. Bounded so a fully-logged-ahead history can't spin.
let after = new Date(now.getTime() - 1);
let picked: Occurrence | null = null;
for (let step = 0; step < 64; step++) {
const occ = nextOccurrenceAfter(canonical, after, recurrenceCtx);
if (!occ) break;
if (!slotIsResolved(occ.at, allIntakes)) {
picked = occ;
break;
}
// This slot is resolved — step strictly past it and keep looking.
after = new Date(occ.at.getTime());
}
if (picked) consider(picked.at, picked.graceUntil);
}
const hasClosedCycles = expectedShort > 0;
+54 -2
View File
@@ -186,6 +186,49 @@ export function coefficientOfVariation(values: number[]): number | null {
return stdev / Math.abs(mean);
}
/**
* Score the recent weight trend on its own, with no target band.
*
* Used when the user has weight readings but no derivable target (their
* profile has no `heightCm`, so `defaultWeightTargetFromHeight` returns
* null). Without a goal we can't say "closing the gap", but a sustained
* upward weight trend is the one direction that is broadly an adverse
* signal independent of any individual target, so we score on that
* asymmetry:
*
* - Flat / stable (slope ≈ 0) → 75 (maintenance is a solid result).
* - Gentle-to-steep decline (slope<0) → 75 → 100 (favourable; saturating).
* - Gentle-to-steep rise (slope>0) → 75 → 25 (unfavourable; saturating).
*
* The 75 anchor keeps a stable-but-noisy series mid-high (it lands at
* 75, not at the 50 "neither" midpoint the target-aware path uses) so a
* user simply maintaining weight isn't penalised for lacking a goal.
* The same `SATURATION = 0.05 kg/day` (≈ 1.5 kg/month) curve as the
* target-aware path keeps the two scoring modes numerically consistent.
*
* Returns null when fewer than two readings are supplied (caller already
* guards this, but the helper stays self-contained for direct tests).
*/
function bareWeightTrendScore(
series: Array<{ date: string; kg: number }>,
): number | null {
if (series.length < 2) return null;
const points = series.map((p) => ({ date: p.date, value: p.kg }));
const slope = linearRegressionSlope(points);
if (slope === null) return null;
if (slope === 0) return 75; // exactly stable → maintenance anchor
const SATURATION = 0.05;
const normalised = Math.tanh(Math.abs(slope) / SATURATION);
// Decline lifts the anchor toward 100; rise drops it toward 25. The
// 25-point span keeps the unfavourable floor honest (a steep sustained
// gain reads "concerning", not "failing") while the favourable ceiling
// reaches the full 100.
const score =
slope < 0 ? 75 + 25 * normalised : 75 - 50 * normalised;
return Math.round(Math.max(0, Math.min(100, score)));
}
/**
* Score how well the recent weight trend is closing the gap toward the
* target band [targetMin..targetMax].
@@ -196,13 +239,22 @@ export function coefficientOfVariation(values: number[]): number | null {
* doesn't read more positive than a 0.05 kg/day drop.
* - Below the band → an upward slope is closing.
*
* Insufficient data (< 2 readings, no target) → null.
* No target (no stored height → no BMI-22 fallback band) but ≥ 2
* readings → fall back to scoring the **bare trend** on its own, so a
* user with weight data still gets a populated, weighted pillar instead
* of an empty `{value:null, weight:0, source:"none"}`. See
* `bareWeightTrendScore` for the curve and its rationale. We do NOT
* fabricate a target from the user's own median and pretend it's a goal
* — the trend is scored honestly, without a target.
*
* Insufficient data (< 2 readings) → null regardless of target.
*/
export function weightTrendAlignment(
series: Array<{ date: string; kg: number }>,
target: { min: number; max: number } | null,
): number | null {
if (!target || series.length < 2) return null;
if (series.length < 2) return null;
if (!target) return bareWeightTrendScore(series);
const points = series.map((p) => ({ date: p.date, value: p.kg }));
// Latest reading drives the "are we currently in the band" check.
const sorted = [...points].sort((a, b) =>
@@ -118,3 +118,77 @@ describe("computeNextDueAt — rolling first dose", () => {
expect(next).toBeNull();
});
});
/**
* v1.15.10 BUG 2 — next-due must advance past slots the user has already
* resolved (taken / skipped / auto-missed) and surface the next genuinely
* OPEN slot, not re-surface a present/past resolved slot ("träge").
*
* Twice-daily med, 07:00 + 19:00 Berlin. In June Berlin is UTC+2, so the
* canonical slots are 05:00 UTC (07:00) and 17:00 UTC (19:00).
*/
describe("computeNextDueAt — skip resolved slots (twice-daily)", () => {
function twiceDaily(): WorkerScheduleRow {
return {
id: "sched-2x",
windowStart: "07:00",
windowEnd: "19:00",
daysOfWeek: null,
timesOfDay: ["07:00", "19:00"],
reminderGraceMinutes: null,
rrule: null,
rollingIntervalDays: null,
scheduleType: "SCHEDULED",
cyclicOnWeeks: null,
cyclicOffWeeks: null,
};
}
it("advances to tomorrow's 07:00 when both of today's slots are resolved", () => {
// Afternoon: 07:00 already past + logged, 19:00 logged early. Both today's
// slots resolved → next must be tomorrow 07:00, not today 19:00.
const now = d("2026-06-10T16:00:00Z"); // 18:00 Berlin, before the 19:00 slot
const next = computeNextDueAt({
medication: makeMedication(),
schedules: [twiceDaily()],
now,
userTz: BERLIN,
lastIntakeAt: d("2026-06-10T15:30:00Z"),
resolvedSlots: [
d("2026-06-10T05:00:00Z"), // today 07:00 Berlin
d("2026-06-10T17:00:00Z"), // today 19:00 Berlin
],
});
expect(next).not.toBeNull();
expect(berlinDay(next!)).toBe("2026-06-11");
// 07:00 Berlin = 05:00 UTC.
expect(next!.toISOString()).toBe("2026-06-11T05:00:00.000Z");
});
it("surfaces today's 19:00 when only the 07:00 slot is resolved", () => {
const now = d("2026-06-10T16:00:00Z"); // 18:00 Berlin
const next = computeNextDueAt({
medication: makeMedication(),
schedules: [twiceDaily()],
now,
userTz: BERLIN,
lastIntakeAt: d("2026-06-10T07:13:00Z"),
resolvedSlots: [d("2026-06-10T05:00:00Z")], // only today 07:00
});
expect(next).not.toBeNull();
expect(next!.toISOString()).toBe("2026-06-10T17:00:00.000Z"); // today 19:00
});
it("keeps the legacy purely-time-anchored next-due when no resolvedSlots passed", () => {
const now = d("2026-06-10T16:00:00Z"); // 18:00 Berlin → next slot is 19:00
const next = computeNextDueAt({
medication: makeMedication(),
schedules: [twiceDaily()],
now,
userTz: BERLIN,
lastIntakeAt: null,
});
expect(next).not.toBeNull();
expect(next!.toISOString()).toBe("2026-06-10T17:00:00.000Z"); // today 19:00
});
});
+51 -10
View File
@@ -536,6 +536,43 @@ export function pairDoses(
const centres = sorted.map(
(s) => (s.windowStart.getTime() + s.windowEnd.getTime()) / 2,
);
// Per-slot match index, pre-claimed by the exact-anchor pass below.
const matchFor: number[] = new Array(sorted.length).fill(-1);
// v1.15.10 BUG 3 — exact-anchor pass. An intake's `scheduledFor` is snapped
// to the canonical slot instant (the engine occurrence's `windowStart`) by
// the write path, so it unambiguously identifies WHICH slot it resolves.
// The legacy proximity pass below matches `takenAt` against the slot's
// *window centre*, which for a wide intraday window (a twice-daily med with
// a 07:00–19:00 window spans 12h → its 07:00 slot centres at 13:00, its
// 19:00 slot at 01:00 next day) lands between the real dose times: an
// off-time morning take then pairs to the wrong slot, a user-skip reads as
// taken, a late evening take reads as missed. Anchoring each event to the
// slot whose `windowStart` equals its `scheduledFor` first removes that
// ambiguity for every snapped row; rows that DON'T line up on a slot
// instant (legacy un-snapped data, sparse cadences logged off-day) fall
// through to the proximity pass unchanged.
const ANCHOR_EPSILON_MS = 60_000; // sub-minute DST / rounding slop
for (let i = 0; i < events.length; i++) {
if (claimed.has(i)) continue;
const evt = events[i];
const anchor = evt.scheduledFor.getTime();
let bestSlot = -1;
let bestDist = Infinity;
for (let s = 0; s < sorted.length; s++) {
if (matchFor[s] !== -1) continue;
const dist = Math.abs(sorted[s].windowStart.getTime() - anchor);
if (dist <= ANCHOR_EPSILON_MS && dist < bestDist) {
bestDist = dist;
bestSlot = s;
}
}
if (bestSlot !== -1) {
matchFor[bestSlot] = i;
claimed.add(i);
}
}
const result: PairedDose[] = [];
for (let s = 0; s < sorted.length; s++) {
@@ -553,16 +590,20 @@ export function pairDoses(
s < centres.length - 1 ? centres[s + 1] : null,
),
);
let bestIdx = -1;
let bestDist = Infinity;
for (let i = 0; i < events.length; i++) {
if (claimed.has(i)) continue;
const evt = events[i];
const t = (evt.takenAt ?? evt.scheduledFor).getTime();
const dist = Math.abs(t - centre);
if (dist <= radius && dist < bestDist) {
bestDist = dist;
bestIdx = i;
// Honour an exact-anchor claim from the first pass; otherwise fall back
// to the proximity match (legacy un-snapped rows / sparse off-day takes).
let bestIdx = matchFor[s];
if (bestIdx === -1) {
let bestDist = Infinity;
for (let i = 0; i < events.length; i++) {
if (claimed.has(i)) continue;
const evt = events[i];
const t = (evt.takenAt ?? evt.scheduledFor).getTime();
const dist = Math.abs(t - centre);
if (dist <= radius && dist < bestDist) {
bestDist = dist;
bestIdx = i;
}
}
}
+40 -4
View File
@@ -18,24 +18,60 @@ import {
} from "@/lib/medications/scheduling/worker-helpers";
import { nextOccurrenceAfter } from "@/lib/medications/scheduling/recurrence";
/**
* v1.15.10 — radius an intake may sit from a slot's canonical instant and
* still count as resolving it. Intake writes snap `scheduledFor` to the exact
* engine slot instant, so the match is normally exact; the ±6h tolerance is
* the same defensive half-gap the compliance open-dose search uses (a 12h-gap
* twice-daily med splits cleanly at ±6h).
*/
const RESOLVE_RADIUS_MS = 6 * 60 * 60 * 1000;
export function computeNextDueAt(input: {
medication: WorkerMedicationRow;
schedules: WorkerScheduleRow[];
now: Date;
userTz: string;
lastIntakeAt: Date | null;
/**
* v1.15.10 — slot instants the user has already acted on (taken / skipped /
* auto-missed). The next-due search skips any occurrence that matches one of
* these so a twice-daily med whose remaining slots today are all logged
* advances to the next genuinely-open slot (tomorrow's first dose) instead
* of re-surfacing a resolved present/past slot. Omit for the legacy
* purely-time-anchored next-due.
*/
resolvedSlots?: Date[];
}): Date | null {
const { medication, schedules, now, userTz, lastIntakeAt } = input;
if (schedules.length === 0) return null;
const resolved = input.resolvedSlots ?? [];
const isResolved = (slotAt: Date): boolean => {
const slot = slotAt.getTime();
for (const r of resolved) {
if (Math.abs(r.getTime() - slot) <= RESOLVE_RADIUS_MS) return true;
}
return false;
};
const ctx = buildRecurrenceContext({ medication, userTz, lastIntakeAt });
let earliest: Date | null = null;
for (const schedule of schedules) {
const canonical = buildCanonicalSchedule(schedule);
const next = nextOccurrenceAfter(canonical, now, ctx);
if (next === null) continue;
if (earliest === null || next.at.getTime() < earliest.getTime()) {
earliest = next.at;
// Walk forward past slots the user has already resolved. Bounded so a
// fully-logged-ahead history can't spin.
let after = now;
for (let step = 0; step < 64; step++) {
const next = nextOccurrenceAfter(canonical, after, ctx);
if (next === null) break;
if (!isResolved(next.at)) {
if (earliest === null || next.at.getTime() < earliest.getTime()) {
earliest = next.at;
}
break;
}
after = new Date(next.at.getTime());
}
}
return earliest;
+7
View File
@@ -30,6 +30,13 @@ export function formatTimeWindowRange(
): string {
const s = formatTimeWindowPart(start);
const e = formatTimeWindowPart(end);
// A degenerate window — start == end (a single target time, or a med with
// no real window span) — must read as ONE time, not "07:00 bis 07:00 Uhr" /
// "07:00 – 07:00". The "bis" / "–" separator is reserved for a genuine
// range. The DE branch keeps its "Uhr" suffix on the single time.
if (s === e) {
return locale === undefined || locale === "de" ? `${s} Uhr` : s;
}
if (locale === undefined || locale === "de") {
return `${s} bis ${e} Uhr`;
}