mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 20:42:43 +00:00
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:
46 files changed
+1802
-492
No files matched your search
@@ -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,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.
|
||||
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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",
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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`;
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user