chore(release): v1.4.27

v1.4.27 — Mobile capability and maintainer-finding cleanup.

ResponsiveSheet primitive (bottom-sheet on narrow viewports, centred dialog on desktop)
applied to BD, Weight, Glucose, Mood, Activity forms. Maintainer-finding cleanups across
Coach, Insights, Reports, Privacy. GeoLite2 offline geocoding with resilience layer
(graceful fallback when EULA not yet propagated). E2E gap-fill coverage. 450-line
release notes attached to the GitHub Release.
This commit is contained in:
Marc-André Bombeck authored and GitHub committed 2026-05-15 22:59:10 +02:00
1 parent d98e85cd03
commit 4f09c3f862
314 files changed
+26801 -4419

No files matched your search

+23 -12
View File
@@ -37,6 +37,7 @@ import { join } from "node:path";
const ROOT = join(__dirname, "../../..");
const DASHBOARD_PATH = join(ROOT, "src/app/page.tsx");
const INSIGHTS_PATH = join(ROOT, "src/app/insights/page.tsx");
const INSIGHTS_LAYOUT_PATH = join(ROOT, "src/app/insights/layout.tsx");
function load(path: string): string {
return readFileSync(path, "utf8");
@@ -64,27 +65,37 @@ describe("v1.4.19 A3 — dashboard polish", () => {
});
});
describe("v1.4.20 B2b — /insights wires the Coach drawer", () => {
it("imports <CoachDrawer> and mounts it at the bottom of the page", () => {
const src = load(INSIGHTS_PATH);
expect(src).toContain(
'from "@/components/insights/coach-panel/coach-drawer"',
describe("v1.4.27 R3d MB4 — the layout mounts the Coach drawer", () => {
it("the insights layout wraps children in <CoachLaunchProvider> and mounts the drawer", () => {
// v1.4.27 MB4 promoted the drawer mount from the mother page up to
// `/insights/layout.tsx` so a routed sub-page does not unmount the
// drawer. The layout owns the mount + the provider; the page (and
// every sub-page) consumes the launch context.
const layout = load(INSIGHTS_LAYOUT_PATH);
expect(layout).toContain(
'from "@/lib/insights/coach-launch-context"',
);
expect(src).toMatch(/<CoachDrawer\b[\s\S]*?\/>/);
expect(layout).toMatch(/<CoachLaunchProvider\b/);
expect(layout).toMatch(/<LayoutCoachMount\b/);
});
it("hero strip's onAskCoach handler opens the drawer with no prefill", () => {
it("hero strip's onAskCoach handler calls the launch context", () => {
const src = load(INSIGHTS_PATH);
// The hero supplies onAskCoach which sets coachPrefill to null and
// flips coachOpen.
// The hero supplies onAskCoach which now delegates to
// `coachLaunch.askCoach(prefill)` from the layout-level provider
// instead of toggling local state.
expect(src).toMatch(/<HeroStrip[\s\S]*?onAskCoach=\{/);
expect(src).toMatch(/<HeroStrip[\s\S]*?onPickPrompt=\{/);
expect(src).toContain("useCoachLaunch");
expect(src).toMatch(/coachLaunch\.askCoach/);
});
it("page owns coachOpen + coachPrefill state", () => {
it("the page no longer owns coachOpen + coachPrefill local state", () => {
const src = load(INSIGHTS_PATH);
expect(src).toContain("setCoachOpen");
expect(src).toContain("setCoachPrefill");
// The local useState pair retired with MB4. The launch context
// (mounted by the layout) now owns the open + prefill lifecycle.
expect(src).not.toContain("setCoachOpen");
expect(src).not.toContain("setCoachPrefill");
});
});
+189
View File
@@ -0,0 +1,189 @@
import type { Metadata } from "next";
import Link from "next/link";
/**
* v1.4.27 B3 — Public about / credits page.
*
* Two reasons this page exists:
*
* 1. The runtime image bundles MaxMind GeoLite2-City and
* GeoLite2-ASN databases for offline IP-to-location and
* IP-to-ASN lookups. The CC BY-SA 4.0 licence requires the
* attribution to be reachable from the running application,
* not just the source repository.
* 2. A general home for open-source credits as the project
* accumulates more third-party data sources (already true for
* the upcoming ICD-10 reference table in the iOS Health import
* flow).
*
* The page mirrors `/privacy` in layout and is reachable without a
* session — see `src/proxy.ts` PUBLIC_PATHS.
*
* Intentional: no TOC. The `/privacy` page carries a collapsible
* `<details>` table of contents because it has eleven numbered
* sections that benefit from skim navigation. `/about` is short-form
* (Project + Credits) and fits the fold on every viewport we ship to,
* so a TOC would cost a tap to expand for negligible payoff. The
* scroll-mt-28 anchors stay so deep-links into `#project` /
* `#credits` still clear the sticky header on iPhones with a notch.
*/
export const dynamic = "force-static";
export const revalidate = false;
const LAST_UPDATED = "2026-05-15";
export const metadata: Metadata = {
title: "About — HealthLog",
description:
"Open-source credits and third-party data attributions for the HealthLog project.",
robots: { index: true, follow: true },
};
function Section({
id,
title,
children,
}: {
id: string;
title: string;
children: React.ReactNode;
}) {
return (
<section id={id} className="space-y-3 scroll-mt-28">
<h2 className="text-xl font-semibold tracking-tight md:text-2xl">
{title}
</h2>
<div className="text-muted-foreground space-y-3 text-sm leading-relaxed md:text-base">
{children}
</div>
</section>
);
}
export default function AboutPage() {
return (
<div className="bg-background text-foreground min-h-dvh">
<header className="border-border/60 bg-background/80 sticky top-0 z-10 border-b backdrop-blur pt-[env(safe-area-inset-top)]">
<div className="mx-auto flex max-w-3xl items-center justify-between px-4 py-3 md:px-6">
<Link
href="/"
className="text-foreground hover:text-primary inline-flex min-h-11 items-center text-sm font-semibold tracking-tight"
>
HealthLog
</Link>
<Link
href="/auth/login"
className="text-muted-foreground hover:text-foreground inline-flex min-h-11 items-center text-sm"
>
Sign in
</Link>
</div>
</header>
<main
id="main-content"
className="mx-auto max-w-3xl space-y-10 px-4 py-8 md:px-6 md:py-12"
>
<div className="space-y-3">
<p className="text-muted-foreground text-xs tracking-wider uppercase">
About
</p>
<h1 className="text-3xl font-bold tracking-tight md:text-4xl">
About HealthLog
</h1>
<p className="text-muted-foreground text-sm">
Last updated: {LAST_UPDATED}
</p>
</div>
<Section id="project" title="Project">
<p>
HealthLog is an open-source, self-hostable personal-health-tracking
application. The source code lives at{" "}
<a
href="https://github.com/MBombeck/HealthLog"
target="_blank"
rel="noopener noreferrer"
className="text-primary hover:underline"
>
github.com/MBombeck/HealthLog
</a>{" "}
and is licensed under the GNU Affero General Public License v3.0.
</p>
</Section>
<Section id="credits" title="Credits">
<p>
HealthLog stands on a number of open-source libraries and public
data sources. The list below covers the third-party assets that
ship with the runtime image and whose licences require an explicit
attribution.
</p>
<h3 className="text-foreground text-base font-semibold md:text-lg">
MaxMind GeoLite2
</h3>
<p>
This product includes GeoLite2 data created by MaxMind, available
from{" "}
<a
href="https://www.maxmind.com"
target="_blank"
rel="noopener noreferrer"
className="text-primary hover:underline"
>
www.maxmind.com
</a>
. The bundled databases (
<code className="bg-muted rounded px-1 py-0.5 text-xs">
GeoLite2-City
</code>{" "}
and{" "}
<code className="bg-muted rounded px-1 py-0.5 text-xs">
GeoLite2-ASN
</code>
) power the offline IP-to-location and IP-to-carrier lookups that
decorate the admin login-overview audit table. They are
distributed under the{" "}
<a
href="https://creativecommons.org/licenses/by-sa/4.0/"
target="_blank"
rel="noopener noreferrer"
className="text-primary hover:underline"
>
Creative Commons Attribution-ShareAlike 4.0 International
License
</a>
.
</p>
</Section>
<footer
className="border-border/60 mt-12 border-t pt-6 text-xs text-muted-foreground"
data-slot="about-footer"
>
<p>
HealthLog — open source under{" "}
<a
href="https://github.com/MBombeck/HealthLog/blob/main/LICENSE"
target="_blank"
rel="noopener noreferrer"
className="hover:text-foreground hover:underline"
>
AGPL-3.0
</a>
. See{" "}
<Link
href="/privacy"
className="hover:text-foreground hover:underline"
>
privacy policy
</Link>
.
</p>
</footer>
</main>
</div>
);
}
+1 -1
View File
@@ -273,7 +273,7 @@ export default function AchievementsPage() {
if (authLoading || isLoading) {
return (
<div className="flex items-center justify-center py-20">
<Loader2 className="text-primary h-6 w-6 animate-spin" />
<Loader2 className="text-primary h-6 w-6 animate-spin motion-reduce:animate-none" />
</div>
);
}
+5
View File
@@ -136,6 +136,11 @@ export const GET = apiHandler(async (request: NextRequest) => {
action: true,
ipAddress: true,
location: true,
// v1.4.27 B3 — surface the carrier chip data alongside the
// existing location string. Both nullable so older rows render
// with the same dash that `location` falls back to.
asn: true,
carrier: true,
details: true,
createdAt: true,
user: {
@@ -3,7 +3,7 @@ import { apiHandler, requireAdmin } from "@/lib/api-handler";
import { annotate, getEvent } from "@/lib/logging/context";
import { apiSuccess } from "@/lib/api-response";
import { parseScheduleRecurrence } from "@/lib/medication-schedule";
import { dispatchNotification } from "@/lib/notifications/dispatcher";
import { dispatchLocalisedNotification } from "@/lib/notifications/dispatch-localised";
import { getUserTodayBounds, getDayOfWeekInTz } from "@/lib/timezone";
export const dynamic = "force-dynamic";
@@ -138,20 +138,36 @@ export const POST = apiHandler(async () => {
const timeWindow = `${schedule.windowStart}–${schedule.windowEnd}`;
try {
// v1.4.27 F21 — each notification renders in the affected
// user's `User.locale` (the recipient is the user whose dose
// is overdue, not the admin running the check).
// `dispatchLocalisedNotification` resolves the locale from
// the userId inside the helper.
if (status === "missed") {
await dispatchNotification({
eventType: "MEDICATION_REMINDER",
await dispatchLocalisedNotification({
userId: med.user.id,
title: `Verpasst: ${med.name}`,
message: `<b>${med.name}</b> (${doseInfo}, ${timeWindow}) wurde als verpasst markiert.`,
eventType: "MEDICATION_REMINDER",
titleKey: "notifications.admin.reminderCheckMissedTitle",
messageKey: "notifications.admin.reminderCheckMissedBody",
params: {
medication: med.name,
dose: doseInfo,
window: timeWindow,
},
metadata: { medicationId: med.id },
});
} else {
await dispatchNotification({
eventType: "MEDICATION_REMINDER",
await dispatchLocalisedNotification({
userId: med.user.id,
title: `Erinnerung: ${med.name}`,
message: `Erinnerung: <b>${med.name}</b> (${doseInfo}, ${timeWindow}) wurde noch nicht eingenommen. Seit ${minutesPastEnd} Min überfällig.`,
eventType: "MEDICATION_REMINDER",
titleKey: "notifications.admin.reminderCheckOverdueTitle",
messageKey: "notifications.admin.reminderCheckOverdueBody",
params: {
medication: med.name,
dose: doseInfo,
window: timeWindow,
minutes: minutesPastEnd,
},
metadata: { medicationId: med.id },
});
}
+24 -3
View File
@@ -12,6 +12,8 @@ import type {
ChannelType,
NotificationPayload,
} from "@/lib/notifications/types";
import { defaultLocale, type Locale } from "@/lib/i18n/config";
import { getServerTranslator } from "@/lib/i18n/server-translator";
export const dynamic = "force-dynamic";
@@ -59,6 +61,11 @@ async function ensureTelegramChannel(userId: string): Promise<boolean> {
return true;
}
function isLocale(value: string | null | undefined): value is Locale {
return value === "de" || value === "en" || value === "fr" ||
value === "es" || value === "it" || value === "pl";
}
export const POST = apiHandler(async () => {
const { user } = await requireAdmin();
annotate({ action: { name: "admin.notifications.test" } });
@@ -84,12 +91,26 @@ export const POST = apiHandler(async () => {
});
}
// v1.4.27 F21 — resolve the admin's persisted locale so the test
// notification arrives in their preferred language. The test route
// talks to the senders directly (not the dispatcher) because it
// needs per-channel success / error feedback for the UI, so we
// compose the payload locally rather than via
// `dispatchLocalisedNotification`.
const adminLocaleRow = await prisma.user.findUnique({
where: { id: user.id },
select: { locale: true },
});
const adminLocale: Locale = isLocale(adminLocaleRow?.locale)
? adminLocaleRow.locale
: defaultLocale;
const t = getServerTranslator(adminLocale).t;
const payload: NotificationPayload = {
eventType: "SYSTEM_ALERT",
userId: user.id,
title: "Test-Notification",
message:
"<b>HealthLog Test:</b> If you see this message, your notifications are working!",
title: t("notifications.admin.testNotificationTitle"),
message: t("notifications.admin.testNotificationBody"),
};
const results: Array<{
-46
View File
@@ -1,46 +0,0 @@
import { prisma } from "@/lib/db";
import { apiHandler, requireAuth } from "@/lib/api-handler";
import { annotate } from "@/lib/logging/context";
import { apiSuccess } from "@/lib/api-response";
import { NextRequest } from "next/server";
/**
* Get audit log entries for the current user.
* Query params: limit (default 50, max 200), offset (default 0)
*/
export const GET = apiHandler(async (request: NextRequest) => {
const { user } = await requireAuth();
annotate({ action: { name: "audit-log.list" } });
const { searchParams } = new URL(request.url);
const limit = Math.min(
Math.max(1, parseInt(searchParams.get("limit") ?? "50", 10) || 50),
200,
);
const offset = Math.max(
0,
parseInt(searchParams.get("offset") ?? "0", 10) || 0,
);
const [entries, total] = await Promise.all([
prisma.auditLog.findMany({
where: { userId: user.id },
orderBy: { createdAt: "desc" },
take: limit,
skip: offset,
select: {
id: true,
action: true,
ipAddress: true,
location: true,
details: true,
createdAt: true,
},
}),
prisma.auditLog.count({
where: { userId: user.id },
}),
]);
return apiSuccess({ entries, meta: { total, limit, offset } });
});
@@ -77,7 +77,6 @@ vi.mock("@/lib/insights/features", () => ({
}));
vi.mock("@/lib/insights/prompt", () => ({
getInsightsSystemPrompt: vi.fn(() => "system"),
buildUserPrompt: vi.fn(() => "user"),
}));
@@ -9,7 +9,7 @@ vi.mock("@/lib/api-handler", () => ({
vi.mock("@/lib/db", () => ({
prisma: {
user: { findMany: vi.fn() },
user: { findMany: vi.fn(), findUnique: vi.fn() },
},
}));
@@ -21,6 +21,14 @@ vi.mock("@/lib/notifications/dispatcher", () => ({
dispatchNotification: vi.fn().mockResolvedValue(undefined),
}));
// v1.4.27 F21 — the deploy-webhook route now wraps every admin alert
// in `dispatchLocalisedNotification`, which delegates to the base
// dispatcher. Mock the localised helper so the route-level tests stay
// hermetic and we can assert recipient-specific calls directly.
vi.mock("@/lib/notifications/dispatch-localised", () => ({
dispatchLocalisedNotification: vi.fn().mockResolvedValue(undefined),
}));
vi.mock("@/lib/rate-limit", () => ({
checkRateLimit: vi.fn(),
rateLimitHeaders: vi.fn(() => ({})),
@@ -38,6 +46,7 @@ import { POST, GET } from "../route";
import { prisma } from "@/lib/db";
import { auditLog } from "@/lib/auth/audit";
import { dispatchNotification } from "@/lib/notifications/dispatcher";
import { dispatchLocalisedNotification } from "@/lib/notifications/dispatch-localised";
import { checkRateLimit } from "@/lib/rate-limit";
import { getEvent } from "@/lib/logging/context";
@@ -142,10 +151,11 @@ describe("POST /api/internal/deploy-webhook", () => {
}),
}),
);
expect(dispatchLocalisedNotification).not.toHaveBeenCalled();
expect(dispatchNotification).not.toHaveBeenCalled();
});
it("failure payload writes system.deploy.failure audit row AND fans a SYSTEM_ALERT to every admin", async () => {
it("failure payload writes system.deploy.failure audit row AND fans a localised SYSTEM_ALERT to every admin", async () => {
vi.mocked(prisma.user.findMany).mockResolvedValueOnce([
{ id: "admin-1" },
{ id: "admin-2" },
@@ -176,20 +186,30 @@ describe("POST /api/internal/deploy-webhook", () => {
}),
);
expect(dispatchNotification).toHaveBeenCalledTimes(2);
expect(dispatchNotification).toHaveBeenCalledWith(
// v1.4.27 F21 — the route now wraps every admin alert in
// `dispatchLocalisedNotification`, which composes the title and
// body from translation keys against the admin's locale before
// delegating to the base dispatcher. The test asserts the per-admin
// call carries the right translation keys and params; the actual
// string composition is covered in admin-locale.test.ts.
expect(dispatchLocalisedNotification).toHaveBeenCalledTimes(2);
expect(dispatchLocalisedNotification).toHaveBeenCalledWith(
expect.objectContaining({
eventType: "SYSTEM_ALERT",
userId: "admin-1",
title: expect.stringContaining("Deploy failed"),
message: expect.stringContaining("container exited 137"),
titleKey: "notifications.admin.deployFailedTitle",
messageKey: "notifications.admin.deployFailedBody",
params: expect.objectContaining({
application: "HealthLog",
error: "container exited 137",
deployment: "dep-456",
}),
metadata: expect.objectContaining({
source: "deploy-webhook",
deploymentUuid: "dep-456",
}),
}),
);
expect(dispatchNotification).toHaveBeenCalledWith(
expect(dispatchLocalisedNotification).toHaveBeenCalledWith(
expect.objectContaining({ userId: "admin-2" }),
);
});
@@ -208,7 +228,7 @@ describe("POST /api/internal/deploy-webhook", () => {
"system.deploy.unknown",
expect.any(Object),
);
expect(dispatchNotification).not.toHaveBeenCalled();
expect(dispatchLocalisedNotification).not.toHaveBeenCalled();
});
it("emits a Wide Event warning when a deploy fails but no admin user exists", async () => {
@@ -229,7 +249,7 @@ describe("POST /api/internal/deploy-webhook", () => {
expect(addWarning).toHaveBeenCalledWith(
expect.stringMatching(/no admin user/i),
);
expect(dispatchNotification).not.toHaveBeenCalled();
expect(dispatchLocalisedNotification).not.toHaveBeenCalled();
});
it("returns 429 and skips audit + alert when rate-limited", async () => {
@@ -247,7 +267,7 @@ describe("POST /api/internal/deploy-webhook", () => {
);
expect(res.status).toBe(429);
expect(auditLog).not.toHaveBeenCalled();
expect(dispatchNotification).not.toHaveBeenCalled();
expect(dispatchLocalisedNotification).not.toHaveBeenCalled();
});
it("tolerates malformed (non-string) status fields by treating them as unknown", async () => {
@@ -257,7 +277,7 @@ describe("POST /api/internal/deploy-webhook", () => {
expect(res.status).toBe(200);
const body = (await res.json()) as { outcome: string };
expect(body.outcome).toBe("unknown");
expect(dispatchNotification).not.toHaveBeenCalled();
expect(dispatchLocalisedNotification).not.toHaveBeenCalled();
});
it("preserves the full Coolify payload in the audit-log raw field for forward compat", async () => {
+17 -15
View File
@@ -5,7 +5,7 @@ import { annotate, getEvent } from "@/lib/logging/context";
import { auditLog } from "@/lib/auth/audit";
import { checkRateLimit, rateLimitHeaders } from "@/lib/rate-limit";
import { getClientIp, safeJson } from "@/lib/api-response";
import { dispatchNotification } from "@/lib/notifications/dispatcher";
import { dispatchLocalisedNotification } from "@/lib/notifications/dispatch-localised";
import { prisma } from "@/lib/db";
/**
@@ -119,22 +119,24 @@ async function notifyAdminsOfFailure(event: NormalizedEvent): Promise<void> {
return;
}
const title = `Deploy failed: ${event.applicationName}`;
const message = [
`The Coolify deploy for ${event.applicationName} reported a failure.`,
event.error ? `Error: ${event.error}` : null,
event.deploymentUuid ? `Deployment: ${event.deploymentUuid}` : null,
"Logs: https://apps-01.bombeck.io",
]
.filter((line): line is string => line !== null)
.join("\n");
// v1.4.27 F21 — each admin sees the alert in their own User.locale.
// The body is composed inside `dispatchLocalisedNotification` from
// translation keys; the static "Error:" / "Deployment:" / "Logs:"
// labels are emitted as a single param-substituted body key so all
// six locale bundles can render them natively. The deployment-uuid
// and error lines fall back to an empty string when missing so the
// surrounding template still composes cleanly.
for (const admin of admins) {
await dispatchNotification({
eventType: "SYSTEM_ALERT",
await dispatchLocalisedNotification({
userId: admin.id,
title,
message,
titleKey: "notifications.admin.deployFailedTitle",
messageKey: "notifications.admin.deployFailedBody",
params: {
application: event.applicationName,
error: event.error ?? "",
deployment: event.deploymentUuid ?? "",
logsUrl: "https://apps-01.bombeck.io",
},
metadata: {
source: "deploy-webhook",
applicationName: event.applicationName,
@@ -0,0 +1,202 @@
/**
* v1.4.27 F21 — settings/telegram/test locale path.
*
* Asserts the per-user Telegram test message renders in the user's
* persisted `User.locale` rather than the hardcoded English string
* the pre-fix route used. The actual translator behaviour is covered
* by `notifications/__tests__/admin-locale.test.ts`; this file is
* the route-level smoke test that confirms the wiring.
*/
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
vi.mock("@/lib/api-handler", () => ({
apiHandler: <T extends (...args: unknown[]) => unknown>(fn: T) => fn,
requireAuth: vi.fn(),
}));
vi.mock("@/lib/db", () => ({
prisma: {
user: { findUnique: vi.fn() },
},
}));
vi.mock("@/lib/api-response", () => ({
apiSuccess: (data: unknown) => ({ ok: true, data }),
apiError: (message: string, status: number) => ({
ok: false,
error: message,
status,
}),
}));
vi.mock("@/lib/crypto", () => ({
decrypt: (s: string) => `decrypted(${s})`,
}));
const sendTelegramMessageMock = vi.fn();
vi.mock("@/lib/telegram", () => ({
sendTelegramMessage: (...args: unknown[]) => sendTelegramMessageMock(...args),
}));
vi.mock("@/lib/rate-limit", () => ({
checkRateLimit: vi.fn(),
}));
vi.mock("@/lib/logging/context", () => ({
annotate: vi.fn(),
}));
const tCalls: Array<{ locale: string; key: string }> = [];
vi.mock("@/lib/i18n/server-translator", () => ({
getServerTranslator: (locale: string) => ({
locale,
t: (key: string) => {
tCalls.push({ locale, key });
const fakeStrings: Record<string, Record<string, string>> = {
de: {
"notifications.user.telegramTestBody":
"HealthLog: Verbindung hergestellt. Telegram-Benachrichtigungen sind aktiv.",
},
en: {
"notifications.user.telegramTestBody":
"HealthLog: connection successful. Telegram notifications are active.",
},
fr: {
"notifications.user.telegramTestBody":
"HealthLog: connexion réussie. Les notifications Telegram sont actives.",
},
};
return (fakeStrings[locale] ?? fakeStrings.en)[key] ?? key;
},
}),
}));
import { POST } from "../telegram/test/route";
import { prisma } from "@/lib/db";
import { requireAuth } from "@/lib/api-handler";
import { checkRateLimit } from "@/lib/rate-limit";
beforeEach(() => {
vi.resetAllMocks();
tCalls.length = 0;
vi.mocked(requireAuth).mockResolvedValue({
user: { id: "user-1" },
} as never);
vi.mocked(checkRateLimit).mockResolvedValue({
allowed: true,
remaining: 5,
resetAt: Date.now() + 60_000,
} as never);
sendTelegramMessageMock.mockResolvedValue({ ok: true });
});
afterEach(() => {
vi.clearAllMocks();
});
describe("POST /api/settings/telegram/test — locale-aware message", () => {
it("sends the German body when User.locale=de", async () => {
vi.mocked(prisma.user.findUnique).mockResolvedValueOnce({
telegramBotToken: "enc-token",
telegramChatId: "chat-1",
locale: "de",
} as never);
const result = await (POST as () => Promise<unknown>)();
expect(sendTelegramMessageMock).toHaveBeenCalledTimes(1);
const [, , body] = sendTelegramMessageMock.mock.calls[0];
expect(body).toBe(
"HealthLog: Verbindung hergestellt. Telegram-Benachrichtigungen sind aktiv.",
);
expect(tCalls).toEqual([
{
locale: "de",
key: "notifications.user.telegramTestBody",
},
]);
expect((result as { ok: boolean }).ok).toBe(true);
});
it("sends the French body when User.locale=fr", async () => {
vi.mocked(prisma.user.findUnique).mockResolvedValueOnce({
telegramBotToken: "enc-token",
telegramChatId: "chat-1",
locale: "fr",
} as never);
await (POST as () => Promise<unknown>)();
const [, , body] = sendTelegramMessageMock.mock.calls[0];
expect(body).toBe(
"HealthLog: connexion réussie. Les notifications Telegram sont actives.",
);
});
it("falls back to the default locale (en) when User.locale is null", async () => {
vi.mocked(prisma.user.findUnique).mockResolvedValueOnce({
telegramBotToken: "enc-token",
telegramChatId: "chat-1",
locale: null,
} as never);
await (POST as () => Promise<unknown>)();
const [, , body] = sendTelegramMessageMock.mock.calls[0];
expect(body).toBe(
"HealthLog: connection successful. Telegram notifications are active.",
);
expect(tCalls[0].locale).toBe("en");
});
it("falls back to the default locale (en) when User.locale is unsupported", async () => {
vi.mocked(prisma.user.findUnique).mockResolvedValueOnce({
telegramBotToken: "enc-token",
telegramChatId: "chat-1",
locale: "klingon",
} as never);
await (POST as () => Promise<unknown>)();
expect(tCalls[0].locale).toBe("en");
});
it("returns a 422 error when bot token is not configured", async () => {
vi.mocked(prisma.user.findUnique).mockResolvedValueOnce({
telegramBotToken: null,
telegramChatId: null,
locale: "de",
} as never);
const result = await (POST as () => Promise<unknown>)();
expect(sendTelegramMessageMock).not.toHaveBeenCalled();
expect((result as { status: number }).status).toBe(422);
});
it("returns a 429 error when rate-limited", async () => {
vi.mocked(checkRateLimit).mockResolvedValueOnce({
allowed: false,
remaining: 0,
resetAt: Date.now() + 60_000,
} as never);
const result = await (POST as () => Promise<unknown>)();
expect(sendTelegramMessageMock).not.toHaveBeenCalled();
expect((result as { status: number }).status).toBe(429);
});
it("returns a 422 error when the Telegram send fails", async () => {
vi.mocked(prisma.user.findUnique).mockResolvedValueOnce({
telegramBotToken: "enc-token",
telegramChatId: "chat-1",
locale: "en",
} as never);
sendTelegramMessageMock.mockResolvedValueOnce({ ok: false });
const result = await (POST as () => Promise<unknown>)();
expect((result as { status: number }).status).toBe(422);
});
});
+18 -2
View File
@@ -5,6 +5,13 @@ import { sendTelegramMessage } from "@/lib/telegram";
import { checkRateLimit } from "@/lib/rate-limit";
import { apiHandler, requireAuth } from "@/lib/api-handler";
import { annotate } from "@/lib/logging/context";
import { defaultLocale, type Locale } from "@/lib/i18n/config";
import { getServerTranslator } from "@/lib/i18n/server-translator";
function isLocale(value: string | null | undefined): value is Locale {
return value === "de" || value === "en" || value === "fr" ||
value === "es" || value === "it" || value === "pl";
}
/**
* Send a test Telegram message to verify the bot token and chat ID.
@@ -19,18 +26,27 @@ export const POST = apiHandler(async () => {
const dbUser = await prisma.user.findUnique({
where: { id: user.id },
select: { telegramBotToken: true, telegramChatId: true },
select: { telegramBotToken: true, telegramChatId: true, locale: true },
});
if (!dbUser?.telegramBotToken || !dbUser?.telegramChatId) {
return apiError("Bot token and chat ID must be saved first", 422);
}
// v1.4.27 F21 — the test body now reads from the user's persisted
// locale rather than the hardcoded English string. The route talks
// to `sendTelegramMessage` directly (it needs the boolean send
// result for the UI) so we compose the body via the server
// translator rather than going through the dispatcher.
const userLocale: Locale = isLocale(dbUser.locale)
? dbUser.locale
: defaultLocale;
const t = getServerTranslator(userLocale).t;
const botToken = decrypt(dbUser.telegramBotToken);
const result = await sendTelegramMessage(
botToken,
dbUser.telegramChatId,
"HealthLog: Connection successful! Telegram notifications are active.",
t("notifications.user.telegramTestBody"),
);
if (!result.ok) {
@@ -4,11 +4,17 @@ vi.mock("@/lib/api-handler", () => ({
apiHandler: <T extends (...args: unknown[]) => unknown>(fn: T) => fn,
}));
const offlineGeoReadyMock = vi.fn(() => false);
vi.mock("@/lib/geo", () => ({
offlineGeoReady: () => offlineGeoReadyMock(),
}));
import { GET } from "../route";
beforeEach(() => {
delete process.env.NEXT_PUBLIC_APP_BUILD_SHA;
delete process.env.NEXT_PUBLIC_APP_BUILT_AT;
offlineGeoReadyMock.mockReturnValue(false);
});
interface VersionEnvelope {
@@ -20,6 +26,7 @@ interface VersionEnvelope {
repository: string;
changelog: string;
docs: string;
offlineGeoEnabled: boolean;
};
}
@@ -49,4 +56,18 @@ describe("GET /api/version", () => {
expect(body.data.buildSha).toBe("abc1234");
expect(body.data.builtAt).toBe("2026-05-08T12:00:00.000Z");
});
it("reports offlineGeoEnabled=false when the GeoLite2 databases are absent", async () => {
offlineGeoReadyMock.mockReturnValue(false);
const response = await (GET as unknown as () => Promise<Response>)();
const body = (await response.json()) as VersionEnvelope;
expect(body.data.offlineGeoEnabled).toBe(false);
});
it("reports offlineGeoEnabled=true when the GeoLite2 databases are present", async () => {
offlineGeoReadyMock.mockReturnValue(true);
const response = await (GET as unknown as () => Promise<Response>)();
const body = (await response.json()) as VersionEnvelope;
expect(body.data.offlineGeoEnabled).toBe(true);
});
});
+20 -4
View File
@@ -1,16 +1,24 @@
import { apiHandler } from "@/lib/api-handler";
import { apiSuccess } from "@/lib/api-response";
import { offlineGeoReady } from "@/lib/geo";
import packageJson from "../../../../package.json";
export const dynamic = "force-static";
export const revalidate = false;
// v1.4.27 R5 — `offlineGeoEnabled` reads from the same source the geo
// resolver uses for the runtime fallback gate, so the public state and
// the runtime behaviour cannot disagree. The check hits the filesystem
// (`existsSync` on the `.empty` marker + the City MMDB) which forces
// the route to be dynamic — the previous `force-static` setting would
// have frozen the answer at build time.
export const dynamic = "force-dynamic";
/**
* GET /api/version
*
* Public endpoint exposing the running build's version. Used by the
* Settings → About surface, the footer of every page, and the
* "Check for updates" button (which compares against the GHCR API).
* Settings → About surface, the footer of every page, the
* "Check for updates" button (which compares against the GHCR API),
* and the admin status-summary row that surfaces the offline-geo
* availability state.
*
* The build SHA and built-at timestamp come from environment variables
* baked at image build time:
@@ -21,11 +29,18 @@ export const revalidate = false;
*
* For local `pnpm dev` neither is set; the route returns `null` and
* the UI falls back to "development" wording.
*
* `offlineGeoEnabled` is `true` when the GeoLite2-City MMDB is present
* at the configured `GEOLITE2_DIR` and no `.empty` marker is set.
* When `false` the runtime falls back to the online `ipwho.is`
* provider and the resolver emits a one-shot admin notification on
* first use.
*/
export const GET = apiHandler(async () => {
const version = packageJson.version;
const buildSha = process.env.NEXT_PUBLIC_APP_BUILD_SHA?.trim() || null;
const builtAt = process.env.NEXT_PUBLIC_APP_BUILT_AT?.trim() || null;
const offlineGeoEnabled = offlineGeoReady();
return apiSuccess({
version,
@@ -35,5 +50,6 @@ export const GET = apiHandler(async () => {
repository: "https://github.com/MBombeck/HealthLog",
changelog: "https://github.com/MBombeck/HealthLog/blob/main/CHANGELOG.md",
docs: "https://docs.healthlog.dev",
offlineGeoEnabled,
});
});
@@ -0,0 +1,211 @@
/**
* Unit suite for `GET /api/workouts` — the canonical-dedup contract.
*
* v1.4.27 B7 / BL-P2-3 — wires `pickCanonicalWorkout()` into the read
* path. The test pins the contract:
* - twin workouts on the same `(sportType, ±5 min)` cluster
* collapse to a single row;
* - the source ladder picks APPLE_HEALTH over WITHINGS over MANUAL;
* - the `meta.droppedDuplicates` count reflects the diff between
* the raw fetch and the canonical subset.
*/
import { describe, expect, it, vi, beforeEach } from "vitest";
import { NextRequest } from "next/server";
vi.mock("@/lib/db", () => ({
prisma: {
workout: {
findMany: vi.fn(),
},
},
}));
vi.mock("@/lib/auth/session", () => ({ getSession: vi.fn() }));
vi.mock("@/lib/auth/audit", () => ({
auditLog: vi.fn().mockResolvedValue(undefined),
}));
vi.mock("@/lib/logging/transports", () => ({ emitIfSampled: vi.fn() }));
vi.mock("next/headers", () => ({
headers: vi.fn(async () => ({ get: () => null })),
cookies: vi.fn(async () => ({
get: () => undefined,
set: () => {},
delete: () => {},
})),
}));
import { GET } from "../route";
import { prisma } from "@/lib/db";
import { getSession } from "@/lib/auth/session";
const SESSION_OK = {
session: { id: "sess-1", expiresAt: new Date(Date.now() + 3_600_000) },
user: { id: "user-1", username: "marc", role: "USER" as const },
};
function makeRequest(query = ""): NextRequest {
return new NextRequest(`http://localhost/api/workouts${query}`, {
method: "GET",
});
}
interface FakeWorkoutRow {
id: string;
source: string;
externalId: string | null;
sportType: string;
startedAt: Date;
endedAt: Date;
durationSec: number;
distanceMeters: number | null;
avgHeartRate: number | null;
maxHeartRate: number | null;
energyKcal: number | null;
createdAt: Date;
}
function makeRow(
id: string,
source: string,
startedAt: string,
sportType = "RUNNING",
): FakeWorkoutRow {
const start = new Date(startedAt);
return {
id,
source,
externalId: source === "MANUAL" ? null : `ext-${id}`,
sportType,
startedAt: start,
endedAt: new Date(start.getTime() + 30 * 60_000),
durationSec: 1800,
distanceMeters: 5000,
avgHeartRate: 145,
maxHeartRate: 170,
energyKcal: 320,
createdAt: start,
};
}
describe("GET /api/workouts — canonical dedup", () => {
beforeEach(() => {
vi.clearAllMocks();
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
});
it("collapses an APPLE_HEALTH + WITHINGS twin to the Apple row", async () => {
const apple = makeRow("w-apple", "APPLE_HEALTH", "2026-05-15T07:00:00Z");
const withings = makeRow("w-withings", "WITHINGS", "2026-05-15T07:01:30Z");
vi.mocked(prisma.workout.findMany).mockResolvedValueOnce([
apple,
withings,
] as never);
const res = await GET(makeRequest());
const body = await res.json();
expect(res.status).toBe(200);
expect(body.data.workouts).toHaveLength(1);
expect(body.data.workouts[0].id).toBe("w-apple");
expect(body.data.meta.droppedDuplicates).toBe(1);
expect(body.data.meta.clusters).toBe(1);
});
it("keeps two workouts when they are outside the proximity window", async () => {
const morning = makeRow("w-am", "APPLE_HEALTH", "2026-05-15T07:00:00Z");
const evening = makeRow("w-pm", "APPLE_HEALTH", "2026-05-15T18:00:00Z");
vi.mocked(prisma.workout.findMany).mockResolvedValueOnce([
morning,
evening,
] as never);
const res = await GET(makeRequest());
const body = await res.json();
expect(body.data.workouts).toHaveLength(2);
expect(body.data.meta.droppedDuplicates).toBe(0);
expect(body.data.meta.clusters).toBe(2);
});
it("keeps two workouts when sportType differs at the same instant", async () => {
const run = makeRow("w-run", "APPLE_HEALTH", "2026-05-15T07:00:00Z", "RUNNING");
const walk = makeRow("w-walk", "APPLE_HEALTH", "2026-05-15T07:00:00Z", "WALKING");
vi.mocked(prisma.workout.findMany).mockResolvedValueOnce([
run,
walk,
] as never);
const res = await GET(makeRequest());
const body = await res.json();
expect(body.data.workouts).toHaveLength(2);
expect(body.data.meta.droppedDuplicates).toBe(0);
});
it("honours the limit query parameter after dedup", async () => {
const rows = Array.from({ length: 8 }, (_, i) =>
makeRow(
`w-${i}`,
"APPLE_HEALTH",
new Date(2026, 4, 15, 7 + i, 0, 0).toISOString(),
),
);
vi.mocked(prisma.workout.findMany).mockResolvedValueOnce(rows as never);
const res = await GET(makeRequest("?limit=3"));
const body = await res.json();
expect(body.data.workouts).toHaveLength(3);
expect(body.data.meta.limit).toBe(3);
});
it("paginates across cluster boundaries without double-counting deduped rows", async () => {
// Eight twin clusters: each cluster has an APPLE_HEALTH + WITHINGS
// row within the 5 min window. The picker collapses each cluster to
// its Apple row, so the canonical projection has 8 rows. Page 1
// (offset=0, limit=4) must yield Apple rows for clusters 7–4
// (descending). Page 2 (offset=4, limit=4) must yield Apple rows
// for clusters 3–0 with no overlap and no gaps. `meta.total` must
// reflect the deduped count (8), not the raw row count (16).
const rows: FakeWorkoutRow[] = [];
for (let i = 0; i < 8; i++) {
const start = new Date(2026, 4, 15, 7 + i, 0, 0).toISOString();
const start2 = new Date(2026, 4, 15, 7 + i, 1, 30).toISOString();
rows.push(makeRow(`apple-${i}`, "APPLE_HEALTH", start));
rows.push(makeRow(`withings-${i}`, "WITHINGS", start2));
}
// Mock returns the full filtered set; the route paginates after
// dedup.
vi.mocked(prisma.workout.findMany).mockResolvedValue(rows as never);
const page1 = await GET(makeRequest("?limit=4&offset=0"));
const page1Body = await page1.json();
const page2 = await GET(makeRequest("?limit=4&offset=4"));
const page2Body = await page2.json();
expect(page1Body.data.workouts).toHaveLength(4);
expect(page2Body.data.workouts).toHaveLength(4);
expect(page1Body.data.meta.total).toBe(8);
expect(page2Body.data.meta.total).toBe(8);
expect(page1Body.data.meta.droppedDuplicates).toBe(8);
expect(page2Body.data.meta.droppedDuplicates).toBe(8);
const page1Ids = page1Body.data.workouts.map((w: { id: string }) => w.id);
const page2Ids = page2Body.data.workouts.map((w: { id: string }) => w.id);
// No overlap across the page boundary.
expect(page1Ids.filter((id: string) => page2Ids.includes(id))).toEqual([]);
// All eight clusters are covered between the two pages.
expect([...page1Ids, ...page2Ids].sort()).toEqual(
Array.from({ length: 8 }, (_, i) => `apple-${i}`).sort(),
);
// Descending order is preserved within each page.
expect(page1Ids).toEqual(["apple-7", "apple-6", "apple-5", "apple-4"]);
expect(page2Ids).toEqual(["apple-3", "apple-2", "apple-1", "apple-0"]);
});
});
+42 -12
View File
@@ -356,21 +356,51 @@ async function postBatch(request: NextRequest): Promise<Response> {
// is NULL-distinct, so two manual entries on the same instant
// would both insert. We look these up by the deterministic
// (userId, source, startedAt, sportType) tuple for each row.
const manualMatches = await Promise.all(
withoutExternal.map((p) =>
tx.workout.findFirst({
where: {
userId: user.id,
//
// v1.4.27 B7 / BL-code-M3 — single batched findMany with one
// OR-row per entry instead of N serial findFirst probes. For
// a 100-row batch this cuts the per-batch round-trip count
// from 1 + N to 2; the per-entry "most-recent createdAt"
// tie-break is preserved by sorting the returned rows in
// memory and keying on the deterministic tuple.
type ManualMatch = { id: string; createdAt: Date };
const manualMatchesById = new Map<string, ManualMatch>();
if (withoutExternal.length > 0) {
const manualRows = await tx.workout.findMany({
where: {
userId: user.id,
route: null,
OR: withoutExternal.map((p) => ({
source: p.row.source,
startedAt: p.row.startedAt as Date,
sportType: p.row.sportType,
route: null,
},
select: { id: true },
orderBy: { createdAt: "desc" },
}),
),
);
})),
},
select: {
id: true,
source: true,
startedAt: true,
sportType: true,
createdAt: true,
},
orderBy: { createdAt: "desc" },
});
for (const row of manualRows) {
const key = `${row.source}::${row.startedAt.getTime()}::${row.sportType}`;
const existing = manualMatchesById.get(key);
if (!existing || existing.createdAt < row.createdAt) {
manualMatchesById.set(key, {
id: row.id,
createdAt: row.createdAt,
});
}
}
}
const manualMatches = withoutExternal.map((p) => {
const key = `${p.row.source}::${(p.row.startedAt as Date).getTime()}::${p.row.sportType}`;
const match = manualMatchesById.get(key);
return match ? { id: match.id } : null;
});
const routesToInsert: Prisma.WorkoutRouteCreateManyInput[] = [];
for (let i = 0; i < withExternal.length; i++) {
+155
View File
@@ -0,0 +1,155 @@
/**
* `GET /api/workouts` — paginated workout list with canonical-source
* deduplication.
*
* Consumers (v1.4.27+):
* 1. The /insights workouts surface (and future /workouts page) reads
* from this endpoint so duplicate twin workouts (Apple Watch
* writing a workout that Withings also published over its server-
* to-server feed) collapse to a single canonical row.
* 2. The native iOS app drains historical workouts through the same
* surface — Apple Health is the canonical ladder anchor, so an
* iOS-originated request typically returns its own rows back.
*
* Dedup contract:
* - The route reads `DEFAULT_WORKOUT_SOURCE_PRIORITY` from
* `pick-canonical-workout.ts` (APPLE_HEALTH > WITHINGS > MANUAL >
* IMPORT) with the ±5 min cluster window. A per-user workout
* ladder override is reserved for the v1.5 Settings surface; the
* server already honours it transparently through the picker's
* options bag when the field lands in `User.sourcePriorityJson`.
* - Per-cluster the picker keeps the row from the highest-priority
* source present; the others are dropped from the canonical list.
*
* Query params:
* - `limit` (default 50, max 200)
* - `offset` (default 0)
* - `since` / `until` — ISO timestamps, inclusive. Optional.
* - `sportType` — narrow to one HKWorkoutActivityType. Optional.
*
* Pagination contract:
* Dedup runs over the full filtered row set, then `offset` / `limit`
* slice the canonical projection. A naive `skip` + per-window dedup
* double-counts boundary clusters across pages because a cluster
* whose first member sits in page N-1 and whose later member sits in
* page N would be dropped on N-1 and surface again on N. Workout
* volume for HealthLog is bounded (single user, tens of workouts per
* week — single-digit MB at the table's busiest), so reading the
* full filtered set per request is well within budget. If the
* workload ever grows to where this matters, a `(startedAt, id)`
* cursor with a deterministic over-fetch window is the next step.
*
* v1.4.27 B7 / BL-P2-3 — wires `pickCanonicalWorkout()` into the read
* path. v1.4.27 R4 RC3 — corrects the pagination so `meta.total`
* reflects the deduped count and cluster boundaries no longer leak
* across pages.
*/
import { NextRequest } from "next/server";
import { prisma } from "@/lib/db";
import { apiHandler, requireAuth } from "@/lib/api-handler";
import { annotate } from "@/lib/logging/context";
import { apiSuccess } from "@/lib/api-response";
import {
pickCanonicalWorkout,
DEFAULT_WORKOUT_SOURCE_PRIORITY,
DEFAULT_WORKOUT_PROXIMITY_MINUTES,
type WorkoutPickerRow,
} from "@/lib/sources/pick-canonical-workout";
const DEFAULT_LIMIT = 50;
const MAX_LIMIT = 200;
export const GET = apiHandler(async (request: NextRequest) => {
const { user } = await requireAuth();
annotate({ action: { name: "workouts.list" } });
const { searchParams } = new URL(request.url);
const limit = Math.min(
Math.max(
1,
parseInt(searchParams.get("limit") ?? String(DEFAULT_LIMIT), 10) ||
DEFAULT_LIMIT,
),
MAX_LIMIT,
);
const offset = Math.max(
0,
parseInt(searchParams.get("offset") ?? "0", 10) || 0,
);
const since = searchParams.get("since");
const until = searchParams.get("until");
const sportType = searchParams.get("sportType");
const where: Record<string, unknown> = { userId: user.id };
if (since || until) {
const range: Record<string, Date> = {};
if (since) {
const d = new Date(since);
if (!Number.isNaN(d.getTime())) range.gte = d;
}
if (until) {
const d = new Date(until);
if (!Number.isNaN(d.getTime())) range.lte = d;
}
if (Object.keys(range).length > 0) {
where.startedAt = range;
}
}
if (sportType) {
where.sportType = sportType;
}
// Read the full filtered set then dedupe once. See the file-level
// "Pagination contract" note for the reason this is preferred over
// a per-page over-fetch + slice. The picker is O(n) on cluster
// building plus O(k) per cluster (k = open clusters of the same
// sport type) and the workout table is bounded.
const rows = await prisma.workout.findMany({
where,
orderBy: { startedAt: "desc" },
select: {
id: true,
source: true,
externalId: true,
sportType: true,
startedAt: true,
endedAt: true,
durationSec: true,
distanceMeters: true,
avgHeartRate: true,
maxHeartRate: true,
energyKcal: true,
createdAt: true,
},
});
// The picker only inspects `source`, `startedAt`, `sportType`, and
// `id`. The rest of the selected columns ride through untouched on
// the returned subset.
const pickerRows = rows as Array<(typeof rows)[number] & WorkoutPickerRow>;
const { canonical, clusters } = pickCanonicalWorkout(pickerRows, {
sourcePriority: DEFAULT_WORKOUT_SOURCE_PRIORITY,
proximityMinutes: DEFAULT_WORKOUT_PROXIMITY_MINUTES,
});
// The picker sorts ascending for deterministic cluster building.
// Restore descending order on the canonical projection so the API
// matches the orderBy contract above.
const canonicalDesc = [...canonical].sort(
(a, b) => b.startedAt.getTime() - a.startedAt.getTime(),
);
const page = canonicalDesc.slice(offset, offset + limit);
return apiSuccess({
workouts: page,
meta: {
total: canonicalDesc.length,
limit,
offset,
droppedDuplicates: rows.length - canonicalDesc.length,
clusters: clusters.length,
},
});
});
+28 -5
View File
@@ -1,6 +1,6 @@
"use client";
import { useState } from "react";
import { useId, useState } from "react";
import { useRouter, useSearchParams } from "next/navigation";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { KeyRound, Lock, Loader2 } from "lucide-react";
@@ -23,6 +23,12 @@ export default function LoginPage() {
const [password, setPassword] = useState("");
const [error, setError] = useState<string | null>(null);
const [loading, setLoading] = useState(false);
// v1.4.27 MB3 — explicit error-region id so the email + password
// inputs reference the banner via `aria-describedby`. Screen readers
// pair the error with the field instead of announcing it as a
// standalone alert detached from the form.
const errorId = useId();
const errorDescriptor = error ? errorId : undefined;
const { data: registrationEnabled } = useQuery({
queryKey: ["auth", "registration-status"],
queryFn: async () => {
@@ -118,7 +124,11 @@ export default function LoginPage() {
return (
<div className="flex w-full max-w-sm flex-col gap-4">
<div className="border-border bg-card rounded-xl border p-8 shadow-lg shadow-black/20">
{/* v1.4.27 MB7 / CF-61 — drop `p-8` to `p-6 sm:p-8` so the
auth card breathes on Galaxy Fold / Pixel 5 without stealing
half the visible viewport with padding. Tablet+ keeps the
generous 32 px padding. */}
<div className="border-border bg-card rounded-xl border p-6 shadow-lg shadow-black/20 sm:p-8">
<div className="flex flex-col items-center gap-3 text-center">
<div className="bg-primary/10 flex h-12 w-12 items-center justify-center rounded-lg">
<Logo className="text-primary" size={28} />
@@ -140,7 +150,7 @@ export default function LoginPage() {
disabled={loading}
>
{loading && mode === "passkey" ? (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
<Loader2 className="mr-2 h-4 w-4 animate-spin motion-reduce:animate-none" />
) : (
<KeyRound className="mr-2 h-4 w-4" />
)}
@@ -176,6 +186,13 @@ export default function LoginPage() {
onChange={(e) => setEmail(e.target.value)}
required
autoComplete="username"
inputMode="email"
enterKeyHint="next"
autoCapitalize="none"
spellCheck={false}
aria-required="true"
aria-invalid={!!error || undefined}
aria-describedby={errorDescriptor}
placeholder={t("auth.emailOrUsernamePlaceholder")}
/>
</div>
@@ -188,6 +205,10 @@ export default function LoginPage() {
onChange={(e) => setPassword(e.target.value)}
required
autoComplete="current-password"
enterKeyHint="go"
aria-required="true"
aria-invalid={!!error || undefined}
aria-describedby={errorDescriptor}
placeholder="********"
/>
</div>
@@ -198,7 +219,7 @@ export default function LoginPage() {
disabled={loading}
>
{loading && mode === "password" ? (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
<Loader2 className="mr-2 h-4 w-4 animate-spin motion-reduce:animate-none" />
) : (
<Lock className="mr-2 h-4 w-4" />
)}
@@ -207,7 +228,7 @@ export default function LoginPage() {
<button
type="button"
onClick={() => setMode("passkey")}
className="text-muted-foreground hover:text-foreground w-full text-center text-xs"
className="text-muted-foreground hover:text-foreground inline-flex min-h-11 w-full items-center justify-center text-center text-xs"
>
{t("auth.backToPasskey")}
</button>
@@ -216,7 +237,9 @@ export default function LoginPage() {
{error && (
<div
id={errorId}
role="alert"
aria-live="polite"
className="bg-destructive/10 text-destructive rounded-lg p-3 text-sm"
>
{error}
+41 -4
View File
@@ -1,6 +1,6 @@
"use client";
import { useState } from "react";
import { useId, useState } from "react";
import { useRouter } from "next/navigation";
import { useQueryClient } from "@tanstack/react-query";
import { Loader2, UserPlus } from "lucide-react";
@@ -22,6 +22,12 @@ export default function RegisterPage() {
const [error, setError] = useState<string | null>(null);
const [loading, setLoading] = useState(false);
const { t } = useTranslations();
// v1.4.27 MB3 — explicit error-region id wired to every required
// input via `aria-describedby` so screen readers pair the validation
// failure with the offending field instead of announcing it as a
// standalone alert detached from the form.
const errorId = useId();
const errorDescriptor = error ? errorId : undefined;
async function handleRegister(e: React.FormEvent) {
e.preventDefault();
@@ -59,7 +65,11 @@ export default function RegisterPage() {
return (
<div className="w-full max-w-sm">
<div className="border-border bg-card rounded-xl border p-8 shadow-lg shadow-black/20">
{/* v1.4.27 MB7 / CF-61 — drop `p-8` to `p-6 sm:p-8` so the
auth card breathes on Galaxy Fold / Pixel 5 without stealing
half the visible viewport with padding. Tablet+ keeps the
generous 32 px padding. */}
<div className="border-border bg-card rounded-xl border p-6 shadow-lg shadow-black/20 sm:p-8">
<div className="flex flex-col items-center gap-3 text-center">
<div className="bg-primary/10 flex h-12 w-12 items-center justify-center rounded-lg">
<Logo className="text-primary" size={28} />
@@ -85,6 +95,12 @@ export default function RegisterPage() {
placeholder={t("auth.emailPlaceholder")}
required
autoComplete="email"
enterKeyHint="next"
autoCapitalize="none"
spellCheck={false}
aria-required="true"
aria-invalid={!!error || undefined}
aria-describedby={errorDescriptor}
/>
</div>
@@ -98,6 +114,12 @@ export default function RegisterPage() {
placeholder="user"
required
autoComplete="username"
enterKeyHint="next"
autoCapitalize="none"
spellCheck={false}
aria-required="true"
aria-invalid={!!error || undefined}
aria-describedby={errorDescriptor}
minLength={3}
maxLength={30}
/>
@@ -113,6 +135,10 @@ export default function RegisterPage() {
placeholder={t("auth.passwordMinLength")}
required
autoComplete="new-password"
enterKeyHint="go"
aria-required="true"
aria-invalid={!!error || undefined}
aria-describedby={errorDescriptor}
/>
<PasswordStrength password={password} />
<p className="text-muted-foreground text-xs">
@@ -122,16 +148,27 @@ export default function RegisterPage() {
{error && (
<div
id={errorId}
role="alert"
aria-live="polite"
className="bg-destructive/10 text-destructive rounded-lg p-3 text-sm"
>
{error}
</div>
)}
<Button type="submit" className="w-full" disabled={loading}>
{/* v1.4.27 MB6 — lift the submit to the 44 px tap-target
floor and the `lg` size so the primary action stays
reachable on a narrow viewport without falling under the
iOS keyboard's accessory bar. */}
<Button
type="submit"
size="lg"
className="min-h-11 w-full"
disabled={loading}
>
{loading ? (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
<Loader2 className="mr-2 h-4 w-4 animate-spin motion-reduce:animate-none" />
) : (
<UserPlus className="mr-2 h-4 w-4" />
)}
+1 -1
View File
@@ -228,7 +228,7 @@ export default function BugReportPage() {
<div className="flex items-center justify-end pt-2">
<Button type="submit" disabled={loading}>
{loading && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
{loading && <Loader2 className="mr-2 h-4 w-4 animate-spin motion-reduce:animate-none" />}
<Bug className="mr-2 h-4 w-4" />
{t("bugreport.submit")}
</Button>
+17 -6
View File
@@ -40,11 +40,17 @@ export default function GlobalError({
backgroundColor: "#1e1f29",
color: "#f8f8f2",
margin: 0,
minHeight: "100vh",
// v1.4.27 MB6 — `100dvh` follows the dynamic viewport on
// iOS Safari (URL-bar collapse) so the wrapper still
// anchors centred when the browser chrome animates in or
// out. `safe-area-inset-top` keeps the headline out from
// under the notch / Dynamic Island.
minHeight: "100dvh",
display: "flex",
alignItems: "center",
justifyContent: "center",
padding: "24px",
padding:
"calc(env(safe-area-inset-top, 0px) + 24px) 24px 24px 24px",
}}
>
<div style={{ maxWidth: 560 }}>
@@ -76,9 +82,13 @@ export default function GlobalError({
color: "#282a36",
border: "none",
borderRadius: 6,
padding: "8px 14px",
// v1.4.27 MB6 — 44 px minimum height matches the
// app-wide tap-target floor; the larger inline padding
// keeps the labels comfortably finger-tap-sized.
minHeight: 44,
padding: "10px 16px",
cursor: "pointer",
fontSize: 13,
fontSize: 14,
fontWeight: 500,
}}
>
@@ -91,9 +101,10 @@ export default function GlobalError({
color: "#f8f8f2",
border: "1px solid #44475a",
borderRadius: 6,
padding: "8px 14px",
minHeight: 44,
padding: "10px 16px",
cursor: "pointer",
fontSize: 13,
fontSize: 14,
}}
>
Copy details
+62 -3
View File
@@ -1,15 +1,22 @@
"use client";
import { useQuery } from "@tanstack/react-query";
import dynamic from "next/dynamic";
import Link from "next/link";
import { HeartPulse } from "lucide-react";
import { useAuth } from "@/hooks/use-auth";
import { useInsightStatus } from "@/hooks/use-insight-status";
import { useTranslations } from "@/lib/i18n/context";
import { useInsightsLayoutPrefs } from "@/hooks/use-insights-layout-prefs";
import { Button } from "@/components/ui/button";
import { EmptyState } from "@/components/ui/empty-state";
import { CoachLaunchButton } from "@/components/insights/coach-launch-button";
import { InsightStatusCard } from "@/components/insights/insight-status-card";
import { SubPageShell } from "@/components/insights/sub-page-shell";
import { getBpTargets } from "@/lib/analytics/bp-targets";
import type { DataSummary } from "@/lib/analytics/trends";
import { hasMetricData } from "@/lib/insights/metric-availability";
/**
* v1.4.25 W4 — `/insights/blutdruck`.
@@ -19,10 +26,17 @@ import { getBpTargets } from "@/lib/analytics/bp-targets";
* the BP chart with its chart-cog overlays, the per-section AI
* assessment, and the chart's target zones tied to the user's age.
*
* Empty-state rule (Marc directive): if the user has no BP data, the
* chart's own empty-state takes over — no extra "no data" CTAs stack
* on top of the chart skeleton.
* v1.4.27 F17 — when the user has zero BP observations, the page
* short-circuits to an empty-state CTA pointing at
* `/measurements?add=BLOOD_PRESSURE`. Apple Health convention: empty
* surfaces lead to onboarding hints rather than chart skeletons.
* v1.4.27 MB6 — the previous `/measurements/new` href hit a 404; the
* measurements page now consumes `?add=<TYPE>` and auto-opens the
* dialog with the matching default type.
*/
interface AnalyticsData {
summaries: Record<string, DataSummary>;
}
const HealthChart = dynamic(
() =>
import("@/components/charts/health-chart").then((mod) => ({
@@ -39,6 +53,49 @@ export default function InsightsBlutdruckPage() {
const { data: status, isLoading: isStatusLoading } =
useInsightStatus("blood-pressure");
const { data: analytics } = useQuery({
queryKey: ["analytics"],
queryFn: async () => {
const res = await fetch("/api/analytics");
if (!res.ok) throw new Error("Failed");
const json = await res.json();
return json.data as AnalyticsData;
},
enabled: isAuthenticated,
staleTime: 60 * 1000,
});
if (
isAuthenticated &&
analytics &&
!hasMetricData("BLOOD_PRESSURE_SYS", {
summaries: analytics.summaries,
hasMood: false,
hasMedication: false,
})
) {
return (
<SubPageShell title={t("insights.bloodPressureSectionTitle")}>
<EmptyState
icon={<HeartPulse className="size-6" />}
title={t("insights.emptyState.bloodPressure.title")}
description={t("insights.emptyState.bloodPressure.description")}
ctaSize="lg"
action={
<Button size="sm" asChild>
<Link href="/measurements?add=BLOOD_PRESSURE">
{t("insights.emptyState.bloodPressure.cta")}
</Link>
</Button>
}
/>
<CoachLaunchButton
prefill="I haven't recorded any blood pressure yet — why does it matter, and what should I know before I start?"
/>
</SubPageShell>
);
}
const bpTargets =
user?.dateOfBirth != null ? getBpTargets(new Date(user.dateOfBirth)) : null;
const bpTargetZones = bpTargets
@@ -90,6 +147,8 @@ export default function InsightsBlutdruckPage() {
updatedAt={status?.updatedAt ?? null}
loading={isStatusLoading}
/>
<CoachLaunchButton />
</SubPageShell>
);
}
+63
View File
@@ -1,5 +1,6 @@
"use client";
import { useQuery } from "@tanstack/react-query";
import dynamic from "next/dynamic";
import Link from "next/link";
import { Ruler } from "lucide-react";
@@ -10,8 +11,15 @@ import { useTranslations } from "@/lib/i18n/context";
import { useInsightsLayoutPrefs } from "@/hooks/use-insights-layout-prefs";
import { Button } from "@/components/ui/button";
import { EmptyState } from "@/components/ui/empty-state";
import { CoachLaunchButton } from "@/components/insights/coach-launch-button";
import { InsightStatusCard } from "@/components/insights/insight-status-card";
import { SubPageShell } from "@/components/insights/sub-page-shell";
import type { DataSummary } from "@/lib/analytics/trends";
import { hasMetricData } from "@/lib/insights/metric-availability";
interface AnalyticsData {
summaries: Record<string, DataSummary>;
}
/**
* v1.4.25 W4 — `/insights/bmi`.
@@ -45,6 +53,55 @@ export default function InsightsBmiPage() {
const { data: status, isLoading: isStatusLoading } = useInsightStatus("bmi");
const { data: analytics } = useQuery({
queryKey: ["analytics"],
queryFn: async () => {
const res = await fetch("/api/analytics");
if (!res.ok) throw new Error("Failed");
const json = await res.json();
return json.data as AnalyticsData;
},
enabled: isAuthenticated,
staleTime: 60 * 1000,
});
// v1.4.27 F17 — BMI is derived from WEIGHT. When no weight readings
// exist yet, the existing "set your height" branch can never compute
// anything useful either; surface the empty-state CTA pointing at
// `/measurements?add=WEIGHT` instead so the user logs the weight
// first. v1.4.27 MB6 — query-param replaces the dead
// `/measurements/new` route.
if (
isAuthenticated &&
analytics &&
!hasMetricData("BMI", {
summaries: analytics.summaries,
hasMood: false,
hasMedication: false,
})
) {
return (
<SubPageShell title={t("insights.bmiSectionTitle")}>
<EmptyState
icon={<Ruler className="size-6" />}
title={t("insights.emptyState.bmi.title")}
description={t("insights.emptyState.bmi.description")}
ctaSize="lg"
action={
<Button size="sm" asChild>
<Link href="/measurements?add=WEIGHT">
{t("insights.emptyState.bmi.cta")}
</Link>
</Button>
}
/>
<CoachLaunchButton
prefill="I haven't recorded any weight yet — why does BMI matter for me, and what should I know before I start tracking it?"
/>
</SubPageShell>
);
}
if (!user?.heightCm) {
return (
<SubPageShell title={t("insights.bmiSectionTitle")}>
@@ -52,6 +109,7 @@ export default function InsightsBmiPage() {
icon={<Ruler className="size-6" />}
title={t("insights.bmiEmptyTitle")}
description={t("insights.bmiEmptyDescription")}
ctaSize="lg"
action={
<Button size="sm" asChild>
<Link href="/settings/account">
@@ -60,6 +118,9 @@ export default function InsightsBmiPage() {
</Button>
}
/>
<CoachLaunchButton
prefill="I haven't set my height yet — why does BMI matter, and what should I know before I configure it?"
/>
</SubPageShell>
);
}
@@ -90,6 +151,8 @@ export default function InsightsBmiPage() {
updatedAt={status?.updatedAt ?? null}
loading={isStatusLoading}
/>
<CoachLaunchButton />
</SubPageShell>
);
}
+56
View File
@@ -1,15 +1,26 @@
"use client";
import { useQuery } from "@tanstack/react-query";
import dynamic from "next/dynamic";
import Link from "next/link";
import { Scale } from "lucide-react";
import { useAuth } from "@/hooks/use-auth";
import { useInsightStatus } from "@/hooks/use-insight-status";
import { useTranslations } from "@/lib/i18n/context";
import { useInsightsLayoutPrefs } from "@/hooks/use-insights-layout-prefs";
import { Button } from "@/components/ui/button";
import { EmptyState } from "@/components/ui/empty-state";
import { CoachLaunchButton } from "@/components/insights/coach-launch-button";
import { InsightStatusCard } from "@/components/insights/insight-status-card";
import { SubPageShell } from "@/components/insights/sub-page-shell";
import { buildWeightBandsFromHeight } from "@/lib/analytics/value-bands";
import type { DataSummary } from "@/lib/analytics/trends";
import { hasMetricData } from "@/lib/insights/metric-availability";
interface AnalyticsData {
summaries: Record<string, DataSummary>;
}
/**
* v1.4.25 W4 — `/insights/gewicht`.
@@ -36,6 +47,49 @@ export default function InsightsGewichtPage() {
const { data: status, isLoading: isStatusLoading } =
useInsightStatus("weight");
const { data: analytics } = useQuery({
queryKey: ["analytics"],
queryFn: async () => {
const res = await fetch("/api/analytics");
if (!res.ok) throw new Error("Failed");
const json = await res.json();
return json.data as AnalyticsData;
},
enabled: isAuthenticated,
staleTime: 60 * 1000,
});
if (
isAuthenticated &&
analytics &&
!hasMetricData("WEIGHT", {
summaries: analytics.summaries,
hasMood: false,
hasMedication: false,
})
) {
return (
<SubPageShell title={t("insights.weightSectionTitle")}>
<EmptyState
icon={<Scale className="size-6" />}
title={t("insights.emptyState.weight.title")}
description={t("insights.emptyState.weight.description")}
ctaSize="lg"
action={
<Button size="sm" asChild>
<Link href="/measurements?add=WEIGHT">
{t("insights.emptyState.weight.cta")}
</Link>
</Button>
}
/>
<CoachLaunchButton
prefill="I haven't recorded any weight yet — why does it matter, and what should I know before I start tracking?"
/>
</SubPageShell>
);
}
const weightBands = user?.heightCm
? buildWeightBandsFromHeight(user.heightCm, {
lowerBound: 30,
@@ -68,6 +122,8 @@ export default function InsightsGewichtPage() {
updatedAt={status?.updatedAt ?? null}
loading={isStatusLoading}
/>
<CoachLaunchButton />
</SubPageShell>
);
}
+16 -6
View File
@@ -1,6 +1,8 @@
import type { ReactNode } from "react";
import { InsightsLayoutShell } from "@/components/insights/insights-layout-shell";
import { LayoutCoachMount } from "@/components/insights/layout-coach-mount";
import { CoachLaunchProvider } from "@/lib/insights/coach-launch-context";
/**
* v1.4.25 W4 — shared layout for the routed `/insights` sub-pages.
@@ -10,12 +12,20 @@ import { InsightsLayoutShell } from "@/components/insights/insights-layout-shell
* into a sub-page. The actual page body still owns its own data fetches
* — the layout is a thin presentational frame.
*
* Critical design rule (Marc directive 2026-05-11):
* The `<CoachDrawer>` is NOT mounted in this layout. It stays inside
* `src/app/insights/page.tsx` body so navigating into a sub-page
* unmounts the drawer (matches the Apple Health convention that AI/
* coach surfaces only live on the overview, never on a metric page).
* v1.4.27 R3d MB4 — the Coach drawer now mounts here (above the routed
* children) inside a `<CoachLaunchProvider>` so navigating into a sub-
* page no longer unmounts the drawer. Every sub-page mounts a
* `<CoachLaunchButton>` that calls `askCoach()` on the same context;
* the mother `/insights/page.tsx` consumes the same hook for its hero
* strip + suggested-prompt chips. Decision F (audit MA3) drove the
* promotion: a mobile user who navigates from `/insights` to
* `/insights/blutdruck` keeps a one-tap path back into the Coach.
*/
export default function InsightsLayout({ children }: { children: ReactNode }) {
return <InsightsLayoutShell>{children}</InsightsLayoutShell>;
return (
<CoachLaunchProvider>
<InsightsLayoutShell>{children}</InsightsLayoutShell>
<LayoutCoachMount />
</CoachLaunchProvider>
);
}
+27 -10
View File
@@ -12,6 +12,7 @@ import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { EmptyState } from "@/components/ui/empty-state";
import { Progress } from "@/components/ui/progress";
import { ComplianceHeatmap } from "@/components/charts/compliance-heatmap";
import { CoachLaunchButton } from "@/components/insights/coach-launch-button";
import { InsightStatusCard } from "@/components/insights/insight-status-card";
import { SubPageShell } from "@/components/insights/sub-page-shell";
import { TherapyTimeline } from "@/components/insights/therapy-timeline";
@@ -110,27 +111,34 @@ export default function InsightsMedikamentePage() {
return (
<SubPageShell title={t("insights.medicationCompliance")}>
<div className="flex items-center justify-center py-12">
<Loader2 className="text-primary h-6 w-6 animate-spin" />
<Loader2 className="text-primary h-6 w-6 animate-spin motion-reduce:animate-none" />
</div>
</SubPageShell>
);
}
if (medications.length === 0) {
// v1.4.27 F17 — medication compliance is event-driven so the
// gate reads `medications.length > 0`. CTA targets `/medications`
// (the dedicated medication-management surface).
return (
<SubPageShell title={t("insights.medicationCompliance")}>
<EmptyState
icon={<Pill className="size-6" />}
title={t("insights.subPage.medikamenteEmptyTitle")}
description={t("insights.subPage.medikamenteEmptyDescription")}
title={t("insights.emptyState.medication.title")}
description={t("insights.emptyState.medication.description")}
ctaSize="lg"
action={
<Button size="sm" asChild>
<Link href="/medications">
{t("insights.subPage.medikamenteEmptyAction")}
{t("insights.emptyState.medication.cta")}
</Link>
</Button>
}
/>
<CoachLaunchButton
prefill="I haven't added any medications yet — what should I know before I start tracking medication compliance here?"
/>
</SubPageShell>
);
}
@@ -150,15 +158,22 @@ export default function InsightsMedikamentePage() {
return (
<Card key={med.id}>
<CardHeader className="pb-2">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<Pill className="text-dracula-orange h-4 w-4" />
<CardTitle className="text-sm font-medium">
{/* v1.4.27 MB7 / MA2-F8 — `min-w-0` on the title
wrapper + `truncate` on the CardTitle keeps long
medication names (e.g. "Semaglutid Wegovy 0.5 mg
weekly") from pushing the streak badge off the
card on Galaxy Fold / Pixel 5. The Pill icon
+ CardTitle pair shrinks to fit; the badge stays
on the right at its natural width. */}
<div className="flex items-center justify-between gap-2">
<div className="flex min-w-0 items-center gap-2">
<Pill className="text-dracula-orange h-4 w-4 shrink-0" />
<CardTitle className="truncate text-sm font-medium">
{med.name}
</CardTitle>
</div>
{med.streak > 0 && (
<Badge variant="outline" className="text-xs">
<Badge variant="outline" className="shrink-0 text-xs">
{t("insights.dayStreak", { count: med.streak })}
</Badge>
)}
@@ -222,6 +237,8 @@ export default function InsightsMedikamentePage() {
without an active GLP-1 medication, so the page collapses
back to the compliance grid for everyone else. */}
<TherapyTimeline />
<CoachLaunchButton />
</SubPageShell>
);
}
@@ -247,7 +264,7 @@ function MedicationComplianceCalendar({
if (isLoading) {
return (
<div className="flex h-32 items-center justify-center">
<Loader2 className="text-primary h-4 w-4 animate-spin" />
<Loader2 className="text-primary h-4 w-4 animate-spin motion-reduce:animate-none" />
</div>
);
}
+18 -23
View File
@@ -1,6 +1,5 @@
"use client";
import { useState } from "react";
import { useQuery } from "@tanstack/react-query";
import Link from "next/link";
import { Loader2, TrendingUp } from "lucide-react";
@@ -12,10 +11,10 @@ import { EmptyState } from "@/components/ui/empty-state";
import { InsightAdvisorCard } from "@/components/insights/insight-advisor-card";
import { HeroStrip } from "@/components/insights/hero-strip";
import { DailyBriefing } from "@/components/insights/daily-briefing";
import { CoachDrawer } from "@/components/insights/coach-panel/coach-drawer";
import { TrendsRow } from "@/components/insights/trends-row";
import { CorrelationRow } from "@/components/insights/correlation-row";
import { useInsightsAdvisorQuery } from "@/components/insights/use-insights-advisor";
import { useCoachLaunch } from "@/lib/insights/coach-launch-context";
import type { CorrelationResult } from "@/lib/insights/correlations";
import { toWeekISO } from "@/lib/insights/week-iso";
import type { DataSummary } from "@/lib/analytics/trends";
@@ -96,12 +95,12 @@ export default function InsightsPage() {
const { isAuthenticated, user } = useAuth();
const { t } = useTranslations();
// CoachDrawer state. The hero strip's "Ask the coach" button +
// suggested-prompt chips toggle the drawer here; the drawer ingests
// `coachPrefill` once on open and resets on close so the next open
// starts blank.
const [coachOpen, setCoachOpen] = useState<boolean>(false);
const [coachPrefill, setCoachPrefill] = useState<string | null>(null);
// v1.4.27 R3d MB4 — Coach drawer state lives in the layout-level
// `<CoachLaunchProvider>` so every routed sub-page can reach it.
// The hero strip + suggested-prompt chips call `askCoach(prefill)`
// on the same context, and the drawer itself is mounted next to
// the provider in `src/app/insights/layout.tsx`.
const coachLaunch = useCoachLaunch();
const { data, isLoading } = useQuery({
queryKey: ["insights", "comprehensive"],
@@ -133,7 +132,7 @@ export default function InsightsPage() {
if (isLoading) {
return (
<div className="flex items-center justify-center py-20">
<Loader2 className="text-primary h-6 w-6 animate-spin" />
<Loader2 className="text-primary h-6 w-6 animate-spin motion-reduce:animate-none" />
</div>
);
}
@@ -183,14 +182,16 @@ export default function InsightsPage() {
briefing={briefingPayload}
updatedAt={heroStripUpdatedAt}
userName={heroGreetingName}
onAskCoach={(prefill?: string) => {
setCoachPrefill(prefill ?? null);
setCoachOpen(true);
}}
onPickPrompt={(prompt) => {
setCoachPrefill(prompt);
setCoachOpen(true);
}}
onAskCoach={
coachLaunch
? (prefill?: string) => coachLaunch.askCoach(prefill ?? null)
: undefined
}
onPickPrompt={
coachLaunch
? (prompt) => coachLaunch.askCoach(prompt)
: undefined
}
weeklyReportReady={weeklyReportReady}
weeklyReportHref={currentWeekHref}
healthScore={analytics?.healthScore ?? undefined}
@@ -217,12 +218,6 @@ export default function InsightsPage() {
cachedAt={advisor.payload?.cachedAt ?? null}
legacyPayload={advisor.payload?.legacyPayload ?? false}
/>
<CoachDrawer
open={coachOpen}
onOpenChange={setCoachOpen}
prefill={coachPrefill}
/>
</div>
);
}
+44
View File
@@ -2,12 +2,16 @@
import { useQuery } from "@tanstack/react-query";
import dynamic from "next/dynamic";
import Link from "next/link";
import { Heart } from "lucide-react";
import { useAuth } from "@/hooks/use-auth";
import { useInsightStatus } from "@/hooks/use-insight-status";
import { useTranslations } from "@/lib/i18n/context";
import { useInsightsLayoutPrefs } from "@/hooks/use-insights-layout-prefs";
import { Button } from "@/components/ui/button";
import { EmptyState } from "@/components/ui/empty-state";
import { CoachLaunchButton } from "@/components/insights/coach-launch-button";
import { InsightStatusCard } from "@/components/insights/insight-status-card";
import { SubPageShell } from "@/components/insights/sub-page-shell";
import { Vo2MaxChartRow } from "@/components/insights/vo2-max-chart-row";
@@ -16,6 +20,7 @@ import {
getAgeFromDateOfBirth,
getPersonalizedPulseTarget,
} from "@/lib/analytics/pulse-targets";
import { hasMetricData } from "@/lib/insights/metric-availability";
/**
* v1.4.25 W4 — `/insights/puls`.
@@ -64,6 +69,43 @@ export default function InsightsPulsPage() {
});
const vo2Summary = analytics?.summaries?.VO2_MAX ?? null;
// v1.4.27 F17 — gate the sub-page on at least one pulse observation.
// Brand-new accounts (no manual logs, no Apple-Health upload yet)
// see a one-line empty-state with a CTA into
// `/measurements?add=PULSE` rather than a chart skeleton over a
// blank target band. v1.4.27 MB6 — query-param replaces the dead
// `/measurements/new` route.
if (
isAuthenticated &&
analytics &&
!hasMetricData("PULSE", {
summaries: analytics.summaries,
hasMood: false,
hasMedication: false,
})
) {
return (
<SubPageShell title={t("insights.pulseSectionTitle")}>
<EmptyState
icon={<Heart className="size-6" />}
title={t("insights.emptyState.pulse.title")}
description={t("insights.emptyState.pulse.description")}
ctaSize="lg"
action={
<Button size="sm" asChild>
<Link href="/measurements?add=PULSE">
{t("insights.emptyState.pulse.cta")}
</Link>
</Button>
}
/>
<CoachLaunchButton
prefill="I haven't recorded any resting pulse yet — why does it matter, and what should I know before I start?"
/>
</SubPageShell>
);
}
const pulseAge = getAgeFromDateOfBirth(user?.dateOfBirth ?? null);
const pulseTarget = getPersonalizedPulseTarget(
pulseAge,
@@ -129,6 +171,8 @@ export default function InsightsPulsPage() {
compareBaseline={compareBaseline}
userTimezone={user?.timezone}
/>
<CoachLaunchButton />
</SubPageShell>
);
}
+66
View File
@@ -1,8 +1,18 @@
"use client";
import { useQuery } from "@tanstack/react-query";
import Link from "next/link";
import { Moon } from "lucide-react";
import { useAuth } from "@/hooks/use-auth";
import { useTranslations } from "@/lib/i18n/context";
import { Button } from "@/components/ui/button";
import { EmptyState } from "@/components/ui/empty-state";
import { CoachLaunchButton } from "@/components/insights/coach-launch-button";
import { SleepOverview } from "@/components/insights/sleep-overview";
import { SubPageShell } from "@/components/insights/sub-page-shell";
import type { DataSummary } from "@/lib/analytics/trends";
import { hasMetricData } from "@/lib/insights/metric-availability";
/**
* v1.4.25 W4c — `/insights/schlaf`.
@@ -11,15 +21,71 @@ import { SubPageShell } from "@/components/insights/sub-page-shell";
* the v1.4.23 schema gained but never rendered. All charts live inside
* `<SleepOverview>` so the page-level scaffold stays trivial; data
* fetches and empty-state handling are encapsulated in the component.
*
* v1.4.27 F17 — when `summaries.SLEEP_DURATION.count === 0` (no
* Apple-Health / Withings sleep rows yet), the page short-circuits
* to an empty-state CTA pointing at `/settings/data-sources` so the
* user can connect a sleep source.
*/
interface AnalyticsData {
summaries: Record<string, DataSummary>;
}
export default function InsightsSchlafPage() {
const { isAuthenticated } = useAuth();
const { t } = useTranslations();
const { data: analytics } = useQuery({
queryKey: ["analytics"],
queryFn: async () => {
const res = await fetch("/api/analytics");
if (!res.ok) throw new Error("Failed");
const json = await res.json();
return json.data as AnalyticsData;
},
enabled: isAuthenticated,
staleTime: 60 * 1000,
});
if (
isAuthenticated &&
analytics &&
!hasMetricData("SLEEP_DURATION", {
summaries: analytics.summaries,
hasMood: false,
hasMedication: false,
})
) {
return (
<SubPageShell title={t("insights.sleep.title")}>
<EmptyState
icon={<Moon className="size-6" />}
title={t("insights.emptyState.sleep.title")}
description={t("insights.emptyState.sleep.description")}
ctaSize="lg"
action={
<Button size="sm" asChild>
<Link href="/settings/data-sources">
{t("insights.emptyState.sleep.cta")}
</Link>
</Button>
}
/>
<CoachLaunchButton
prefill="I don't have any sleep data yet — why does sleep tracking matter, and what should I know before I connect a source?"
/>
</SubPageShell>
);
}
return (
<SubPageShell
title={t("insights.sleep.title")}
description={t("insights.sleep.description")}
>
<SleepOverview />
<CoachLaunchButton />
</SubPageShell>
);
}
+14 -5
View File
@@ -11,6 +11,7 @@ import { useTranslations } from "@/lib/i18n/context";
import { useInsightsLayoutPrefs } from "@/hooks/use-insights-layout-prefs";
import { Button } from "@/components/ui/button";
import { EmptyState } from "@/components/ui/empty-state";
import { CoachLaunchButton } from "@/components/insights/coach-launch-button";
import { InsightStatusCard } from "@/components/insights/insight-status-card";
import { SubPageShell } from "@/components/insights/sub-page-shell";
@@ -58,21 +59,27 @@ export default function InsightsStimmungPage() {
const moodCount = comprehensive?.moodSummary?.count ?? 0;
// v1.4.27 F17 — Mood is event-driven so the gate reads
// `hasMood = moodCount > 0`. CTA targets `/mood` (the dedicated
// mood-logging surface) — short-circuits the user to the quickest
// path to log their first entry.
if (isAuthenticated && comprehensive && moodCount === 0) {
return (
<SubPageShell title={t("insights.moodSectionTitle")}>
<EmptyState
icon={<Smile className="size-6" />}
title={t("insights.subPage.stimmungEmptyTitle")}
description={t("insights.subPage.stimmungEmptyDescription")}
title={t("insights.emptyState.mood.title")}
description={t("insights.emptyState.mood.description")}
ctaSize="lg"
action={
<Button size="sm" asChild>
<Link href="/mood">
{t("insights.subPage.stimmungEmptyAction")}
</Link>
<Link href="/mood">{t("insights.emptyState.mood.cta")}</Link>
</Button>
}
/>
<CoachLaunchButton
prefill="I haven't logged any mood entries yet — why does mood tracking matter, and how should I start?"
/>
</SubPageShell>
);
}
@@ -97,6 +104,8 @@ export default function InsightsStimmungPage() {
updatedAt={status?.updatedAt ?? null}
loading={isStatusLoading}
/>
<CoachLaunchButton />
</SubPageShell>
);
}
+77 -29
View File
@@ -1,28 +1,66 @@
"use client";
import { useState } from "react";
import { useEffect, useState } from "react";
import { useAuth } from "@/hooks/use-auth";
import { MeasurementForm } from "@/components/measurements/measurement-form";
import { MeasurementList } from "@/components/measurements/measurement-list";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog";
import { ResponsiveSheet } from "@/components/ui/responsive-sheet";
import { Plus, Loader2 } from "lucide-react";
import { useRouter } from "next/navigation";
import { useEffect } from "react";
import { useRouter, useSearchParams } from "next/navigation";
import { useTranslations } from "@/lib/i18n/context";
// v1.4.27 MB6 — the insights empty-state CTAs link here with a
// `?add=<TYPE>` query param to auto-open the add-measurement dialog
// with the right default type. Keep the allow-list aligned with
// MEASUREMENT_TYPES inside `measurement-form.tsx`; an unknown value
// is dropped silently so a stale link can never trap the user on a
// broken form.
const ALLOWED_ADD_TYPES = new Set([
"BLOOD_PRESSURE",
"WEIGHT",
"PULSE",
"BMI",
"GLUCOSE",
"TEMPERATURE",
"BODY_FAT",
"HEART_RATE",
]);
export default function MeasurementsPage() {
const { isAuthenticated, isLoading } = useAuth();
const router = useRouter();
const [dialogOpen, setDialogOpen] = useState(false);
const searchParams = useSearchParams();
const { t } = useTranslations();
// v1.4.27 MB6 — read the `?add=<TYPE>` query param during render so
// the dialog opens on the first paint (no flash of the page behind
// it). The state-from-prop pattern follows `account-section.tsx`:
// store the "addToken we acted on" sentinel and let render kick off
// the open + replace transition once per param value. The lint rule
// `react-hooks/set-state-in-effect` rejects setState from inside an
// effect, so the open-on-param work is render-driven instead.
const addParam = searchParams.get("add");
const [dialogOpen, setDialogOpen] = useState(false);
const [defaultType, setDefaultType] = useState<string | undefined>(
undefined,
);
const [consumedAddParam, setConsumedAddParam] = useState<string | null>(null);
// v1.4.27 R4 RC2 — DOM handle the form portals its action row into so
// the Sheet branch can sticky-pin Save / Cancel.
const [footerEl, setFooterEl] = useState<HTMLDivElement | null>(null);
if (addParam && addParam !== consumedAddParam) {
setConsumedAddParam(addParam);
if (ALLOWED_ADD_TYPES.has(addParam)) {
setDefaultType(addParam);
setDialogOpen(true);
}
// Drop the query string so the back-button leaves the user on
// `/measurements` rather than re-opening the dialog.
router.replace("/measurements");
}
useEffect(() => {
if (!isLoading && !isAuthenticated) {
router.push("/auth/login");
@@ -32,7 +70,7 @@ export default function MeasurementsPage() {
if (isLoading) {
return (
<div className="flex h-64 items-center justify-center">
<Loader2 className="text-primary h-8 w-8 animate-spin" />
<Loader2 className="text-primary h-8 w-8 animate-spin motion-reduce:animate-none" />
</div>
);
}
@@ -48,25 +86,35 @@ export default function MeasurementsPage() {
{t("measurements.subtitle")}
</p>
</div>
<Dialog open={dialogOpen} onOpenChange={setDialogOpen}>
<DialogTrigger asChild>
<Button>
<Plus className="mr-2 h-4 w-4" />
{t("measurements.addMeasurement")}
</Button>
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>{t("measurements.addMeasurement")}</DialogTitle>
</DialogHeader>
<MeasurementForm
onSuccess={() => setDialogOpen(false)}
onCancel={() => setDialogOpen(false)}
/>
</DialogContent>
</Dialog>
<Button onClick={() => setDialogOpen(true)}>
<Plus className="mr-2 h-4 w-4" />
{t("measurements.addMeasurement")}
</Button>
</div>
<ResponsiveSheet
open={dialogOpen}
onOpenChange={(open) => {
setDialogOpen(open);
if (!open) setDefaultType(undefined);
}}
title={t("measurements.addMeasurement")}
footer={<div ref={setFooterEl} className="flex w-full" />}
>
<MeasurementForm
defaultType={defaultType}
onSuccess={() => {
setDialogOpen(false);
setDefaultType(undefined);
}}
onCancel={() => {
setDialogOpen(false);
setDefaultType(undefined);
}}
footerSlot={footerEl}
/>
</ResponsiveSheet>
<MeasurementList onAddFirst={() => setDialogOpen(true)} />
</div>
);
+1 -1
View File
@@ -51,7 +51,7 @@ export default function IntakeHistoryPage({
if (authLoading || medLoading) {
return (
<div className="flex h-64 items-center justify-center">
<Loader2 className="text-primary h-8 w-8 animate-spin" />
<Loader2 className="text-primary h-8 w-8 animate-spin motion-reduce:animate-none" />
</div>
);
}
+22 -15
View File
@@ -18,6 +18,7 @@ import {
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { ResponsiveSheet } from "@/components/ui/responsive-sheet";
import {
DropdownMenu,
DropdownMenuContent,
@@ -78,6 +79,10 @@ export default function MedicationsPage() {
id: string;
name: string;
} | null>(null);
// v1.4.27 R4 RC2 — DOM handle the medication form portals its
// action-row into so the sheet branch can sticky-pin Save / Cancel
// above the keyboard.
const [footerEl, setFooterEl] = useState<HTMLDivElement | null>(null);
const {
data: medications,
@@ -113,7 +118,7 @@ export default function MedicationsPage() {
if (authLoading) {
return (
<div className="flex h-64 items-center justify-center">
<Loader2 className="text-primary h-6 w-6 animate-spin" />
<Loader2 className="text-primary h-6 w-6 animate-spin motion-reduce:animate-none" />
</div>
);
}
@@ -161,7 +166,7 @@ export default function MedicationsPage() {
{isLoading ? (
<div className="flex h-32 items-center justify-center">
<Loader2 className="text-primary h-6 w-6 animate-spin" />
<Loader2 className="text-primary h-6 w-6 animate-spin motion-reduce:animate-none" />
</div>
) : isError ? (
<div className="bg-card border-border flex h-64 items-center justify-center rounded-xl border">
@@ -256,17 +261,20 @@ export default function MedicationsPage() {
onClose={() => setImportMedId(null)}
/>
{/* Create/Edit Dialog */}
<Dialog open={dialogOpen} onOpenChange={setDialogOpen}>
<DialogContent className="max-h-[90vh] overflow-y-auto sm:max-w-lg">
<DialogHeader>
<DialogTitle>
{editingMed
? t("medications.editMedication")
: t("medications.newMedication")}
</DialogTitle>
</DialogHeader>
{/* Create/Edit Sheet — bottom-sheet on `<md`, centred Dialog on `md+`. */}
<ResponsiveSheet
open={dialogOpen}
onOpenChange={setDialogOpen}
title={
editingMed
? t("medications.editMedication")
: t("medications.newMedication")
}
className="sm:max-w-lg"
footer={<div ref={setFooterEl} className="flex w-full" />}
>
<MedicationForm
footerSlot={footerEl}
initial={
editingMed
? {
@@ -307,8 +315,7 @@ export default function MedicationsPage() {
onSuccess={closeDialog}
onCancel={closeDialog}
/>
</DialogContent>
</Dialog>
</ResponsiveSheet>
</div>
);
}
@@ -499,7 +506,7 @@ function IntakeImportDialog({
onClick={handleImport}
disabled={importing || !jsonText.trim()}
>
{importing && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
{importing && <Loader2 className="mr-2 h-4 w-4 animate-spin motion-reduce:animate-none" />}
<Upload className="mr-2 h-4 w-4" />
{t("common.import")}
</Button>
+23 -25
View File
@@ -5,13 +5,7 @@ import { useAuth } from "@/hooks/use-auth";
import { MoodForm } from "@/components/mood/mood-form";
import { MoodList } from "@/components/mood/mood-list";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog";
import { ResponsiveSheet } from "@/components/ui/responsive-sheet";
import { Plus, Loader2 } from "lucide-react";
import { useRouter } from "next/navigation";
import { useEffect } from "react";
@@ -21,6 +15,10 @@ export default function MoodPage() {
const { isAuthenticated, isLoading } = useAuth();
const router = useRouter();
const [dialogOpen, setDialogOpen] = useState(false);
// v1.4.27 R4 RC2 — DOM-ref handle the form portals its action row
// into. The ref lives on the `<ResponsiveSheet>` footer slot so the
// Sheet branch can sticky-pin Save / Cancel above the keyboard.
const [footerEl, setFooterEl] = useState<HTMLDivElement | null>(null);
const { t } = useTranslations();
useEffect(() => {
@@ -32,7 +30,7 @@ export default function MoodPage() {
if (isLoading) {
return (
<div className="flex h-64 items-center justify-center">
<Loader2 className="text-primary h-8 w-8 animate-spin" />
<Loader2 className="text-primary h-8 w-8 animate-spin motion-reduce:animate-none" />
</div>
);
}
@@ -48,25 +46,25 @@ export default function MoodPage() {
{t("mood.subtitle")}
</p>
</div>
<Dialog open={dialogOpen} onOpenChange={setDialogOpen}>
<DialogTrigger asChild>
<Button>
<Plus className="mr-2 h-4 w-4" />
{t("mood.addEntry")}
</Button>
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>{t("mood.addEntry")}</DialogTitle>
</DialogHeader>
<MoodForm
onSuccess={() => setDialogOpen(false)}
onCancel={() => setDialogOpen(false)}
/>
</DialogContent>
</Dialog>
<Button onClick={() => setDialogOpen(true)}>
<Plus className="mr-2 h-4 w-4" />
{t("mood.addEntry")}
</Button>
</div>
<ResponsiveSheet
open={dialogOpen}
onOpenChange={setDialogOpen}
title={t("mood.addEntry")}
footer={<div ref={setFooterEl} className="flex w-full" />}
>
<MoodForm
onSuccess={() => setDialogOpen(false)}
onCancel={() => setDialogOpen(false)}
footerSlot={footerEl}
/>
</ResponsiveSheet>
<MoodList onAddFirst={() => setDialogOpen(true)} />
</div>
);
+48
View File
@@ -0,0 +1,48 @@
import Link from "next/link";
import { Logo } from "@/components/ui/logo";
/**
* v1.4.27 MB6 — branded 404 page. Next.js mounts this file for any
* route that doesn't resolve to a server component, so the previous
* implicit fallback was the framework's bare "404" text. The shape
* here intentionally stays lighter than `<ErrorDetails>` — Decision L
* in the v1.4.27 mobile-fix plan — because a missing URL is not an
* application error and doesn't need the bug-report path. Just a
* branded splash with one link home.
*
* Mobile-first: `min-h-dvh` follows the dynamic viewport so the
* panel anchors centred under iOS Safari's animated URL bar;
* `safe-area-inset-top` keeps the headline clear of the notch or
* Dynamic Island.
*/
export default function NotFound() {
return (
<main
id="main-content"
className="bg-background text-foreground flex min-h-dvh flex-col items-center justify-center gap-6 px-4 py-12 pt-[calc(env(safe-area-inset-top)+3rem)]"
>
<div className="bg-primary/10 flex h-14 w-14 items-center justify-center rounded-xl">
<Logo className="text-primary" size={32} />
</div>
<div className="max-w-sm space-y-2 text-center">
<p className="text-muted-foreground text-xs tracking-wider uppercase">
404
</p>
<h1 className="text-2xl font-bold tracking-tight md:text-3xl">
Page not found
</h1>
<p className="text-muted-foreground text-sm leading-relaxed">
The page you were looking for doesn&apos;t exist or has been moved.
Head back to the dashboard to pick up where you left off.
</p>
</div>
<Link
href="/"
className="bg-primary text-primary-foreground hover:bg-primary/90 inline-flex min-h-11 items-center justify-center rounded-md px-5 text-sm font-medium transition-colors"
>
Back to dashboard
</Link>
</main>
);
}
+1 -1
View File
@@ -129,7 +129,7 @@ export default function NotificationsPage() {
if (authLoading || isLoading) {
return (
<div className="flex items-center justify-center py-20">
<Loader2 className="text-primary h-6 w-6 animate-spin" />
<Loader2 className="text-primary h-6 w-6 animate-spin motion-reduce:animate-none" />
</div>
);
}
+68 -78
View File
@@ -18,6 +18,7 @@ import {
Waves,
} from "lucide-react";
import { convertGlucose, resolveGlucoseUnit } from "@/lib/glucose";
import { cn } from "@/lib/utils";
import {
resolveDashboardLayout,
type DashboardLayout,
@@ -26,12 +27,7 @@ import type { DataSummary as DataSummaryType } from "@/lib/analytics/trends";
import dynamic from "next/dynamic";
import { Button } from "@/components/ui/button";
import { EmptyState } from "@/components/ui/empty-state";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { ResponsiveSheet } from "@/components/ui/responsive-sheet";
import {
DropdownMenu,
DropdownMenuContent,
@@ -46,8 +42,6 @@ import { summaryToTrend7Delta } from "@/lib/analytics/trend-delta";
import { GettingStartedChecklist } from "@/components/onboarding/getting-started-checklist";
import { TourLauncher } from "@/components/onboarding/tour-launcher";
import { RecentAchievementsCard } from "@/components/gamification/recent-achievements-card";
import { InsightsCardPreview } from "@/components/insights/insights-card";
import { useInsightsAdvisorQuery } from "@/components/insights/use-insights-advisor";
import { Glp1Tile } from "@/components/dashboard/glp1-tile";
const HealthChart = dynamic(
@@ -197,6 +191,11 @@ export default function DashboardPage() {
const [quickEntryDialog, setQuickEntryDialog] = useState<
"measurement" | "mood" | null
>(null);
// v1.4.27 R4 RC2 — DOM handles for the form action-row portal target
// on each quick-entry sheet. The Sheet branch sticky-pins this slot.
const [measurementFooterEl, setMeasurementFooterEl] =
useState<HTMLDivElement | null>(null);
const [moodFooterEl, setMoodFooterEl] = useState<HTMLDivElement | null>(null);
const { data } = useQuery({
queryKey: queryKeys.analytics(),
@@ -234,16 +233,17 @@ export default function DashboardPage() {
enabled: isAuthenticated,
});
// v1.4.16 phase D reconcile (CRITICAL C2) — pull the rich advisor
// payload so the dashboard can surface a compact preview of the top
// severity-ordered AI recommendations + ring confidence meter +
// "View all" CTA. Shares the cache with /insights via the
// queryKeys.insightsAdvisor() key so a regenerate on either surface
// refreshes the other without a second LLM round-trip. Returns null
// when the user has no provider configured (route 422 → null), so
// the preview self-hides without burning rate-limit tokens on
// unconfigured accounts.
const advisor = useInsightsAdvisorQuery(isAuthenticated);
// v1.4.27 B1 — the dashboard's `<InsightsCardPreview>` retired (it
// duplicated the much-richer `/insights` advisor surface). The advisor
// query lives on `/insights` directly; the dashboard no longer needs
// its own hook subscription, so the local `useInsightsAdvisorQuery`
// call dropped with the preview.
// v1.4.27 — per-tile availability gates live a few lines below as the
// existing `hasWeight` / `hasBp` / `hasPulse` / `hasBodyFat` / `hasMood` /
// `hasSleep` / `hasSteps` flags. They mirror `hasMetricData` from
// `src/lib/insights/metric-availability.ts` for the routed Insights
// surfaces — both branches read `summaries[METRIC].count > 0`.
const w = data?.summaries?.WEIGHT;
const sys = data?.summaries?.BLOOD_PRESSURE_SYS;
@@ -340,12 +340,6 @@ export default function DashboardPage() {
// the layout-toggle gate here. No data-floor check (the empty card is
// intentional — the maintainer wants the user to discover the feature).
const showAchievementsCard = isChartVisible("achievements");
// v1.4.16 phase D reconcile (CRITICAL C2) — gate the dashboard
// insights preview by the layout toggle. The component itself
// returns null when the advisor payload is missing or has no
// recommendations, so the gate is sufficient — no extra data-floor
// check needed.
const showInsightsPreview = isChartVisible("insightsPreview");
// Glucose widget — visible iff layout enables it AND at least one reading exists.
// Glucose has no separate chart slot today, so the tile flag is the
@@ -530,35 +524,33 @@ export default function DashboardPage() {
* line is free for established users. */}
<TourLauncher ready={data !== undefined} />
{/* Quick Entry Dialogs */}
<Dialog
{/* Quick Entry Sheets — bottom-sheet on `<md`, centred Dialog on `md+`. */}
<ResponsiveSheet
open={quickEntryDialog === "measurement"}
onOpenChange={(open) => !open && setQuickEntryDialog(null)}
title={t("measurements.addMeasurement")}
footer={
<div ref={setMeasurementFooterEl} className="flex w-full" />
}
>
<DialogContent>
<DialogHeader>
<DialogTitle>{t("measurements.addMeasurement")}</DialogTitle>
</DialogHeader>
<MeasurementForm
onSuccess={() => setQuickEntryDialog(null)}
onCancel={() => setQuickEntryDialog(null)}
/>
</DialogContent>
</Dialog>
<Dialog
<MeasurementForm
onSuccess={() => setQuickEntryDialog(null)}
onCancel={() => setQuickEntryDialog(null)}
footerSlot={measurementFooterEl}
/>
</ResponsiveSheet>
<ResponsiveSheet
open={quickEntryDialog === "mood"}
onOpenChange={(open) => !open && setQuickEntryDialog(null)}
title={t("mood.addEntry")}
footer={<div ref={setMoodFooterEl} className="flex w-full" />}
>
<DialogContent>
<DialogHeader>
<DialogTitle>{t("mood.addEntry")}</DialogTitle>
</DialogHeader>
<MoodForm
onSuccess={() => setQuickEntryDialog(null)}
onCancel={() => setQuickEntryDialog(null)}
/>
</DialogContent>
</Dialog>
<MoodForm
onSuccess={() => setQuickEntryDialog(null)}
onCancel={() => setQuickEntryDialog(null)}
footerSlot={moodFooterEl}
/>
</ResponsiveSheet>
{(() => {
type TrendEntry = { id: string; order: number; node: React.ReactNode };
@@ -1225,51 +1217,49 @@ export default function DashboardPage() {
height for any non-zero count. */}
{trendCards.length > 0 && (
<div
// CSS Grid with `auto-fit + minmax(9rem, 1fr)` is the v1.4.4
// attempt's flex-strip replacement: every tile gets EXACTLY
// the same width (1fr each in the row's track list), the gap
// is symmetric, and the strip starts and ends at the same
// x-coordinates as the charts below because both inherit the
// same parent container. When the row no longer fits a 9rem
// floor, the grid wraps to a new row instead of horizontal-
// scrolling — the maintainer tested both and prefers the v1.3-era
// wrap behaviour over the one-row scroll for the symmetry
// it preserves.
className="grid auto-rows-fr [grid-template-columns:repeat(auto-fit,minmax(min(100%,9rem),1fr))] gap-3 pb-2"
// v1.4.27 MB7 / CF-42 — at `<sm` the tile strip
// switches to a `flex overflow-x-auto` row so the
// tiles scroll horizontally instead of wrapping to
// 3-4 rows on Pixel 5 / Galaxy Fold. Each tile keeps
// a `min-w-[10rem]` so the user sees ~2.5 tiles per
// viewport — enough to read "there's more" without
// crowding the headline value on the visible tile.
// From `sm:` upwards the strip falls back to the
// canonical `grid auto-fit + minmax(9rem, 1fr)`
// layout (every tile equal width, wraps to a new
// row when the 9 rem floor no longer fits) that the
// maintainer pinned in v1.4.4.
//
// `snap-x snap-mandatory` makes the scroll feel
// deliberate on touch and is a no-op on the grid
// branch above `sm:`.
className={cn(
"flex snap-x snap-mandatory gap-3 overflow-x-auto pb-2",
"sm:grid sm:snap-none sm:auto-rows-fr sm:overflow-visible",
"sm:[grid-template-columns:repeat(auto-fit,minmax(min(100%,9rem),1fr))]",
)}
data-slot="dashboard-tile-strip"
data-tour-id="dashboard-tile-strip"
data-tile-count={trendCards.length}
>
{trendCards.map((entry) => (
<div key={entry.id} className="flex min-w-0">
<div
key={entry.id}
className="flex min-w-[10rem] shrink-0 snap-start sm:min-w-0 sm:shrink"
>
{entry.node}
</div>
))}
</div>
)}
{/* v1.4.16 phase D reconcile (CRITICAL C2) — dashboard
preview of the polished AI recommendations surface.
Pinned above the chart row (out-of-band from the sorted
charts[] array) so it stays at the top regardless of
widget reorder operations. Self-hides when the user has
no provider configured OR no recommendations to surface
(`<InsightsCardPreview>` returns null), so the preview
doesn't paint an empty card on first visit. */}
{showInsightsPreview && (
<InsightsCardPreview
insight={advisor.payload?.insights ?? null}
/>
)}
{/* v1.4.25 W6 — GLP-1 status tile. The tile self-gates on
`Medication.treatmentClass === "GLP1"` (route returns
`data: null` when the user has no active GLP-1 med), so
we always mount it and let the tile suppress itself.
Slot is below the InsightsCardPreview and above the
chart row — high enough that a Mounjaro / Ozempic user
sees their dose-response chart without scrolling past
BP / pulse, but below the AI preview so the latter
stays the "scroll-stop hero" Marc anchored the dashboard
around. */}
v1.4.27 — the standalone insights preview retired (it
duplicated the much-richer `/insights` advisor surface);
the GLP-1 tile now anchors the top of the chart-row
stack. */}
<Glp1Tile />
{charts.map((entry) => (
<div key={entry.id} className="space-y-2">
+119 -23
View File
@@ -48,7 +48,7 @@ interface SectionProps {
function Section({ id, title, children }: SectionProps) {
return (
<section id={id} className="space-y-3 scroll-mt-20">
<section id={id} className="space-y-3 scroll-mt-28">
<h2 className="text-xl font-semibold tracking-tight md:text-2xl">
{title}
</h2>
@@ -107,17 +107,17 @@ export default function PrivacyPage() {
{/* Top bar — visible home-link so an App-Store reviewer can confirm
the policy belongs to the same brand as the iOS app. Public; no
nav menu (this page is reachable without a session). */}
<header className="border-border/60 bg-background/80 sticky top-0 z-10 border-b backdrop-blur">
<header className="border-border/60 bg-background/80 sticky top-0 z-10 border-b backdrop-blur pt-[env(safe-area-inset-top)]">
<div className="mx-auto flex max-w-3xl items-center justify-between px-4 py-3 md:px-6">
<Link
href="/"
className="text-foreground hover:text-primary text-sm font-semibold tracking-tight"
className="text-foreground hover:text-primary inline-flex min-h-11 items-center text-sm font-semibold tracking-tight"
>
HealthLog
</Link>
<Link
href="/auth/login"
className="text-muted-foreground hover:text-foreground text-sm"
className="text-muted-foreground hover:text-foreground inline-flex min-h-11 items-center text-sm"
>
Sign in
</Link>
@@ -143,6 +143,102 @@ export default function PrivacyPage() {
</p>
</div>
{/* v1.4.27 MB6 — collapsible TOC. Default-closed on every
viewport so the policy's first paragraphs reach the fold
on `<sm`; an open summary on a wide screen costs nothing,
and the reader can flip it open to skim the section list. */}
<details
className="border-border/60 bg-card/40 group rounded-md border"
data-slot="privacy-toc"
>
<summary className="text-foreground hover:bg-muted/50 cursor-pointer list-none rounded-md px-4 py-3 text-sm font-medium select-none">
Contents
<span
aria-hidden="true"
className="text-muted-foreground ml-2 inline-block transition-transform group-open:rotate-90"
>
›
</span>
</summary>
<nav
aria-label="Privacy policy contents"
className="border-border/60 border-t px-4 py-3"
>
<ol className="text-muted-foreground space-y-1.5 text-sm leading-relaxed">
<li>
<a className="hover:text-foreground hover:underline" href="#intro">
1. Overview
</a>
</li>
<li>
<a
className="hover:text-foreground hover:underline"
href="#data-we-collect"
>
2. Data we collect
</a>
</li>
<li>
<a className="hover:text-foreground hover:underline" href="#purpose">
3. Why we collect each category
</a>
</li>
<li>
<a
className="hover:text-foreground hover:underline"
href="#sub-processors"
>
4. Third-party sub-processors
</a>
</li>
<li>
<a className="hover:text-foreground hover:underline" href="#storage">
5. Data storage and retention
</a>
</li>
<li>
<a className="hover:text-foreground hover:underline" href="#rights">
6. Your rights (GDPR Art. 15-22, DSGVO)
</a>
</li>
<li>
<a
className="hover:text-foreground hover:underline"
href="#medical-boundary"
>
7. Medical-device boundary (EU MDR 2017/745, MDCG 2021-24)
</a>
</li>
<li>
<a
className="hover:text-foreground hover:underline"
href="#apple-categories"
>
8. Apple App Store privacy categories
</a>
</li>
<li>
<a
className="hover:text-foreground hover:underline"
href="#children"
>
9. Children
</a>
</li>
<li>
<a className="hover:text-foreground hover:underline" href="#changes">
10. Changes to this policy
</a>
</li>
<li>
<a className="hover:text-foreground hover:underline" href="#contact">
11. Contact
</a>
</li>
</ol>
</nav>
</details>
<Section id="intro" title="1. Overview">
<p>
HealthLog is an open-source, self-hostable personal-health-tracking
@@ -288,64 +384,64 @@ export default function PrivacyPage() {
data-slot="privacy-hk-quantity"
>
<li>
<code className="text-xs">bodyMass</code>
<code className="text-xs break-all">bodyMass</code>
</li>
<li>
<code className="text-xs">bodyFatPercentage</code>
<code className="text-xs break-all">bodyFatPercentage</code>
</li>
<li>
<code className="text-xs">bodyTemperature</code>
<code className="text-xs break-all">bodyTemperature</code>
</li>
<li>
<code className="text-xs">bloodPressureSystolic</code>
<code className="text-xs break-all">bloodPressureSystolic</code>
</li>
<li>
<code className="text-xs">bloodPressureDiastolic</code>
<code className="text-xs break-all">bloodPressureDiastolic</code>
</li>
<li>
<code className="text-xs">bloodGlucose</code>
<code className="text-xs break-all">bloodGlucose</code>
</li>
<li>
<code className="text-xs">oxygenSaturation</code>
<code className="text-xs break-all">oxygenSaturation</code>
</li>
<li>
<code className="text-xs">heartRate</code>
<code className="text-xs break-all">heartRate</code>
</li>
<li>
<code className="text-xs">restingHeartRate</code>
<code className="text-xs break-all">restingHeartRate</code>
</li>
<li>
<code className="text-xs">heartRateVariabilitySDNN</code>
<code className="text-xs break-all">heartRateVariabilitySDNN</code>
</li>
<li>
<code className="text-xs">vo2Max</code>
<code className="text-xs break-all">vo2Max</code>
</li>
<li>
<code className="text-xs">stepCount</code>
<code className="text-xs break-all">stepCount</code>
</li>
<li>
<code className="text-xs">activeEnergyBurned</code>
<code className="text-xs break-all">activeEnergyBurned</code>
</li>
<li>
<code className="text-xs">flightsClimbed</code>
<code className="text-xs break-all">flightsClimbed</code>
</li>
<li>
<code className="text-xs">distanceWalkingRunning</code>
<code className="text-xs break-all">distanceWalkingRunning</code>
</li>
<li>
<code className="text-xs">environmentalAudioExposure</code>
<code className="text-xs break-all">environmentalAudioExposure</code>
</li>
<li>
<code className="text-xs">headphoneAudioExposure</code>
<code className="text-xs break-all">headphoneAudioExposure</code>
</li>
<li>
<code className="text-xs">timeInDaylight</code> (iOS 17 and later)
<code className="text-xs break-all">timeInDaylight</code> (iOS 17 and later)
</li>
</ul>
<p className="font-medium">HKCategoryTypeIdentifier:</p>
<ul className="list-disc space-y-1 pl-5">
<li>
<code className="text-xs">sleepAnalysis</code> — read with full
<code className="text-xs break-all">sleepAnalysis</code> — read with full
per-stage granularity (Awake, REM, Core, Deep). Stages are stored
server-side as separate rows so the chart can stack them.
</li>
+1 -1
View File
@@ -196,7 +196,7 @@ export default function TargetsPage() {
if (isLoading) {
return (
<div className="flex items-center justify-center py-20">
<Loader2 className="text-primary h-6 w-6 animate-spin" />
<Loader2 className="text-primary h-6 w-6 animate-spin motion-reduce:animate-none" />
</div>
);
}