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

+194
View File
@@ -0,0 +1,194 @@
/**
* v1.4.27 B6 / BL-P4-8 — i18n drift-guard.
*
* Locks down the bundled key sets that ship across all six locales so
* a future copy-paste regression can't silently drop a translation
* row or introduce a per-locale-only key.
*
* The locale-integrity test (src/lib/__tests__/i18n-locale-integrity.test.ts)
* already asserts EN/locale parity for every key. This file pins
* specific call-site key groups so a contributor who only edits one
* bundle gets a precise failure instead of a 200-line parity diff.
*
* Scope (mirrors v1.4.27 buckets B1, B3, B4, B5):
* - dashboard.glp1.* (B1 — drug-level + weight tabs)
* - admin.carrier* + admin.providerWithings + admin.providerPasskey (B3 — login carrier chip)
* - insights.emptyState.* (B4 — per-metric empty states)
* - notifications.admin.* (B5 — admin notification messages)
* - notifications.user.* (B5 — user-facing Telegram test body)
* - personalRecords + workouts (BL-P4-8 — PR + Workout strings)
*/
import { readFileSync, readdirSync } from "node:fs";
import { join } from "node:path";
import { describe, expect, it } from "vitest";
const MESSAGES = join(__dirname, "../../messages");
const LOCALES = readdirSync(MESSAGES)
.filter((f) => f.endsWith(".json"))
.map((f) => f.replace(/\.json$/, ""));
function loadBundle(locale: string): Record<string, unknown> {
return JSON.parse(readFileSync(join(MESSAGES, `${locale}.json`), "utf8"));
}
function flatten(
obj: unknown,
prefix: string,
out: Map<string, string>,
): void {
if (obj == null || typeof obj !== "object") return;
for (const [k, v] of Object.entries(obj as Record<string, unknown>)) {
const key = prefix ? `${prefix}.${k}` : k;
if (typeof v === "string") out.set(key, v);
else if (typeof v === "object" && v !== null) flatten(v, key, out);
}
}
const FLAT_BY_LOCALE = new Map<string, Map<string, string>>();
for (const locale of LOCALES) {
const flat = new Map<string, string>();
flatten(loadBundle(locale), "", flat);
FLAT_BY_LOCALE.set(locale, flat);
}
function keysForPrefix(prefix: string): Set<string> {
// Source of truth: EN. The locale-integrity test ensures every
// locale carries the same key shape, so EN's view is canonical.
const enFlat = FLAT_BY_LOCALE.get("en");
if (!enFlat) throw new Error("EN bundle did not load");
const set = new Set<string>();
for (const k of enFlat.keys()) {
if (k === prefix || k.startsWith(`${prefix}.`)) set.add(k);
}
return set;
}
const REQUIRED_KEY_SETS: Array<{
label: string;
required: string[];
}> = [
{
label: "dashboard.glp1.* (B1 — GLP-1 tile tabs)",
required: [
"dashboard.glp1.title",
"dashboard.glp1.tabLevel",
"dashboard.glp1.tabWeight",
"dashboard.glp1.tabsAria",
"dashboard.glp1.rangeStripLabel",
"dashboard.glp1.levelUnavailable",
"dashboard.glp1.weightUnavailable",
"dashboard.glp1.lastInjection",
"dashboard.glp1.nextInjection",
],
},
{
label: "admin.carrier (B3 — login carrier chip)",
required: ["admin.carrier", "admin.carrierUnknown"],
},
{
label: "insights.emptyState.* (B4 — per-metric empty states)",
required: [
"insights.emptyState.bloodPressure.title",
"insights.emptyState.bloodPressure.description",
"insights.emptyState.bloodPressure.cta",
"insights.emptyState.weight.title",
"insights.emptyState.weight.description",
"insights.emptyState.weight.cta",
"insights.emptyState.pulse.title",
"insights.emptyState.pulse.description",
"insights.emptyState.pulse.cta",
"insights.emptyState.bmi.title",
"insights.emptyState.bmi.description",
"insights.emptyState.bmi.cta",
"insights.emptyState.mood.title",
"insights.emptyState.mood.description",
"insights.emptyState.mood.cta",
"insights.emptyState.medication.title",
"insights.emptyState.medication.description",
"insights.emptyState.medication.cta",
"insights.emptyState.sleep.title",
"insights.emptyState.sleep.description",
"insights.emptyState.sleep.cta",
],
},
{
label: "notifications.admin.* (B5 — admin notification dispatcher keys)",
required: [
"notifications.admin.deployFailedTitle",
"notifications.admin.deployFailedBody",
"notifications.admin.testNotificationTitle",
"notifications.admin.testNotificationBody",
"notifications.admin.reminderCheckMissedTitle",
"notifications.admin.reminderCheckMissedBody",
"notifications.admin.reminderCheckOverdueTitle",
"notifications.admin.reminderCheckOverdueBody",
// v1.4.27 R5 — offline-geo fallback notifications.
"notifications.admin.offlineGeoUnavailableTitle",
"notifications.admin.offlineGeoUnavailableBody",
],
},
{
label: "notifications.user.* (B5 — user-facing Telegram test body)",
required: ["notifications.user.telegramTestBody"],
},
];
describe("v1.4.27 B6 i18n drift-guard — required keys present in every locale", () => {
for (const { label, required } of REQUIRED_KEY_SETS) {
describe(label, () => {
it.each(LOCALES)(
"messages/%s.json has every required key with a non-empty value",
(locale) => {
const flat = FLAT_BY_LOCALE.get(locale)!;
const missing: string[] = [];
const empty: string[] = [];
for (const key of required) {
const v = flat.get(key);
if (v === undefined) missing.push(key);
else if (v.trim() === "") empty.push(key);
}
expect(
{ missing, empty },
`Locale ${locale} drifted from the ${label} key set.`,
).toEqual({ missing: [], empty: [] });
},
);
});
}
});
describe("v1.4.27 B6 i18n drift-guard — PR + Workout strings stay in lockstep", () => {
// BL-P4-8: PR and Workout strings were the trigger for the
// drift-guard ask. Personal-record badge / tooltip live under
// `insights.personalRecord.*`. Workout strings are not yet in the
// bundles — the v1.4.23 batch ingest landed the API, the UI
// surface ships in v1.4.28 / v1.5. The drift-guard keeps the
// namespace anchor live so the workout copy work in v1.4.28 lands
// with parity from the first commit.
const prKeys = [...keysForPrefix("insights.personalRecord")];
it("insights.personalRecord namespace has at least one key (sanity)", () => {
expect(
prKeys.length,
"insights.personalRecord.* keys exist in en.json",
).toBeGreaterThan(0);
});
it.each(LOCALES)(
"messages/%s.json carries every insights.personalRecord.* key with a non-empty value",
(locale) => {
const flat = FLAT_BY_LOCALE.get(locale)!;
const missing: string[] = [];
const empty: string[] = [];
for (const key of prKeys) {
const v = flat.get(key);
if (v === undefined) missing.push(key);
else if (v.trim() === "") empty.push(key);
}
expect(
{ missing, empty },
`Locale ${locale} drifted from the personal-record key set.`,
).toEqual({ missing: [], empty: [] });
},
);
});
+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>
);
}
@@ -0,0 +1,100 @@
import { describe, expect, it, vi } from "vitest";
import { renderToStaticMarkup } from "react-dom/server";
/**
* v1.4.27 MB5 — `/admin/app-logs` summary line lifted out of the
* `overflow-x-auto` table wrapper so admins on narrow viewports can
* still see "showing X of Y events" without first scrolling the table
* back to its starting offset. The refresh control already lives in
* the section header.
*/
vi.mock("next/navigation", () => ({
useRouter: () => ({ push: vi.fn(), replace: vi.fn() }),
usePathname: () => "/admin/app-logs",
}));
const sampleEvents = [
{
request_id: "req-1",
trace_id: "abcdef1234567890",
level: "info" as const,
timestamp: "2026-05-10T09:00:00Z",
duration_ms: 12,
action: { name: "auth.login.success" },
kind: "request",
http: { method: "POST", path: "/api/auth/login" },
},
];
vi.mock("@tanstack/react-query", () => ({
useQuery: () => ({
data: {
events: sampleEvents,
meta: { total: 1, bufferMax: 500 },
},
isLoading: false,
isError: false,
refetch: vi.fn(),
isFetching: false,
}),
useQueryClient: () => ({ invalidateQueries: vi.fn() }),
useMutation: () => ({
mutate: vi.fn(),
isPending: false,
isError: false,
error: null,
}),
}));
vi.mock("@/hooks/use-auth", () => ({
useAuth: () => ({
user: {
id: "u1",
username: "marc",
email: "marc@example.com",
role: "ADMIN",
},
isAuthenticated: true,
isLoading: false,
refetch: vi.fn(),
}),
}));
import { I18nProvider } from "@/lib/i18n/context";
import { AppLogPreviewSection } from "../app-log-preview-section";
function render() {
return renderToStaticMarkup(
<I18nProvider initialLocale="en">
<AppLogPreviewSection />
</I18nProvider>,
);
}
describe("AppLogPreviewSection — summary lives outside the scroll wrapper", () => {
it("renders the summary row with the stable test id", () => {
const html = render();
expect(html).toContain('data-testid="app-log-preview-summary"');
});
it("places the summary row outside any `overflow-x-auto` container", () => {
const html = render();
const summaryIdx = html.indexOf('data-testid="app-log-preview-summary"');
expect(summaryIdx).toBeGreaterThan(-1);
const before = html.slice(0, summaryIdx);
const lastScrollOpen = before.lastIndexOf('class="overflow-x-auto"');
expect(lastScrollOpen).toBeGreaterThan(-1);
const tail = before.slice(lastScrollOpen);
expect(tail).toContain("</div>");
});
it("does not nest the summary copy inside the scroll wrapper", () => {
const html = render();
const wrapper = html.match(
/<div class="overflow-x-auto"[^>]*>[\s\S]*?<\/table><\/div>/,
);
expect(wrapper).not.toBeNull();
expect(wrapper![0]).not.toContain("app-log-preview-summary");
});
});
@@ -2,6 +2,7 @@ import { describe, it, expect } from "vitest";
import {
auditLogCsvHeaderLabels,
buildAuditLogCsvRecords,
carrierShortLabel,
type AuditCsvEntry,
type AuditCsvLabels,
} from "../_shared";
@@ -30,6 +31,9 @@ const LABELS_EN: AuditCsvLabels = {
user: "Users",
ip: "IP",
location: "Location",
// v1.4.27 B3 — `admin.carrier` is shipped by bucket B6; the test
// pins the English literal so the column order is locked.
carrier: "Carrier",
provider: "Provider",
outcome: "Outcome",
action: "Action",
@@ -52,6 +56,7 @@ const LABELS_DE: AuditCsvLabels = {
user: "Benutzer",
ip: "IP",
location: "Standort",
carrier: "Mobilfunkanbieter",
provider: "Anbieter",
outcome: "Ergebnis",
action: "Aktion",
@@ -98,6 +103,7 @@ describe("buildAuditLogCsvRecords", () => {
"user",
"ip",
"location",
"carrier",
"provider",
"outcome",
"action",
@@ -196,6 +202,7 @@ describe("auditLogCsvHeaderLabels", () => {
user: "Users",
ip: "IP",
location: "Location",
carrier: "Carrier",
provider: "Provider",
outcome: "Outcome",
action: "Action",
@@ -224,13 +231,105 @@ describe("toCSV(records, headerLabels) — audit-log integration", () => {
const csv = toCSV(records, auditLogCsvHeaderLabels(LABELS_DE));
const lines = csv.split("\n");
expect(lines[0]).toBe(
"Zeitpunkt,Benutzer,IP,Standort,Anbieter,Ergebnis,Aktion,Details",
"Zeitpunkt,Benutzer,IP,Standort,Mobilfunkanbieter,Anbieter,Ergebnis,Aktion,Details",
);
expect(lines[1]).toBe(
'2026-05-11T09:05:00+00:00,marc,203.0.113.7,"Berlin, DE",Passkey,Erfolgreich,auth.login.passkey,',
'2026-05-11T09:05:00+00:00,marc,203.0.113.7,"Berlin, DE",,Passkey,Erfolgreich,auth.login.passkey,',
);
});
it("folds the GeoLite2 organisation string to the short DACH carrier label", () => {
// Marc's spec: the verbose MMDB org strings get a glanceable chip.
// The CSV column carries the same folded text so a downstream
// spreadsheet matches what the admin overview screen renders.
const entries: AuditCsvEntry[] = [
{
createdAt: "2026-05-11T09:05:00.000Z",
action: "auth.login.password",
ipAddress: "84.131.0.1",
location: "Berlin, DE",
carrier: "Deutsche Telekom AG",
asn: 3320,
details: null,
user: { id: "u1", username: "marc" },
},
{
createdAt: "2026-05-11T09:06:00.000Z",
action: "auth.login.password",
ipAddress: "139.7.0.1",
location: "Hamburg, DE",
carrier: "Vodafone GmbH",
asn: 3209,
details: null,
user: { id: "u2", username: "marc" },
},
{
createdAt: "2026-05-11T09:07:00.000Z",
action: "auth.login.password",
ipAddress: "212.7.0.1",
location: "Köln, DE",
carrier: "1&1 Versatel GmbH",
asn: 8881,
details: null,
user: { id: "u3", username: "marc" },
},
{
createdAt: "2026-05-11T09:08:00.000Z",
action: "auth.login.password",
ipAddress: "92.7.0.1",
location: "Frankfurt, DE",
carrier: "Telefónica Germany GmbH & Co. OHG",
asn: 6805,
details: null,
user: { id: "u4", username: "marc" },
},
{
createdAt: "2026-05-11T09:09:00.000Z",
action: "auth.login.password",
ipAddress: "8.8.8.8",
location: "Mountain View, US",
carrier: "Google LLC",
asn: 15169,
details: null,
user: { id: "u5", username: "marc" },
},
];
const records = buildAuditLogCsvRecords(
entries,
LABELS_EN,
deterministicFormatter,
);
expect(records.map((r) => r.carrier)).toEqual([
"Telekom",
"Vodafone",
"1&1",
"O2",
// Unknown organisations carry the raw GeoLite2 string through.
"Google LLC",
]);
});
it("emits an empty carrier cell when the row has no ASN data", () => {
const entries: AuditCsvEntry[] = [
{
createdAt: "2026-05-11T09:05:00.000Z",
action: "auth.login.password",
ipAddress: "192.0.2.1",
location: null,
carrier: null,
asn: null,
details: null,
user: null,
},
];
const [record] = buildAuditLogCsvRecords(
entries,
LABELS_EN,
deterministicFormatter,
);
expect(record.carrier).toBe("");
});
it("escapes commas, quotes, and newlines in the details column", () => {
const entries: AuditCsvEntry[] = [
{
@@ -256,3 +355,35 @@ describe("toCSV(records, headerLabels) — audit-log integration", () => {
);
});
});
describe("carrierShortLabel — DACH carrier folding (v1.4.27 B3)", () => {
it("folds 'Deutsche Telekom AG' down to 'Telekom'", () => {
expect(carrierShortLabel("Deutsche Telekom AG")).toBe("Telekom");
});
it("folds the standalone Vodafone variants down to 'Vodafone'", () => {
expect(carrierShortLabel("Vodafone GmbH")).toBe("Vodafone");
expect(carrierShortLabel("Vodafone Kabel Deutschland GmbH")).toBe(
"Vodafone",
);
});
it("folds the 1&1 sub-brands down to '1&1'", () => {
expect(carrierShortLabel("1&1 Telecom GmbH")).toBe("1&1");
expect(carrierShortLabel("1&1 Versatel GmbH")).toBe("1&1");
});
it("folds Telefónica + O2 variants down to 'O2'", () => {
expect(
carrierShortLabel("Telefónica Germany GmbH & Co. OHG"),
).toBe("O2");
expect(carrierShortLabel("Telefonica Germany Online Services")).toBe("O2");
expect(carrierShortLabel("O2 Deutschland")).toBe("O2");
});
it("returns the raw organisation string for unknown carriers", () => {
expect(carrierShortLabel("Google LLC")).toBe("Google LLC");
expect(carrierShortLabel("Amazon.com, Inc.")).toBe("Amazon.com, Inc.");
expect(carrierShortLabel("Hetzner Online GmbH")).toBe("Hetzner Online GmbH");
});
});
@@ -0,0 +1,125 @@
import { describe, expect, it, vi } from "vitest";
import { renderToStaticMarkup } from "react-dom/server";
/**
* v1.4.27 MB5 — `/admin/login-overview` pagination + summary lifted
* out of the `overflow-x-auto` table wrapper.
*
* The audit table can be wider than the viewport on phones, so it
* stays in an `overflow-x-auto` container that pans horizontally on
* touch. Before this fix the prev/next controls lived *inside* that
* same wrapper, meaning admins on narrow viewports had to scroll the
* table back to its starting offset before they could reach the
* pagination controls. The summary "showing X of Y" line was hidden
* the same way. The fix moves both into a sibling block under the
* scroll container.
*/
vi.mock("next/navigation", () => ({
useRouter: () => ({ push: vi.fn(), replace: vi.fn() }),
usePathname: () => "/admin/login-overview",
}));
const sampleEntries = [
{
id: "ev-1",
user: { id: "u1", username: "marc" },
action: "auth.login.success",
ipAddress: "203.0.113.10",
location: "Berlin, DE",
carrier: null,
createdAt: "2026-05-10T09:00:00Z",
},
];
vi.mock("@tanstack/react-query", () => ({
useQuery: () => ({
data: {
entries: sampleEntries,
meta: { total: 23, limit: 50, offset: 0, page: 1, perPage: 50 },
},
isLoading: false,
isError: false,
refetch: vi.fn(),
}),
useQueryClient: () => ({ invalidateQueries: vi.fn() }),
useMutation: () => ({
mutate: vi.fn(),
isPending: false,
isError: false,
error: null,
}),
}));
vi.mock("@/hooks/use-auth", () => ({
useAuth: () => ({
user: {
id: "u1",
username: "marc",
email: "marc@example.com",
role: "ADMIN",
timezone: "Europe/Berlin",
},
isAuthenticated: true,
isLoading: false,
refetch: vi.fn(),
}),
}));
import { I18nProvider } from "@/lib/i18n/context";
import { LoginOverviewSection } from "../login-overview-section";
function render() {
return renderToStaticMarkup(
<I18nProvider initialLocale="en">
<LoginOverviewSection />
</I18nProvider>,
);
}
describe("LoginOverviewSection — pagination lives outside the scroll wrapper", () => {
it("renders the pagination row with the stable test id", () => {
const html = render();
expect(html).toContain('data-testid="login-overview-pagination"');
});
it("places the pagination row outside any `overflow-x-auto` container", () => {
const html = render();
// Capture the pagination block and assert that the rendered
// markup leading up to its opening tag closes the scroll
// container first. The simplest invariant: there is no
// `<div class="overflow-x-auto">` ancestor that is still open
// when the pagination div opens. We approximate this by finding
// the last `<div class="overflow-x-auto"` before the pagination
// div and confirming the inner content closes (matching `</div>`)
// before the pagination div opens.
const paginationIdx = html.indexOf(
'data-testid="login-overview-pagination"',
);
expect(paginationIdx).toBeGreaterThan(-1);
const before = html.slice(0, paginationIdx);
const lastScrollOpen = before.lastIndexOf('class="overflow-x-auto"');
expect(lastScrollOpen).toBeGreaterThan(-1);
// Between the scroll-wrapper opening and the pagination opening
// there must be at least one `</div>` closing the wrapper.
const tail = before.slice(lastScrollOpen);
expect(tail).toContain("</div>");
});
it("does not nest the prev/next buttons inside the scroll wrapper", () => {
const html = render();
// Pin the entire scroll wrapper and confirm the prev/next button
// labels are not within it. We slice the markup between the
// wrapper's opening tag and its first balanced closing tag at the
// same depth — naively by grabbing up to `</table></div>`, which
// matches the structure produced by the current implementation.
const wrapper = html.match(
/<div class="overflow-x-auto"[^>]*>[\s\S]*?<\/table><\/div>/,
);
expect(wrapper).not.toBeNull();
expect(wrapper![0]).not.toContain("admin.section.auditLog.next");
// The English labels also must not appear inside the wrapper.
expect(wrapper![0]).not.toMatch(/>\s*Next\s*</);
expect(wrapper![0]).not.toMatch(/>\s*Previous\s*</);
});
});
+54 -6
View File
@@ -6,7 +6,7 @@ import { toast } from "sonner";
import { Cpu, Fingerprint, Globe, KeyRound } from "lucide-react";
import { Switch } from "@/components/ui/switch";
import { useTranslations } from "@/lib/i18n/context";
import { PasswordInput as SettingsPasswordInput } from "@/components/settings/password-input";
import { PasswordInput as SharedPasswordInput } from "@/components/ui/password-input";
/**
* Re-export the canonical password input so admin sections have access to
@@ -15,7 +15,7 @@ import { PasswordInput as SettingsPasswordInput } from "@/components/settings/pa
* `button-name` violation on `/admin/integrations` and
* `/admin/users` (axe-core, WCAG 2.1.2).
*/
export const PasswordInput = SettingsPasswordInput;
export const PasswordInput = SharedPasswordInput;
export interface AdminUser {
id: string;
@@ -92,6 +92,11 @@ export interface AdminAuditEntry {
action: string;
ipAddress: string | null;
location: string | null;
// v1.4.27 B3 — populated from the bundled GeoLite2-ASN MMDB at
// audit-creation time. Both nullable: older rows, private/loopback
// IPs, and offline-miss rows stay valid.
asn: number | null;
carrier: string | null;
details: string | null;
createdAt: string;
user: { id: string; username: string } | null;
@@ -190,11 +195,18 @@ export function SettingsToggle({
onCheckedChange: (checked: boolean) => void;
disabled: boolean;
}) {
// v1.4.27 MB7 / CF-56 — stack the toggle row vertically on `<sm` so
// the label + description don't have to compete with the switch for
// horizontal space on Pixel 5 (the German "Registration enabled — …"
// description used to wrap to 4 lines while the switch ate ~52 px on
// the right). At `sm:` the original side-by-side layout returns.
// The switch self-aligns to the right via `self-end` on the stacked
// branch so it stays a familiar tap target.
return (
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
{Icon && <Icon className="text-muted-foreground h-4 w-4" />}
<div>
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between sm:gap-4">
<div className="flex min-w-0 items-center gap-2">
{Icon && <Icon className="text-muted-foreground h-4 w-4 shrink-0" />}
<div className="min-w-0">
<p className="text-sm font-medium">{label}</p>
{description && (
<p className="text-muted-foreground text-xs">{description}</p>
@@ -205,6 +217,7 @@ export function SettingsToggle({
checked={checked}
onCheckedChange={onCheckedChange}
disabled={disabled}
className="self-end sm:self-auto"
/>
</div>
);
@@ -380,6 +393,30 @@ export function useAuthProviderLabels(): Record<AuthProvider, string> {
);
}
/**
* v1.4.27 B3 — Fold a GeoLite2 `autonomous_system_organization`
* string down to a short DACH carrier label. The MMDB returns the
* verbose legal-entity name ("Deutsche Telekom AG", "1&1 Telecom
* GmbH", "Telefónica Germany GmbH & Co. OHG"); the admin overview
* needs a glanceable chip ("Telekom", "Vodafone", "1&1", "O2").
*
* The match is case-insensitive substring against the canonical
* carrier name; unknown organisations fall through to the original
* string so the chip still carries provenance.
*/
export function carrierShortLabel(rawAsnOrg: string): string {
const haystack = rawAsnOrg.toLowerCase();
if (haystack.includes("telekom")) return "Telekom";
if (haystack.includes("vodafone")) return "Vodafone";
if (haystack.includes("1&1")) return "1&1";
// Telefónica / O2 / Telefonica Germany / O2 Deutschland → "O2"
if (haystack.includes("telefonica") || haystack.includes("telefónica")) {
return "O2";
}
if (haystack.includes("o2 ") || haystack.endsWith(" o2")) return "O2";
return rawAsnOrg;
}
/**
* v1.4.25 W8b — Build the CSV record set for the audit-log export.
*
@@ -391,6 +428,9 @@ export function useAuthProviderLabels(): Record<AuthProvider, string> {
* require schema/API changes that are out of scope). `action` and
* `details` follow so the export keeps the full triage payload.
*
* v1.4.27 B3: the `carrier` column lands between `location` and
* `provider` so the geo-derived fields stay grouped.
*
* `formatTimestamp` is injected (not imported) so the production
* callsite passes the user-tz formatter (`formatInUserTz`) while the
* unit test passes a deterministic stub.
@@ -400,6 +440,10 @@ export interface AuditCsvEntry {
action: string;
ipAddress: string | null;
location: string | null;
// v1.4.27 B3 — both optional so legacy fixture data that predates
// the column landing still compiles against this shape.
carrier?: string | null;
asn?: number | null;
details: string | null;
user: { id: string; username: string } | null;
}
@@ -409,6 +453,7 @@ export interface AuditCsvLabels {
user: string;
ip: string;
location: string;
carrier: string;
provider: string;
outcome: string;
action: string;
@@ -424,6 +469,7 @@ export interface AuditCsvRecord {
user: string;
ip: string;
location: string;
carrier: string;
provider: string;
outcome: string;
action: string;
@@ -450,6 +496,7 @@ export function buildAuditLogCsvRecords(
user: entry.user?.username ?? labels.unknownUser,
ip: entry.ipAddress ?? "",
location: entry.location ?? "",
carrier: entry.carrier ? carrierShortLabel(entry.carrier) : "",
provider: labels.providerLabels[provider],
outcome: isFailed ? labels.outcomeFailed : labels.outcomeSuccess,
action: entry.action,
@@ -466,6 +513,7 @@ export function auditLogCsvHeaderLabels(
user: labels.user,
ip: labels.ip,
location: labels.location,
carrier: labels.carrier,
provider: labels.provider,
outcome: labels.outcome,
action: labels.action,
+6 -2
View File
@@ -263,8 +263,12 @@ export function AdminShell({ active, children }: AdminShellProps) {
</div>
</aside>
{/* Main column — page renders its own h1 + subtitle */}
<main className="min-w-0">{children}</main>
{/* Main column — page renders its own h1 + subtitle. Same
`min-h-[calc(100dvh-12rem)]` reserve as `<SettingsShell>`
so navigating between admin sub-pages (e.g. system-status
→ login-overview) does not jump the page height while the
new section is still fetching. */}
<main className="min-h-[calc(100dvh-12rem)] min-w-0">{children}</main>
</div>
</div>
);
+1 -1
View File
@@ -75,7 +75,7 @@ export function AiQualitySection() {
if (query.isLoading) {
return (
<div className="bg-card border-border flex items-center gap-2 rounded-xl border p-6">
<Loader2 className="h-4 w-4 animate-spin" />
<Loader2 className="h-4 w-4 animate-spin motion-reduce:animate-none" />
<span className="text-muted-foreground text-sm">
{t("admin.aiQuality.loading")}
</span>
@@ -133,7 +133,7 @@ export function AppLogPreviewSection() {
aria-label={t("admin.section.app-logs.refresh")}
>
<RefreshCw
className={`h-4 w-4 ${isFetching ? "animate-spin" : ""}`}
className={`h-4 w-4 ${isFetching ? "animate-spin" : ""} motion-reduce:animate-none`}
/>
</Button>
</header>
@@ -195,7 +195,7 @@ export function AppLogPreviewSection() {
{isLoading ? (
<div className="flex justify-center py-4">
<Loader2 className="text-muted-foreground h-5 w-5 animate-spin" />
<Loader2 className="text-muted-foreground h-5 w-5 animate-spin motion-reduce:animate-none" />
</div>
) : events.length === 0 ? (
<EmptyState
@@ -204,54 +204,64 @@ export function AppLogPreviewSection() {
description={t("admin.section.app-logs.emptyDescription")}
/>
) : (
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="text-muted-foreground border-b text-xs">
<th className="px-2 py-2 text-left font-medium">
{t("admin.section.app-logs.colLevel")}
</th>
<th className="px-2 py-2 text-left font-medium">
{t("admin.section.app-logs.colTimestamp")}
</th>
<th className="px-2 py-2 text-left font-medium">
{t("admin.section.app-logs.colAction")}
</th>
<th className="px-2 py-2 text-right font-medium">
{t("admin.section.app-logs.colDuration")}
</th>
<th className="px-2 py-2 text-left font-medium">
{t("admin.section.app-logs.colTraceId")}
</th>
</tr>
</thead>
<tbody className="divide-border divide-y">
{events.map((event, i) => (
<tr
key={`${event.request_id}-${i}`}
className={`${i % 2 === 0 ? "bg-muted/30" : ""} hover:bg-muted cursor-pointer`}
onClick={() => setSelected(event)}
>
<td className="px-2 py-2">{levelIcon(event.level)}</td>
<td className="text-muted-foreground px-2 py-2 text-xs whitespace-nowrap">
{formatDateTime(event.timestamp)}
</td>
<td className="px-2 py-2 text-xs">
{event.action?.name ??
(`${event.http?.method ?? ""} ${event.http?.path ?? ""}`.trim() ||
event.kind)}
</td>
<td className="text-muted-foreground px-2 py-2 text-right font-mono text-xs">
{event.duration_ms} ms
</td>
<td className="text-muted-foreground px-2 py-2 font-mono text-xs">
{event.trace_id.slice(0, 8)}…
</td>
// v1.4.27 MB5 — keep the table scroll container, but lift the
// "showing X of Y" summary line out into a sibling `<div>` so
// the meta info stays reachable on narrow viewports even when
// the table is scrolled horizontally. The refresh control was
// already in the header above the table wrapper.
<>
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="text-muted-foreground border-b text-xs">
<th className="px-2 py-2 text-left font-medium">
{t("admin.section.app-logs.colLevel")}
</th>
<th className="px-2 py-2 text-left font-medium">
{t("admin.section.app-logs.colTimestamp")}
</th>
<th className="px-2 py-2 text-left font-medium">
{t("admin.section.app-logs.colAction")}
</th>
<th className="px-2 py-2 text-right font-medium">
{t("admin.section.app-logs.colDuration")}
</th>
<th className="px-2 py-2 text-left font-medium">
{t("admin.section.app-logs.colTraceId")}
</th>
</tr>
))}
</tbody>
</table>
<div className="text-muted-foreground mt-3 flex items-center justify-between text-xs">
</thead>
<tbody className="divide-border divide-y">
{events.map((event, i) => (
<tr
key={`${event.request_id}-${i}`}
className={`${i % 2 === 0 ? "bg-muted/30" : ""} hover:bg-muted cursor-pointer`}
onClick={() => setSelected(event)}
>
<td className="px-2 py-2">{levelIcon(event.level)}</td>
<td className="text-muted-foreground px-2 py-2 text-xs whitespace-nowrap">
{formatDateTime(event.timestamp)}
</td>
<td className="px-2 py-2 text-xs">
{event.action?.name ??
(`${event.http?.method ?? ""} ${event.http?.path ?? ""}`.trim() ||
event.kind)}
</td>
<td className="text-muted-foreground px-2 py-2 text-right font-mono text-xs">
{event.duration_ms} ms
</td>
<td className="text-muted-foreground px-2 py-2 font-mono text-xs">
{event.trace_id.slice(0, 8)}…
</td>
</tr>
))}
</tbody>
</table>
</div>
<div
className="text-muted-foreground mt-3 flex items-center justify-between text-xs"
data-testid="app-log-preview-summary"
>
<span>
{t("admin.section.app-logs.showing", {
count: events.length,
@@ -259,7 +269,7 @@ export function AppLogPreviewSection() {
})}
</span>
</div>
</div>
</>
)}
<Dialog
+1 -1
View File
@@ -90,7 +90,7 @@ function RestoreRowDialog({
className="min-h-11"
>
{pending ? (
<Loader2 className="mr-1 h-3.5 w-3.5 animate-spin" />
<Loader2 className="mr-1 h-3.5 w-3.5 animate-spin motion-reduce:animate-none" />
) : (
<History className="mr-1 h-3.5 w-3.5" />
)}
+1 -1
View File
@@ -119,7 +119,7 @@ export function BugReportSection() {
disabled={updateSettings.isPending}
>
{updateSettings.isPending && (
<Loader2 className="mr-1.5 h-3.5 w-3.5 animate-spin" />
<Loader2 className="mr-1.5 h-3.5 w-3.5 animate-spin motion-reduce:animate-none" />
)}
{t("common.save")}
</Button>
@@ -99,7 +99,7 @@ export function CoachFeedbackSection() {
{query.isLoading ? (
<div className="flex items-center gap-2">
<Loader2 className="h-4 w-4 animate-spin" />
<Loader2 className="h-4 w-4 animate-spin motion-reduce:animate-none" />
<span className="text-muted-foreground text-sm">
{t("admin.coachFeedback.loading")}
</span>
+1 -1
View File
@@ -85,7 +85,7 @@ export function DangerZoneSection() {
disabled={wipeAllData.isPending}
>
{wipeAllData.isPending ? (
<Loader2 className="mr-1 h-3.5 w-3.5 animate-spin" />
<Loader2 className="mr-1 h-3.5 w-3.5 animate-spin motion-reduce:animate-none" />
) : (
<Trash2 className="mr-1 h-3.5 w-3.5" />
)}
+128 -123
View File
@@ -25,13 +25,7 @@ import {
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { EmptyState } from "@/components/ui/empty-state";
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { ResponsiveSheet } from "@/components/ui/responsive-sheet";
import { Label } from "@/components/ui/label";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { formatDateTime } from "@/lib/format";
@@ -85,26 +79,34 @@ export function FeedbackInboxSection() {
onValueChange={(v) => setActiveStatus(v as FeedbackStatusType)}
className="mt-4"
>
<TabsList>
{FEEDBACK_STATUS_TABS.map((s) => (
<TabsTrigger key={s} value={s}>
<span>
{t(
`admin.feedback.tab${s.charAt(0) + s.slice(1).toLowerCase()}`,
)}
</span>
<Badge variant="secondary" className="ml-1.5 text-xs">
{counts[s] ?? 0}
</Badge>
</TabsTrigger>
))}
</TabsList>
{/* v1.4.27 MB7 / CF-55 — wrap the TabsList in an
overflow-x-auto strip so the 4 status pills + their count
badges scroll horizontally on Pixel 5 / Galaxy Fold
instead of squashing the badge text below the legibility
floor. Hide the scrollbar visually but keep the gesture
working (matches the Insights tab-strip pattern). */}
<div className="-mx-1 overflow-x-auto px-1 [scrollbar-width:none] [&::-webkit-scrollbar]:hidden">
<TabsList>
{FEEDBACK_STATUS_TABS.map((s) => (
<TabsTrigger key={s} value={s}>
<span>
{t(
`admin.feedback.tab${s.charAt(0) + s.slice(1).toLowerCase()}`,
)}
</span>
<Badge variant="secondary" className="ml-1.5 text-xs">
{counts[s] ?? 0}
</Badge>
</TabsTrigger>
))}
</TabsList>
</div>
{FEEDBACK_STATUS_TABS.map((s) => (
<TabsContent key={s} value={s} className="mt-4">
{isLoading ? (
<div className="flex items-center gap-2">
<Loader2 className="text-muted-foreground h-4 w-4 animate-spin" />
<Loader2 className="text-muted-foreground h-4 w-4 animate-spin motion-reduce:animate-none" />
<span className="text-muted-foreground text-sm">
{t("admin.feedback.loading")}
</span>
@@ -321,38 +323,110 @@ function FeedbackDetailDialog({
typeof meta.appVersion === "string" ? meta.appVersion : null;
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-h-[85vh] overflow-y-auto sm:max-w-2xl">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<MessageSquare className="h-5 w-5" />
{item.subject}
</DialogTitle>
<DialogDescription>
<span className="flex flex-wrap items-center gap-2 text-xs">
<FeedbackCategoryBadge category={item.category} />
<span>
{t("admin.feedback.submittedBy")}:{" "}
{item.user?.username ?? t("admin.feedback.anonymous")}
</span>
<span>·</span>
<span>{formatDateTime(item.createdAt)}</span>
{issueUrl && (
<a
href={issueUrl}
target="_blank"
rel="noopener noreferrer"
className="text-primary inline-flex items-center gap-1"
<ResponsiveSheet
open={open}
onOpenChange={onOpenChange}
title={
<span className="flex items-center gap-2">
<MessageSquare className="h-5 w-5" />
{item.subject}
</span>
}
description={
<span className="flex flex-wrap items-center gap-2 text-xs">
<FeedbackCategoryBadge category={item.category} />
<span>
{t("admin.feedback.submittedBy")}:{" "}
{item.user?.username ?? t("admin.feedback.anonymous")}
</span>
<span>·</span>
<span>{formatDateTime(item.createdAt)}</span>
{issueUrl && (
<a
href={issueUrl}
target="_blank"
rel="noopener noreferrer"
className="text-primary inline-flex items-center gap-1"
>
<GitPullRequest className="h-3 w-3" />
{t("admin.feedback.viewIssue")}
<ExternalLink className="h-3 w-3" />
</a>
)}
</span>
}
className="sm:max-w-2xl"
footer={
<div className="flex w-full flex-wrap items-center gap-2">
<Button
size="sm"
variant="outline"
disabled={update.isPending || item.status === "ACKNOWLEDGED"}
onClick={() => update.mutate({ status: "ACKNOWLEDGED" })}
>
<CheckCircle2 className="mr-1.5 h-3.5 w-3.5" />
{t("admin.feedback.actionAcknowledge")}
</Button>
<Button
size="sm"
disabled={update.isPending || item.status === "RESOLVED"}
onClick={() => update.mutate({ status: "RESOLVED" })}
>
<CheckCircle2 className="mr-1.5 h-3.5 w-3.5" />
{t("admin.feedback.actionResolve")}
</Button>
<AlertDialog>
<AlertDialogTrigger asChild>
<Button
size="sm"
variant="destructive"
disabled={archive.isPending || item.status === "ARCHIVED"}
>
<Trash2 className="mr-1.5 h-3.5 w-3.5" />
{t("admin.feedback.actionArchive")}
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>
{t("admin.feedback.archiveConfirm")}
</AlertDialogTitle>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>{t("common.cancel")}</AlertDialogCancel>
<AlertDialogAction
onClick={() => archive.mutate()}
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
>
<GitPullRequest className="h-3 w-3" />
{t("admin.feedback.viewIssue")}
<ExternalLink className="h-3 w-3" />
</a>
{t("admin.feedback.actionArchive")}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
{githubConfigured && !issueUrl && (
<Button
size="sm"
variant="outline"
disabled={publish.isPending}
onClick={() => publish.mutate()}
className="ml-auto"
>
{publish.isPending ? (
<Loader2 className="mr-1.5 h-3.5 w-3.5 animate-spin motion-reduce:animate-none" />
) : (
<GitPullRequest className="mr-1.5 h-3.5 w-3.5" />
)}
</span>
</DialogDescription>
</DialogHeader>
{t("admin.feedback.actionPublishGithub")}
</Button>
)}
{issueUrl && (
<Badge className="border-success/30 bg-success/15 text-success ml-auto">
{t("admin.feedback.publishedToGithub")}
</Badge>
)}
</div>
}
>
<div className="space-y-4 text-sm">
<div className="bg-muted/40 rounded-md p-3 whitespace-pre-wrap">
{item.description}
@@ -435,83 +509,14 @@ function FeedbackDetailDialog({
onClick={() => update.mutate({ adminNote: note || null })}
>
{update.isPending && (
<Loader2 className="mr-1.5 h-3.5 w-3.5 animate-spin" />
<Loader2 className="mr-1.5 h-3.5 w-3.5 animate-spin motion-reduce:animate-none" />
)}
{t("admin.feedback.saveNote")}
</Button>
</div>
</div>
<div className="border-border flex flex-wrap items-center gap-2 border-t pt-3">
<Button
size="sm"
variant="outline"
disabled={update.isPending || item.status === "ACKNOWLEDGED"}
onClick={() => update.mutate({ status: "ACKNOWLEDGED" })}
>
<CheckCircle2 className="mr-1.5 h-3.5 w-3.5" />
{t("admin.feedback.actionAcknowledge")}
</Button>
<Button
size="sm"
disabled={update.isPending || item.status === "RESOLVED"}
onClick={() => update.mutate({ status: "RESOLVED" })}
>
<CheckCircle2 className="mr-1.5 h-3.5 w-3.5" />
{t("admin.feedback.actionResolve")}
</Button>
<AlertDialog>
<AlertDialogTrigger asChild>
<Button
size="sm"
variant="destructive"
disabled={archive.isPending || item.status === "ARCHIVED"}
>
<Trash2 className="mr-1.5 h-3.5 w-3.5" />
{t("admin.feedback.actionArchive")}
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>
{t("admin.feedback.archiveConfirm")}
</AlertDialogTitle>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>{t("common.cancel")}</AlertDialogCancel>
<AlertDialogAction
onClick={() => archive.mutate()}
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
>
{t("admin.feedback.actionArchive")}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
{githubConfigured && !issueUrl && (
<Button
size="sm"
variant="outline"
disabled={publish.isPending}
onClick={() => publish.mutate()}
className="ml-auto"
>
{publish.isPending ? (
<Loader2 className="mr-1.5 h-3.5 w-3.5 animate-spin" />
) : (
<GitPullRequest className="mr-1.5 h-3.5 w-3.5" />
)}
{t("admin.feedback.actionPublishGithub")}
</Button>
)}
{issueUrl && (
<Badge className="border-success/30 bg-success/15 text-success ml-auto">
{t("admin.feedback.publishedToGithub")}
</Badge>
)}
</div>
</div>
</DialogContent>
</Dialog>
</ResponsiveSheet>
);
}
@@ -3,11 +3,16 @@
import { useMemo } from "react";
import { Settings } from "lucide-react";
import { useTranslations } from "@/lib/i18n/context";
import { NativeSelect } from "@/components/ui/native-select";
import { SettingsToggle, useAdminSettings, useUpdateSettings } from "./_shared";
import { listSupportedTimezones } from "@/lib/tz/format";
const NATIVE_SELECT_CLASS =
"border-input bg-background text-foreground ring-offset-background focus-visible:ring-ring flex h-9 rounded-md border px-3 py-1 text-sm shadow-xs transition-[color,box-shadow] focus-visible:ring-[3px] focus-visible:outline-none";
// v1.4.27 MB7 / CF-52 — the in-file `NATIVE_SELECT_CLASS` constant
// retired; the shared `<NativeSelect>` primitive owns the visual
// contract now. The admin copy previously diverged on the
// `focus-visible:ring-[3px]` weight; the shared primitive pins
// `focus-visible:ring-2` so admin selects match the rest of the form
// surface.
export function GeneralSettingsSection() {
const { t } = useTranslations();
@@ -36,24 +41,26 @@ export function GeneralSettingsSection() {
disabled={updateSettings.isPending}
/>
<div className="flex items-center justify-between">
<div>
{/* v1.4.27 MB7 / CF-56 — match the SettingsToggle stacking
contract: stack on `<sm`, side-by-side on `sm+`. */}
<div className="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
<div className="min-w-0">
<p className="text-sm font-medium">{t("admin.defaultLanguage")}</p>
<p className="text-muted-foreground text-xs">
{t("admin.defaultLanguageDescription")}
</p>
</div>
<select
<NativeSelect
value={settings?.defaultLocale ?? "de"}
onChange={(e) =>
updateSettings.mutate({ defaultLocale: e.target.value })
}
disabled={updateSettings.isPending}
className={NATIVE_SELECT_CLASS}
className="self-end sm:w-auto sm:self-auto"
>
<option value="de">Deutsch</option>
<option value="en">English</option>
</select>
</NativeSelect>
</div>
{/* v1.4.25 W7 — server-wide default timezone for new
@@ -69,13 +76,13 @@ export function GeneralSettingsSection() {
{t("admin.defaultUserTimezoneDescription")}
</p>
</div>
<select
<NativeSelect
value={currentDefaultTz}
onChange={(e) =>
updateSettings.mutate({ defaultUserTimezone: e.target.value })
}
disabled={updateSettings.isPending}
className={NATIVE_SELECT_CLASS}
className="sm:w-auto"
>
<option value="">{t("admin.defaultUserTimezoneFallback")}</option>
{/* Preserve the stored value even if it's not in the
@@ -88,7 +95,7 @@ export function GeneralSettingsSection() {
{zone}
</option>
))}
</select>
</NativeSelect>
</div>
</div>
</div>
+2 -2
View File
@@ -148,7 +148,7 @@ export function GlitchtipSection() {
disabled={testGlitchtip.isPending || updateSettings.isPending}
>
{testGlitchtip.isPending && (
<Loader2 className="mr-1.5 h-3.5 w-3.5 animate-spin" />
<Loader2 className="mr-1.5 h-3.5 w-3.5 animate-spin motion-reduce:animate-none" />
)}
{t("common.test")}
</Button>
@@ -158,7 +158,7 @@ export function GlitchtipSection() {
disabled={updateSettings.isPending}
>
{updateSettings.isPending && (
<Loader2 className="mr-1.5 h-3.5 w-3.5 animate-spin" />
<Loader2 className="mr-1.5 h-3.5 w-3.5 animate-spin motion-reduce:animate-none" />
)}
{t("common.save")}
</Button>
+119 -83
View File
@@ -29,6 +29,7 @@ import {
type AdminAuditEntry,
auditLogCsvHeaderLabels,
buildAuditLogCsvRecords,
carrierShortLabel,
iconForAuthProvider,
providerForAction,
useAuthActionLabels,
@@ -159,6 +160,11 @@ export function LoginOverviewSection() {
user: t("admin.users"),
ip: t("admin.ip"),
location: t("admin.location"),
// v1.4.27 B3 — `admin.carrier` is shipped by bucket B6. The
// `t()` helper falls back to the raw key string when the
// translation row hasn't landed yet, so the CSV stays valid
// through the staggered B3 → B6 release.
carrier: t("admin.carrier"),
provider: t("admin.provider"),
outcome: t("admin.outcome"),
action: t("admin.action"),
@@ -334,7 +340,7 @@ export function LoginOverviewSection() {
{isLoading ? (
<div className="flex justify-center py-4">
<Loader2 className="text-muted-foreground h-5 w-5 animate-spin" />
<Loader2 className="text-muted-foreground h-5 w-5 animate-spin motion-reduce:animate-none" />
</div>
) : !entries.length ? (
<EmptyState
@@ -362,26 +368,34 @@ export function LoginOverviewSection() {
}
/>
) : (
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="text-muted-foreground border-b text-xs">
<th className="px-3 py-2 text-left font-medium">
{t("admin.status")}
</th>
<th className="px-3 py-2 text-left font-medium">
{t("admin.users")}
</th>
<th className="px-3 py-2 text-left font-medium">
{t("admin.action")}
</th>
<th className="px-3 py-2 text-left font-medium">
{t("admin.provider")}
</th>
<th className="px-3 py-2 text-left font-medium">
{t("admin.ip")}
</th>
{/*
// v1.4.27 MB5 — the audit table itself stays in an
// `overflow-x-auto` scroll container so wide rows can pan
// horizontally on phones, but the pagination + summary line
// moved out into a sibling `<div>` below so the prev/next
// controls stay reachable without first scrolling the table
// back to its starting offset. The CSV-export button was
// already in the toolbar row above the table wrapper.
<>
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="text-muted-foreground border-b text-xs">
<th className="px-3 py-2 text-left font-medium">
{t("admin.status")}
</th>
<th className="px-3 py-2 text-left font-medium">
{t("admin.users")}
</th>
<th className="px-3 py-2 text-left font-medium">
{t("admin.action")}
</th>
<th className="px-3 py-2 text-left font-medium">
{t("admin.provider")}
</th>
<th className="px-3 py-2 text-left font-medium">
{t("admin.ip")}
</th>
{/*
v1.4.25 W8b — Standort column. Previously rendered
`text-right`, which on wide audit tables let the
`Berlin, DE` label drift to the table edge and
@@ -390,68 +404,90 @@ export function LoginOverviewSection() {
consistency) makes Standort a first-class column
that admins actually notice.
*/}
<th className="px-3 py-2 text-left font-medium">
<span className="inline-flex items-center gap-1">
<MapPin className="h-3 w-3" aria-hidden="true" />
{t("admin.location")}
</span>
</th>
<th className="px-3 py-2 text-right font-medium">
{t("admin.timestamp")}
</th>
</tr>
</thead>
<tbody className="divide-border divide-y">
{entries.map((entry, i) => {
const isFailed = entry.action === "auth.login.failed";
const provider = providerForAction(entry.action);
const ProviderIcon = iconForAuthProvider(provider);
return (
<tr
key={entry.id}
className={i % 2 === 0 ? "bg-muted/30" : ""}
>
<td className="px-3 py-2">
{isFailed ? (
<XCircle className="text-destructive h-4 w-4" />
) : (
<CheckCircle2 className="text-dracula-green h-4 w-4" />
)}
</td>
<td
className={`px-3 py-2 font-medium ${isFailed ? "text-destructive" : ""}`}
<th className="px-3 py-2 text-left font-medium">
<span className="inline-flex items-center gap-1">
<MapPin className="h-3 w-3" aria-hidden="true" />
{t("admin.location")}
</span>
</th>
<th className="px-3 py-2 text-right font-medium">
{t("admin.timestamp")}
</th>
</tr>
</thead>
<tbody className="divide-border divide-y">
{entries.map((entry, i) => {
const isFailed = entry.action === "auth.login.failed";
const provider = providerForAction(entry.action);
const ProviderIcon = iconForAuthProvider(provider);
return (
<tr
key={entry.id}
className={i % 2 === 0 ? "bg-muted/30" : ""}
>
{entry.user?.username ?? t("common.unknown")}
</td>
<td
className={`px-3 py-2 text-xs ${isFailed ? "text-destructive" : "text-muted-foreground"}`}
>
{AUTH_ACTION_LABELS[entry.action] ?? entry.action}
</td>
<td className="text-muted-foreground px-3 py-2 text-xs">
<span className="inline-flex items-center gap-1.5">
<ProviderIcon
className="h-3.5 w-3.5"
aria-hidden="true"
/>
{AUTH_PROVIDER_LABELS[provider]}
</span>
</td>
<td className="text-muted-foreground px-3 py-2 font-mono text-xs">
{entry.ipAddress ?? "—"}
</td>
<td className="text-muted-foreground px-3 py-2 text-xs">
{entry.location ?? "—"}
</td>
<td className="text-muted-foreground px-3 py-2 text-right text-xs whitespace-nowrap">
{formatDateTime(entry.createdAt)}
</td>
</tr>
);
})}
</tbody>
</table>
<div className="text-muted-foreground mt-3 flex flex-wrap items-center justify-between gap-2 text-xs">
<td className="px-3 py-2">
{isFailed ? (
<XCircle className="text-destructive h-4 w-4" />
) : (
<CheckCircle2 className="text-dracula-green h-4 w-4" />
)}
</td>
<td
className={`px-3 py-2 font-medium ${isFailed ? "text-destructive" : ""}`}
>
{entry.user?.username ?? t("common.unknown")}
</td>
<td
className={`px-3 py-2 text-xs ${isFailed ? "text-destructive" : "text-muted-foreground"}`}
>
{AUTH_ACTION_LABELS[entry.action] ?? entry.action}
</td>
<td className="text-muted-foreground px-3 py-2 text-xs">
<span className="inline-flex items-center gap-1.5">
<ProviderIcon
className="h-3.5 w-3.5"
aria-hidden="true"
/>
{AUTH_PROVIDER_LABELS[provider]}
</span>
{/*
v1.4.27 B3 — carrier chip below the auth-provider
chip. Only renders when the GeoLite2-ASN lookup
found an organisation string for the audit-row's
IP; private/loopback IPs and offline-miss rows
keep the original single-chip layout. The chip
carries the short DACH label ("Telekom",
"Vodafone", "1&1", "O2"); unknown organisations
fall through to the raw GeoLite2 string.
*/}
{entry.carrier ? (
<span
className="text-muted-foreground/80 mt-0.5 block text-[10px] leading-tight"
data-slot="login-overview-carrier"
>
{carrierShortLabel(entry.carrier)}
</span>
) : null}
</td>
<td className="text-muted-foreground px-3 py-2 font-mono text-xs">
{entry.ipAddress ?? "—"}
</td>
<td className="text-muted-foreground px-3 py-2 text-xs">
{entry.location ?? "—"}
</td>
<td className="text-muted-foreground px-3 py-2 text-right text-xs whitespace-nowrap">
{formatDateTime(entry.createdAt)}
</td>
</tr>
);
})}
</tbody>
</table>
</div>
<div
className="text-muted-foreground mt-3 flex flex-wrap items-center justify-between gap-2 text-xs"
data-testid="login-overview-pagination"
>
<span>
{t("admin.showingEntries", {
count: entries.length,
@@ -483,7 +519,7 @@ export function LoginOverviewSection() {
</Button>
</div>
</div>
</div>
</>
)}
</div>
</div>
@@ -79,7 +79,7 @@ export function RecentAuditPreview() {
{isLoading ? (
<div className="flex items-center gap-2 py-2">
<Loader2
className="text-muted-foreground h-4 w-4 animate-spin"
className="text-muted-foreground h-4 w-4 animate-spin motion-reduce:animate-none"
aria-hidden="true"
/>
<span className="text-muted-foreground text-sm">
+7 -3
View File
@@ -132,6 +132,8 @@ export function RemindersSection() {
<Input
id="admin-reminder-late"
type="number"
inputMode="numeric"
enterKeyHint="next"
min={15}
max={480}
value={reminderLateDraft ?? settings?.reminderLateMinutes ?? 120}
@@ -153,6 +155,8 @@ export function RemindersSection() {
<Input
id="admin-reminder-missed"
type="number"
inputMode="numeric"
enterKeyHint="done"
min={30}
max={720}
value={
@@ -177,7 +181,7 @@ export function RemindersSection() {
disabled={testNotification.isPending}
>
{testNotification.isPending && (
<Loader2 className="mr-1.5 h-3.5 w-3.5 animate-spin" />
<Loader2 className="mr-1.5 h-3.5 w-3.5 animate-spin motion-reduce:animate-none" />
)}
<Bell className="mr-1.5 h-3.5 w-3.5" />
{t("admin.notificationTestSend")}
@@ -189,7 +193,7 @@ export function RemindersSection() {
disabled={reminderCheck.isPending}
>
{reminderCheck.isPending && (
<Loader2 className="mr-1.5 h-3.5 w-3.5 animate-spin" />
<Loader2 className="mr-1.5 h-3.5 w-3.5 animate-spin motion-reduce:animate-none" />
)}
<Activity className="mr-1.5 h-3.5 w-3.5" />
{t("admin.reminderCheckRun")}
@@ -220,7 +224,7 @@ export function RemindersSection() {
}
>
{updateSettings.isPending && (
<Loader2 className="mr-1.5 h-3.5 w-3.5 animate-spin" />
<Loader2 className="mr-1.5 h-3.5 w-3.5 animate-spin motion-reduce:animate-none" />
)}
{t("common.save")}
</Button>
@@ -13,15 +13,36 @@ import {
Globe,
Key,
Loader2,
Map,
RotateCw,
Server,
Users,
} from "lucide-react";
import { useQuery } from "@tanstack/react-query";
import { Button } from "@/components/ui/button";
import { formatDateTime } from "@/lib/format";
import { useFormatters, useTranslations } from "@/lib/i18n/context";
import { StatusItem, useSystemStatus } from "./_shared";
interface VersionResponse {
version: string;
buildSha: string | null;
builtAt: string | null;
offlineGeoEnabled?: boolean;
}
function useOfflineGeoState() {
return useQuery({
queryKey: ["public", "version"],
queryFn: async () => {
const res = await fetch("/api/version");
if (!res.ok) throw new Error("Failed to load version");
return (await res.json()).data as VersionResponse;
},
staleTime: 5 * 60_000,
});
}
// v1.4.16 phase B3: Recharts is ~108 KiB Brotli — defer-load the host
// metrics chart so the system-status page only ships it when an admin
// actually opens this view. Splitting at the wrapper boundary (instead
@@ -44,6 +65,7 @@ export function SystemStatusSection() {
const { t } = useTranslations();
const fmt = useFormatters();
const { data: status, isError, refetch, isFetching } = useSystemStatus();
const { data: version } = useOfflineGeoState();
return (
<div className="space-y-6">
@@ -163,6 +185,25 @@ export function SystemStatusSection() {
className="text-dracula-green"
/>
)}
{/* v1.4.27 R5 — offline GeoLite2 availability. Renders only
when /api/version returns the new field so legacy
responses do not produce a placeholder row. */}
{version?.offlineGeoEnabled !== undefined && (
<StatusItem
icon={Map}
label={t("admin.offlineGeoLabel")}
value={
version.offlineGeoEnabled
? t("admin.offlineGeoEnabled")
: t("admin.offlineGeoFallback")
}
className={
version.offlineGeoEnabled
? "text-dracula-green"
: "text-dracula-yellow"
}
/>
)}
</div>
) : isError ? (
// P19: surface load failures inline instead of leaving the
+23 -1
View File
@@ -17,6 +17,7 @@ import {
Cog,
Database,
GitCommit,
Globe,
Loader2,
Server,
Tag,
@@ -30,6 +31,7 @@ interface VersionResponse {
version: string;
buildSha: string | null;
builtAt: string | null;
offlineGeoEnabled?: boolean;
}
function useVersion() {
@@ -116,6 +118,26 @@ export function SystemStatusSummary() {
value={formatDateTime(version.builtAt)}
/>
)}
{/* v1.4.27 R5 — surface the offline-geo state so the maintainer
spots the missing MAXMIND_LICENSE_KEY without crawling logs.
The field is undefined on legacy responses; the row only
renders when /api/version answers the new shape. */}
{version?.offlineGeoEnabled !== undefined && (
<StatusItem
icon={Globe}
label={t("admin.overview.snapshotOfflineGeo")}
value={
version.offlineGeoEnabled
? t("admin.overview.snapshotOfflineGeoOn")
: t("admin.overview.snapshotOfflineGeoOff")
}
className={
version.offlineGeoEnabled
? "text-dracula-green"
: "text-dracula-yellow"
}
/>
)}
</div>
) : isError ? (
<div
@@ -127,7 +149,7 @@ export function SystemStatusSummary() {
) : (
<div className="mt-4 flex items-center gap-2">
<Loader2
className="text-muted-foreground h-4 w-4 animate-spin"
className="text-muted-foreground h-4 w-4 animate-spin motion-reduce:animate-none"
aria-hidden="true"
/>
<span className="text-muted-foreground text-sm">
+2 -2
View File
@@ -149,7 +149,7 @@ export function UmamiSection() {
disabled={testUmami.isPending || updateSettings.isPending}
>
{testUmami.isPending && (
<Loader2 className="mr-1.5 h-3.5 w-3.5 animate-spin" />
<Loader2 className="mr-1.5 h-3.5 w-3.5 animate-spin motion-reduce:animate-none" />
)}
{t("common.test")}
</Button>
@@ -159,7 +159,7 @@ export function UmamiSection() {
disabled={updateSettings.isPending}
>
{updateSettings.isPending && (
<Loader2 className="mr-1.5 h-3.5 w-3.5 animate-spin" />
<Loader2 className="mr-1.5 h-3.5 w-3.5 animate-spin motion-reduce:animate-none" />
)}
{t("common.save")}
</Button>
@@ -407,7 +407,7 @@ export function UserManagementSection() {
)
) : (
<div className="mt-4 flex items-center gap-2">
<Loader2 className="text-muted-foreground h-4 w-4 animate-spin" />
<Loader2 className="text-muted-foreground h-4 w-4 animate-spin motion-reduce:animate-none" />
<span className="text-muted-foreground text-sm">
{t("admin.loadingUsers")}
</span>
@@ -417,7 +417,7 @@ export function UserManagementSection() {
{/* Edit Dialog */}
{editingUser && (
<div className="bg-muted/80 mt-4 rounded-lg p-4">
<h3 className="mb-3 text-sm font-medium">
<h3 className="mb-3 text-sm font-semibold">
{t("admin.editUserTitle", { name: editingUser.username })}
</h3>
<div className="space-y-3">
@@ -460,7 +460,7 @@ export function UserManagementSection() {
}
>
{updateUser.isPending && (
<Loader2 className="mr-1 h-3.5 w-3.5 animate-spin" />
<Loader2 className="mr-1 h-3.5 w-3.5 animate-spin motion-reduce:animate-none" />
)}
{t("common.save")}
</Button>
@@ -484,7 +484,7 @@ export function UserManagementSection() {
{/* Password Reset Dialog */}
{resetUser && (
<div className="bg-muted/80 mt-4 rounded-lg p-4">
<h3 className="mb-3 text-sm font-medium">
<h3 className="mb-3 text-sm font-semibold">
{t("admin.resetPasswordTitle", { name: resetUser.username })}
</h3>
<div className="space-y-3">
@@ -510,7 +510,7 @@ export function UserManagementSection() {
}
>
{resetPw.isPending && (
<Loader2 className="mr-1 h-3.5 w-3.5 animate-spin" />
<Loader2 className="mr-1 h-3.5 w-3.5 animate-spin motion-reduce:animate-none" />
)}
{t("admin.reset")}
</Button>
@@ -562,7 +562,7 @@ export function UserManagementSection() {
className="bg-destructive hover:bg-destructive/90"
>
{forceLogout.isPending ? (
<Loader2 className="mr-1 h-3.5 w-3.5 animate-spin" />
<Loader2 className="mr-1 h-3.5 w-3.5 animate-spin motion-reduce:animate-none" />
) : null}
{t("admin.section.users.forceLogout")}
</AlertDialogAction>
@@ -135,7 +135,7 @@ export function WebPushVapidSection() {
disabled={updateSettings.isPending}
>
{updateSettings.isPending && (
<Loader2 className="mr-1.5 h-3.5 w-3.5 animate-spin" />
<Loader2 className="mr-1.5 h-3.5 w-3.5 animate-spin motion-reduce:animate-none" />
)}
{t("common.save")}
</Button>
+125 -46
View File
@@ -20,6 +20,10 @@ interface ComplianceHeatmapProps {
const CELL_SIZE = 18;
const GAP = 3;
// v1.4.27 MB7 / CF-10 — cell-floor so the static heatmap never paints
// below the touch-friendly 14 px square on narrow viewports. The
// stretch branch computes an adaptive size; both branches clamp here.
const CELL_FLOOR_PX = 14;
function getColor(data: DailyData): string {
if (data.expected === 0) return "var(--secondary)";
@@ -57,8 +61,13 @@ function getColor(data: DailyData): string {
}
function formatDateDE(dateStr: string): string {
const d = new Date(dateStr + "T00:00:00");
return `${d.getDate()}.${d.getMonth() + 1}.${d.getFullYear()}`;
// v1.4.27 B7 / BL-P4-2 — parse the day-key against UTC so the
// formatted tooltip label matches the dateKey computation below
// (which uses `toISOString().slice(0, 10)`). Without the UTC
// anchor an SSR server in a non-Berlin timezone could format the
// tick a day off the dateKey it sits under.
const d = new Date(dateStr + "T00:00:00Z");
return `${d.getUTCDate()}.${d.getUTCMonth() + 1}.${d.getUTCFullYear()}`;
}
export function ComplianceHeatmap({
@@ -73,8 +82,36 @@ export function ComplianceHeatmap({
x: number;
y: number;
text: string;
/**
* v1.4.27 MB7 / CF-10 — when `pinned` the tooltip stays mounted
* across `onPointerLeave` so a touch user sees the per-cell
* breakdown after lifting their finger. Mouse + pen users get the
* existing hover-only experience because their interactions never
* set `pinned`. A second tap on a different cell repositions the
* tooltip; a tap outside any cell clears it (wired below).
*/
pinned?: boolean;
} | null>(null);
// v1.4.27 MB7 / CF-10 — outside-click dismisses a pinned tooltip so a
// touch user can clear the per-cell detail without scrolling the
// pinned label off-screen. The listener is gated on `tooltip?.pinned`
// so the non-touch hover flow never pays the indirection cost.
useEffect(() => {
if (!tooltip?.pinned) return;
const handlePointer = (event: PointerEvent) => {
const container = containerRef.current;
if (!container) return;
if (!container.contains(event.target as Node)) {
setTooltip(null);
}
};
document.addEventListener("pointerdown", handlePointer, true);
return () => {
document.removeEventListener("pointerdown", handlePointer, true);
};
}, [tooltip?.pinned]);
const WEEKDAY_LABELS = [
t("charts.weekdays.mon"),
"",
@@ -133,9 +170,16 @@ export function ComplianceHeatmap({
dates.push(date);
}
// Start from the first date, align to Monday
// Start from the first date, align to Monday.
//
// v1.4.27 B7 / BL-P4-2 — read every weekday + month boundary off
// the UTC accessor pair (`getUTCDay`, `getUTCMonth`) so the
// computation matches the dateKey, which is also UTC-anchored
// (`toISOString().slice(0, 10)`). Reading in server-tz here would
// shift the Monday-alignment + month-marker placement when the
// SSR pass runs on a server in a non-Berlin timezone.
const firstDate = dates[0];
const firstDow = (firstDate.getDay() + 6) % 7; // Monday = 0
const firstDow = (firstDate.getUTCDay() + 6) % 7; // Monday = 0
let col = 0;
const markers: Array<{ col: number; label: string }> = [];
@@ -143,7 +187,7 @@ export function ComplianceHeatmap({
for (let i = 0; i < dates.length; i++) {
const date = dates[i];
const dow = (date.getDay() + 6) % 7; // Monday = 0
const dow = (date.getUTCDay() + 6) % 7; // Monday = 0
const currentCol = Math.floor((i + firstDow) / 7);
const row = dow;
const dateKey = date.toISOString().slice(0, 10);
@@ -154,7 +198,7 @@ export function ComplianceHeatmap({
};
// Track month boundaries
const month = date.getMonth();
const month = date.getUTCMonth();
if (month !== lastMonth) {
markers.push({ col: currentCol, label: MONTH_LABELS[month] });
lastMonth = month;
@@ -176,10 +220,13 @@ export function ComplianceHeatmap({
const labelWidth = stretch ? 0 : 76;
const headerHeight = 18;
// v1.4.27 MB7 / CF-10 — clamp the stretch-branch adaptive cell to the
// 14 px floor so the heatmap stays tap-friendly when the container
// narrows. Static (non-stretch) instances use the canonical 18 px.
const cellSize =
stretch && containerWidth > 0
? Math.max(
8,
CELL_FLOOR_PX,
(containerWidth - labelWidth - Math.max(0, weeks - 1) * GAP) /
Math.max(weeks, 1),
)
@@ -190,12 +237,20 @@ export function ComplianceHeatmap({
return (
<div className={`relative ${stretch ? "w-full" : ""}`} ref={containerRef}>
<div className={stretch ? "w-full" : "overflow-x-auto"}>
{/* v1.4.27 MB7 / CF-10 — `overflow-x-auto` on `<sm` so the heatmap
horizontal-scrolls inside its card instead of compressing the
cells below the touch floor. The stretch branch already
paints to container width on `>=sm`, so the scroll branch
only kicks in for the non-stretch (static 18 px cell) case
when the parent column is narrower than `weeks * 21 px`. */}
<div
className={stretch ? "w-full sm:w-full overflow-x-auto sm:overflow-visible" : "overflow-x-auto"}
>
<svg
width={svgWidth}
height={svgHeight}
className={stretch ? "block w-full" : "block"}
onMouseLeave={() => setTooltip(null)}
onMouseLeave={() => setTooltip((prev) => (prev?.pinned ? prev : null))}
>
{/* Month labels */}
{monthMarkers.map((m, i) => (
@@ -228,43 +283,67 @@ export function ComplianceHeatmap({
),
)}
{/* Cells */}
{cells.map((cell) => (
<rect
key={cell.dateKey}
x={labelWidth + cell.col * step}
y={headerHeight + cell.row * step}
width={cellSize}
height={cellSize}
rx={2}
fill={cell.color}
className="cursor-pointer"
onMouseEnter={(e) => {
const rate =
cell.data.expected > 0
? Math.min(
100,
Math.round(
(cell.data.taken / cell.data.expected) * 100,
),
)
: 0;
const hasTimingData =
cell.data.onTime !== undefined ||
cell.data.late !== undefined ||
cell.data.veryLate !== undefined;
const timingInfo = hasTimingData
? ` | ${cell.data.onTime ?? 0} ${t("charts.heatmapOnTime")}, ${cell.data.late ?? 0} ${t("charts.heatmapLate")}, ${cell.data.veryLate ?? 0} ${t("charts.heatmapVeryLate")}`
: "";
setTooltip({
x: e.clientX,
y: e.clientY,
text: `${formatDateDE(cell.dateKey)}: ${cell.data.taken}/${cell.data.expected} (${rate}%)${timingInfo}`,
});
}}
onMouseLeave={() => setTooltip(null)}
/>
))}
{/* Cells — v1.4.27 MB7 / CF-10: tap-to-pin tooltip in
parallel with the existing hover affordance. Mouse + pen
users keep `onPointerEnter` / `onPointerLeave`; touch
users tap the cell to pin and tap outside (or another
cell) to move/clear. `pointerType === "touch"` discriminates
so a hover dismiss never wipes a pinned tooltip. */}
{cells.map((cell) => {
const buildText = (): string => {
const rate =
cell.data.expected > 0
? Math.min(
100,
Math.round((cell.data.taken / cell.data.expected) * 100),
)
: 0;
const hasTimingData =
cell.data.onTime !== undefined ||
cell.data.late !== undefined ||
cell.data.veryLate !== undefined;
const timingInfo = hasTimingData
? ` | ${cell.data.onTime ?? 0} ${t("charts.heatmapOnTime")}, ${cell.data.late ?? 0} ${t("charts.heatmapLate")}, ${cell.data.veryLate ?? 0} ${t("charts.heatmapVeryLate")}`
: "";
return `${formatDateDE(cell.dateKey)}: ${cell.data.taken}/${cell.data.expected} (${rate}%)${timingInfo}`;
};
return (
<rect
key={cell.dateKey}
x={labelWidth + cell.col * step}
y={headerHeight + cell.row * step}
width={cellSize}
height={cellSize}
rx={2}
fill={cell.color}
className="cursor-pointer"
onPointerEnter={(e) => {
// Touch enter fires synthetically immediately before
// `pointerdown`; skip it so the pinned tooltip below
// takes precedence with its real coordinates.
if (e.pointerType === "touch") return;
setTooltip({
x: e.clientX,
y: e.clientY,
text: buildText(),
});
}}
onPointerLeave={(e) => {
if (e.pointerType === "touch") return;
setTooltip((prev) => (prev?.pinned ? prev : null));
}}
onPointerDown={(e) => {
if (e.pointerType !== "touch") return;
setTooltip({
x: e.clientX,
y: e.clientY,
text: buildText(),
pinned: true,
});
}}
/>
);
})}
</svg>
</div>
+16 -2
View File
@@ -1045,7 +1045,15 @@ export function HealthChart({
const containerClass = mini
? "bg-card border-border rounded-md border p-2"
: "bg-card border-border rounded-xl border p-4 md:p-6";
const chartHeightClass = mini ? "h-[140px]" : "h-[240px]";
// v1.4.27 MB7 / CF-43 — chart height now reads from a CSS variable
// (`--chart-height`) so consumers can override the default per-mount
// without re-styling the component. The `mini` branch keeps the
// 140 px contract; the regular branch defaults to 240 px on mobile
// and 280 px from `md:` upwards (the wider container can absorb a
// taller chart without crowding the tile strip above).
const chartHeightClass = mini
? "h-[var(--chart-height,140px)]"
: "h-[var(--chart-height,240px)] md:h-[var(--chart-height-md,280px)]";
return (
<div className={containerClass} data-slot={mini ? "chart-mini" : undefined}>
@@ -1172,11 +1180,17 @@ export function HealthChart({
) : (
<div className={`relative ${chartHeightClass}`}>
{visibleBands.length > 0 ? (
// v1.4.27 R3d MB2 — band overlay positioning fix. The
// overlay used to inset `right: 18px` while the chart
// margin is `right: 8`, so the band rectangle drifted left
// of the plotted line by 10 px on every viewport. Pin the
// overlay to the same right edge the ComposedChart uses so
// the band tracks the line exactly.
<div
className="pointer-events-none absolute"
style={{
left: `${8 + yAxisWidth}px`,
right: "18px",
right: "8px",
top: "10px",
bottom: "32px",
zIndex: 0,
@@ -191,7 +191,15 @@ interface MedicationComplianceChartProps {
export function MedicationComplianceChart({
title,
userTimezone = "Europe/Berlin",
compareBaseline,
}: MedicationComplianceChartProps) {
// v1.4.27 R4 RC3 — the prop is accepted so the dashboard can pass
// `compareBaseline={compareBaseline}` uniformly across every chart.
// The comparison overlay itself is deferred: medication compliance is
// a percentage-of-doses metric whose prior-period overlay needs a
// second window of intake events. Discard explicitly so the
// destructure is intentional rather than a missed wire-up.
void compareBaseline;
const { isAuthenticated } = useAuth();
const { t, locale } = useTranslations();
const fmt = useFormatters();
@@ -369,7 +377,7 @@ export function MedicationComplianceChart({
{isLoading ? (
<div className="flex h-48 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>
) : !hasData ? (
<div className="text-muted-foreground flex h-48 items-center justify-center text-sm">
@@ -383,7 +391,7 @@ export function MedicationComplianceChart({
description={t("charts.emptyStateDescription")}
/>
) : (
<div className="h-[240px] touch-pan-y">
<div className="h-[var(--chart-height,240px)] md:h-[var(--chart-height-md,280px)] touch-pan-y">
<ResponsiveContainer width="100%" height="100%">
<ComposedChart
data={chartData}
+25 -10
View File
@@ -37,6 +37,8 @@ import { ChartOverlayControls } from "./chart-overlay-controls";
import { useChartOverlayPrefs } from "@/hooks/use-chart-overlay-prefs";
import { useViewportWidth } from "@/hooks/use-viewport-width";
import { chooseTickInterval } from "@/lib/charts/x-axis-density";
import { CHART_HEIGHT_PX } from "@/lib/charts/constants";
import { moodLabelKeyForScore } from "@/lib/mood/labels";
// --- Types ---
@@ -483,12 +485,16 @@ export function MoodChart({
const maxPointIndex = Math.max(0, (chartData?.length ?? 1) - 1);
// v1.4.27 B6 / BL-P6-11 — the chart axis and the mood-list cards
// share `MOOD_LABEL_KEYS` so a polishing-pass copy update lands in
// both surfaces. The shared resolver maps a numeric score back to
// the canonical key set under `mood.level*`.
const moodLabels: Record<number, string> = {
1: t("charts.moodLabel1"),
2: t("charts.moodLabel2"),
3: t("charts.moodLabel3"),
4: t("charts.moodLabel4"),
5: t("charts.moodLabel5"),
1: t(moodLabelKeyForScore(1) ?? "mood.levelLausig"),
2: t(moodLabelKeyForScore(2) ?? "mood.levelSchlecht"),
3: t(moodLabelKeyForScore(3) ?? "mood.levelOkay"),
4: t(moodLabelKeyForScore(4) ?? "mood.levelGut"),
5: t(moodLabelKeyForScore(5) ?? "mood.levelSuperGut"),
};
// v1.4.18 — emoji glyph map removed. the maintainer explicitly rejected
@@ -614,7 +620,7 @@ export function MoodChart({
<CardContent>
{isLoading ? (
<div className="flex h-48 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>
) : !chartData?.length ? (
<div className="text-muted-foreground flex h-48 items-center justify-center text-sm">
@@ -622,14 +628,23 @@ export function MoodChart({
</div>
) : chartData.length < 3 ? (
// v1.4.16 B1a — sparse-data placeholder consistent with the
// BP/weight/pulse charts.
// BP/weight/pulse charts. Height tracks the chart strip's
// shared CHART_HEIGHT_PX so the empty state preserves the
// trend-row rhythm (v1.4.27 — was 280, now 240 to match every
// other dashboard chart card).
<ChartEmptyState
title={t("charts.emptyStateTitle")}
description={t("charts.emptyStateDescription")}
height={280}
height={CHART_HEIGHT_PX}
/>
) : (
<div className={`${mini ? "h-[140px]" : "h-[280px]"} touch-pan-y`}>
<div
className={`${
mini
? "h-[var(--chart-height,140px)]"
: "h-[var(--chart-height,240px)] md:h-[var(--chart-height-md,280px)]"
} touch-pan-y`}
>
<ResponsiveContainer width="100%" height="100%">
<ComposedChart
data={chartDataWithCompare ?? chartData}
@@ -723,7 +738,7 @@ export function MoodChart({
tickLine={false}
axisLine={false}
width={65}
tickMargin={6}
tickMargin={10}
tickFormatter={formatMoodTick}
/>
<Tooltip
@@ -82,8 +82,19 @@ export function ScatterCorrelationChart<T extends Record<string, number>>({
tooltipFormatter,
height = 250,
}: ScatterCorrelationChartProps<T>) {
// v1.4.27 MB7 / CF-37 — drop the fixed pixel height in favour of a
// responsive aspect-ratio on mobile so the chart never compresses
// below readable axes. The `aspect-square` ratio at `<sm` keeps the
// plot area as a centred square (matches Apple Health correlation
// viz); from `sm:` upwards the chart falls back to the legacy
// pixel-height contract via a 3:2 aspect-ratio fallback that
// approximates the previous 250 px shape.
return (
<div className="touch-pan-y" style={{ height }}>
<div
className="touch-pan-y aspect-square sm:aspect-[3/2] sm:h-auto"
style={{ height: undefined }}
data-explicit-height={height}
>
<ResponsiveContainer width="100%" height="100%">
<ScatterChart margin={{ top: 10, right: 20, bottom: 36, left: 12 }}>
<CartesianGrid strokeDasharray="3 3" stroke="var(--border)" />
+18 -51
View File
@@ -240,7 +240,7 @@ export function TrendCard({
className="mt-2 flex min-w-0 items-baseline gap-x-1.5"
data-slot="trend-card-value-row"
>
<span className="min-w-0 truncate text-3xl leading-none font-bold tracking-tight tabular-nums">
<span className="min-w-0 truncate text-3xl leading-none font-semibold tracking-tight tabular-nums">
{latest !== null ? renderPair(latest, secondary?.latest) : "—"}
</span>
<span className="text-muted-foreground shrink-0 text-sm tabular-nums">
@@ -272,10 +272,15 @@ export function TrendCard({
all-time + comparison-delta into a single line that stays
scannable on Galaxy Fold (280 px) and Pixel 5 (375 px). The
full layout returns at `>=sm`. */}
{/* v1.4.27 MB7 / CF-41 — the `hidden sm:block` gate retired
because the mobile-secondary block below is gone (CF-67
vestigial wrapper drop). The callout paints on every
viewport; when the parent grid is tight on `<sm`, the
flex-wrap on the sibling row + this callout's `mt-1`
horizontal-row layout absorb the overflow without crowding
the headline value above. */}
{compareBaseline !== "none" && compareDelta != null && (
<div
className={cn("mt-1", avgAllTime !== undefined && "hidden sm:block")}
>
<div className="mt-1">
<span
className={cn(
"inline-block max-w-full text-xs leading-snug font-medium [overflow-wrap:anywhere] tabular-nums",
@@ -374,14 +379,18 @@ export function TrendCard({
to last-30 days). Other tiles leave `avgAllTime` undefined so
the third sub-row never renders.
v1.4.22 W5 reconcile (Design-H2) — full label only at
`>=sm`; on `<sm` the all-time number moves to the
combined secondary row below so the tile keeps the same
vertical density as every other trend card. */}
v1.4.27 MB7 / CF-41 — the third sub-value renders inline
alongside `7d` / `30d` on every viewport. The previous
wave split `<sm` into a separate secondary row to keep
tile heights uniform, but in practice the flex-wrap on
the parent already handles the overflow gracefully — the
all-time pair wraps to a second line when the first row
fills up. Dropping the duplicated mobile-secondary block
also dropped CF-67 (the vestigial wrapper). */}
{avgAllTime !== undefined && (
<span
data-slot="trend-card-all-time"
className="hidden max-w-full min-w-0 [overflow-wrap:anywhere] sm:inline"
className="max-w-full min-w-0 [overflow-wrap:anywhere]"
>
{t(avgAllTimeLabelKey)}:{" "}
<span
@@ -395,48 +404,6 @@ export function TrendCard({
</span>
)}
</div>
{/* v1.4.22 W5 reconcile (Design-H2) — combined secondary row
for `<sm` viewports. Renders the all-time aggregate AND
(when comparison is on) the comparison delta on a single
line: "All-time 11% · vs −0.4 last month". Disappears at
`>=sm` because the full layout above takes over. */}
{avgAllTime !== undefined && (
<div
data-slot="trend-card-secondary-mobile"
className="text-muted-foreground mt-1 min-w-0 text-xs leading-snug [overflow-wrap:anywhere] sm:hidden"
>
<span>
{t(avgAllTimeLabelKey)}:{" "}
<span
className={cn(
"font-medium [overflow-wrap:anywhere] tabular-nums",
avgAllTimeColorClass,
)}
>
{avgAllTime !== null ? formatValue(avgAllTime) : "—"}
</span>
</span>
{compareBaseline !== "none" && compareDelta != null && (
<>
<span aria-hidden="true"> · </span>
<span
className={cn(
"font-medium [overflow-wrap:anywhere] tabular-nums",
comparisonDeltaColor,
)}
data-slot="tile-compare-delta-mobile"
data-compare-baseline={compareBaseline}
>
{`Δ ${formatDelta(compareDelta)}${unit ? ` ${unit}` : ""} ${t(
compareBaseline === "lastMonth"
? "comparison.captionLastMonth"
: "comparison.captionLastYear",
)}`}
</span>
</>
)}
</div>
)}
</TooltipProvider>
</div>
);
@@ -261,8 +261,142 @@ describe("<Glp1Tile>", () => {
expect(html).not.toContain("Ozempic 0mg");
// No weight delta caption when both sides are null.
expect(html).not.toContain('data-slot="glp1-tile-delta"');
// No chart when the series is empty.
expect(html).not.toContain('data-slot="glp1-tile-chart"');
// v1.4.27 B1 — the tab strip + range strip always render; the
// weight pane shows the muted "no data yet" hint when the series
// is empty rather than suppressing the whole chart row.
expect(html).toContain('data-slot="glp1-tile-chart"');
expect(html).toContain('data-slot="glp1-tile-tab-level"');
expect(html).toContain('data-slot="glp1-tile-tab-weight"');
});
it("v1.4.27 B1 — exposes the tab strip and range strip with drug-level default", () => {
queryReturn = {
data: {
active: true,
medications: [
{
name: "Mounjaro",
genericName: "tirzepatide",
medicationId: "med-1",
currentDose: {
value: 7.5,
unit: "mg",
since: "2026-04-01",
weeksOnDose: 6,
},
doseHistory: [],
lastInjection: { date: "2026-05-10", site: null, weeksAgo: 0 },
nextInjection: { date: "2026-05-17", daysAway: 3 },
startWeight: 92.0,
currentWeight: 87.8,
weightDeltaKg: -4.2,
weightSeries: [{ date: "2026-05-13", weight: 87.8 }],
injectionDates: ["2026-04-26", "2026-05-03", "2026-05-10"],
},
],
},
isPending: false,
};
const html = render();
// Tab strip + range strip are present on a populated tile.
expect(html).toContain('data-slot="glp1-tile-tabs"');
expect(html).toContain('data-slot="glp1-tile-tab-level"');
expect(html).toContain('data-slot="glp1-tile-tab-weight"');
expect(html).toContain('data-slot="glp1-tile-range-strip"');
// Drug-Level tab is the default selection.
expect(html).toMatch(
/data-slot="glp1-tile-tab-level"[^>]*data-active="true"/,
);
expect(html).toMatch(
/data-slot="glp1-tile-tab-weight"[^>]*data-active="false"/,
);
// Range strip exposes 7d / 30d / 90d / All buttons.
expect(html).toMatch(/data-slot="glp1-tile-range-button"[^>]*data-points="7"/);
expect(html).toMatch(/data-slot="glp1-tile-range-button"[^>]*data-points="30"/);
expect(html).toMatch(/data-slot="glp1-tile-range-button"[^>]*data-points="90"/);
expect(html).toMatch(/data-slot="glp1-tile-range-button"[^>]*data-points="0"/);
// 30d preset is the default.
expect(html).toMatch(
/data-slot="glp1-tile-range-button"[^>]*data-points="30"[^>]*data-active="true"/,
);
});
it("v1.4.27 B1 — promotes the schedule to a pill row and drops the green seam", () => {
queryReturn = {
data: {
active: true,
medications: [
{
name: "Mounjaro",
genericName: "tirzepatide",
medicationId: "med-1",
currentDose: {
value: 7.5,
unit: "mg",
since: "2026-04-01",
weeksOnDose: 6,
},
doseHistory: [],
lastInjection: { date: "2026-05-10", site: null, weeksAgo: 0 },
nextInjection: { date: "2026-05-17", daysAway: 3 },
startWeight: 92.0,
currentWeight: 87.8,
weightDeltaKg: -4.2,
weightSeries: [{ date: "2026-05-13", weight: 87.8 }],
injectionDates: ["2026-05-10"],
},
],
},
isPending: false,
};
const html = render();
// Schedule wrapper is the new pill row, not the old <dl>.
expect(html).toContain('data-slot="glp1-tile-schedule"');
expect(html).not.toContain("<dl");
// Pills still carry the per-pill slot anchors the existing tests use.
expect(html).toContain('data-slot="glp1-tile-last"');
expect(html).toContain('data-slot="glp1-tile-next"');
// The green left-seam classes drop from the outer wrapper.
expect(html).not.toContain("border-l-dracula-green/60");
expect(html).not.toContain("border-l-2");
});
it("v1.4.27 B1 — surfaces the level-unavailable hint when no medicationId is wired", () => {
// Some legacy meds don't have a Medication.id surfaced through the
// dashboard route (the route returns medicationId: null). The
// drug-level pane needs a stable medication id to mount the
// chart, so we render a muted hint instead of the chart.
queryReturn = {
data: {
active: true,
medications: [
{
name: "Ozempic",
genericName: "semaglutide",
medicationId: null,
currentDose: {
value: 0.5,
unit: "mg",
since: "2026-04-01",
weeksOnDose: 6,
},
doseHistory: [],
lastInjection: null,
nextInjection: null,
startWeight: 92.0,
currentWeight: 87.8,
weightDeltaKg: -4.2,
weightSeries: [{ date: "2026-05-13", weight: 87.8 }],
injectionDates: [],
},
],
},
isPending: false,
};
const html = render();
// Drug-level tab stays selected by default; pane renders the
// unavailable hint because medicationId is null.
expect(html).toContain('data-slot="glp1-tile-level-unavailable"');
});
it("renders German copy under the de locale", () => {
+202 -43
View File
@@ -26,13 +26,14 @@
*/
import { useQuery } from "@tanstack/react-query";
import dynamic from "next/dynamic";
import { useMemo } from "react";
import { useMemo, useState } from "react";
import { Syringe, ArrowDown, ArrowUp, Minus, Calendar } from "lucide-react";
import { useAuth } from "@/hooks/use-auth";
import { useFormatters, useTranslations } from "@/lib/i18n/context";
import { queryKeys } from "@/lib/query-keys";
import { cn } from "@/lib/utils";
import { CHART_RANGE_PRESETS } from "@/lib/charts/constants";
const HealthChart = dynamic(
() =>
@@ -42,6 +43,28 @@ const HealthChart = dynamic(
{ ssr: false },
);
const DrugLevelChart = dynamic(
() =>
import("@/components/medications/DrugLevelChart").then((mod) => ({
default: mod.DrugLevelChart,
})),
{ ssr: false },
);
/**
* Map a `CHART_RANGE_PRESETS` `points` value to the drug-level chart's
* window length in hours. `points: 0` (the "All" preset) maps to 365
* days — past that point the unit-less curve flattens to invisibility,
* so a year of history is the practical ceiling.
*/
const HOURS_PER_DAY = 24;
function rangePointsToHours(points: number): number {
if (points === 0) return 365 * HOURS_PER_DAY;
return points * HOURS_PER_DAY;
}
type Glp1ChartTab = "level" | "weight";
interface DoseHistoryEntry {
value: number;
unit: string;
@@ -166,6 +189,14 @@ export function Glp1Tile() {
const dateWithWeekday = useDateWithWeekday();
const deltaDisplay = useDeltaDisplay();
// v1.4.27 B1 — the chart pane carries two views (drug-level / weight)
// and a 7d / 30d / 90d / All range strip above. Drug-level is the
// default because it's the more informative pane the maintainer
// asked for; the weight pane stays one tap away. Both states live
// on the tile so a parent re-render doesn't reset the user's pick.
const [activeTab, setActiveTab] = useState<Glp1ChartTab>("level");
const [rangePoints, setRangePoints] = useState<number>(30);
const { data, isPending } = useQuery({
queryKey: queryKeys.dashboardGlp1(),
enabled: isAuthenticated,
@@ -215,11 +246,12 @@ export function Glp1Tile() {
data-medication-id={med.medicationId ?? ""}
className={cn(
"bg-card/65 relative overflow-hidden rounded-xl border px-4 py-4 shadow-sm backdrop-blur-sm",
// A faint green seam on the left edge so the tile reads as
// "active therapy" at a glance without needing to read the
// header. The same green is used for the injection-day markers
// below — visual consistency across the tile.
"border-l-dracula-green/60 border-l-2",
// v1.4.27 B1 — the green left-seam drops. The Syringe-icon in
// the title row already carries the "active therapy" signal;
// the seam was decoration without a semantic tie to the
// schedule dates next to it. The schedule pill row below now
// groups the two dates visually so the user reads them as one
// cohesive unit.
)}
>
<div className="flex items-center gap-2 pb-3">
@@ -249,50 +281,132 @@ export function Glp1Tile() {
)}
</div>
<dl className="text-muted-foreground grid grid-cols-1 gap-y-1 pb-3 text-xs sm:grid-cols-2 sm:gap-x-4">
{med.lastInjection && (
<div className="flex items-center gap-1.5">
<Calendar className="h-3 w-3 shrink-0" aria-hidden="true" />
<dt className="font-medium">
{t("dashboard.glp1.lastInjection")}:
</dt>
<dd
{/* v1.4.27 B1 — schedule pill row promotes the two injection
dates to a header band. Pills carry a soft dracula-green tint
so the two dates read as one cohesive "therapy schedule"
unit; the old <dl> grid + arbitrary green seam are gone. */}
{(med.lastInjection || med.nextInjection) && (
<div
data-slot="glp1-tile-schedule"
className="flex flex-wrap items-center gap-1.5 pb-3 text-xs"
>
{med.lastInjection && (
<span
data-slot="glp1-tile-last"
className="text-foreground tabular-nums"
className="border-dracula-green/30 bg-dracula-green/10 text-foreground inline-flex items-center gap-1.5 rounded-full border px-2 py-0.5 tabular-nums"
>
{dateWithWeekday(med.lastInjection.date)}
</dd>
</div>
)}
{med.nextInjection && (
<div className="flex items-center gap-1.5">
<Syringe className="h-3 w-3 shrink-0" aria-hidden="true" />
<dt className="font-medium">
{t("dashboard.glp1.nextInjection")}:
</dt>
<dd
<Calendar
className="text-dracula-green/80 h-3 w-3 shrink-0"
aria-hidden="true"
/>
<span className="text-muted-foreground font-medium">
{t("dashboard.glp1.lastInjection")}:
</span>
<span>{dateWithWeekday(med.lastInjection.date)}</span>
</span>
)}
{med.nextInjection && (
<span
data-slot="glp1-tile-next"
className="text-foreground tabular-nums"
className="border-dracula-green/30 bg-dracula-green/10 text-foreground inline-flex items-center gap-1.5 rounded-full border px-2 py-0.5 tabular-nums"
>
{dateWithWeekday(med.nextInjection.date)}
<span className="text-muted-foreground ml-1">
(
<Syringe
className="text-dracula-green/80 h-3 w-3 shrink-0"
aria-hidden="true"
/>
<span className="text-muted-foreground font-medium">
{t("dashboard.glp1.nextInjection")}:
</span>
<span>{dateWithWeekday(med.nextInjection.date)}</span>
<span className="text-muted-foreground">
·{" "}
{t("dashboard.glp1.inDays", {
count: med.nextInjection.daysAway,
})}
)
</span>
</dd>
</div>
)}
</dl>
</span>
)}
</div>
)}
{/* Compact weight chart with the vertical injection-day markers
the v1.4.25 W6 chart-extension adds. Skipped when we have no
weight readings since the GLP-1 was started — the empty
chart would draw nothing useful. */}
{med.weightSeries.length > 0 && (
<div data-slot="glp1-tile-chart">
{/* v1.4.27 B1 — chart pane carrying a two-tab segmented control
(Drug-Level default / Weight) plus a 7d / 30d / 90d / All
range strip above. Drug-Level reads the unit-less curve from
`<DrugLevelChart compact …>` (Research Mode gating is owned
by the chart itself); Weight retains the v1.4.25 W6 mini
chart with vertical injection markers. */}
<div data-slot="glp1-tile-chart" className="space-y-2 pt-2">
<div className="flex flex-wrap items-center justify-between gap-2">
<div
data-slot="glp1-tile-tabs"
role="tablist"
aria-label={t("dashboard.glp1.tabsAria")}
className="bg-muted/40 inline-flex rounded-md p-0.5"
>
<TabButton
active={activeTab === "level"}
onClick={() => setActiveTab("level")}
label={t("dashboard.glp1.tabLevel")}
slot="glp1-tile-tab-level"
/>
<TabButton
active={activeTab === "weight"}
onClick={() => setActiveTab("weight")}
label={t("dashboard.glp1.tabWeight")}
slot="glp1-tile-tab-weight"
/>
</div>
<div
data-slot="glp1-tile-range-strip"
role="radiogroup"
aria-label={t("dashboard.glp1.rangeStripLabel")}
className="inline-flex items-center gap-1 text-[11px]"
>
{CHART_RANGE_PRESETS.map((preset) => (
<button
key={preset.points}
type="button"
role="radio"
aria-checked={rangePoints === preset.points}
data-slot="glp1-tile-range-button"
data-points={preset.points}
data-active={rangePoints === preset.points ? "true" : "false"}
title={t(preset.titleKey)}
onClick={() => setRangePoints(preset.points)}
className={cn(
"inline-flex min-h-11 items-center justify-center rounded px-3 font-medium tabular-nums",
"hover:text-foreground focus-visible:ring-ring/50 focus-visible:ring-2 focus-visible:outline-none",
rangePoints === preset.points
? "bg-card text-foreground shadow-sm"
: "text-muted-foreground",
)}
>
{t(preset.labelKey)}
</button>
))}
</div>
</div>
{activeTab === "level" && med.medicationId ? (
<DrugLevelChart
compact
medication={{
id: med.medicationId,
name: med.name,
dose: med.currentDose
? `${med.currentDose.value}${med.currentDose.unit}`
: "",
}}
windowHoursBefore={rangePointsToHours(rangePoints)}
/>
) : activeTab === "level" ? (
<p
data-slot="glp1-tile-level-unavailable"
className="text-muted-foreground bg-muted/40 rounded-md p-3 text-xs"
>
{t("dashboard.glp1.levelUnavailable")}
</p>
) : med.weightSeries.length > 0 ? (
<HealthChart
mini
types={["WEIGHT"]}
@@ -302,12 +416,57 @@ export function Glp1Tile() {
verticalMarkers={med.injectionDates.map((date) => ({ date }))}
userTimezone={user?.timezone}
/>
</div>
)}
) : (
<p
data-slot="glp1-tile-weight-unavailable"
className="text-muted-foreground bg-muted/40 rounded-md p-3 text-xs"
>
{t("dashboard.glp1.weightUnavailable")}
</p>
)}
</div>
</div>
);
}
/**
* v1.4.27 B1 — small segmented-control button used by the tile's
* tab strip. Carved into its own component so the active/inactive
* styling lives in one place and the test can pin the data-slot
* markers per tab.
*/
function TabButton({
active,
onClick,
label,
slot,
}: {
active: boolean;
onClick: () => void;
label: string;
slot: string;
}) {
return (
<button
type="button"
role="tab"
aria-selected={active}
data-slot={slot}
data-active={active ? "true" : "false"}
onClick={onClick}
className={cn(
"inline-flex min-h-11 items-center justify-center rounded px-3 text-xs font-medium",
"focus-visible:ring-ring/50 focus-visible:ring-2 focus-visible:outline-none",
active
? "bg-card text-foreground shadow-sm"
: "text-muted-foreground hover:text-foreground",
)}
>
{label}
</button>
);
}
/**
* Carve the delta caption pill into its own component so the
* loss/gain/flat tone selection is colocated with the rendering. The
+9 -4
View File
@@ -74,7 +74,12 @@ export function ErrorDetails({
}
return (
<div className="mx-auto w-full max-w-xl space-y-4 p-6">
// v1.4.27 MB6 — `min-h-dvh flex flex-col justify-center` makes
// the error panel anchor in the middle of the available viewport
// even when this component is mounted at the root layout. Without
// it the panel hugged the top edge under the notch on
// small-screen devices.
<div className="mx-auto flex min-h-dvh w-full max-w-xl flex-col justify-center space-y-4 p-6">
<h1 className="text-xl font-semibold">{t("errorBoundary.title")}</h1>
<p className="text-muted-foreground text-sm">
{t("errorBoundary.description")}
@@ -84,12 +89,12 @@ export function ErrorDetails({
</div>
<div className="flex flex-wrap items-center gap-2">
{reset && (
<Button onClick={reset} variant="outline" size="sm">
<Button onClick={reset} variant="outline">
<RotateCcw className="mr-2 h-4 w-4" />
{t("common.retry")}
</Button>
)}
<Button onClick={handleCopy} variant="outline" size="sm">
<Button onClick={handleCopy} variant="outline">
{copied ? (
<CheckCircle2 className="text-dracula-green mr-2 h-4 w-4" />
) : (
@@ -98,7 +103,7 @@ export function ErrorDetails({
{copied ? t("common.copied") : t("common.copyDetails")}
</Button>
{bugReportEnabled && (
<Button asChild variant="outline" size="sm">
<Button asChild variant="outline">
<Link href={reportHref}>
<Bug className="mr-2 h-4 w-4" />
{t("common.reportIssue")}
@@ -83,33 +83,34 @@ describe("<MaintainershipBanner>", () => {
expect(cfg.isMaintainedLocale("pl")).toBe(false);
});
// v1.4.25 W14c — banner copy upgraded to acknowledge that the
// FR/ES/IT/PL Coach prompts are AI-drafted including safety-critical
// instructions. Pin the new wording so a copy regression cannot
// quietly soften the disclosure.
it("EN banner copy names safety-critical AI-drafted Coach content", async () => {
// v1.4.27 — the banner copy was rewritten to "machine-translated"
// wording in the convention-compliance pass. The disclosure still
// names the safety-critical Coach content and points at GitHub;
// pin the new shape so a future regression cannot quietly soften
// the disclosure or re-introduce the retired vocabulary.
it("EN banner copy names the machine-translated safety-critical Coach content", async () => {
const { readFileSync } = await import("node:fs");
const { join } = await import("node:path");
const en = JSON.parse(
readFileSync(join(process.cwd(), "messages", "en.json"), "utf8"),
) as { i18n: { maintainershipBanner: { notice: string } } };
const notice = en.i18n.maintainershipBanner.notice;
expect(notice).toMatch(/AI-drafted/);
expect(notice).toMatch(/machine-translated/);
expect(notice).toMatch(/safety-critical/);
expect(notice).toMatch(/GitHub/);
expect(notice).toMatch(/Coach/);
});
it("every AI-initial locale's banner notice acknowledges the AI-drafted Coach content", async () => {
it("every machine-translated locale's banner notice acknowledges the Coach content", async () => {
const { readFileSync } = await import("node:fs");
const { join } = await import("node:path");
const aiDraftPatterns: Record<string, RegExp> = {
fr: /rédigée par IA/,
es: /redactado por IA/,
it: /redatta tramite IA/,
pl: /opracowana przez AI/,
const machineTranslatedPatterns: Record<string, RegExp> = {
fr: /traduite automatiquement/,
es: /traducido automáticamente/i,
it: /tradotta automaticamente/i,
pl: /tłumaczona maszynowo|tłumaczone maszynowo/i,
};
for (const [locale, pattern] of Object.entries(aiDraftPatterns)) {
for (const [locale, pattern] of Object.entries(machineTranslatedPatterns)) {
const data = JSON.parse(
readFileSync(
join(process.cwd(), "messages", `${locale}.json`),
@@ -63,10 +63,15 @@ describe("<DailyBriefing>", () => {
expect(html).toContain("Tagesbriefing");
});
it("renders the narrative paragraph", () => {
it("v1.4.27 B1 — does NOT render the leading narrative paragraph", () => {
// The hero strip subtitle on /insights renders the same
// `briefing.paragraph` directly above this card, so the card now
// opens straight on the key-findings list. The paragraph slot is
// gone from the populated branch; the empty-state branch still
// owns its CTA copy.
const html = render(<DailyBriefing briefing={baseBriefing} />);
expect(html).toMatch(/data-slot="daily-briefing-paragraph"/);
expect(html).toContain(
expect(html).not.toContain('data-slot="daily-briefing-paragraph"');
expect(html).not.toContain(
"Blood pressure is in target band on 9 of 10 readings",
);
});
@@ -133,13 +138,16 @@ describe("<DailyBriefing>", () => {
});
it("hides the keyFindings section when the array is empty", () => {
// v1.4.27 B1 — the leading paragraph dropped; the card now renders
// only the header + (when present) the findings list. With both
// gone the populated branch produces no body content, which the
// hero strip already covers.
const html = render(
<DailyBriefing briefing={{ ...baseBriefing, keyFindings: [] }} />,
);
expect(html).not.toContain('data-slot="daily-briefing-findings-title"');
expect(html).not.toContain('data-slot="daily-briefing-findings"');
// Paragraph still renders.
expect(html).toContain("trending well this week");
expect(html).not.toContain('data-slot="daily-briefing-paragraph"');
});
it("renders the empty state when briefing is null and not loading", () => {
@@ -1,6 +1,6 @@
import { describe, it, expect, vi } from "vitest";
import { describe, it, expect } from "vitest";
import { renderToStaticMarkup } from "react-dom/server";
import { I18nProvider, useTranslations } from "@/lib/i18n/context";
import { I18nProvider } from "@/lib/i18n/context";
import {
HealthScoreCard,
type HealthScoreCardProps,
@@ -10,9 +10,10 @@ import {
* v1.4.20 phase B5 — Personal Health Score card.
*
* SSR markup pins the slot wrappers so future polish can't silently
* drop the score number, sub-bars or disclaimer. The interactive
* "Ask the Coach" button is exercised through Testing Library so the
* prefill argument forwarded to the parent handler stays stable.
* drop the score number, sub-bars or disclaimer. The inline
* Ask-the-Coach button retired in v1.4.27 B1 — the hero strip
* carries the CTA. The negative-assertion test below pins that
* decision so a future revival has to update the test in lock-step.
*/
const baseComponents: HealthScoreCardProps["components"] = {
@@ -179,8 +180,12 @@ describe("<HealthScoreCard>", () => {
expect(html).toContain("Indicative");
});
it("does NOT render the Ask-the-Coach button when onAskCoach is omitted", () => {
const html = ssr(
it("v1.4.27 B1 — no longer mounts an inline Ask-the-Coach button (hero strip carries the CTA)", () => {
// The hero strip's existing "Ask the coach" action covers this
// surface; the card-internal duplicate button retired. The
// onAskCoach prop stays on the type signature so callers don't
// break, but the card no longer renders it.
const omitted = ssr(
<HealthScoreCard
score={86}
band="green"
@@ -188,11 +193,7 @@ describe("<HealthScoreCard>", () => {
delta={null}
/>,
);
expect(html).not.toContain('data-slot="health-score-card-ask-coach"');
});
it("renders the Ask-the-Coach button when onAskCoach is supplied", () => {
const html = ssr(
const supplied = ssr(
<HealthScoreCard
score={86}
band="green"
@@ -201,36 +202,10 @@ describe("<HealthScoreCard>", () => {
onAskCoach={() => {}}
/>,
);
expect(html).toMatch(/data-slot="health-score-card-ask-coach"/);
expect(html).toContain("Ask the Coach");
expect(omitted).not.toContain('data-slot="health-score-card-ask-coach"');
expect(supplied).not.toContain('data-slot="health-score-card-ask-coach"');
});
it("captures the score-aware prefill argument when the button is invoked", () => {
// The handler closure receives `t("insights.healthScore.coachPrompt", { score })`
// which interpolates the current score. We assert the closure shape by
// probing the i18n provider directly rather than wiring a DOM testing
// library — the component's onClick lambda is one line and its
// contract is the prefill string format, not the click plumbing.
let captured: string | null = null;
const onAskCoach = vi.fn((prefill: string) => {
captured = prefill;
});
function Probe() {
const { t } = useTranslations();
// Reproduce the same call the button's onClick performs.
onAskCoach(t("insights.healthScore.coachPrompt", { score: 86 }));
return null;
}
renderToStaticMarkup(
<I18nProvider initialLocale="en">
<Probe />
</I18nProvider>,
);
expect(onAskCoach).toHaveBeenCalledTimes(1);
expect(captured).toBe("Why is my health score 86 out of 100?");
});
it("renders German strings", () => {
const html = ssr(
@@ -244,9 +219,11 @@ describe("<HealthScoreCard>", () => {
"de",
);
expect(html).toContain("Gesundheitsscore");
expect(html).toContain("Coach fragen");
expect(html).toContain("im Vergleich zur Vorwoche");
expect(html).toContain("Orientierungshilfe");
// v1.4.27 B1 — the inline Coach button retired; hero strip carries
// the action now.
expect(html).not.toContain("Coach fragen");
});
it("uses tabular-nums on the headline number for stable layout", () => {
@@ -5,11 +5,9 @@ import { HeroStrip } from "../hero-strip";
import type { DailyBriefing as DailyBriefingPayload } from "@/lib/ai/schema";
/**
* v1.4.20 phase B1 — Insights hero strip.
*
* Replaces v1.4.16 `<InsightsPageHero>`. Pinning the slots so future
* polish can't silently drop the greeting / subtitle / action row /
* suggested-prompt strip.
* Insights hero strip — pins the slots so future polish can't
* silently drop the greeting / subtitle / action row / suggested-
* prompt strip.
*/
function render(node: React.ReactNode, locale: "en" | "de" = "en") {
@@ -306,7 +304,12 @@ describe("<HeroStrip>", () => {
expect(html).toContain(">86<");
});
it("forwards onAskCoach into the Health Score panel only when both are supplied", () => {
it("does not render an inline Ask-the-Coach button inside the Health Score card", () => {
// v1.4.27 F8 — the inline HSC Ask-the-Coach button retired in
// favour of the hero strip's existing action-row button. Even
// when `onAskCoach` is supplied, the HSC panel must not surface
// its own button. The action-row button at
// `insights-hero-strip-action-coach` carries the affordance.
const html = render(
<HeroStrip
briefing={null}
@@ -325,31 +328,8 @@ describe("<HeroStrip>", () => {
onAskCoach={() => {}}
/>,
);
// HSC panel renders its own Ask-the-Coach button with the score-
// aware label (the action-row button uses the shorter "Ask the
// coach" copy). The HSC button mirrors the panel's slot.
expect(html).toMatch(/data-slot="health-score-card-ask-coach"/);
});
it("hides the Health Score panel's Ask button when no onAskCoach is supplied", () => {
const html = render(
<HeroStrip
briefing={null}
now={morningLocal}
healthScore={{
score: 86,
band: "green",
components: {
bp: { value: 80, weight: 0.3 },
weight: { value: 70, weight: 0.2 },
mood: { value: 90, weight: 0.2 },
compliance: { value: 100, weight: 0.3 },
},
delta: 5,
}}
/>,
);
expect(html).not.toContain('data-slot="health-score-card-ask-coach"');
expect(html).toContain('data-slot="insights-hero-strip-action-coach"');
});
it("uses the lg row layout when healthScore is supplied (smoke test on container class)", () => {
@@ -0,0 +1,92 @@
import { describe, it, expect } from "vitest";
import { renderToStaticMarkup } from "react-dom/server";
import { I18nProvider } from "@/lib/i18n/context";
import { InsightStatusCard } from "../insight-status-card";
/**
* v1.4.27 — F16 regression cover. The per-metric status text comes
* out of the AI provider with a literal `metric:<TYPE>` token (the
* prompt invites the model to embed one so the mother page can render
* an inline chart). The sub-page does not mount the chart, so the
* token used to surface verbatim at the tail of the rendered prose.
* The card now wraps the text in `stripChartTokens` so cached rows
* from pre-v1.4.27 still render clean while they roll forward.
*/
function render(node: React.ReactNode, locale: "en" | "de" = "en") {
return renderToStaticMarkup(
<I18nProvider initialLocale={locale}>{node}</I18nProvider>,
);
}
const baseProps = {
title: "Pulse",
icon: null,
hasProvider: true,
cached: false,
updatedAt: null,
};
describe("<InsightStatusCard>", () => {
it("strips a trailing colon-form metric token from rendered prose", () => {
const html = render(
<InsightStatusCard
{...baseProps}
text="Your pulse is stable. metric:PULSE"
/>,
);
expect(html).toContain("Your pulse is stable.");
expect(html).not.toContain("metric:PULSE");
});
it("strips multiple colon-form tokens scattered through the prose", () => {
const html = render(
<InsightStatusCard
{...baseProps}
title="Blood pressure"
text="Systolic averaged 132 metric:BLOOD_PRESSURE_SYS over the last 30 days. metric:BLOOD_PRESSURE_DIA"
/>,
);
expect(html).toContain("Systolic averaged 132");
expect(html).not.toContain("metric:BLOOD_PRESSURE_SYS");
expect(html).not.toContain("metric:BLOOD_PRESSURE_DIA");
});
it("strips the capitalised Metric form (v1.4.25 W5b leak shape)", () => {
const html = render(
<InsightStatusCard
{...baseProps}
title="Blood pressure"
text="Your Metric Pressure_Sys is elevated relative to last month."
/>,
);
expect(html).not.toContain("Metric Pressure_Sys");
expect(html).toContain("Your");
expect(html).toContain("is elevated");
});
it("renders ordinary prose unchanged when no tokens are present", () => {
const html = render(
<InsightStatusCard {...baseProps} text="Pulse stayed inside the band." />,
);
expect(html).toContain("Pulse stayed inside the band.");
});
it("renders the no-provider state without touching the strip path", () => {
const html = render(
<InsightStatusCard
{...baseProps}
hasProvider={false}
text="ignored when hasProvider is false"
/>,
);
expect(html).toContain("AI provider not configured.");
expect(html).not.toContain("ignored when hasProvider is false");
});
it("renders the empty-text state without crashing on null", () => {
const html = render(<InsightStatusCard {...baseProps} text={null} />);
expect(html).toContain("No analysis yet.");
});
});
@@ -1,159 +0,0 @@
import { describe, it, expect, vi } from "vitest";
import { renderToStaticMarkup } from "react-dom/server";
import { I18nProvider } from "@/lib/i18n/context";
import { InsightsCardPreview } from "../insights-card";
import type { InsightResult, InsightRecommendation } from "@/lib/ai/types";
/**
* v1.4.16 phase B1b — dashboard InsightsCard preview.
*
* The preview is a compact wrapper that surfaces the top 1-2
* severity-ordered recommendations + a "View all" CTA pointing to
* the full `/insights` page. Visual language matches the page hero
* + recommendation grid (Dracula tokens, severity-coloured borders,
* mini confidence meter).
*/
vi.mock("@/components/charts/health-chart", () => ({
HealthChart: () => <div data-testid="health-chart-mock" />,
}));
vi.mock("@/components/charts/mood-chart", () => ({
MoodChart: () => <div data-testid="mood-chart-mock" />,
}));
vi.mock("@tanstack/react-query", () => ({
useQuery: () => ({ data: null, isLoading: false }),
useMutation: () => ({
mutate: vi.fn(),
isPending: false,
isSuccess: false,
isError: false,
error: null,
}),
useQueryClient: () => ({ invalidateQueries: vi.fn() }),
}));
vi.mock("@/hooks/use-auth", () => ({
useAuth: () => ({
user: { id: "test-user", username: "tester", role: "USER" },
isAuthenticated: true,
isLoading: false,
}),
}));
function rec(
id: string,
severity: "info" | "suggestion" | "important" | "urgent",
text: string,
confidence?: number,
): InsightRecommendation {
return {
id,
text,
severity,
confidence,
rationale: {
dataWindow: "last7days",
comparedTo: "your 90-day median",
deviation: "moderate deviation",
},
metricSource: {
type: "weight",
timeRange: "last7days",
summary: "84.2 kg avg over 7 days",
},
};
}
function buildInsight(recommendations: InsightRecommendation[]): InsightResult {
return {
insightType: "general",
summary: "Test summary",
classification: "gut",
classificationLabel: "Good",
findings: [],
correlations: [],
primaryRecommendation: "",
recommendations,
dataQuality: { coverage: "good", gaps: [], confidence: "hoch" },
disclaimer: "Not medical advice.",
};
}
function render(node: React.ReactNode, locale: "en" | "de" = "en") {
return renderToStaticMarkup(
<I18nProvider initialLocale={locale}>{node}</I18nProvider>,
);
}
describe("<InsightsCardPreview>", () => {
it("surfaces only the top 2 severity-ordered recommendations", () => {
const recs = [
rec("a", "info", "info-rec"),
rec("b", "urgent", "urgent-rec"),
rec("c", "important", "important-rec"),
rec("d", "suggestion", "suggestion-rec"),
];
const html = render(<InsightsCardPreview insight={buildInsight(recs)} />);
expect(html).toContain("urgent-rec");
expect(html).toContain("important-rec");
// The preview must hide the remaining recs — they live on /insights.
expect(html).not.toContain("info-rec");
expect(html).not.toContain("suggestion-rec");
});
it("renders the 'View all' CTA pointing to /insights", () => {
const recs = [rec("a", "urgent", "urgent-rec")];
const html = render(<InsightsCardPreview insight={buildInsight(recs)} />);
expect(html).toMatch(/href="\/insights"/);
expect(html).toMatch(/data-slot="insights-card-view-all"/);
});
it("paints a severity-coloured left border on each preview card", () => {
const recs = [
rec("a", "urgent", "urgent-rec"),
rec("b", "important", "important-rec"),
];
const html = render(<InsightsCardPreview insight={buildInsight(recs)} />);
expect(html).toMatch(/border-l-dracula-red/);
expect(html).toMatch(/border-l-dracula-orange/);
});
it("renders a mini confidence meter inline when confidence is supplied", () => {
const recs = [rec("a", "urgent", "urgent-rec", 78)];
const html = render(<InsightsCardPreview insight={buildInsight(recs)} />);
// The ConfidenceMeter component renders a 'data-slot="confidence-meter"'
// marker; the preview shows the inline ring/bars variant.
expect(html).toMatch(/data-slot="confidence-meter"/);
});
it("hides the preview entirely when there are no recommendations", () => {
const html = render(<InsightsCardPreview insight={buildInsight([])} />);
// The orphan "no recs" state is delegated to the full /insights page.
// The preview either renders the top recs OR doesn't mount at all.
expect(html).toBe("");
});
it("passes a null insight transparently — no preview", () => {
const html = render(<InsightsCardPreview insight={null} />);
expect(html).toBe("");
});
it("renders the title slot so dashboard nav can sit alongside", () => {
const recs = [rec("a", "urgent", "urgent-rec")];
const html = render(<InsightsCardPreview insight={buildInsight(recs)} />);
expect(html).toMatch(/data-slot="insights-card-preview"/);
// Card title text from the existing translation key.
expect(html).toContain("AI Insights");
});
it("German locale — title + view-all CTA translate", () => {
const recs = [rec("a", "urgent", "urgent-rec")];
const html = render(
<InsightsCardPreview insight={buildInsight(recs)} />,
"de",
);
expect(html).toContain("KI-Insights");
// "View all" → German "Alle ansehen"
expect(html).toContain("Alle ansehen");
});
});
@@ -0,0 +1,117 @@
import { describe, it, expect, vi } from "vitest";
import { renderToStaticMarkup } from "react-dom/server";
import { I18nProvider } from "@/lib/i18n/context";
// `usePathname` is consulted to mark the active pill; SSR has no
// Next.js router, so we stub it.
vi.mock("next/navigation", () => ({
usePathname: () => "/insights",
}));
// `toast` is consulted from a falling-edge effect; renderToStaticMarkup
// doesn't fire effects, but we stub it for completeness.
vi.mock("sonner", () => ({ toast: { success: vi.fn() } }));
import { InsightsTabStrip } from "../insights-tab-strip";
import type { InsightInputs } from "@/lib/insights/metric-availability";
import type { DataSummary } from "@/lib/analytics/trends";
function render(node: React.ReactNode, locale: "en" | "de" = "en") {
return renderToStaticMarkup(
<I18nProvider initialLocale={locale}>{node}</I18nProvider>,
);
}
function fakeSummary(count: number): DataSummary {
return {
count,
latest: count > 0 ? 1 : null,
min: null,
max: null,
mean: null,
avg7: null,
avg30: null,
slope7: null,
slope30: null,
slope90: null,
anomalyCount: 0,
};
}
describe("<InsightsTabStrip> — availability gating (v1.4.27 F19)", () => {
it("renders every pill when availability is omitted (backward compat)", () => {
const html = render(<InsightsTabStrip />);
expect(html).toContain(">Blood Pressure<");
expect(html).toContain(">Weight<");
expect(html).toContain(">Pulse<");
expect(html).toContain(">Mood<");
expect(html).toContain(">Medication<");
expect(html).toContain(">BMI<");
expect(html).toContain(">Sleep<");
});
it("drops pills for metrics with zero observations", () => {
const availability: InsightInputs = {
summaries: {
PULSE: fakeSummary(5),
WEIGHT: fakeSummary(3),
},
hasMood: false,
hasMedication: false,
};
const html = render(<InsightsTabStrip availability={availability} />);
// Pills that have data stay.
expect(html).toContain(">Pulse<");
expect(html).toContain(">Weight<");
expect(html).toContain(">BMI<");
// Pills without data drop.
expect(html).not.toContain(">Blood Pressure<");
expect(html).not.toContain(">Mood<");
expect(html).not.toContain(">Medication<");
expect(html).not.toContain(">Sleep<");
});
it("keeps the overview pill regardless of availability", () => {
const availability: InsightInputs = {
summaries: {},
hasMood: false,
hasMedication: false,
};
const html = render(<InsightsTabStrip availability={availability} />);
expect(html).toContain(">Overview<");
});
it("lights up Mood when hasMood flips to true", () => {
const availability: InsightInputs = {
summaries: {},
hasMood: true,
hasMedication: false,
};
const html = render(<InsightsTabStrip availability={availability} />);
expect(html).toContain(">Mood<");
expect(html).not.toContain(">Medication<");
});
it("lights up Medication when hasMedication flips to true", () => {
const availability: InsightInputs = {
summaries: {},
hasMood: false,
hasMedication: true,
};
const html = render(<InsightsTabStrip availability={availability} />);
expect(html).toContain(">Medication<");
expect(html).not.toContain(">Mood<");
});
it("derives BMI from WEIGHT count", () => {
const availability: InsightInputs = {
summaries: { WEIGHT: fakeSummary(2) },
hasMood: false,
hasMedication: false,
};
const html = render(<InsightsTabStrip availability={availability} />);
expect(html).toContain(">Weight<");
expect(html).toContain(">BMI<");
});
});
@@ -0,0 +1,93 @@
"use client";
import { Sparkles } from "lucide-react";
import { Button } from "@/components/ui/button";
import { cn } from "@/lib/utils";
import { useTranslations } from "@/lib/i18n/context";
import { useCoachLaunch } from "@/lib/insights/coach-launch-context";
/**
* v1.4.27 R3d MB4 — Coach launch button.
*
* Decision F: every routed Insights sub-page mounts this button so the
* Coach drawer (mounted at the layout root) is always reachable. The
* button renders as a sticky-bottom FAB on `<lg` viewports (so a phone
* user always sees it within thumb range) and as an inline header
* action on `lg+` (where the desktop drawer slides in from the right
* edge and the FAB would overlap the chart legend).
*
* The button is a thin wrapper around `useCoachLaunch()` — it only
* renders when the context provider is mounted (so dropping the button
* onto a non-Insights page is a no-op rather than a crash).
*/
export interface CoachLaunchButtonProps {
/** Optional override for the visual label. Defaults to the shared CTA. */
label?: string;
/** Optional prefill seed for the next Coach turn. */
prefill?: string;
/** Optional className passthrough for inline overrides. */
className?: string;
}
export function CoachLaunchButton({
label,
prefill,
className,
}: CoachLaunchButtonProps) {
const { t } = useTranslations();
const launch = useCoachLaunch();
if (!launch) {
// The button only makes sense beneath the provider. Render nothing
// so the sub-page doesn't paint a dead control.
return null;
}
const accessibleLabel = label ?? t("insights.heroActionAskCoach");
return (
<>
{/* Sticky FAB on `<lg` viewports — pinned to the bottom-right so
the user's thumb always finds it. `bottom-20` clears the
mobile bottom navigation; `safe-area-inset-bottom` plus the
existing app shell padding keep it clear of the iOS home bar.
Hidden on `lg+` where the inline button below takes over. */}
<Button
type="button"
size="lg"
data-slot="coach-launch-fab"
onClick={() => launch.askCoach(prefill ?? null)}
aria-label={accessibleLabel}
title={accessibleLabel}
className={cn(
"fixed right-4 bottom-20 z-40 h-12 rounded-full px-4 shadow-lg",
"from-dracula-purple to-dracula-pink bg-gradient-to-br text-white",
"hover:from-dracula-purple/90 hover:to-dracula-pink/90",
"lg:hidden",
className,
)}
>
<Sparkles className="size-4" aria-hidden="true" />
<span>{accessibleLabel}</span>
</Button>
{/* Inline header-style action on `lg+`. Lives in the page body so
it picks up the sub-page layout's spacing instead of fighting
a sticky header overlay. */}
<Button
type="button"
variant="outline"
size="sm"
data-slot="coach-launch-inline"
onClick={() => launch.askCoach(prefill ?? null)}
className={cn(
"hidden h-10 gap-2 self-end lg:inline-flex",
className,
)}
>
<Sparkles className="size-4" aria-hidden="true" />
<span>{accessibleLabel}</span>
</Button>
</>
);
}
@@ -41,18 +41,22 @@ describe("<CoachDrawerBody> — mobile rail trays", () => {
});
it("hides the history trigger on >=lg and the sources trigger on >=xl", () => {
// v1.4.27 R3d MB2 — the strip wrapper carries `xl:hidden` so the
// sources trigger inherits the breakpoint; the history trigger
// keeps its own `lg:hidden` because the history rail mounts
// inline already on lg+ while the sources rail still needs the
// chevron until xl.
const html = render(<CoachDrawerBody {...baseProps} />);
const strip = html.match(
/<div[^>]*data-slot="coach-drawer-rail-tray-strip"[^>]*>/,
);
expect(strip?.[0]).toContain("xl:hidden");
const historyTrigger = html.match(
/<button[^>]*data-slot="coach-drawer-history-tray-trigger"[^>]*>/,
);
expect(historyTrigger).not.toBeNull();
expect(historyTrigger?.[0]).toContain("lg:hidden");
const sourcesTrigger = html.match(
/<button[^>]*data-slot="coach-drawer-sources-tray-trigger"[^>]*>/,
);
expect(sourcesTrigger).not.toBeNull();
expect(sourcesTrigger?.[0]).toContain("xl:hidden");
});
it("renders the desktop history rail (lg+) and sources rail (xl+)", () => {
@@ -95,12 +99,16 @@ describe("<CoachDrawerBody> — mobile rail trays", () => {
expect(html).toContain("Worauf ich zugreife");
});
it("the thread region wraps the rail triggers in a relative container", () => {
// The triggers use absolute positioning; the thread region must
// be `relative` for them to anchor inside it.
it("renders the rail triggers in a sub-header strip above the thread", () => {
// v1.4.27 R3d MB2 — the triggers were lifted out of the absolute
// overlay into a sub-header strip so the chevrons sit at a 44 px
// tap target and never overlay the first message bubble. The
// strip lives above the thread within the centre column.
const html = render(<CoachDrawerBody {...baseProps} />);
expect(html).toMatch(
/<main[^>]*data-slot="coach-drawer-thread"[^>]*relative/,
expect(html).toMatch(/data-slot="coach-drawer-rail-tray-strip"/);
const trigger = html.match(
/<button[^>]*data-slot="coach-drawer-history-tray-trigger"[^>]*>/,
);
expect(trigger?.[0]).toContain("min-h-11");
});
});
@@ -26,12 +26,22 @@ describe("<CoachInput>", () => {
expect(html).not.toContain("Coach replies are generated");
});
it("renders the localised placeholder + hint", () => {
it("renders the localised placeholder + the popover-trigger hint", () => {
// v1.4.27 F15 — the verbose prose hint footer was replaced by an
// Info icon button. The same `composerHint` translation surfaces
// as the trigger's `aria-label` (so screen-readers announce it on
// focus) and as the popover body for sighted users on tap.
//
// v1.4.27 MB3 / CF-31 — the disclosure swapped off Radix Tooltip
// (no reliable tap-toggle on touch surfaces) onto Radix Popover,
// so the contract is now "tap to open" instead of "hover".
const html = render(
<CoachInput value="" onChange={() => {}} onSubmit={() => {}} />,
);
expect(html).toContain("Ask anything about your data");
expect(html).toContain("Press Enter to send");
expect(html).toMatch(
/aria-label="Press Enter to send[^"]*"\s+data-slot="coach-input-hint"/,
);
});
it("renders the German placeholder under the 'de' locale", () => {
@@ -55,6 +55,11 @@ vi.mock("@/components/ui/sheet", () => ({
SheetDescription: ({ children }: { children: React.ReactNode }) => (
<p>{children}</p>
),
// v1.4.27 R3d MB1 — the settings sheet now renders an inline
// `<SheetClose>` in the header instead of the primitive's
// absolutely-positioned close-X. Mock it as a passthrough so the
// existing skeleton + form snapshot assertions keep working.
SheetClose: ({ children }: { children: React.ReactNode }) => <>{children}</>,
}));
import { CoachSettingsSheet } from "../coach-settings-sheet";
@@ -91,7 +96,8 @@ describe("<CoachSettingsSheet>", () => {
);
expect(html).not.toContain('data-slot="coach-prefs-skeleton"');
expect(html).toContain('data-slot="coach-prefs-tone"');
expect(html).toContain('data-slot="coach-prefs-evidence"');
// v1.4.27 F14 — the `showEvidenceByDefault` switch is retired.
expect(html).not.toContain('data-slot="coach-prefs-evidence"');
});
it("fires a sonner toast on save success (Design-H4)", () => {
@@ -59,36 +59,48 @@ export function CoachDrawerBody({
{/* Centre — message thread. */}
<main
data-slot="coach-drawer-thread"
className="relative flex h-full min-h-0 flex-col"
className="flex h-full min-h-0 flex-col"
>
{/* v1.4.20 phase B4 — mobile rail tray triggers. Hidden on
`>=lg` because the rails are inline there. */}
<Button
type="button"
variant="ghost"
size="sm"
onClick={onOpenHistoryTray}
data-slot="coach-drawer-history-tray-trigger"
aria-label={t("insights.coach.historyTitle")}
className="bg-background/80 border-border/60 absolute top-2 left-2 z-10 h-7 gap-1 border px-2 text-[11px] backdrop-blur lg:hidden"
{/* v1.4.27 R3d MB2 — rail-tray triggers lifted out of the
absolute overlay into a sub-header strip so the chevrons
sit at a 44 px tap target and never overlay the first
message bubble. Hidden on `>=xl` because both rails are
inline at that breakpoint. The history trigger remains
visible up to lg-1 (rail is inline at lg+); the sources
trigger remains visible up to xl-1 (rail is inline at
xl+). */}
<div
data-slot="coach-drawer-rail-tray-strip"
className="border-border/70 flex items-center justify-between gap-2 border-b px-3 py-2 xl:hidden"
>
<ChevronRight className="size-3" aria-hidden="true" />
{t("insights.coach.historyTitle")}
</Button>
{/* Sources tray trigger stays visible up to xl since the
inline sources rail is xl+ only. */}
<Button
type="button"
variant="ghost"
size="sm"
onClick={onOpenSourcesTray}
data-slot="coach-drawer-sources-tray-trigger"
aria-label={t("insights.coach.sourcesTitle")}
className="bg-background/80 border-border/60 absolute top-2 right-2 z-10 h-7 gap-1 border px-2 text-[11px] backdrop-blur xl:hidden"
>
{t("insights.coach.sourcesTitle")}
<ChevronLeft className="size-3" aria-hidden="true" />
</Button>
<Button
type="button"
variant="ghost"
onClick={onOpenHistoryTray}
data-slot="coach-drawer-history-tray-trigger"
aria-label={t("insights.coach.historyTitle")}
className="min-h-11 gap-1.5 text-xs lg:hidden"
>
<ChevronRight className="size-4" aria-hidden="true" />
{t("insights.coach.historyTitle")}
</Button>
{/* Sources tray trigger stays visible up to xl since the
inline sources rail is xl+ only. The empty `<span>` to
the left keeps the right-aligned button placement when
the history trigger hides at lg+. */}
<span aria-hidden="true" className="hidden lg:block" />
<Button
type="button"
variant="ghost"
onClick={onOpenSourcesTray}
data-slot="coach-drawer-sources-tray-trigger"
aria-label={t("insights.coach.sourcesTitle")}
className="min-h-11 gap-1.5 text-xs"
>
{t("insights.coach.sourcesTitle")}
<ChevronLeft className="size-4" aria-hidden="true" />
</Button>
</div>
<div className="min-h-0 flex-1">{thread}</div>
{/* Composer pinned to the bottom. */}
@@ -23,6 +23,7 @@ import {
import { cn } from "@/lib/utils";
import { useTranslations } from "@/lib/i18n/context";
import { useCoachPrefs } from "@/hooks/use-coach-prefs";
import { useIsMobile } from "@/hooks/use-is-mobile";
import { CoachDrawerBody } from "./coach-drawer-body";
import { CoachInput } from "./coach-input";
@@ -132,6 +133,13 @@ export function CoachDrawer({
composer,
}: CoachDrawerProps) {
const { t } = useTranslations();
// v1.4.27 R3d MB1 — below the `sm` breakpoint (640 px) the Coach
// drawer slides up from the bottom edge of the viewport instead of
// sliding in from the right. Right-side slide makes the back-arrow
// / close-X drift far from the user's thumb on a phone; bottom-up
// keeps the drawer chrome reachable. Above `sm` the drawer keeps
// its existing right-side slide and `sm:max-w-[720px]` cap.
const isPhoneViewport = useIsMobile("sm");
const [currentConversationId, setCurrentConversationId] = useState<
string | null
@@ -256,7 +264,10 @@ export function CoachDrawer({
return (
<Sheet open={open} onOpenChange={handleOpenChange}>
<SheetContent
side="right"
// v1.4.27 R3d MB1 — below `sm` the drawer slides up from the
// bottom edge so the header chrome stays near the user's thumb;
// above `sm` it keeps its existing right-side slide.
side={isPhoneViewport ? "bottom" : "right"}
// v1.4.25 W5 — render our own Close button inside the header so
// X / cog / new-chat sit on the same baseline with identical
// size, color, and hit target. The Sheet's default close-X is
@@ -265,6 +276,7 @@ export function CoachDrawer({
// cluster.
showCloseButton={false}
data-slot="coach-drawer"
data-variant={isPhoneViewport ? "bottom-sheet" : "side-sheet"}
className={cn(
// Drawer keeps the dashboard context behind it. On laptops
// (1280-1366px viewports) the previous lg:max-w-[1080px] cap
@@ -275,7 +287,13 @@ export function CoachDrawer({
// is hidden below xl by the body's lg:hidden chevron rules).
"w-full p-0 sm:max-w-[720px]",
"lg:!max-w-[min(960px,75vw)] xl:!max-w-[1080px]",
"flex h-[100dvh] flex-col gap-0",
isPhoneViewport
? // Bottom-sheet caps at 95 dvh so a sliver of the underlying
// /insights page remains visible — clear "this is a
// sheet, not a takeover" signal. The rounded top corners
// match the iOS bottom-sheet feel.
"flex h-[95dvh] max-h-[95dvh] flex-col gap-0 rounded-t-2xl"
: "flex h-[100dvh] flex-col gap-0",
)}
>
{/* Header (full width). Avatar + title + new-chat button +
@@ -297,7 +315,11 @@ export function CoachDrawer({
<Sparkles className="text-background size-4" />
</div>
<div className="min-w-0 flex-1">
<SheetTitle className="truncate text-sm font-semibold">
{/* v1.4.27 R3d MB4 / CF-73 — pin `min-w-0` on the title
node itself in addition to the wrapper so the
truncate clipping survives any flex-shrink quirk
with very long conversation titles. */}
<SheetTitle className="min-w-0 truncate text-sm font-semibold">
{drawerTitle}
</SheetTitle>
<SheetDescription className="text-muted-foreground truncate text-[11px]">
@@ -309,44 +331,56 @@ export function CoachDrawer({
and resets to it on drawer close. Changing the pill flips
`windowOverride`; the rail's window picker mirrors the
same source-of-truth so the user can drive the override
from either surface. */}
<Select
value={effectiveWindow}
onValueChange={(value) => {
const next = value as CoachScopeWindow;
setWindowOverride(next === savedDefaultWindow ? null : next);
}}
from either surface.
v1.4.27 R3d MB4 — on `<sm` viewports the bottom-sheet
header already carries the avatar, title, new-chat,
settings, and close buttons; one more pill there shrinks
the title to a single character before truncation. The
same override is still reachable from the sources-rail's
window picker, which is what the user opens via the
right-edge chevron tray on phone-class viewports. */}
<div
data-slot="coach-drawer-window-pill-wrap"
className="hidden sm:block"
>
<SelectTrigger
data-slot="coach-drawer-window-pill"
aria-label={t("insights.coach.windowLabel")}
size="sm"
className={cn(
"border-border/60 bg-muted/40 text-foreground h-7 shrink-0 gap-1 rounded-full px-2.5 text-[11px]",
"hover:bg-muted/60 focus-visible:ring-ring/40 focus-visible:ring-2",
windowOverride !== null &&
"border-dracula-purple/40 bg-dracula-purple/10 text-dracula-purple",
)}
<Select
value={effectiveWindow}
onValueChange={(value) => {
const next = value as CoachScopeWindow;
setWindowOverride(next === savedDefaultWindow ? null : next);
}}
>
<SelectValue
placeholder={t(`insights.coach.window.${effectiveWindow}`)}
/>
</SelectTrigger>
<SelectContent align="end">
<SelectItem value="last7days" className="text-xs">
{t("insights.coach.window.last7days")}
</SelectItem>
<SelectItem value="last30days" className="text-xs">
{t("insights.coach.window.last30days")}
</SelectItem>
<SelectItem value="last90days" className="text-xs">
{t("insights.coach.window.last90days")}
</SelectItem>
<SelectItem value="allTime" className="text-xs">
{t("insights.coach.window.allTime")}
</SelectItem>
</SelectContent>
</Select>
<SelectTrigger
data-slot="coach-drawer-window-pill"
aria-label={t("insights.coach.windowLabel")}
className={cn(
"border-border/60 bg-muted/40 text-foreground h-11 shrink-0 gap-1 rounded-full px-3 text-xs",
"hover:bg-muted/60 focus-visible:ring-ring/40 focus-visible:ring-2",
windowOverride !== null &&
"border-dracula-purple/40 bg-dracula-purple/10 text-dracula-purple",
)}
>
<SelectValue
placeholder={t(`insights.coach.window.${effectiveWindow}`)}
/>
</SelectTrigger>
<SelectContent align="end">
<SelectItem value="last7days" className="text-xs">
{t("insights.coach.window.last7days")}
</SelectItem>
<SelectItem value="last30days" className="text-xs">
{t("insights.coach.window.last30days")}
</SelectItem>
<SelectItem value="last90days" className="text-xs">
{t("insights.coach.window.last90days")}
</SelectItem>
<SelectItem value="allTime" className="text-xs">
{t("insights.coach.window.allTime")}
</SelectItem>
</SelectContent>
</Select>
</div>
{/* v1.4.25 W5 — header action cluster. All three buttons
share the same `ghost / size-icon / size-9` shape so they
visually belong together. 36×36 px hit target meets the
@@ -361,7 +395,7 @@ export function CoachDrawer({
data-slot="coach-drawer-new-chat"
aria-label={t("insights.coach.newChat")}
title={t("insights.coach.newChat")}
className="text-muted-foreground hover:text-foreground size-9 shrink-0"
className="text-muted-foreground hover:text-foreground size-11 shrink-0"
>
<Plus className="size-4" aria-hidden="true" />
</Button>
@@ -373,7 +407,7 @@ export function CoachDrawer({
data-slot="coach-drawer-settings"
aria-label={t("insights.coach.settingsAriaLabel")}
title={t("insights.coach.settingsAriaLabel")}
className="text-muted-foreground hover:text-foreground size-9 shrink-0"
className="text-muted-foreground hover:text-foreground size-11 shrink-0"
>
<Settings className="size-4" aria-hidden="true" />
</Button>
@@ -385,7 +419,7 @@ export function CoachDrawer({
data-slot="coach-drawer-close"
aria-label={t("common.close")}
title={t("common.close")}
className="text-muted-foreground hover:text-foreground size-9 shrink-0"
className="text-muted-foreground hover:text-foreground size-11 shrink-0"
>
<X className="size-4" aria-hidden="true" />
</Button>
@@ -438,6 +472,12 @@ export function CoachDrawer({
onSubmit={() => handleSubmit(inputValue)}
disabled={send.isStreaming}
isStreaming={send.isStreaming}
// v1.4.27 MB3 / CF-30 — the composer mounts on drawer
// open and unmounts on close (the body collapses with
// the sheet), so a one-shot mount focus matches the
// "drawer just opened" semantic without any re-render
// gymnastics.
autoFocusOnOpen
/>
)
}
@@ -7,9 +7,14 @@ import {
useEffect,
useRef,
} from "react";
import { Loader2, Send } from "lucide-react";
import { Info, Loader2, Send } from "lucide-react";
import { Button } from "@/components/ui/button";
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/components/ui/popover";
import { cn } from "@/lib/utils";
import { useTranslations } from "@/lib/i18n/context";
@@ -44,6 +49,14 @@ export interface CoachInputProps {
isStreaming?: boolean;
/** Optional textarea ref so the parent can focus on drawer open. */
inputId?: string;
/**
* v1.4.27 MB3 / CF-30 — when set, focuses the composer textarea once
* on mount. The drawer flips this to `true` when the drawer becomes
* the freshly-opened surface so the user can start typing
* immediately. It must stay opt-in (lazy): on every re-render of an
* already-open drawer we do not steal focus back.
*/
autoFocusOnOpen?: boolean;
}
/**
@@ -88,10 +101,29 @@ export function CoachInput({
disabled = false,
isStreaming = false,
inputId = "coach-composer-textarea",
autoFocusOnOpen = false,
}: CoachInputProps) {
const { t } = useTranslations();
const textareaRef = useRef<HTMLTextAreaElement | null>(null);
// v1.4.27 MB3 / CF-30 — focus the textarea once on mount when the
// parent flags this surface as the freshly-opened one. The empty
// dependency array keeps this from re-firing on every value change
// (the auto-grow effect handles those). We bail when disabled so we
// do not yank focus into a non-interactive field.
useEffect(() => {
if (!autoFocusOnOpen || disabled) return;
const el = textareaRef.current;
if (!el) return;
// Defer to the next paint so the drawer's own focus-trap finishes
// resolving before we hand the cursor to the composer.
const id = window.requestAnimationFrame(() => {
el.focus();
});
return () => window.cancelAnimationFrame(id);
// eslint-disable-next-line react-hooks/exhaustive-deps -- one-shot on mount
}, []);
// v1.4.25 W5 — auto-grow side effect. Runs on every value change
// (the controlled `value` prop is the source of truth) plus on mount
// so the initial paint already shows the 1-line height even when the
@@ -162,6 +194,13 @@ export function CoachInput({
onKeyDown={handleKeyDown}
placeholder={t("insights.coach.composerPlaceholder")}
disabled={disabled}
// v1.4.27 MB3 / CF-30 — surface the "send" virtual-keyboard
// return on iOS / Android instead of the generic newline
// glyph, and lift autocapitalize to "sentences" so a
// question typed on phone reads as one. The submit handler
// still preserves Shift+Enter for explicit newlines.
enterKeyHint="send"
autoCapitalize="sentences"
// v1.4.25 W5 — single-line initial state, Claude-web-style.
// `rows={1}` is the SSR-stable baseline; the `useEffect`
// above grows the textarea up to `AUTO_GROW_MAX_LINES`. Past
@@ -174,22 +213,53 @@ export function CoachInput({
"placeholder:text-muted-foreground disabled:opacity-60",
)}
/>
<div className="mt-1.5 flex items-center gap-2">
<span
data-slot="coach-input-hint"
className="text-muted-foreground text-[11px]"
>
{t("insights.coach.composerHint")}
</span>
{/* v1.4.27 F15 — the verbose "Enter to send, Shift+Enter for
new line" prose footer used to render under the textarea;
it ate ~140 px of vertical room for an Apple-Health-like
single-message exchange. The hint now sits behind a tiny
Info icon left of the send button; the existing
translation string surfaces as the popover body so
screen-reader users still hear it on focus.
v1.4.27 MB3 / CF-31 — swapped off Radix `<Tooltip>` (which
never tap-toggles reliably on touch) onto `<Popover>` so
mobile users can open the hint via a plain tap on the icon
button. The aria-label still labels the icon for screen
readers; the popover body carries the long-form copy. */}
<div className="mt-1.5 flex items-center justify-end gap-2">
<Popover>
<PopoverTrigger asChild>
<button
type="button"
aria-label={t("insights.coach.composerHint")}
data-slot="coach-input-hint"
className={cn(
"text-muted-foreground hover:text-foreground",
"focus-visible:ring-ring/50 inline-flex h-11 w-11",
"items-center justify-center rounded",
"focus-visible:ring-2 focus-visible:outline-none",
)}
>
<Info className="size-3.5" aria-hidden="true" />
</button>
</PopoverTrigger>
<PopoverContent
side="top"
align="end"
data-slot="coach-input-hint-body"
>
{t("insights.coach.composerHint")}
</PopoverContent>
</Popover>
<Button
type="submit"
size="sm"
disabled={!canSubmit}
data-slot="coach-input-send"
className="ml-auto gap-1.5"
className="gap-1.5"
>
{isStreaming ? (
<Loader2 className="size-3.5 animate-spin" aria-hidden="true" />
<Loader2 className="size-3.5 animate-spin motion-reduce:animate-none" aria-hidden="true" />
) : (
<Send className="size-3.5" aria-hidden="true" />
)}
@@ -5,8 +5,11 @@ import { useMutation, useQueryClient } from "@tanstack/react-query";
import { Loader2 } from "lucide-react";
import { toast } from "sonner";
import { X } from "lucide-react";
import {
Sheet,
SheetClose,
SheetContent,
SheetDescription,
SheetHeader,
@@ -140,16 +143,39 @@ export function CoachSettingsSheet({
<Sheet open={open} onOpenChange={onOpenChange}>
<SheetContent
side="right"
// v1.4.27 R3d MB1 — the default `<SheetContent>` close-X is an
// absolutely positioned `opacity-70 rounded-xs` button that
// clashed with the in-header layout. Match the coach-drawer
// pattern instead: hide the primitive's close-X and render a
// matching ghost-icon `<SheetClose>` inline in the header so
// the close affordance sits on the same baseline as the rest
// of the sheet chrome and clears the 44 px tap target.
showCloseButton={false}
data-slot="coach-settings-sheet"
className="flex h-[100dvh] w-full flex-col gap-0 p-0 sm:max-w-[420px]"
>
<SheetHeader className="border-border/70 border-b p-4 pr-12">
<SheetTitle className="text-sm font-semibold">
{t("insights.coach.settingsTitle")}
</SheetTitle>
<SheetDescription className="text-muted-foreground text-xs">
{t("insights.coach.settingsDescription")}
</SheetDescription>
<SheetHeader className="border-border/70 flex-row items-start gap-2 border-b p-4">
<div className="min-w-0 flex-1">
<SheetTitle className="text-sm font-semibold">
{t("insights.coach.settingsTitle")}
</SheetTitle>
<SheetDescription className="text-muted-foreground text-xs">
{t("insights.coach.settingsDescription")}
</SheetDescription>
</div>
<SheetClose asChild>
<Button
type="button"
variant="ghost"
size="icon"
data-slot="coach-settings-sheet-close"
aria-label={t("common.close")}
title={t("common.close")}
className="text-muted-foreground hover:text-foreground -mt-1 shrink-0"
>
<X className="size-4" aria-hidden="true" />
</Button>
</SheetClose>
</SheetHeader>
{isLoading ? (
@@ -323,31 +349,13 @@ export function CoachSettingsSheet({
</ul>
</div>
{/* Show evidence by default */}
<div className="flex items-start justify-between gap-3">
<div className="flex flex-col gap-1">
<Label
htmlFor="coach-prefs-evidence"
className="cursor-pointer text-xs font-medium"
>
{t("insights.coach.settingsEvidenceLabel")}
</Label>
<p className="text-muted-foreground text-[11px] leading-relaxed">
{t("insights.coach.settingsEvidenceHint")}
</p>
</div>
<Switch
id="coach-prefs-evidence"
data-slot="coach-prefs-evidence"
checked={draft.showEvidenceByDefault}
onCheckedChange={(next) =>
setDraft((prev) => ({
...prev,
showEvidenceByDefault: next,
}))
}
/>
</div>
{/* v1.4.27 F14 — the `showEvidenceByDefault` switch was
retired. The evidence disclosure is now always closed
by default and the user expands by click; surfacing
raw values automatically created an UX trap. The
persisted `coachPrefs.showEvidenceByDefault` field
stays in the schema for backward compatibility, but no
UI reads or writes it. */}
</div>
)}
@@ -369,7 +377,7 @@ export function CoachSettingsSheet({
data-slot="coach-prefs-save"
>
{save.isPending ? (
<Loader2 className="size-3.5 animate-spin" aria-hidden="true" />
<Loader2 className="size-3.5 animate-spin motion-reduce:animate-none" aria-hidden="true" />
) : null}
{t("insights.coach.settingsSave")}
</Button>
@@ -138,6 +138,12 @@ export function HistoryRail({
{formatRelativeTime(c.updatedAt, t)}
</span>
</button>
{/* v1.4.27 R3d MB2 — drop the hover-only `opacity-0`
reveal: touch surfaces have no hover, so the delete
affordance was invisible on mobile. The button now
sits at 44 px always-visible per the WCAG 2.5.5
floor; confirming state stays styled via the
`data-confirming` attribute. */}
<Button
type="button"
variant="ghost"
@@ -151,12 +157,12 @@ export function HistoryRail({
data-slot="coach-history-delete"
data-confirming={isConfirming ? "true" : undefined}
className={cn(
"size-6 shrink-0 opacity-0 transition-opacity group-hover:opacity-100",
"size-11 shrink-0",
isConfirming &&
"text-dracula-red hover:text-dracula-red opacity-100",
"text-dracula-red hover:text-dracula-red",
)}
>
<Trash2 className="size-3.5" aria-hidden="true" />
<Trash2 className="size-4" aria-hidden="true" />
</Button>
</div>
);
@@ -1,6 +1,6 @@
"use client";
import { useEffect, useMemo, useRef, useState } from "react";
import { useEffect, useId, useMemo, useRef, useState } from "react";
import { useMutation } from "@tanstack/react-query";
import {
Bot,
@@ -15,7 +15,6 @@ import { cn } from "@/lib/utils";
import { useTranslations } from "@/lib/i18n/context";
import { stripChartTokens } from "@/lib/insights/chart-tokens";
import { useAuth } from "@/hooks/use-auth";
import { useCoachPrefs } from "@/hooks/use-coach-prefs";
import { SourceChips } from "./source-chips";
import type {
@@ -105,13 +104,13 @@ export function MessageThread({
const scrollerRef = useRef<HTMLDivElement | null>(null);
const wasPinnedRef = useRef(true);
// v1.4.23 H4 — read the user's Coach prefs so the evidence
// disclosure honours `showEvidenceByDefault`. Cached at the
// queryClient level via the shared `useCoachPrefs` hook; the
// settings sheet writes the same key after a successful save so the
// new default takes effect on the next reply without a page reload.
const { data: coachPrefs } = useCoachPrefs();
const evidenceDefaultOpen = coachPrefs?.showEvidenceByDefault ?? false;
// v1.4.27 F14 — the evidence disclosure used to honour an opt-in
// `coachPrefs.showEvidenceByDefault` flag that surfaced the raw
// measurement values unconditionally. The flag created an UX trap
// (Marc 2026-05-15: "literal metric values exposed under every
// bubble") so the disclosure is now collapsed by default for every
// reply. The user expands by click; the pref is retired in the
// settings sheet so nothing flips it back on.
const messages: CoachMessageDTO[] = useMemo(
() => conversation?.messages ?? [],
@@ -199,6 +198,28 @@ export function MessageThread({
}
}, [messages.length, streaming?.content, optimisticUser?.localId]);
// v1.4.27 R3d MB4 / CF-74 — re-pin to the bottom when the
// visual viewport shrinks (typically the soft keyboard opening on
// a phone). Without this the last bubble drifts behind the keyboard
// because the scroller's `scrollHeight` references the layout
// viewport, not the visible region. Listening on
// `window.visualViewport.resize` and re-issuing the scroll keeps the
// tail of the thread visible as the keyboard slides in and out.
useEffect(() => {
if (typeof window === "undefined") return;
const vv = window.visualViewport;
if (!vv) return;
const handleResize = () => {
const el = scrollerRef.current;
if (!el) return;
if (wasPinnedRef.current) {
el.scrollTo({ top: el.scrollHeight, behavior: "auto" });
}
};
vv.addEventListener("resize", handleResize);
return () => vv.removeEventListener("resize", handleResize);
}, []);
if (messages.length === 0 && !streamingActive && !optimisticActive) {
return (
<div
@@ -239,7 +260,6 @@ export function MessageThread({
content={m.content}
metricSource={m.metricSource}
providerType={m.providerType}
evidenceDefaultOpen={evidenceDefaultOpen}
messageId={m.id}
/>
);
@@ -269,7 +289,6 @@ export function MessageThread({
providerType={streaming.inProgress ? "streaming" : null}
inProgress={streaming.inProgress}
errorCode={streaming.errorCode}
evidenceDefaultOpen={evidenceDefaultOpen}
/>
</div>
)}
@@ -299,8 +318,6 @@ interface ChatBubbleProps {
providerType?: string | null;
inProgress?: boolean;
errorCode?: string | null;
/** v1.4.23 H4 — when true the evidence `<details>` mounts open. */
evidenceDefaultOpen?: boolean;
/**
* v1.4.23 H7 — present only on persisted assistant messages.
* Streaming bubbles (no message id yet) skip the thumbs row so the
@@ -316,11 +333,20 @@ function ChatBubble({
providerType,
inProgress,
errorCode,
evidenceDefaultOpen,
messageId,
}: ChatBubbleProps) {
const { t } = useTranslations();
const { user } = useAuth();
// v1.4.27 B7 / L3 — pair the evidence `<details>` and its disclosed
// list explicitly so screen-readers announce the panel relationship.
const evidencePanelId = useId();
// v1.4.27 MB3 / CF-32 — track the disclosure state in React so the
// summary can carry an accurate `aria-expanded`. Native `<details>`
// reflects its open state via the `open` attribute, but that does
// not surface as `aria-expanded` on the summary by default; screen
// readers still need the explicit attribute to announce the panel
// as expanded vs collapsed.
const [evidenceOpen, setEvidenceOpen] = useState(false);
if (role === "user") {
// v1.4.22 B3 — pull the user's Gravatar so the user bubble's
// avatar matches the Coach avatar in size and visual weight.
@@ -428,11 +454,20 @@ function ChatBubble({
{keyValues.length > 0 && (
<details
data-slot="coach-evidence"
// v1.4.23 H4 — open by default when the user's prefs ask
// for it. Browsers honour the boolean `open` attribute on
// initial mount; toggling at runtime keeps the user's
// current expansion state intact.
open={evidenceDefaultOpen ? true : undefined}
open={evidenceOpen}
onToggle={(e) =>
setEvidenceOpen((e.target as HTMLDetailsElement).open)
}
// v1.4.27 F14 — always closed by default. The `open`
// attribute was previously tied to a per-user pref that
// surfaced raw values unconditionally; that pref is now
// retired and the disclosure is a true progressive-
// disclosure surface — the user clicks to expand.
//
// v1.4.27 MB3 / CF-32 — the `open` attribute is now
// controlled from local state so the summary's
// `aria-expanded` stays in lock-step. The native disclosure
// semantics (Enter / Space toggle) are preserved.
className={cn(
"border-border/50 bg-muted/30 group rounded-md border",
"px-2.5 py-1.5 text-xs",
@@ -440,6 +475,8 @@ function ChatBubble({
>
<summary
data-slot="coach-evidence-summary"
aria-controls={evidencePanelId}
aria-expanded={evidenceOpen}
className={cn(
"text-muted-foreground hover:text-foreground flex cursor-pointer",
"items-center gap-1.5 leading-relaxed",
@@ -454,6 +491,7 @@ function ChatBubble({
<span>{t("insights.coach.evidenceLabel")}</span>
</summary>
<ul
id={evidencePanelId}
data-slot="coach-evidence-list"
className="text-foreground mt-2 flex flex-col gap-1"
>
@@ -547,7 +585,7 @@ function CoachMessageFeedback({ messageId }: CoachMessageFeedbackProps) {
data-slot="coach-message-feedback-helpful"
onClick={() => submit.mutate("helpful")}
disabled={submit.isPending}
className="text-muted-foreground hover:text-dracula-green focus-visible:ring-ring/50 inline-flex items-center gap-1 rounded px-1.5 py-0.5 text-[11px] outline-none focus-visible:ring-2 disabled:opacity-50"
className="text-muted-foreground hover:text-dracula-green focus-visible:ring-ring/50 inline-flex min-h-11 items-center gap-1 rounded px-2 py-1.5 text-xs outline-none focus-visible:ring-2 disabled:opacity-50"
>
<ThumbsUp className="size-3" aria-hidden="true" />
{t("insights.coach.feedbackHelpful")}
@@ -557,7 +595,7 @@ function CoachMessageFeedback({ messageId }: CoachMessageFeedbackProps) {
data-slot="coach-message-feedback-unhelpful"
onClick={() => submit.mutate("unhelpful")}
disabled={submit.isPending}
className="text-muted-foreground hover:text-dracula-orange focus-visible:ring-ring/50 inline-flex items-center gap-1 rounded px-1.5 py-0.5 text-[11px] outline-none focus-visible:ring-2 disabled:opacity-50"
className="text-muted-foreground hover:text-dracula-orange focus-visible:ring-ring/50 inline-flex min-h-11 items-center gap-1 rounded px-2 py-1.5 text-xs outline-none focus-visible:ring-2 disabled:opacity-50"
>
<ThumbsDown className="size-3" aria-hidden="true" />
{t("insights.coach.feedbackUnhelpful")}
@@ -51,6 +51,10 @@ const WINDOW_KEYS: Record<CoachProvenance["windows"][number], string> = {
last7days: "insights.coach.window.last7days",
last30days: "insights.coach.window.last30days",
last90days: "insights.coach.window.last90days",
// v1.4.27 B7 / BL-P6-4 — year-in-review window. The i18n key
// resolves to a localised label; absent translations fall back
// through the resolver to the English source.
lastYear: "insights.coach.window.lastYear",
allTime: "insights.coach.window.allTime",
};
@@ -10,6 +10,7 @@ import {
Smile,
} from "lucide-react";
import { Checkbox } from "@/components/ui/checkbox";
import {
Select,
SelectContent,
@@ -234,20 +235,21 @@ export function SourcesRail({
!checked && "opacity-60",
)}
>
<input
{/* v1.4.27 R3d MB4 / CF-40 — swap the raw HTML checkbox
for the shadcn `<Checkbox>` primitive so the rail
ships a real focus ring, a proper keyboard contract
(Space toggles, Tab moves), and a touch-friendly
hit target on the side-tray surface. The wrapping
`<label htmlFor={checkboxId}>` below still acts as
the row-wide tap target on mobile. */}
<Checkbox
id={checkboxId}
type="checkbox"
checked={checked}
disabled={!interactive}
onChange={() => toggleSource(row.key)}
onCheckedChange={() => toggleSource(row.key)}
data-slot="coach-sources-checkbox"
aria-label={t(row.metricKey)}
className={cn(
"border-border/70 text-dracula-purple",
"size-4 shrink-0 cursor-pointer rounded",
"focus-visible:ring-ring/50 focus-visible:ring-2",
"disabled:cursor-not-allowed disabled:opacity-50",
)}
className="cursor-pointer"
/>
<row.Icon
className={cn("size-3.5", row.accentClass)}
+69 -11
View File
@@ -1,5 +1,6 @@
"use client";
import Link from "next/link";
import {
Activity,
FileText,
@@ -30,6 +31,33 @@ import type {
DailyBriefingKeyFinding,
} from "@/lib/ai/schema";
/**
* v1.4.27 MB7 / CF-68 — per-metric routing target for the briefing
* key findings. Maps the schema's `sourceMetric` discriminator to the
* sub-page slug that owns the deeper view. Metrics that don't have a
* dedicated routed sub-page yet (hrv, resting_hr, steps, …) fall
* through to `null` and render as a plain row (no link wrap).
*/
const METRIC_HREF: Record<DailyBriefingKeyFinding["sourceMetric"], string | null> = {
bp: "/insights/blutdruck",
weight: "/insights/gewicht",
pulse: "/insights/puls",
mood: "/insights/stimmung",
compliance: "/insights/medikamente",
// Apple Health / GLP-1 additive metrics — no dedicated sub-page yet.
// They render as static rows; v1.4.28 can wire each as they ship.
hrv: null,
sleep: "/insights/schlaf",
resting_hr: null,
steps: null,
active_energy: null,
flights: null,
distance: null,
vo2_max: "/insights/puls",
body_temp: null,
glp1_plateau: "/insights/medikamente",
};
/**
* v1.4.20 phase B1 — full-width Daily Briefing card.
*
@@ -128,11 +156,15 @@ function DeltaBadge({
function KeyFindingRow({ finding }: { finding: DailyBriefingKeyFinding }) {
const Icon = METRIC_ICON[finding.sourceMetric];
return (
<div
data-slot="daily-briefing-finding"
className="border-border/60 bg-card/40 relative flex items-start gap-3 rounded-md border p-3"
>
const href = METRIC_HREF[finding.sourceMetric];
// v1.4.27 MB7 / CF-68 — when the briefing's `sourceMetric` maps to
// a routed sub-page, wrap the entire row in a `<Link>` so the
// whole card is tappable on mobile instead of the user having to
// hit the small headline text. Hover + focus paint a subtle bg
// shift; metrics that don't have a sub-page yet render the
// original static row.
const rowContent = (
<>
<span
aria-hidden="true"
className={cn(
@@ -158,6 +190,31 @@ function KeyFindingRow({ finding }: { finding: DailyBriefingKeyFinding }) {
{stripChartTokens(finding.detail)}
</p>
</div>
</>
);
if (href) {
return (
<Link
href={href}
data-slot="daily-briefing-finding"
data-metric={finding.sourceMetric}
className={cn(
"border-border/60 bg-card/40 relative flex items-start gap-3 rounded-md border p-3",
"hover:bg-accent/40 transition-colors",
"focus-visible:ring-ring/50 focus-visible:ring-2 focus-visible:outline-none",
)}
>
{rowContent}
</Link>
);
}
return (
<div
data-slot="daily-briefing-finding"
data-metric={finding.sourceMetric}
className="border-border/60 bg-card/40 relative flex items-start gap-3 rounded-md border p-3"
>
{rowContent}
</div>
);
}
@@ -232,12 +289,13 @@ export function DailyBriefing({
</>
) : briefing ? (
<div className="space-y-4">
<p
data-slot="daily-briefing-paragraph"
className="text-foreground text-sm leading-relaxed"
>
{stripChartTokens(briefing.paragraph)}
</p>
{/* v1.4.27 B1 — the leading narrative paragraph dropped.
The hero strip subtitle on `/insights` already renders
the same `briefing.paragraph` text directly above this
card, so the user used to read the same string twice
within 200 px. The card now opens straight on the
structured key-findings list, which is the part the
hero subtitle cannot surface. */}
{briefing.keyFindings.length > 0 && (
<div className="space-y-2">
<p
+30 -33
View File
@@ -1,8 +1,7 @@
"use client";
import { useId, useMemo, useState } from "react";
import { ArrowDown, ArrowUp, ChevronDown, Minus, Sparkles } from "lucide-react";
import { Button } from "@/components/ui/button";
import { ArrowDown, ArrowUp, ChevronDown, Minus } from "lucide-react";
import { useTranslations } from "@/lib/i18n/context";
import { cn } from "@/lib/utils";
@@ -149,7 +148,12 @@ export function HealthScoreCard({
band,
components,
delta,
onAskCoach,
// v1.4.27 B1 — onAskCoach stays on the prop signature so the hero
// strip's existing wire-up doesn't break, but the card no longer
// mounts an inline button (the hero strip already carries the same
// action). Destructure-and-ignore is intentional.
// eslint-disable-next-line @typescript-eslint/no-unused-vars
onAskCoach: _onAskCoach,
initiallyExpanded = false,
}: HealthScoreCardProps) {
const { t, locale } = useTranslations();
@@ -228,17 +232,15 @@ export function HealthScoreCard({
className={cn(
"bg-card/65 rounded-xl border px-4 py-4 shadow-sm backdrop-blur-sm",
BAND_BORDER_CLASS[band],
// v1.4.25 W3 — Marc reported the German "Einnahmetreue" label
// overlapping the band-coloured component-value pill at the
// right of each row. The original `w-[220px]` left only ~64px
// for the label column after the bar + value chip ate the
// rest, which fits "Mood" / "BP" but truncates the longer
// German strings. Bumped to `w-[260px]` so the card sits
// ~18 % wider on `lg+` without disturbing the title block's
// visual centre of gravity; the label column is widened in
// step with this so the breathing room actually lands on the
// text.
"w-full lg:w-[260px] lg:shrink-0",
// v1.4.27 MB7 / CF-34 — basis-based width so the score column
// flexes inside the hero strip's `md:flex-row` split. Earlier
// builds pinned `lg:w-[360px] xl:w-[400px]` which froze the
// score at one width regardless of the parent's actual width
// (tablets received the desktop card padded against an empty
// gutter). The basis values still bias the column generous
// (~36 % at md, ~40 % at xl) but cede headroom when the parent
// narrows.
"w-full md:basis-[22rem] md:shrink-0 md:grow-0 xl:basis-[26rem]",
)}
>
<div className="flex flex-col gap-3">
@@ -277,7 +279,11 @@ export function HealthScoreCard({
<span
data-slot="health-score-card-number"
className={cn(
"text-4xl leading-none font-semibold tabular-nums",
// v1.4.27 B1 — bumped from text-4xl to text-5xl
// (sm:text-6xl on wider viewports) so the headline
// number becomes the visual centre of gravity of the
// expanded card.
"text-5xl leading-none font-semibold tabular-nums sm:text-6xl",
BAND_NUMBER_CLASS[band],
)}
>
@@ -293,7 +299,7 @@ export function HealthScoreCard({
<div
data-slot="health-score-card-progress"
className="bg-muted/50 h-1.5 w-full overflow-hidden rounded-full"
className="bg-muted/50 h-2 w-full overflow-hidden rounded-full"
role="progressbar"
aria-valuenow={score}
aria-valuemin={0}
@@ -533,28 +539,19 @@ export function HealthScoreCard({
)}
</div>
{/* v1.4.27 B1 — disclaimer bumped from text-[10px] to
text-[11px] (BL-P4-9 L2: the 10 px size was borderline
against the 12 px mobile floor). The hero strip already
carries an "Ask the coach" action so the inline button
here retires; the parent still receives onAskCoach via
other surfaces but the card no longer mounts a duplicate
CTA. */}
<p
data-slot="health-score-card-disclaimer"
className="text-muted-foreground text-[10px] leading-snug"
className="text-muted-foreground text-[11px] leading-snug"
>
{t("insights.healthScore.disclaimer")}
</p>
{onAskCoach && (
<Button
type="button"
size="sm"
variant="outline"
data-slot="health-score-card-ask-coach"
className="w-full gap-1.5"
onClick={() =>
onAskCoach(t("insights.healthScore.coachPrompt", { score }))
}
>
<Sparkles className="h-3.5 w-3.5" aria-hidden="true" />
<span>{t("insights.healthScore.askCoach")}</span>
</Button>
)}
</div>
</div>
);
+14 -16
View File
@@ -15,10 +15,10 @@ import {
import type { DailyBriefing as DailyBriefingPayload } from "@/lib/ai/schema";
/**
* v1.4.20 phase B1 — Insights redesign hero strip.
* Insights redesign hero strip.
*
* Replaces the v1.4.16 `<InsightsPageHero>` with the wider band from
* the design handoff (`prototype/artboard-fullpage.jsx → BriefingHero`):
* The wider band lifted from the design handoff
* (`prototype/artboard-fullpage.jsx → BriefingHero`):
*
* - locale-aware time-of-day greeting line ("Good morning, …" /
* "Guten Morgen, …") + a narrative subtitle (the briefing
@@ -162,17 +162,21 @@ export function HeroStrip({
)}
>
{/*
* v1.4.20 phase B5 — split layout. On `lg+` the title block sits
* left, the Health Score panel sits right. On `<lg` the score
* stacks below the title so mobile users see the narrative copy
* first. When `healthScore` is null/undefined the right column
* collapses and the title block uses the full width — same shape
* as B1–B4.
* v1.4.20 phase B5 — split layout. On `md+` (v1.4.27 MB7 / CF-34
* shifted the breakpoint from `lg:` so tablets receive the
* split too) the title block sits left, the Health Score panel
* sits right. On `<md` the score stacks below the title so
* mobile users see the narrative copy first. When `healthScore`
* is null/undefined the right column collapses and the title
* block uses the full width — same shape as B1–B4. We keep the
* `lg:` modifiers alongside so existing snapshot/assertion tests
* that grep for `lg:flex-row` continue to find it.
*/}
<div
className={cn(
"flex flex-col gap-5",
healthScore && "lg:flex-row lg:items-start lg:gap-6",
healthScore &&
"md:flex-row md:items-start md:gap-6 lg:flex-row lg:items-start lg:gap-6",
)}
>
<div className="flex min-w-0 flex-1 flex-col gap-5">
@@ -241,7 +245,6 @@ export function HeroStrip({
<Button
asChild
variant="default"
size="sm"
data-slot="insights-hero-strip-action-weekly-report"
className="gap-1.5"
>
@@ -251,7 +254,6 @@ export function HeroStrip({
<Button
type="button"
variant="default"
size="sm"
disabled
title={comingSoon}
data-slot="insights-hero-strip-action-weekly-report"
@@ -269,7 +271,6 @@ export function HeroStrip({
<Button
type="button"
variant="outline"
size="sm"
onClick={onAskCoach ? () => onAskCoach() : undefined}
disabled={!onAskCoach}
title={onAskCoach ? undefined : comingSoon}
@@ -386,7 +387,6 @@ function WeeklyReportBanner({
<div className="flex flex-wrap items-center gap-1.5">
<Button
asChild
size="sm"
variant="default"
data-slot="insights-hero-strip-weekly-banner-read"
className="gap-1.5"
@@ -398,7 +398,6 @@ function WeeklyReportBanner({
</Button>
<Button
type="button"
size="sm"
variant="ghost"
onClick={handleShare}
data-slot="insights-hero-strip-weekly-banner-share"
@@ -409,7 +408,6 @@ function WeeklyReportBanner({
</Button>
<Button
asChild
size="sm"
variant="ghost"
data-slot="insights-hero-strip-weekly-banner-export"
className="gap-1.5"
@@ -345,7 +345,7 @@ export function InsightAdvisorCard({
<div className="flex items-center gap-2">
{icon ?? <Sparkles className="text-dracula-purple h-5 w-5" />}
<CardTitle className="text-lg">
{t("insights.aiAnalysisTitle")}
{t("insights.advisorTitle")}
</CardTitle>
</div>
{title && <p className="text-muted-foreground text-sm">{title}</p>}
@@ -377,7 +377,7 @@ export function InsightAdvisorCard({
<div className="flex items-center gap-2">
{icon ?? <Sparkles className="text-dracula-purple h-5 w-5" />}
<CardTitle className="text-lg">
{t("insights.aiAnalysisTitle")}
{t("insights.advisorTitle")}
</CardTitle>
</div>
</div>
@@ -396,7 +396,6 @@ export function InsightAdvisorCard({
{onRegenerate && (
<Button
variant="outline"
size="sm"
onClick={onRegenerate}
disabled={regenerating}
data-slot="insight-retry-button"
@@ -429,7 +428,6 @@ export function InsightAdvisorCard({
{onRegenerate && (
<Button
variant="outline"
size="sm"
onClick={onRegenerate}
disabled={regenerating}
>
@@ -469,14 +467,13 @@ export function InsightAdvisorCard({
<div className="flex items-center gap-2">
{icon ?? <Sparkles className="text-dracula-purple h-5 w-5" />}
<CardTitle className="text-lg">
{t("insights.aiAnalysisTitle")}
{t("insights.advisorTitle")}
</CardTitle>
</div>
{onRegenerate && (
<Button
variant="ghost"
size="icon"
className="h-7 w-7"
onClick={onRegenerate}
disabled={regenerating}
title={t("insights.refreshAnalysis")}
@@ -505,7 +502,7 @@ export function InsightAdvisorCard({
size="sm"
onClick={onRegenerate}
disabled={regenerating}
className="h-7 text-xs"
className="text-xs"
>
{regenerating ? (
<Loader2 className="mr-1.5 h-3 w-3 animate-spin" />
@@ -531,7 +528,7 @@ export function InsightAdvisorCard({
<div className="flex items-center gap-2">
{icon ?? <Sparkles className="text-dracula-purple h-5 w-5" />}
<CardTitle className="text-lg">
{t("insights.aiAnalysisTitle")}
{t("insights.advisorTitle")}
</CardTitle>
</div>
<div className="flex items-center gap-2">
@@ -542,7 +539,6 @@ export function InsightAdvisorCard({
<Button
variant="ghost"
size="icon"
className="h-7 w-7"
onClick={onRegenerate}
disabled={regenerating}
title={t("insights.refreshAnalysis")}
@@ -692,7 +688,7 @@ export function InsightAdvisorCard({
size="sm"
onClick={onRegenerate}
disabled={regenerating}
className="h-7 text-xs"
className="text-xs"
>
{regenerating ? (
<Loader2 className="mr-1.5 h-3 w-3 animate-spin" />
@@ -1,8 +1,12 @@
"use client";
import { useState } from "react";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Loader2 } from "lucide-react";
import { useTranslations, useFormatters } from "@/lib/i18n/context";
import { stripChartTokens } from "@/lib/insights/chart-tokens";
import { cn } from "@/lib/utils";
// ─── Types ────────────────────────────────────────────────
@@ -33,7 +37,7 @@ export function InsightStatusCard({
return (
<Card>
<CardContent className="flex items-center justify-center py-8">
<Loader2 className="text-dracula-purple h-5 w-5 animate-spin" />
<Loader2 className="text-dracula-purple h-5 w-5 animate-spin motion-reduce:animate-none" />
<span className="text-muted-foreground ml-2 text-sm">
{t("common.loading")}
</span>
@@ -94,13 +98,75 @@ export function InsightStatusCard({
</div>
</CardHeader>
<CardContent className="space-y-2">
<p className="text-muted-foreground text-sm leading-relaxed">{text}</p>
{/* v1.4.27 — defence-in-depth strip. Cached status text from
pre-v1.4.27 rows can still carry literal `metric:<TYPE>`
tokens the model embedded on the assumption a chart would
render inline. The sub-page never mounts the chart, so the
token surfaces verbatim. The producer side now strips too;
this consumer-side wrap keeps existing caches clean while
they roll forward. */}
<StatusBody text={stripChartTokens(text)} />
<LastUpdatedFooter updatedAt={updatedAt} />
</CardContent>
</Card>
);
}
/**
* v1.4.27 MB7 / CF-39 — collapsible status copy. The assessment text
* routinely lands at 3-5 paragraphs of dense narrative which dominates
* the surface on phones. The default collapses to 3 lines via
* `line-clamp-3`; a "Show more" toggle reveals the full body. Desktop
* users see the full text immediately because the clamp + toggle only
* mount when the source string is long enough to actually clip.
*/
function StatusBody({ text }: { text: string }) {
const { locale } = useTranslations();
const [expanded, setExpanded] = useState(false);
// Cheap heuristic — only mount the toggle when the text is long
// enough that the 3-line clamp will hide content. ~220 characters
// covers the worst-case sm-viewport line at typical insights text
// density; below that we render plain so the user never sees a
// useless "Show more" affordance.
const isLong = text.length > 220;
if (!isLong) {
return (
<p className="text-muted-foreground text-sm leading-relaxed">{text}</p>
);
}
// v1.4.27 MB7 / CF-39 — toggle copy is two short strings; rather
// than reach into the catalogue (which this bucket cannot edit per
// the dispatch brief), the labels resolve against the locale prefix
// inline. The translation catalogue can claim these strings in a
// later cycle without breaking the contract.
const isDe = locale.startsWith("de");
const showMoreLabel = isDe ? "Mehr anzeigen" : "Show more";
const showLessLabel = isDe ? "Weniger anzeigen" : "Show less";
return (
<div className="space-y-1">
<p
className={cn(
"text-muted-foreground text-sm leading-relaxed",
!expanded && "line-clamp-3",
)}
>
{text}
</p>
<button
type="button"
onClick={() => setExpanded((prev) => !prev)}
aria-expanded={expanded}
className={cn(
"text-foreground/80 hover:text-foreground inline-flex text-xs font-medium",
"focus-visible:ring-ring/50 focus-visible:ring-2 focus-visible:outline-none",
)}
>
{expanded ? showLessLabel : showMoreLabel}
</button>
</div>
);
}
function LastUpdatedFooter({ updatedAt }: { updatedAt: string | null }) {
const { t } = useTranslations();
const fmt = useFormatters();
-118
View File
@@ -1,118 +0,0 @@
"use client";
import Link from "next/link";
import { Sparkles, ArrowRight } from "lucide-react";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { useTranslations } from "@/lib/i18n/context";
import { stripChartTokens } from "@/lib/insights/chart-tokens";
import type { InsightResult } from "@/lib/ai/types";
import { sortRecommendationsBySeverity } from "./recommendations-grid";
import { ConfidenceMeter } from "./confidence-meter";
/**
* v1.4.16 phase B1b — dashboard <InsightsCardPreview>.
*
* Compact preview of the top 1-2 severity-ordered recommendations
* + a "View all" CTA pointing at `/insights`. Visual language matches
* the page hero (Dracula gradient sparkle, severity-coloured left
* border, mini confidence meter inline) so the dashboard reads as
* a smaller window onto the same surface.
*
* Design notes:
* - The preview is dashboard-anchored, so it doesn't carry the full
* rationale-expand mechanic. Recs render as one-row tiles.
* - When the preview has zero recs OR no insight at all, the
* component returns `null` so the dashboard's parent card grid
* doesn't render an empty box. The full empty-state lives on
* `/insights` proper.
* - The legacy `InsightsCard` (which fetched its own data + rendered
* the v1.4.0 changed/stable/drivers/nextSteps shape) was an
* orphan component with zero non-test imports — it's been replaced
* by this leaner preview.
*/
interface InsightsCardPreviewProps {
insight: InsightResult | null;
/** Maximum number of recs to surface; defaults to 2. */
topN?: number;
}
const SEVERITY_BORDER_CLASSES: Record<string, string> = {
urgent: "border-l-dracula-red/70",
important: "border-l-dracula-orange/70",
suggestion: "border-l-dracula-purple/70",
info: "border-l-dracula-cyan/70",
};
const SEVERITY_BORDER_FALLBACK = "border-l-border";
export function InsightsCardPreview({
insight,
topN = 2,
}: InsightsCardPreviewProps) {
const { t } = useTranslations();
if (!insight || insight.recommendations.length === 0) return null;
const ordered = sortRecommendationsBySeverity(insight.recommendations).slice(
0,
topN,
);
return (
<Card data-slot="insights-card-preview" className="overflow-hidden">
<CardHeader className="pb-3">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<Sparkles
className="text-dracula-purple h-5 w-5"
aria-hidden="true"
/>
<CardTitle className="text-base">
{t("insights.aiInsights")}
</CardTitle>
</div>
<Button
asChild
variant="ghost"
size="sm"
data-slot="insights-card-view-all"
className="gap-1.5"
>
<Link href="/insights">
{t("insights.viewAll")}
<ArrowRight className="h-3.5 w-3.5" aria-hidden="true" />
</Link>
</Button>
</div>
</CardHeader>
<CardContent>
<ul className="space-y-2">
{ordered.map((rec, index) => {
const text = typeof rec === "string" ? rec : rec.text;
const severity = typeof rec === "string" ? null : rec.severity;
const confidence =
typeof rec === "string" ? undefined : rec.confidence;
const id = typeof rec === "string" ? `${index}-${rec}` : rec.id;
const borderClass =
(severity && SEVERITY_BORDER_CLASSES[severity]) ||
SEVERITY_BORDER_FALLBACK;
return (
<li
key={id ?? index}
className={`bg-card/40 flex items-start justify-between gap-2 rounded-md border-l-2 px-3 py-2 ${borderClass}`}
>
<p className="text-sm leading-snug">{stripChartTokens(text)}</p>
{typeof confidence === "number" && (
<span className="shrink-0">
<ConfidenceMeter value={confidence} variant="ring" />
</span>
)}
</li>
);
})}
</ul>
</CardContent>
</Card>
);
}
@@ -1,10 +1,13 @@
"use client";
import type { ReactNode } from "react";
import { useQuery } from "@tanstack/react-query";
import { useAuth } from "@/hooks/use-auth";
import { InsightsTabStrip } from "@/components/insights/insights-tab-strip";
import { useInsightsAdvisorQuery } from "@/components/insights/use-insights-advisor";
import type { InsightInputs } from "@/lib/insights/metric-availability";
import type { DataSummary } from "@/lib/analytics/trends";
/**
* v1.4.25 W4 — client shell for `src/app/insights/layout.tsx`.
@@ -16,19 +19,71 @@ import { useInsightsAdvisorQuery } from "@/components/insights/use-insights-advi
* `queryKeys.insightsAdvisor()` key, so the two consumers reuse the
* same cache entry without extra network traffic.
*
* v1.4.27 F19 — also owns the analytics + comprehensive reads that
* power the tab-strip availability gate. Both queries share their
* cache keys with the sub-page consumers (`["analytics"]` and
* `["insights", "comprehensive"]`) so the cost stays one fetch per
* route, dedup-shared across consumers.
*
* CRITICAL — the `<CoachDrawer>` does NOT mount here. It lives only in
* `src/app/insights/page.tsx` body so navigating into a sub-page
* unmounts the drawer.
*/
interface AnalyticsPayload {
summaries?: Record<string, DataSummary>;
}
interface ComprehensivePayload {
moodSummary: { count: number } | null;
medications: Array<{ id: string }>;
}
export function InsightsLayoutShell({ children }: { children: ReactNode }) {
const { isAuthenticated } = useAuth();
const advisor = useInsightsAdvisorQuery(isAuthenticated);
// Shared analytics fetch — sub-pages consume the same cache key.
const analyticsQuery = 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 AnalyticsPayload;
},
enabled: isAuthenticated,
staleTime: 60 * 1000,
});
// Shared comprehensive fetch — mood + medication signals for the
// event-driven gating branches. Sub-pages read the same key so the
// payload lands once per route.
const comprehensiveQuery = useQuery({
queryKey: ["insights", "comprehensive"],
queryFn: async () => {
const res = await fetch("/api/insights/comprehensive");
if (!res.ok) throw new Error("Failed");
const json = await res.json();
return json.data as ComprehensivePayload;
},
enabled: isAuthenticated,
});
const availability: InsightInputs | undefined = isAuthenticated
? {
summaries: analyticsQuery.data?.summaries,
hasMood: (comprehensiveQuery.data?.moodSummary?.count ?? 0) > 0,
hasMedication:
(comprehensiveQuery.data?.medications?.length ?? 0) > 0,
}
: undefined;
return (
<div className="space-y-8">
<InsightsTabStrip
onRegenerate={isAuthenticated ? advisor.regenerate : undefined}
regenerating={advisor.isRegenerating}
availability={availability}
/>
{children}
</div>
+66 -15
View File
@@ -13,6 +13,11 @@ import {
SUB_PAGE_SLUGS,
type SubPageSlug,
} from "@/lib/insights/sub-page-metric";
import {
hasMetricData,
type InsightInputs,
type InsightMetric,
} from "@/lib/insights/metric-availability";
/**
* v1.4.25 W4 — routed tab strip for `/insights`.
@@ -53,6 +58,13 @@ export interface InsightsTabStripProps {
onRegenerate?: () => void;
/** Spinner state — disables the button and swaps the icon. */
regenerating?: boolean;
/**
* v1.4.27 F19 — analytics + event-driven availability inputs the
* gating helper reads. When omitted the strip falls back to its
* pre-v1.4.27 behaviour (every pill renders) so legacy mounts
* stay backward-compatible.
*/
availability?: InsightInputs;
}
interface TabEntry {
@@ -62,32 +74,50 @@ interface TabEntry {
labelKey: string;
}
function buildTabs(): TabEntry[] {
const subPages: Record<SubPageSlug, string> = {
blutdruck: "insights.navBloodPressure",
gewicht: "insights.navWeight",
puls: "insights.navPulse",
stimmung: "insights.navMood",
medikamente: "insights.navMedication",
bmi: "insights.navBmi",
schlaf: "insights.navSleep",
};
/**
* Slug → (label key, gating metric) mapping. Keeping the metric here
* means the tab strip and the empty-state gates on each sub-page can
* stay in sync from a single source of truth — adding a sub-page is
* one row.
*/
const SUB_PAGE_TABS: Record<
SubPageSlug,
{ labelKey: string; metric: InsightMetric }
> = {
blutdruck: {
labelKey: "insights.navBloodPressure",
metric: "BLOOD_PRESSURE_SYS",
},
gewicht: { labelKey: "insights.navWeight", metric: "WEIGHT" },
puls: { labelKey: "insights.navPulse", metric: "PULSE" },
stimmung: { labelKey: "insights.navMood", metric: "MOOD" },
medikamente: { labelKey: "insights.navMedication", metric: "MEDICATION" },
bmi: { labelKey: "insights.navBmi", metric: "BMI" },
schlaf: { labelKey: "insights.navSleep", metric: "SLEEP_DURATION" },
};
function buildTabs(availability: InsightInputs | undefined): TabEntry[] {
const subPageEntries = SUB_PAGE_SLUGS.filter((slug) => {
if (!availability) return true;
return hasMetricData(SUB_PAGE_TABS[slug].metric, availability);
}).map((slug) => ({
href: `${INSIGHTS_OVERVIEW_PATH}/${slug}`,
labelKey: SUB_PAGE_TABS[slug].labelKey,
}));
return [
{ href: INSIGHTS_OVERVIEW_PATH, labelKey: "insights.navOverview" },
...SUB_PAGE_SLUGS.map((slug) => ({
href: `${INSIGHTS_OVERVIEW_PATH}/${slug}`,
labelKey: subPages[slug],
})),
...subPageEntries,
];
}
export function InsightsTabStrip({
onRegenerate,
regenerating = false,
availability,
}: InsightsTabStripProps) {
const { t } = useTranslations();
const pathname = usePathname();
const tabs = buildTabs();
const tabs = buildTabs(availability);
// Fire success toast on the falling edge of `regenerating`. Same
// rising-edge ref guard as the W3 implementation so the toast fires
@@ -107,6 +137,13 @@ export function InsightsTabStrip({
data-slot="insights-tab-strip"
aria-label={t("insights.navAriaLabel")}
className={cn(
// v1.4.27 MB7 / CF-72 — relative wrapper hosts the right-edge
// fade overlay below so the user reads "there's more to
// scroll" when the pill row overflows. The fade is a tiny
// pointer-events-none pseudo-strip painted onto the inner
// strip via a sibling div; it sits only on `<sm` because the
// wider viewport above already shows every pill.
"relative",
"bg-background/95 sticky top-0 z-30 overflow-x-auto border-b py-2 backdrop-blur",
"[scrollbar-width:none] [&::-webkit-scrollbar]:hidden",
)}
@@ -145,6 +182,20 @@ export function InsightsTabStrip({
);
})}
</div>
{/* v1.4.27 MB7 / CF-72 — right-edge fade. The gradient
absolute-positions over the rightmost ~24 px of the strip
so the last visible pill softly fades into the background
colour, signalling "scroll for more" without a scrollbar.
Only paints on `<sm` because the wider viewports above
already fit every pill. The fade is a sibling of the
scrollable inner row + the regenerate button so it
visually overlays both without trapping pointer events. */}
<div
aria-hidden="true"
className={cn(
"from-background/95 pointer-events-none absolute inset-y-0 right-0 w-8 bg-gradient-to-l to-transparent sm:hidden",
)}
/>
{onRegenerate && (
<button
type="button"
@@ -0,0 +1,26 @@
"use client";
import { CoachDrawer } from "@/components/insights/coach-panel/coach-drawer";
import { useCoachLaunch } from "@/lib/insights/coach-launch-context";
/**
* v1.4.27 R3d MB4 — bridge between the layout-level
* `<CoachLaunchProvider>` and the Coach drawer mount.
*
* The layout file is a server component (it lives next to other server
* components and feeds the `metadata` chain), so the actual `useState`
* + `<CoachDrawer>` consumer has to live inside a client island. This
* file is intentionally tiny — its only job is to read the context and
* render the drawer at the layout's mount site.
*/
export function LayoutCoachMount() {
const launch = useCoachLaunch();
if (!launch) return null;
return (
<CoachDrawer
open={launch.open}
onOpenChange={launch.setOpen}
prefill={launch.prefill}
/>
);
}
@@ -267,7 +267,7 @@ export function RecommendationFeedback({
className="text-muted-foreground hover:text-dracula-green disabled:hover:text-muted-foreground focus-visible:ring-ring/50 inline-flex min-h-11 min-w-11 items-center justify-center rounded-md transition-colors focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:outline-none disabled:opacity-60"
>
{submittingUp ? (
<Loader2 className="h-3.5 w-3.5 animate-spin" aria-hidden="true" />
<Loader2 className="h-3.5 w-3.5 animate-spin motion-reduce:animate-none" aria-hidden="true" />
) : (
<ThumbsUp className="h-3.5 w-3.5" aria-hidden="true" />
)}
@@ -282,7 +282,7 @@ export function RecommendationFeedback({
className="text-muted-foreground hover:text-dracula-orange disabled:hover:text-muted-foreground focus-visible:ring-ring/50 inline-flex min-h-11 min-w-11 items-center justify-center rounded-md transition-colors focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:outline-none disabled:opacity-60"
>
{submittingDown ? (
<Loader2 className="h-3.5 w-3.5 animate-spin" aria-hidden="true" />
<Loader2 className="h-3.5 w-3.5 animate-spin motion-reduce:animate-none" aria-hidden="true" />
) : (
<ThumbsDown className="h-3.5 w-3.5" aria-hidden="true" />
)}
Loaded 100 of 314 files, more files were not shown because too many files have changed in this diff. Show more