mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-12 06:06:10 +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
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user