mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-10 05:12:14 +00:00
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:
1 parent
d98e85cd03
commit
4f09c3f862
314 files changed
+26801
-4419
No files matched your search
@@ -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: [] });
|
||||
},
|
||||
);
|
||||
});
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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 },
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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<{
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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"]);
|
||||
});
|
||||
});
|
||||
@@ -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++) {
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
});
|
||||
});
|
||||
@@ -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}
|
||||
|
||||
@@ -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" />
|
||||
)}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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'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>
|
||||
);
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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>
|
||||
|
||||
@@ -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*</);
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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" />
|
||||
)}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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" />
|
||||
)}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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)" />
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
Reference in new issue
Block a user