mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-10 13:22:14 +00:00
Lighten the client shell and finish four client surfaces
- Load each settings section on its own: the section route imported all twenty-one statically. A preload starts the chunk beside the record gate's read, and the loading state paints the settings card shell. /settings/[section] goes from 611 to 403 KB gz. - Take Zod out of the shell every route shares: the account-access parse run on each boot is plain TypeScript now, held to the published schema by an equivalence test over good and broken blocks. The canonical unit table moves to its own module, so the display transform no longer pulls Zod in either. Layout entry 199 to 136 KB gz; /insights 369 to 304, /medications 428 to 365, /labs 400 to 337. - Open a saved Coach conversation at its last answer: an instant jump instead of the smooth scroll that lost the end, then follow late layout (charts, tables) for a few seconds until the reader scrolls. - Show the document archive as a month grid of preview tiles, two across on a phone and three or four on a desktop, with the month headings kept. - Add the admin card for the measurement table maintenance: a status read on the maintenance route, one calm confirmation, and the run's state while it is queued, running, finished or stopped. - Re-base the bundle budget: /insights at 304, /settings/[section] watched at 403, aggregate cap 4335 with the reason written down.
This commit is contained in:
1 parent
bb563b6506
commit
12e422c25e
38 files changed
+2505
-406
No files matched your search
@@ -190,6 +190,17 @@ const ALLOWED: Readonly<Record<string, { count: number; reason: string }>> = {
|
||||
count: 1,
|
||||
reason: "See readActiveJobsStartedBefore: null means no queue schema.",
|
||||
},
|
||||
"lib/jobs/measurement-maintenance-status.ts::readMeasurementMaintenanceStatus":
|
||||
{
|
||||
count: 1,
|
||||
reason:
|
||||
"No pgboss schema on a web-only deployment; the admin card reads `available: false` and says so.",
|
||||
},
|
||||
"lib/jobs/measurement-maintenance-status.ts::readSizes": {
|
||||
count: 1,
|
||||
reason:
|
||||
"A size probe for an admin card: null shows no figure, and the status read must not fail over it.",
|
||||
},
|
||||
"lib/jobs/job-observer.ts::emitJobLine": {
|
||||
count: 1,
|
||||
reason:
|
||||
|
||||
@@ -97,6 +97,11 @@ const PINNED_AFFORDANCES: Record<
|
||||
"toast.success": 1,
|
||||
},
|
||||
"src/components/admin/glitchtip-section.tsx": { "toast.success": 1 },
|
||||
// Fires only when the route answered `enqueued: true`; a run already queued
|
||||
// reads as an info toast instead, and the outcome itself is the status line.
|
||||
"src/components/admin/measurement-maintenance-card.tsx": {
|
||||
"toast.success": 1,
|
||||
},
|
||||
"src/components/admin/invite-tokens-section.tsx": {
|
||||
"text-success": 2,
|
||||
"toast.success": 3,
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
* a given slug. Lives next to the route page so the shell can stay a
|
||||
* server component while the auth-gated body remains a client component.
|
||||
*
|
||||
* This is the equivalent of `SECTION_COMPONENTS` in
|
||||
* This is the equivalent of `SettingsSectionLoader` for
|
||||
* `src/app/settings/[section]/page.tsx`, lifted to a separate file so
|
||||
* the route page can be a server component (Settings can keep
|
||||
* `"use client"`-leaning components in its page because none of them
|
||||
@@ -36,6 +36,7 @@ import { ModuleAvailabilitySection } from "@/components/admin/module-availabilit
|
||||
import { RemindersSection } from "@/components/admin/reminders-section";
|
||||
import { ServicesSection } from "@/components/admin/services-section";
|
||||
import { SystemStatusSection } from "@/components/admin/system-status-section";
|
||||
import { MeasurementMaintenanceCard } from "@/components/admin/measurement-maintenance-card";
|
||||
import { InviteTokensSection } from "@/components/admin/invite-tokens-section";
|
||||
import { UserManagementSection } from "@/components/admin/user-management-section";
|
||||
import type { AdminSectionSlug } from "@/components/admin/section-slugs";
|
||||
@@ -60,6 +61,8 @@ export function AdminSectionRenderer({
|
||||
return (
|
||||
<SectionFrame>
|
||||
<SystemStatusSection />
|
||||
{/* v1.42 — the table maintenance after the tombstone purge. */}
|
||||
<MeasurementMaintenanceCard />
|
||||
</SectionFrame>
|
||||
);
|
||||
case "general":
|
||||
|
||||
@@ -0,0 +1,143 @@
|
||||
/**
|
||||
* `GET /api/admin/maintenance/measurements` — the status the admin card
|
||||
* starts and follows the run with. Admin-only, and a web-only deployment
|
||||
* (no pg-boss schema) answers `available: false` instead of failing.
|
||||
*/
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { NextRequest } from "next/server";
|
||||
|
||||
vi.mock("@/lib/db", () => ({ prisma: { $queryRaw: 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("@/lib/db-compat", () => ({
|
||||
ensureDbCompatibility: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
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";
|
||||
import { runStateOf } from "@/lib/jobs/measurement-maintenance-status";
|
||||
|
||||
const ADMIN = {
|
||||
session: { id: "s-1", expiresAt: new Date(Date.now() + 3_600_000) },
|
||||
user: { id: "admin-1", username: "admin", role: "ADMIN" as const },
|
||||
};
|
||||
const USER = {
|
||||
session: { id: "s-2", expiresAt: new Date(Date.now() + 3_600_000) },
|
||||
user: { id: "user-1", username: "bob", role: "USER" as const },
|
||||
};
|
||||
|
||||
const get = () =>
|
||||
(GET as unknown as (r: NextRequest) => Promise<Response>)(
|
||||
new NextRequest("http://localhost/api/admin/maintenance/measurements"),
|
||||
);
|
||||
|
||||
beforeEach(() => {
|
||||
vi.resetAllMocks();
|
||||
});
|
||||
|
||||
describe("GET /api/admin/maintenance/measurements", () => {
|
||||
it("is refused to anyone but an admin", async () => {
|
||||
vi.mocked(getSession).mockResolvedValue(USER as never);
|
||||
expect((await get()).status).toBe(403);
|
||||
expect(prisma.$queryRaw).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("reports a running pass, the purge and the table size", async () => {
|
||||
vi.mocked(getSession).mockResolvedValue(ADMIN as never);
|
||||
vi.mocked(prisma.$queryRaw)
|
||||
// sizes
|
||||
.mockResolvedValueOnce([
|
||||
{ table_bytes: BigInt(5_000_000_000), index_bytes: BigInt(2e9) },
|
||||
] as never)
|
||||
// newest run
|
||||
.mockResolvedValueOnce([
|
||||
{
|
||||
state: "active",
|
||||
created_on: new Date("2026-10-08T08:00:00Z"),
|
||||
started_on: new Date("2026-10-08T08:00:05Z"),
|
||||
completed_on: null,
|
||||
outcome: null,
|
||||
},
|
||||
] as never)
|
||||
// purge queue
|
||||
.mockResolvedValueOnce([{ n: 0 }] as never);
|
||||
|
||||
const body = await (await get()).json();
|
||||
expect(body.data).toEqual({
|
||||
available: true,
|
||||
purgePending: false,
|
||||
run: {
|
||||
state: "running",
|
||||
requestedAt: "2026-10-08T08:00:00.000Z",
|
||||
startedAt: "2026-10-08T08:00:05.000Z",
|
||||
finishedAt: null,
|
||||
outcome: null,
|
||||
},
|
||||
sizes: { tableBytes: 5_000_000_000, indexBytes: 2_000_000_000 },
|
||||
});
|
||||
});
|
||||
|
||||
it("says when a finished run refused because the purge was still working", async () => {
|
||||
vi.mocked(getSession).mockResolvedValue(ADMIN as never);
|
||||
vi.mocked(prisma.$queryRaw)
|
||||
.mockResolvedValueOnce([
|
||||
{ table_bytes: BigInt(1), index_bytes: BigInt(1) },
|
||||
] as never)
|
||||
.mockResolvedValueOnce([
|
||||
{
|
||||
state: "completed",
|
||||
created_on: new Date("2026-10-08T08:00:00Z"),
|
||||
started_on: new Date("2026-10-08T08:00:01Z"),
|
||||
completed_on: new Date("2026-10-08T08:00:02Z"),
|
||||
outcome: "refused_purge_running",
|
||||
},
|
||||
] as never)
|
||||
.mockResolvedValueOnce([{ n: 1 }] as never);
|
||||
|
||||
const body = await (await get()).json();
|
||||
expect(body.data.purgePending).toBe(true);
|
||||
expect(body.data.run.outcome).toBe("refused_purge_running");
|
||||
});
|
||||
|
||||
it("answers available: false where there is no queue to ask", async () => {
|
||||
vi.mocked(getSession).mockResolvedValue(ADMIN as never);
|
||||
vi.mocked(prisma.$queryRaw)
|
||||
.mockResolvedValueOnce([
|
||||
{ table_bytes: BigInt(10), index_bytes: BigInt(4) },
|
||||
] as never)
|
||||
.mockRejectedValue(new Error('relation "pgboss.job" does not exist'));
|
||||
|
||||
const res = await get();
|
||||
expect(res.status).toBe(200);
|
||||
const body = await res.json();
|
||||
expect(body.data).toEqual({
|
||||
available: false,
|
||||
purgePending: false,
|
||||
run: null,
|
||||
sizes: { tableBytes: 10, indexBytes: 4 },
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("runStateOf", () => {
|
||||
it("maps every pg-boss state onto the card's four", () => {
|
||||
expect(runStateOf("created")).toBe("queued");
|
||||
expect(runStateOf("retry")).toBe("queued");
|
||||
expect(runStateOf("active")).toBe("running");
|
||||
expect(runStateOf("completed")).toBe("completed");
|
||||
expect(runStateOf("failed")).toBe("failed");
|
||||
expect(runStateOf("cancelled")).toBe("failed");
|
||||
});
|
||||
});
|
||||
@@ -8,6 +8,9 @@
|
||||
* start while the compaction-tombstone purge is still working. At most one
|
||||
* run is queued or running; a second press answers `enqueued: false`.
|
||||
* Runbook: `docs/ops/measurement-maintenance.md`.
|
||||
*
|
||||
* `GET` reads where the newest run stands, for the admin card that starts it
|
||||
* and follows it.
|
||||
*/
|
||||
import { NextRequest } from "next/server";
|
||||
import { z } from "zod/v4";
|
||||
@@ -26,6 +29,7 @@ import {
|
||||
MEASUREMENT_MAINTENANCE_SEND_OPTIONS,
|
||||
type MeasurementMaintenancePayload,
|
||||
} from "@/lib/jobs/measurement-maintenance";
|
||||
import { readMeasurementMaintenanceStatus } from "@/lib/jobs/measurement-maintenance-status";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
|
||||
/** Two booleans; anything longer is not a request for this route. */
|
||||
@@ -90,3 +94,17 @@ export const POST = apiHandler(async (request: NextRequest) => {
|
||||
|
||||
return apiSuccess({ enqueued }, 202);
|
||||
});
|
||||
|
||||
export const GET = apiHandler(async () => {
|
||||
await requireAdmin();
|
||||
const status = await readMeasurementMaintenanceStatus();
|
||||
annotate({
|
||||
action: { name: "admin.maintenance.measurements.status" },
|
||||
meta: {
|
||||
available: status.available,
|
||||
run_state: status.run?.state ?? null,
|
||||
purge_pending: status.purgePending,
|
||||
},
|
||||
});
|
||||
return apiSuccess(status);
|
||||
});
|
||||
@@ -1,33 +1,14 @@
|
||||
import { notFound } from "next/navigation";
|
||||
import type { JSX } from "react";
|
||||
|
||||
import { AccessSection } from "@/components/settings/access-section";
|
||||
import { AccountSection } from "@/components/settings/account-section";
|
||||
import { AnamnesisSection } from "@/components/settings/anamnesis-section";
|
||||
import { SecuritySection } from "@/components/settings/security-section";
|
||||
import { AboutSection } from "@/components/settings/about-section";
|
||||
import { AdvancedSection } from "@/components/settings/advanced-section";
|
||||
import { AiSection } from "@/components/settings/ai-section";
|
||||
import { ApiSection } from "@/components/settings/api-section";
|
||||
import { CoachSection } from "@/components/settings/coach-section";
|
||||
import { EnvironmentSection } from "@/components/settings/environment-section";
|
||||
import { ExportSection } from "@/components/settings/export-section";
|
||||
import { GesundheitsakteSection } from "@/components/settings/gesundheitsakte-section";
|
||||
import { IntegrationsSection } from "@/components/settings/integrations-section";
|
||||
import { LayoutSection } from "@/components/settings/layout-section";
|
||||
import { McpSection } from "@/components/settings/mcp-section";
|
||||
import { ModulesSection } from "@/components/settings/modules-section";
|
||||
import { NotificationsSection } from "@/components/settings/notifications-section";
|
||||
import { PrivacySection } from "@/components/settings/privacy-section";
|
||||
import { ScoreSection } from "@/components/settings/score-section";
|
||||
import { SourcesSection } from "@/components/settings/sources-section";
|
||||
import { ThresholdsSection } from "@/components/settings/thresholds-section";
|
||||
import {
|
||||
SETTINGS_SECTION_SLUGS,
|
||||
isSettingsSectionSlug,
|
||||
type SettingsSectionSlug,
|
||||
} from "@/components/settings/section-slugs";
|
||||
import { SettingsShell } from "@/components/settings/settings-shell";
|
||||
import {
|
||||
SettingsSectionLoader,
|
||||
SettingsSectionPreload,
|
||||
} from "@/components/settings/settings-section-loader";
|
||||
import { RecordSettingsSectionGate } from "@/components/settings/record-settings-section-gate";
|
||||
import { getIntegrationCallbackUrls } from "@/lib/integrations/callback-urls";
|
||||
|
||||
@@ -47,44 +28,6 @@ export function generateStaticParams() {
|
||||
return SETTINGS_SECTION_SLUGS.map((section) => ({ section }));
|
||||
}
|
||||
|
||||
/**
|
||||
* Integrations is the one section that needs a server-resolved value: the
|
||||
* OAuth callback URL each provider registers. It is read from the runtime env
|
||||
* here, per request, and handed to the client section as a prop, because a
|
||||
* `NEXT_PUBLIC_*` read inside a client module is inlined at build time and the
|
||||
* published image is built without it.
|
||||
*/
|
||||
function IntegrationsSectionWithCallbackUrls() {
|
||||
return <IntegrationsSection callbackUrls={getIntegrationCallbackUrls()} />;
|
||||
}
|
||||
|
||||
const SECTION_COMPONENTS: Record<
|
||||
SettingsSectionSlug,
|
||||
() => JSX.Element | null
|
||||
> = {
|
||||
account: AccountSection,
|
||||
access: AccessSection,
|
||||
security: SecuritySection,
|
||||
modules: ModulesSection,
|
||||
about: AboutSection,
|
||||
ai: AiSection,
|
||||
coach: CoachSection,
|
||||
integrations: IntegrationsSectionWithCallbackUrls,
|
||||
sources: SourcesSection,
|
||||
notifications: NotificationsSection,
|
||||
layout: LayoutSection,
|
||||
environment: EnvironmentSection,
|
||||
anamnesis: AnamnesisSection,
|
||||
score: ScoreSection,
|
||||
thresholds: ThresholdsSection,
|
||||
api: ApiSection,
|
||||
mcp: McpSection,
|
||||
gesundheitsakte: GesundheitsakteSection,
|
||||
export: ExportSection,
|
||||
advanced: AdvancedSection,
|
||||
privacy: PrivacySection,
|
||||
};
|
||||
|
||||
interface PageProps {
|
||||
// Next.js 16 made route `params` an async Promise. We `await` it before use.
|
||||
params: Promise<{ section: string }>;
|
||||
@@ -100,10 +43,13 @@ export default async function SettingsSectionPage({ params }: PageProps) {
|
||||
notFound();
|
||||
}
|
||||
|
||||
// `SECTION_COMPONENTS` is an exhaustive Record over the slug union, so a
|
||||
// slug without a wired component is a compile error — the old runtime
|
||||
// `<SectionPlaceholder>` fallback could never render and was removed.
|
||||
const SectionComponent = SECTION_COMPONENTS[section];
|
||||
// Each section is its own chunk (see `SettingsSectionLoader`), so the route
|
||||
// ships the one it shows rather than all twenty-one. Integrations is the
|
||||
// one section that needs a server-resolved value: the OAuth callback URL
|
||||
// each provider registers, read from the runtime env here, per request,
|
||||
// because a `NEXT_PUBLIC_*` read inside a client module is inlined at build
|
||||
// time and the published image is built without it.
|
||||
//
|
||||
// v1.18.6.1 — the heading + subtitle (and the Layout-hub "← back" link)
|
||||
// live in `<SettingsShell>`, which places them in their own grid row
|
||||
// spanning only the content column so the left nav's first item lines up
|
||||
@@ -112,12 +58,20 @@ export default async function SettingsSectionPage({ params }: PageProps) {
|
||||
// `settings-section-<slug>-title` `aria-labelledby` linkage still resolves.
|
||||
return (
|
||||
<SettingsShell active={section}>
|
||||
<SettingsSectionPreload section={section} />
|
||||
<RecordSettingsSectionGate section={section}>
|
||||
<section
|
||||
aria-labelledby={`settings-section-${section}-title`}
|
||||
className="space-y-6"
|
||||
>
|
||||
<SectionComponent />
|
||||
<SettingsSectionLoader
|
||||
section={section}
|
||||
callbackUrls={
|
||||
section === "integrations"
|
||||
? getIntegrationCallbackUrls()
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
</section>
|
||||
</RecordSettingsSectionGate>
|
||||
</SettingsShell>
|
||||
|
||||
@@ -0,0 +1,247 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* `<MeasurementMaintenanceCard>` — start the measurement table maintenance
|
||||
* and follow it, from the admin console instead of a `curl` against the
|
||||
* route (runbook: `docs/ops/measurement-maintenance.md`).
|
||||
*
|
||||
* Calm on purpose. The button asks once, says what the run does and that it
|
||||
* can take a while, and then the card shows where the run stands: waiting,
|
||||
* running since when, finished, or stopped. It polls only while a run is
|
||||
* queued or running. It stays disabled while the compaction-tombstone purge
|
||||
* is still at work, because a run started then would refuse at once.
|
||||
*/
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { Database, Loader2, Wrench } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
|
||||
import { StatTile } from "@/components/admin/_stat-tile";
|
||||
import { SettingsCardActions } from "@/components/settings/_card-actions";
|
||||
import { SettingsCardHeader } from "@/components/settings/_card-header";
|
||||
import { SettingsCard } from "@/components/settings/settings-card";
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
AlertDialogTrigger,
|
||||
} from "@/components/ui/alert-dialog";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { QueryErrorCard } from "@/components/ui/query-error-card";
|
||||
import { apiFetchRaw, apiGet } from "@/lib/api/api-fetch";
|
||||
import { formatBytes } from "@/lib/format/bytes";
|
||||
import { useFormatters, useTranslations } from "@/lib/i18n/context";
|
||||
import type { MeasurementMaintenanceStatus } from "@/lib/jobs/measurement-maintenance-status";
|
||||
import { queryKeys } from "@/lib/query-keys";
|
||||
import { getApiErrorMessage } from "./_shared";
|
||||
|
||||
const ENDPOINT = "/api/admin/maintenance/measurements";
|
||||
|
||||
/** How often the card asks while a run is waiting or working. */
|
||||
const POLL_MS = 10_000;
|
||||
|
||||
const GIB = 1024 * 1024 * 1024;
|
||||
|
||||
function sizeLabel(
|
||||
bytes: number,
|
||||
fmt: ReturnType<typeof useFormatters>,
|
||||
): string {
|
||||
// The shared formatter stops at MB; this table is routinely gigabytes.
|
||||
return bytes >= GIB
|
||||
? `${fmt.number(bytes / GIB, 2)} GB`
|
||||
: formatBytes(bytes, fmt);
|
||||
}
|
||||
|
||||
export function MeasurementMaintenanceCard() {
|
||||
const { t } = useTranslations();
|
||||
const fmt = useFormatters();
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const status = useQuery({
|
||||
queryKey: queryKeys.adminMeasurementMaintenance(),
|
||||
queryFn: () =>
|
||||
apiGet<MeasurementMaintenanceStatus>(ENDPOINT, {
|
||||
credentials: "include",
|
||||
}),
|
||||
refetchInterval: (query) => {
|
||||
const state = query.state.data?.run?.state;
|
||||
return state === "queued" || state === "running" ? POLL_MS : false;
|
||||
},
|
||||
});
|
||||
|
||||
const start = useMutation({
|
||||
mutationFn: async (): Promise<{ enqueued: boolean }> => {
|
||||
const res = await apiFetchRaw(ENDPOINT, {
|
||||
method: "POST",
|
||||
credentials: "include",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({ vacuum: true, reindex: true }),
|
||||
});
|
||||
if (!res.ok) throw new Error(await getApiErrorMessage(res));
|
||||
return ((await res.json()) as { data: { enqueued: boolean } }).data;
|
||||
},
|
||||
onSuccess: ({ enqueued }) => {
|
||||
if (enqueued) toast.success(t("admin.section.maintenance.started"));
|
||||
else toast.info(t("admin.section.maintenance.alreadyQueued"));
|
||||
void queryClient.invalidateQueries({
|
||||
queryKey: queryKeys.adminMeasurementMaintenance(),
|
||||
});
|
||||
},
|
||||
onError: (err: Error) => {
|
||||
toast.error(err.message || t("admin.section.maintenance.startFailed"));
|
||||
},
|
||||
});
|
||||
|
||||
const header = (
|
||||
<SettingsCardHeader
|
||||
icon={Database}
|
||||
title={t("admin.section.maintenance.title")}
|
||||
description={t("admin.section.maintenance.description")}
|
||||
/>
|
||||
);
|
||||
|
||||
if (status.isError) {
|
||||
return (
|
||||
<QueryErrorCard
|
||||
title={t("admin.section.maintenance.loadError")}
|
||||
onRetry={() => void status.refetch()}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const s = status.data;
|
||||
const run = s?.run ?? null;
|
||||
const active = run?.state === "queued" || run?.state === "running";
|
||||
const blocked = !s || !s.available || s.purgePending || active;
|
||||
|
||||
return (
|
||||
<SettingsCard data-testid="admin-measurement-maintenance">
|
||||
{header}
|
||||
<p className="text-sm">{t("admin.section.maintenance.body")}</p>
|
||||
|
||||
{s?.sizes ? (
|
||||
<div className="grid gap-3 sm:grid-cols-2">
|
||||
<StatTile
|
||||
label={t("admin.section.maintenance.tableSize")}
|
||||
value={sizeLabel(s.sizes.tableBytes, fmt)}
|
||||
/>
|
||||
<StatTile
|
||||
label={t("admin.section.maintenance.indexSize")}
|
||||
value={sizeLabel(s.sizes.indexBytes, fmt)}
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div
|
||||
className="text-sm"
|
||||
role="status"
|
||||
aria-live="polite"
|
||||
data-slot="maintenance-state"
|
||||
data-state={status.isLoading ? "loading" : (run?.state ?? "idle")}
|
||||
>
|
||||
{status.isLoading ? (
|
||||
<span className="text-muted-foreground flex items-center gap-2">
|
||||
<Loader2
|
||||
className="size-4 animate-spin motion-reduce:animate-none"
|
||||
aria-hidden
|
||||
/>
|
||||
{t("admin.section.maintenance.loading")}
|
||||
</span>
|
||||
) : !s?.available ? (
|
||||
<span className="text-muted-foreground">
|
||||
{t("admin.section.maintenance.unavailable")}
|
||||
</span>
|
||||
) : run?.state === "queued" ? (
|
||||
<span className="flex items-center gap-2">
|
||||
<Loader2
|
||||
className="text-muted-foreground size-4 animate-spin motion-reduce:animate-none"
|
||||
aria-hidden
|
||||
/>
|
||||
{t("admin.section.maintenance.state.queued")}
|
||||
</span>
|
||||
) : run?.state === "running" ? (
|
||||
<span className="flex items-center gap-2">
|
||||
<Loader2
|
||||
className="text-muted-foreground size-4 animate-spin motion-reduce:animate-none"
|
||||
aria-hidden
|
||||
/>
|
||||
{t("admin.section.maintenance.state.running", {
|
||||
since: fmt.time(run.startedAt ?? run.requestedAt),
|
||||
})}
|
||||
</span>
|
||||
) : run?.state === "completed" ? (
|
||||
<span>
|
||||
{run.outcome === "refused_purge_running"
|
||||
? t("admin.section.maintenance.state.refusedPurge")
|
||||
: run.outcome === "already_running"
|
||||
? t("admin.section.maintenance.state.alreadyRunning")
|
||||
: t("admin.section.maintenance.state.completed", {
|
||||
at: fmt.dateTime(run.finishedAt ?? run.requestedAt),
|
||||
})}
|
||||
</span>
|
||||
) : run?.state === "failed" ? (
|
||||
<span className="text-destructive">
|
||||
{t("admin.section.maintenance.state.failed", {
|
||||
at: fmt.dateTime(run.finishedAt ?? run.requestedAt),
|
||||
})}
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-muted-foreground">
|
||||
{t("admin.section.maintenance.state.idle")}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{s?.available && s.purgePending ? (
|
||||
<p className="text-muted-foreground text-xs">
|
||||
{t("admin.section.maintenance.purgePending")}
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
<SettingsCardActions>
|
||||
<AlertDialog>
|
||||
<AlertDialogTrigger asChild>
|
||||
<Button
|
||||
size="sm"
|
||||
className="min-h-11 sm:min-h-9"
|
||||
disabled={blocked || start.isPending}
|
||||
data-testid="admin-maintenance-start"
|
||||
>
|
||||
{start.isPending ? (
|
||||
<Loader2
|
||||
className="size-3.5 animate-spin motion-reduce:animate-none"
|
||||
aria-hidden
|
||||
/>
|
||||
) : (
|
||||
<Wrench className="size-3.5" aria-hidden />
|
||||
)}
|
||||
{t("admin.section.maintenance.start")}
|
||||
</Button>
|
||||
</AlertDialogTrigger>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>
|
||||
{t("admin.section.maintenance.confirmTitle")}
|
||||
</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
{t("admin.section.maintenance.confirmBody")}
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>
|
||||
{t("admin.section.maintenance.cancel")}
|
||||
</AlertDialogCancel>
|
||||
<AlertDialogAction onClick={() => start.mutate()}>
|
||||
{t("admin.section.maintenance.confirm")}
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</SettingsCardActions>
|
||||
</SettingsCard>
|
||||
);
|
||||
}
|
||||
@@ -336,3 +336,40 @@ describe("<UploadStateCard>", () => {
|
||||
expect(html).toContain("1 GB");
|
||||
});
|
||||
});
|
||||
|
||||
describe('<DocumentCard variant="tile"> (the archive grid)', () => {
|
||||
it("puts a 4:3 preview above the title, falling back to the kind icon", () => {
|
||||
const html = render(
|
||||
<DocumentCard
|
||||
variant="tile"
|
||||
document={doc()}
|
||||
selected={false}
|
||||
onToggleSelected={noop}
|
||||
onOpen={noop}
|
||||
highlighted={false}
|
||||
/>,
|
||||
);
|
||||
expect(html).toContain('data-variant="tile"');
|
||||
const preview = html.indexOf('data-slot="document-preview"');
|
||||
expect(preview).toBeGreaterThan(-1);
|
||||
expect(html).toMatch(/data-slot="document-preview"[^>]*aspect-\[4\/3\]/);
|
||||
// Preview first, title after it.
|
||||
expect(html.indexOf("MRT Knie")).toBeGreaterThan(preview);
|
||||
// No thumbnail rendered yet: the kind icon fills the preview, no <img>.
|
||||
expect(html).not.toContain("<img");
|
||||
});
|
||||
|
||||
it("keeps the row card as the default for every other surface", () => {
|
||||
const html = render(
|
||||
<DocumentCard
|
||||
document={doc()}
|
||||
selected={false}
|
||||
onToggleSelected={noop}
|
||||
onOpen={noop}
|
||||
highlighted={false}
|
||||
/>,
|
||||
);
|
||||
expect(html).toContain('data-variant="row"');
|
||||
expect(html).not.toContain('data-slot="document-preview"');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,52 @@
|
||||
/**
|
||||
* The archive grid's shape: two tiles side by side on a phone, three or four
|
||||
* on a desktop, measured from the grid's own width (the AuthShell inset is
|
||||
* already taken off by the time the grid measures itself).
|
||||
*/
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
|
||||
import { I18nProvider } from "@/lib/i18n/context";
|
||||
import {
|
||||
columnsForWidth,
|
||||
DocumentMonthHeading,
|
||||
estimatedRowHeight,
|
||||
} from "../document-month-grid";
|
||||
|
||||
describe("columnsForWidth", () => {
|
||||
it("lays a 390 px phone out in two columns, the narrowest screens in one", () => {
|
||||
// 390 viewport minus the shell's 16 px gutters.
|
||||
expect(columnsForWidth(358)).toBe(2);
|
||||
expect(columnsForWidth(320 - 32)).toBe(1);
|
||||
});
|
||||
|
||||
it("uses three columns on a small desktop and four on a wide one", () => {
|
||||
expect(columnsForWidth(1024 - 48)).toBe(3);
|
||||
// 1440 viewport: the shell caps the container at 1280 minus its gutters.
|
||||
expect(columnsForWidth(1280 - 48)).toBe(4);
|
||||
});
|
||||
|
||||
it("never asks for a fifth column", () => {
|
||||
expect(columnsForWidth(4000)).toBe(4);
|
||||
});
|
||||
});
|
||||
|
||||
describe("estimatedRowHeight", () => {
|
||||
it("grows with the tile's width, so the first guess is close to the measured row", () => {
|
||||
expect(estimatedRowHeight(358, 2)).toBeLessThan(
|
||||
estimatedRowHeight(1232, 2),
|
||||
);
|
||||
expect(estimatedRowHeight(358, 2)).toBeGreaterThan(200);
|
||||
});
|
||||
});
|
||||
|
||||
describe("<DocumentMonthHeading>", () => {
|
||||
it("keeps the month heading as the grid's section label", () => {
|
||||
const html = renderToStaticMarkup(
|
||||
<I18nProvider initialLocale="en">
|
||||
<DocumentMonthHeading label="March 2026" />
|
||||
</I18nProvider>,
|
||||
);
|
||||
expect(html).toMatch(/^<h2 [^>]*uppercase[^>]*>March 2026<\/h2>$/);
|
||||
});
|
||||
});
|
||||
@@ -52,7 +52,14 @@ export function DocumentCard({
|
||||
tabIndex = 0,
|
||||
onCardFocus,
|
||||
onPrefetch,
|
||||
variant = "row",
|
||||
}: {
|
||||
/**
|
||||
* `row` (default): the compact card with the preview at its leading edge.
|
||||
* `tile`: the archive grid's tile, the preview filling the top of the card
|
||||
* so a page is recognisable before its title is read.
|
||||
*/
|
||||
variant?: "row" | "tile";
|
||||
document: InboundDocumentDto;
|
||||
selected: boolean;
|
||||
/** `range` = extend the selection from the last anchor (shift-click). */
|
||||
@@ -127,9 +134,76 @@ export function DocumentCard({
|
||||
wasProcessingRef.current = processing;
|
||||
}, [processing]);
|
||||
|
||||
const tile = variant === "tile";
|
||||
const showThumbnail = !!thumbnailUrl && !thumbnailFailed;
|
||||
// An object URL over bytes the app transport already fetched, not a
|
||||
// `/api/…` subresource: the browser issues a subresource itself and so
|
||||
// cannot carry the record-session assertion, which the fence refuses on any
|
||||
// session that has been inside a shared record. `next/image` is wrong here
|
||||
// for the older reason too — it would proxy a PHI blob we deliberately
|
||||
// never cache. Decorative (alt="") — the title beside it names the
|
||||
// document. The laziness that `loading="lazy"` used to provide now lives in
|
||||
// the hook's null path: a card with no thumbnail, or one whose thumbnail
|
||||
// already failed, issues no request at all. onError falls back to the kind
|
||||
// icon.
|
||||
const thumbnail = showThumbnail ? (
|
||||
// eslint-disable-next-line @next/next/no-img-element
|
||||
<img
|
||||
src={thumbnailUrl}
|
||||
alt=""
|
||||
decoding="async"
|
||||
onError={() => setThumbFailed(true)}
|
||||
className={cn("size-full object-cover", tile && "object-top")}
|
||||
/>
|
||||
) : null;
|
||||
|
||||
const selection = onToggleSelected ? (
|
||||
<Checkbox
|
||||
checked={selected}
|
||||
// Explicit click handling instead of onCheckedChange: the mouse
|
||||
// event carries `shiftKey` for file-manager range selection.
|
||||
// preventDefault stops Radix's internal toggle (state is fully
|
||||
// controlled by the page's selection set anyway).
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
onToggleSelected(document.id, e.shiftKey);
|
||||
}}
|
||||
aria-label={t("documents.card.selectLabel", { title })}
|
||||
className={cn(
|
||||
"relative z-10 shrink-0 transition-opacity",
|
||||
"opacity-0 group-focus-within:opacity-100 group-hover:opacity-100",
|
||||
selected && "opacity-100",
|
||||
// On a tile the box floats over the preview, which can be any
|
||||
// colour; the card background behind it keeps it readable.
|
||||
tile && "bg-background absolute top-4 right-4",
|
||||
)}
|
||||
/>
|
||||
) : null;
|
||||
|
||||
const titleBlock = (
|
||||
<div className="flex min-w-0 flex-1 flex-col">
|
||||
<p className="truncate text-sm font-medium">{title}</p>
|
||||
<p className="text-muted-foreground truncate text-xs">
|
||||
{date} · {size}
|
||||
</p>
|
||||
{showFilename ? (
|
||||
// Filename on its OWN muted line so it never clips the date/size
|
||||
// meta; the full name stays reachable on hover via `title`.
|
||||
<p
|
||||
data-slot="document-card-filename"
|
||||
className="text-muted-foreground truncate text-xs"
|
||||
title={document.filename ?? undefined}
|
||||
>
|
||||
{document.filename}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<Card
|
||||
data-slot="document-card"
|
||||
data-variant={variant}
|
||||
data-document-id={document.id}
|
||||
className={cn(
|
||||
"group relative h-full gap-2 py-3 transition-shadow md:py-4",
|
||||
@@ -137,76 +211,47 @@ export function DocumentCard({
|
||||
highlighted && "ring-primary ring-2",
|
||||
)}
|
||||
>
|
||||
<CardContent className="flex h-full flex-col gap-2 px-4">
|
||||
<div className="flex items-start gap-2">
|
||||
{thumbnailUrl && !thumbnailFailed ? (
|
||||
// Leading-edge preview tile. Decorative (alt="") — the title
|
||||
// beside it names the document. The laziness that `loading="lazy"`
|
||||
// used to provide now lives in the hook's null path: a card with no
|
||||
// thumbnail, or one whose thumbnail already failed, issues no
|
||||
// request at all. onError falls back to the kind icon.
|
||||
<CardContent
|
||||
className={cn(
|
||||
"flex h-full flex-col gap-2",
|
||||
// A dense tile (UI standards §1): two or more of them sit side by
|
||||
// side on a phone, where the default inset would eat the preview.
|
||||
tile ? "px-3" : "px-4",
|
||||
)}
|
||||
>
|
||||
{tile ? (
|
||||
<>
|
||||
<span
|
||||
data-slot="document-thumbnail"
|
||||
className="bg-muted mt-0.5 block size-12 shrink-0 overflow-hidden rounded-md"
|
||||
data-slot="document-preview"
|
||||
className="bg-muted flex aspect-[4/3] w-full items-center justify-center overflow-hidden rounded-lg"
|
||||
>
|
||||
{/* An object URL over bytes the app transport already fetched,
|
||||
not a `/api/…` subresource: the browser issues a subresource
|
||||
itself and so cannot carry the record-session assertion, which
|
||||
the fence refuses on any session that has been inside a shared
|
||||
record. `next/image` is wrong here for the older reason too —
|
||||
it would proxy a PHI blob we deliberately never cache. */}
|
||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||
<img
|
||||
src={thumbnailUrl}
|
||||
alt=""
|
||||
decoding="async"
|
||||
onError={() => setThumbFailed(true)}
|
||||
className="size-full object-cover"
|
||||
/>
|
||||
</span>
|
||||
) : (
|
||||
<Icon
|
||||
className="text-foreground mt-0.5 size-5 shrink-0"
|
||||
aria-hidden
|
||||
/>
|
||||
)}
|
||||
<div className="flex min-w-0 flex-1 flex-col">
|
||||
<p className="truncate text-sm font-medium">{title}</p>
|
||||
<p className="text-muted-foreground truncate text-xs">
|
||||
{date} · {size}
|
||||
</p>
|
||||
{showFilename ? (
|
||||
// Filename on its OWN muted line so it never clips the date/size
|
||||
// meta; the full name stays reachable on hover via `title`.
|
||||
<p
|
||||
data-slot="document-card-filename"
|
||||
className="text-muted-foreground truncate text-xs"
|
||||
title={document.filename ?? undefined}
|
||||
>
|
||||
{document.filename}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
{onToggleSelected && (
|
||||
<Checkbox
|
||||
checked={selected}
|
||||
// Explicit click handling instead of onCheckedChange: the mouse
|
||||
// event carries `shiftKey` for file-manager range selection.
|
||||
// preventDefault stops Radix's internal toggle (state is fully
|
||||
// controlled by the page's selection set anyway).
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
onToggleSelected(document.id, e.shiftKey);
|
||||
}}
|
||||
aria-label={t("documents.card.selectLabel", { title })}
|
||||
className={cn(
|
||||
"relative z-10 shrink-0 transition-opacity",
|
||||
"opacity-0 group-focus-within:opacity-100 group-hover:opacity-100",
|
||||
selected && "opacity-100",
|
||||
{thumbnail ?? (
|
||||
<Icon className="text-foreground size-8" aria-hidden />
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</span>
|
||||
{titleBlock}
|
||||
{selection}
|
||||
</>
|
||||
) : (
|
||||
<div className="flex items-start gap-2">
|
||||
{thumbnail ? (
|
||||
// Leading-edge preview tile.
|
||||
<span
|
||||
data-slot="document-thumbnail"
|
||||
className="bg-muted mt-0.5 block size-12 shrink-0 overflow-hidden rounded-md"
|
||||
>
|
||||
{thumbnail}
|
||||
</span>
|
||||
) : (
|
||||
<Icon
|
||||
className="text-foreground mt-0.5 size-5 shrink-0"
|
||||
aria-hidden
|
||||
/>
|
||||
)}
|
||||
{titleBlock}
|
||||
{selection}
|
||||
</div>
|
||||
)}
|
||||
{document.conditionLinks.length > 0 ||
|
||||
document.servingClass === "attachment" ||
|
||||
processing ||
|
||||
|
||||
@@ -0,0 +1,92 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* The archive's month grid: a month heading, then that month's documents as
|
||||
* preview tiles, so a page is recognisable by its look before its title is
|
||||
* read. The timeline owns windowing and keyboard focus; this module owns how
|
||||
* one windowed item is laid out and how many tiles share a row.
|
||||
*
|
||||
* Two tiles side by side on a phone (one on the narrowest screens), three or
|
||||
* four on a desktop. The count comes from the grid's measured width, not from
|
||||
* viewport breakpoints, so the virtualizer's row model and the painted grid
|
||||
* can never disagree.
|
||||
*/
|
||||
import type { InboundDocumentDto } from "@/lib/validations/inbound-documents";
|
||||
import { DocumentCard } from "./document-card";
|
||||
|
||||
/** Measured grid width → tiles per row. */
|
||||
export function columnsForWidth(width: number): number {
|
||||
if (width >= 1100) return 4;
|
||||
if (width >= 760) return 3;
|
||||
if (width >= 330) return 2;
|
||||
return 1;
|
||||
}
|
||||
|
||||
/** Gap between tiles and between rows, in px (`gap-4` / `pb-4`). */
|
||||
const GRID_GAP_PX = 16;
|
||||
|
||||
/**
|
||||
* First guess at a row's height before the virtualizer measures it: a 4:3
|
||||
* preview across the tile's width plus the title and meta lines under it.
|
||||
*/
|
||||
export function estimatedRowHeight(width: number, columns: number): number {
|
||||
const cols = Math.max(1, columns);
|
||||
const tileWidth = Math.max(0, (width - GRID_GAP_PX * (cols - 1)) / cols);
|
||||
return Math.round(tileWidth * 0.75) + 96 + GRID_GAP_PX;
|
||||
}
|
||||
|
||||
export function DocumentMonthHeading({ label }: { label: string }) {
|
||||
return (
|
||||
<h2 className="text-muted-foreground pt-2 pb-3 text-xs font-medium tracking-wide uppercase">
|
||||
{label}
|
||||
</h2>
|
||||
);
|
||||
}
|
||||
|
||||
export function DocumentMonthRow({
|
||||
documents,
|
||||
columns,
|
||||
selectedIds,
|
||||
onToggleSelected,
|
||||
onOpen,
|
||||
onDelete,
|
||||
highlightId,
|
||||
rovingId,
|
||||
onCardFocus,
|
||||
onPrefetch,
|
||||
}: {
|
||||
documents: InboundDocumentDto[];
|
||||
columns: number;
|
||||
selectedIds: ReadonlySet<string>;
|
||||
onToggleSelected?: (id: string, range?: boolean) => void;
|
||||
onOpen: (id: string) => void;
|
||||
onDelete?: (id: string) => void;
|
||||
highlightId: string | null;
|
||||
rovingId: string | null;
|
||||
onCardFocus: (id: string) => void;
|
||||
onPrefetch?: (id: string) => void;
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
data-slot="document-month-row"
|
||||
className="grid gap-4 pb-4"
|
||||
style={{ gridTemplateColumns: `repeat(${columns}, minmax(0, 1fr))` }}
|
||||
>
|
||||
{documents.map((doc) => (
|
||||
<DocumentCard
|
||||
key={doc.id}
|
||||
variant="tile"
|
||||
document={doc}
|
||||
selected={selectedIds.has(doc.id)}
|
||||
onToggleSelected={onToggleSelected}
|
||||
onOpen={onOpen}
|
||||
onDelete={onDelete}
|
||||
highlighted={highlightId === doc.id}
|
||||
tabIndex={rovingId === doc.id ? 0 : -1}
|
||||
onCardFocus={onCardFocus}
|
||||
onPrefetch={onPrefetch}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,7 +1,8 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* The vault timeline: a virtualized, month-sectioned card grid windowed
|
||||
* The vault timeline: a virtualized, month-sectioned grid of preview tiles
|
||||
* (`document-month-grid.tsx`) windowed
|
||||
* with `@tanstack/react-virtual` over the shell's scroll container
|
||||
* (`#main-content` — single scroll owner per the design standards; the
|
||||
* timeline never brings its own scrollport). The flat item list (month
|
||||
@@ -9,8 +10,9 @@
|
||||
* mounted DOM stays bounded (< ~400 nodes) regardless of corpus size.
|
||||
*
|
||||
* Columns are measured, not breakpoint-classed: a ResizeObserver on the
|
||||
* grid container drives the per-row chunking (4 / 3 / 2 / 1), which keeps
|
||||
* the virtualizer's row model and the painted grid in lockstep.
|
||||
* grid container drives the per-row chunking (4 / 3 on a desktop, 2 on a
|
||||
* phone, 1 on the narrowest screens), which keeps the virtualizer's row model
|
||||
* and the painted grid in lockstep.
|
||||
*
|
||||
* In-flight / failed uploads render as a small non-virtualized grid above
|
||||
* the timeline — they are few (client concurrency 3) and must appear
|
||||
@@ -28,20 +30,18 @@ import { useVirtualizer } from "@tanstack/react-virtual";
|
||||
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import type { InboundDocumentDto } from "@/lib/validations/inbound-documents";
|
||||
import { DocumentCard, UploadStateCard } from "./document-card";
|
||||
import { UploadStateCard } from "./document-card";
|
||||
import {
|
||||
columnsForWidth,
|
||||
DocumentMonthHeading,
|
||||
DocumentMonthRow,
|
||||
estimatedRowHeight,
|
||||
} from "./document-month-grid";
|
||||
import type { UploadQueueItem } from "./use-document-upload";
|
||||
import { buildTimelineItems, formatMonthLabel } from "./vault-utils";
|
||||
|
||||
const SCROLL_CONTAINER_ID = "main-content";
|
||||
|
||||
/** Measured-width → column count (desktop 4/3, tablet 2, phone 1). */
|
||||
function columnsForWidth(width: number): number {
|
||||
if (width >= 1200) return 4;
|
||||
if (width >= 900) return 3;
|
||||
if (width >= 600) return 2;
|
||||
return 1;
|
||||
}
|
||||
|
||||
export function DocumentTimeline({
|
||||
documents,
|
||||
uploadItems,
|
||||
@@ -77,6 +77,7 @@ export function DocumentTimeline({
|
||||
const { t, locale } = useTranslations();
|
||||
const listRef = useRef<HTMLDivElement | null>(null);
|
||||
const [columns, setColumns] = useState(1);
|
||||
const [gridWidth, setGridWidth] = useState(0);
|
||||
const [scrollMargin, setScrollMargin] = useState(0);
|
||||
|
||||
// Roving tabindex over the card grid: exactly one card is tabbable; the
|
||||
@@ -88,7 +89,10 @@ export function DocumentTimeline({
|
||||
useEffect(() => {
|
||||
const el = listRef.current;
|
||||
if (!el) return;
|
||||
const update = () => setColumns(columnsForWidth(el.clientWidth));
|
||||
const update = () => {
|
||||
setColumns(columnsForWidth(el.clientWidth));
|
||||
setGridWidth(el.clientWidth);
|
||||
};
|
||||
update();
|
||||
const ro = new ResizeObserver(update);
|
||||
ro.observe(el);
|
||||
@@ -127,7 +131,10 @@ export function DocumentTimeline({
|
||||
const virtualizer = useVirtualizer({
|
||||
count: items.length,
|
||||
getScrollElement: () => document.getElementById(SCROLL_CONTAINER_ID),
|
||||
estimateSize: (index) => (items[index].type === "month" ? 40 : 140),
|
||||
estimateSize: (index) =>
|
||||
items[index].type === "month"
|
||||
? 40
|
||||
: estimatedRowHeight(gridWidth, columns),
|
||||
getItemKey: (index) => items[index].key,
|
||||
overscan: 6,
|
||||
scrollMargin,
|
||||
@@ -282,31 +289,22 @@ export function DocumentTimeline({
|
||||
}}
|
||||
>
|
||||
{item.type === "month" ? (
|
||||
<h2 className="text-muted-foreground pt-2 pb-3 text-xs font-medium tracking-wide uppercase">
|
||||
{formatMonthLabel(item.key, locale)}
|
||||
</h2>
|
||||
<DocumentMonthHeading
|
||||
label={formatMonthLabel(item.key, locale)}
|
||||
/>
|
||||
) : (
|
||||
<div
|
||||
className="grid gap-4 pb-4"
|
||||
style={{
|
||||
gridTemplateColumns: `repeat(${columns}, minmax(0, 1fr))`,
|
||||
}}
|
||||
>
|
||||
{item.documents.map((doc) => (
|
||||
<DocumentCard
|
||||
key={doc.id}
|
||||
document={doc}
|
||||
selected={selectedIds.has(doc.id)}
|
||||
onToggleSelected={onToggleSelected}
|
||||
onOpen={onOpen}
|
||||
onDelete={onDelete}
|
||||
highlighted={highlightId === doc.id}
|
||||
tabIndex={rovingId === doc.id ? 0 : -1}
|
||||
onCardFocus={setActiveId}
|
||||
onPrefetch={onPrefetch}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
<DocumentMonthRow
|
||||
documents={item.documents}
|
||||
columns={columns}
|
||||
selectedIds={selectedIds}
|
||||
onToggleSelected={onToggleSelected}
|
||||
onOpen={onOpen}
|
||||
onDelete={onDelete}
|
||||
highlightId={highlightId}
|
||||
rovingId={rovingId}
|
||||
onCardFocus={setActiveId}
|
||||
onPrefetch={onPrefetch}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -993,10 +993,17 @@ export function DocumentsView() {
|
||||
{list.isPending ? (
|
||||
<div
|
||||
data-slot="documents-loading"
|
||||
className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3"
|
||||
// The archive's tile shape (a 4:3 preview over two text lines), in
|
||||
// the timeline's column counts, so the grid does not jump when the
|
||||
// first page lands.
|
||||
className="grid grid-cols-2 gap-4 lg:grid-cols-3 xl:grid-cols-4"
|
||||
>
|
||||
{Array.from({ length: 9 }, (_, i) => (
|
||||
<Skeleton key={i} className="h-28 rounded-xl" />
|
||||
{Array.from({ length: 8 }, (_, i) => (
|
||||
<div key={i} className="space-y-2">
|
||||
<Skeleton className="aspect-[4/3] w-full rounded-xl" />
|
||||
<Skeleton className="h-4 w-3/4" />
|
||||
<Skeleton className="h-3 w-1/2" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : list.isError ? (
|
||||
|
||||
@@ -0,0 +1,144 @@
|
||||
/**
|
||||
* A saved conversation opens at its last answer and stays there while late
|
||||
* layout (charts, tables) grows the thread, until the reader takes over.
|
||||
* Driven against a fake scroller: the test environment has no layout, and
|
||||
* the behaviour under test is which scroll the thread asks for, and when.
|
||||
*/
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { OPEN_AT_END_SETTLE_MS, openAtEnd } from "../open-at-end";
|
||||
|
||||
type Listener = () => void;
|
||||
|
||||
function fakeScroller(children = 3) {
|
||||
const listeners = new Map<string, Set<Listener>>();
|
||||
const el = {
|
||||
scrollHeight: 1000,
|
||||
children: Array.from({ length: children }, () => ({}) as Element),
|
||||
scrollTo: vi.fn(),
|
||||
addEventListener: (type: string, fn: Listener) => {
|
||||
if (!listeners.has(type)) listeners.set(type, new Set());
|
||||
listeners.get(type)!.add(fn);
|
||||
},
|
||||
removeEventListener: (type: string, fn: Listener) => {
|
||||
listeners.get(type)?.delete(fn);
|
||||
},
|
||||
fire: (type: string) => {
|
||||
for (const fn of [...(listeners.get(type) ?? [])]) fn();
|
||||
},
|
||||
listenerCount: () =>
|
||||
[...listeners.values()].reduce((n, set) => n + set.size, 0),
|
||||
};
|
||||
return el;
|
||||
}
|
||||
|
||||
/** A ResizeObserver whose callback the test fires by hand. */
|
||||
function fakeResizeObserver() {
|
||||
const instances: Array<{ fire: () => void; observed: unknown[] }> = [];
|
||||
class FakeResizeObserver {
|
||||
observed: unknown[] = [];
|
||||
disconnected = false;
|
||||
constructor(private readonly cb: () => void) {
|
||||
instances.push(this as never);
|
||||
}
|
||||
observe(node: unknown) {
|
||||
this.observed.push(node);
|
||||
}
|
||||
disconnect() {
|
||||
this.disconnected = true;
|
||||
}
|
||||
fire() {
|
||||
if (!this.disconnected) this.cb();
|
||||
}
|
||||
}
|
||||
return {
|
||||
Ctor: FakeResizeObserver as unknown as typeof ResizeObserver,
|
||||
instances,
|
||||
};
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
});
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
describe("openAtEnd", () => {
|
||||
it("jumps to the end at once, without the smooth animation that lost it", () => {
|
||||
const el = fakeScroller();
|
||||
openAtEnd(el, { ResizeObserver: undefined, MutationObserver: undefined });
|
||||
expect(el.scrollTo).toHaveBeenCalledTimes(1);
|
||||
expect(el.scrollTo).toHaveBeenCalledWith({
|
||||
top: 1000,
|
||||
behavior: "instant",
|
||||
});
|
||||
});
|
||||
|
||||
it("follows the end when a chart that loads later grows the thread", () => {
|
||||
const el = fakeScroller();
|
||||
const ro = fakeResizeObserver();
|
||||
openAtEnd(el, { ResizeObserver: ro.Ctor, MutationObserver: undefined });
|
||||
expect(ro.instances[0].observed).toHaveLength(3);
|
||||
|
||||
el.scrollHeight = 1070; // one chart's height, the phone shortfall
|
||||
ro.instances[0].fire();
|
||||
expect(el.scrollTo).toHaveBeenLastCalledWith({
|
||||
top: 1070,
|
||||
behavior: "instant",
|
||||
});
|
||||
});
|
||||
|
||||
it("lets go the moment the reader scrolls, touches or presses a key", () => {
|
||||
for (const event of ["wheel", "touchstart", "keydown", "pointerdown"]) {
|
||||
const el = fakeScroller();
|
||||
const ro = fakeResizeObserver();
|
||||
openAtEnd(el, { ResizeObserver: ro.Ctor, MutationObserver: undefined });
|
||||
el.fire(event);
|
||||
el.scrollHeight = 2000;
|
||||
ro.instances[0].fire();
|
||||
expect(el.scrollTo, event).toHaveBeenCalledTimes(1);
|
||||
expect(el.listenerCount(), event).toBe(0);
|
||||
}
|
||||
});
|
||||
|
||||
it("stops following on its own once the layout has had time to settle", () => {
|
||||
const el = fakeScroller();
|
||||
const ro = fakeResizeObserver();
|
||||
openAtEnd(el, { ResizeObserver: ro.Ctor, MutationObserver: undefined });
|
||||
vi.advanceTimersByTime(OPEN_AT_END_SETTLE_MS);
|
||||
el.scrollHeight = 3000;
|
||||
ro.instances[0].fire();
|
||||
expect(el.scrollTo).toHaveBeenCalledTimes(1);
|
||||
expect(el.listenerCount()).toBe(0);
|
||||
});
|
||||
|
||||
it("stops when the caller says so, as on switching conversations", () => {
|
||||
const el = fakeScroller();
|
||||
const ro = fakeResizeObserver();
|
||||
const stop = openAtEnd(el, {
|
||||
ResizeObserver: ro.Ctor,
|
||||
MutationObserver: undefined,
|
||||
});
|
||||
stop();
|
||||
ro.instances[0].fire();
|
||||
expect(el.scrollTo).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("the thread wires it to the conversation it opens", () => {
|
||||
it("runs on a layout effect keyed on the conversation", async () => {
|
||||
const { readFileSync } = await import("node:fs");
|
||||
const { join } = await import("node:path");
|
||||
const source = readFileSync(
|
||||
join(
|
||||
process.cwd(),
|
||||
"src/components/insights/coach-panel/message-thread.tsx",
|
||||
),
|
||||
"utf8",
|
||||
);
|
||||
expect(source).toMatch(
|
||||
/useLayoutEffect\(\(\) => \{[\s\S]*?return openAtEnd\(el\);[\s\S]*?\}, \[conversationId, hasMessages\]\);/,
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -4,6 +4,7 @@ import {
|
||||
Fragment,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useLayoutEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
@@ -18,6 +19,7 @@ import { ChatBubble, type FollowUpOffer, type ReplyOffer } from "./chat-bubble";
|
||||
import type { SuggestedReply } from "./suggested-replies";
|
||||
import { CoachMessageDatesProvider } from "./coach-results";
|
||||
import { ScrollToBottomButton } from "./scroll-to-bottom-button";
|
||||
import { openAtEnd } from "./open-at-end";
|
||||
import { focusCoachComposer } from "./composer-focus";
|
||||
import {
|
||||
EMPTY_LIVE_TURN_KEYS,
|
||||
@@ -579,6 +581,21 @@ export function MessageThread({
|
||||
el.scrollTo({ top: el.scrollHeight, behavior: scrollBehaviorForUser() });
|
||||
}, []);
|
||||
|
||||
// Opening a saved conversation lands on its last answer, and stays there
|
||||
// while the answers' charts and tables finish loading (see `open-at-end`).
|
||||
// Keyed on the conversation, so switching to another one re-opens at its
|
||||
// end even when both have the same number of messages; a layout effect, so
|
||||
// the first painted frame is already the end rather than the top.
|
||||
const conversationId = conversation?.id ?? null;
|
||||
const hasMessages = messages.length > 0;
|
||||
useLayoutEffect(() => {
|
||||
const el = scrollerRef.current;
|
||||
if (!el || !conversationId || !hasMessages) return;
|
||||
wasPinnedRef.current = true;
|
||||
setPinned(true);
|
||||
return openAtEnd(el);
|
||||
}, [conversationId, hasMessages]);
|
||||
|
||||
// Auto-scroll on new messages OR streaming-content growth, but only
|
||||
// when the user was already at the bottom. v1.4.25 W5 — the
|
||||
// optimistic user bubble counts as a new message; scroll on its
|
||||
|
||||
@@ -0,0 +1,102 @@
|
||||
/**
|
||||
* Open a saved conversation at its end, and keep it there while the answers
|
||||
* finish laying out.
|
||||
*
|
||||
* A conversation opened from the history used to land at the top on a wide
|
||||
* screen and short of the last answer on a phone. Two things worked against
|
||||
* the thread's ordinary auto-scroll. It scrolled smoothly, and the scroll
|
||||
* events of that animation passed through positions that were not at the
|
||||
* bottom, so the thread concluded the reader had scrolled away and stopped
|
||||
* following. And the answers' charts and tables load after the messages, so
|
||||
* the end moved down after the scroll had already finished: about 70 px on a
|
||||
* phone, one chart's height.
|
||||
*
|
||||
* So the opening jump is instant, and for a short while afterwards every
|
||||
* growth of the thread's content moves the view to the new end. The settling
|
||||
* stops early the moment the reader does anything that means "let me look
|
||||
* around" (a wheel, a touch, a key, a pointer press inside the thread), and
|
||||
* on its own after {@link OPEN_AT_END_SETTLE_MS}.
|
||||
*/
|
||||
|
||||
/** How long late layout (charts, tables, images) may still move the end. */
|
||||
export const OPEN_AT_END_SETTLE_MS = 4000;
|
||||
|
||||
/** Events that mean the reader has taken over the scroll position. */
|
||||
const READER_EVENTS = [
|
||||
"wheel",
|
||||
"touchstart",
|
||||
"keydown",
|
||||
"pointerdown",
|
||||
] as const;
|
||||
|
||||
/** The part of an element the settling needs; a real `HTMLElement` fits. */
|
||||
export interface OpenAtEndScroller {
|
||||
readonly scrollHeight: number;
|
||||
readonly children: ArrayLike<Element>;
|
||||
scrollTo(options: ScrollToOptions): void;
|
||||
addEventListener(type: string, listener: () => void): void;
|
||||
removeEventListener(type: string, listener: () => void): void;
|
||||
}
|
||||
|
||||
/** The observers to use; the browser's by default, fakes in a test. */
|
||||
interface Observers {
|
||||
ResizeObserver?: typeof ResizeObserver;
|
||||
MutationObserver?: typeof MutationObserver;
|
||||
}
|
||||
|
||||
/**
|
||||
* Jump to the end now and follow the end while the content settles. Returns
|
||||
* the function that stops following; call it on unmount or when another
|
||||
* conversation opens.
|
||||
*/
|
||||
export function openAtEnd(
|
||||
el: OpenAtEndScroller,
|
||||
observers: Observers = {
|
||||
ResizeObserver: globalThis.ResizeObserver,
|
||||
MutationObserver: globalThis.MutationObserver,
|
||||
},
|
||||
): () => void {
|
||||
// `instant`, not `auto`: the thread carries `scroll-smooth`, and under it
|
||||
// `auto` animates, which is exactly the motion that lost the end before.
|
||||
const toEnd = () =>
|
||||
el.scrollTo({ top: el.scrollHeight, behavior: "instant" });
|
||||
toEnd();
|
||||
|
||||
let stopped = false;
|
||||
const resize = observers.ResizeObserver
|
||||
? new observers.ResizeObserver(() => {
|
||||
if (!stopped) toEnd();
|
||||
})
|
||||
: null;
|
||||
const watch = (node: Element) => resize?.observe(node);
|
||||
for (const child of Array.from(el.children)) watch(child);
|
||||
|
||||
// A bubble that mounts after the opening (a lazily loaded answer part)
|
||||
// is watched from then on, and its own arrival moves the end too.
|
||||
const mutations = observers.MutationObserver
|
||||
? new observers.MutationObserver((records) => {
|
||||
if (stopped) return;
|
||||
for (const record of records) {
|
||||
for (const node of Array.from(record.addedNodes)) {
|
||||
// An element (nodeType 1), not a text node: only elements resize.
|
||||
if (node.nodeType === 1) watch(node as Element);
|
||||
}
|
||||
}
|
||||
toEnd();
|
||||
})
|
||||
: null;
|
||||
mutations?.observe(el as unknown as Node, { childList: true });
|
||||
|
||||
const stop = () => {
|
||||
if (stopped) return;
|
||||
stopped = true;
|
||||
resize?.disconnect();
|
||||
mutations?.disconnect();
|
||||
clearTimeout(timer);
|
||||
for (const type of READER_EVENTS) el.removeEventListener(type, stop);
|
||||
};
|
||||
for (const type of READER_EVENTS) el.addEventListener(type, stop);
|
||||
const timer = setTimeout(stop, OPEN_AT_END_SETTLE_MS);
|
||||
|
||||
return stop;
|
||||
}
|
||||
@@ -0,0 +1,193 @@
|
||||
"use client";
|
||||
|
||||
import dynamic from "next/dynamic";
|
||||
import { useEffect, type ComponentType } from "react";
|
||||
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { SettingsCard } from "@/components/settings/settings-card";
|
||||
import type { SettingsSectionSlug } from "@/components/settings/section-slugs";
|
||||
import type { IntegrationCallbackUrls } from "@/lib/integrations/callback-urls";
|
||||
|
||||
/**
|
||||
* One settings section, loaded on its own.
|
||||
*
|
||||
* The section route renders exactly one of twenty-one sections, but importing
|
||||
* them statically put all twenty-one (and everything under them: the import
|
||||
* panel, the integration cards, the MCP and API token tables, the score
|
||||
* editor) into the route's entry bundle, so opening "About" downloaded the
|
||||
* whole settings tree. Each section is now its own chunk. A first load still
|
||||
* renders the section on the server (`ssr` stays on), so nothing jumps; the
|
||||
* placeholder below only shows while a client-side switch between sections
|
||||
* fetches the next chunk.
|
||||
*/
|
||||
|
||||
/** Props a section takes. Only Integrations takes any. */
|
||||
interface SectionProps {
|
||||
callbackUrls?: IntegrationCallbackUrls;
|
||||
}
|
||||
|
||||
function SectionLoading() {
|
||||
// The settings card shell itself, so the section does not move when the
|
||||
// real cards arrive (UI standards §13: a loading state paints the card).
|
||||
return (
|
||||
<div className="space-y-6" data-slot="settings-section-loading">
|
||||
<SettingsCard data-slot="settings-section-loading-card">
|
||||
<Skeleton className="h-6 w-40" />
|
||||
<Skeleton className="h-4 w-full max-w-md" />
|
||||
<Skeleton className="h-9 w-full" />
|
||||
</SettingsCard>
|
||||
<SettingsCard data-slot="settings-section-loading-card">
|
||||
<Skeleton className="h-6 w-32" />
|
||||
<Skeleton className="h-4 w-full max-w-sm" />
|
||||
</SettingsCard>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* One import per section, shared by the lazy component below and by the
|
||||
* preload. One call site per file keeps the bundler to one chunk per section;
|
||||
* a second `import()` of the same file elsewhere emitted a second copy.
|
||||
*/
|
||||
const LOADERS: Record<
|
||||
SettingsSectionSlug,
|
||||
() => Promise<ComponentType<SectionProps>>
|
||||
> = {
|
||||
account: () =>
|
||||
import("@/components/settings/account-section").then(
|
||||
(m) => m.AccountSection,
|
||||
),
|
||||
access: () =>
|
||||
import("@/components/settings/access-section").then((m) => m.AccessSection),
|
||||
security: () =>
|
||||
import("@/components/settings/security-section").then(
|
||||
(m) => m.SecuritySection,
|
||||
),
|
||||
modules: () =>
|
||||
import("@/components/settings/modules-section").then(
|
||||
(m) => m.ModulesSection,
|
||||
),
|
||||
about: () =>
|
||||
import("@/components/settings/about-section").then((m) => m.AboutSection),
|
||||
ai: () => import("@/components/settings/ai-section").then((m) => m.AiSection),
|
||||
coach: () =>
|
||||
import("@/components/settings/coach-section").then((m) => m.CoachSection),
|
||||
integrations: () =>
|
||||
import("@/components/settings/integrations-section").then(
|
||||
(m) =>
|
||||
function IntegrationsWithUrls({ callbackUrls }: SectionProps) {
|
||||
// The route always passes the server-resolved URLs; the guard
|
||||
// keeps the section's own prop contract required.
|
||||
if (!callbackUrls) return null;
|
||||
return <m.IntegrationsSection callbackUrls={callbackUrls} />;
|
||||
},
|
||||
),
|
||||
sources: () =>
|
||||
import("@/components/settings/sources-section").then(
|
||||
(m) => m.SourcesSection,
|
||||
),
|
||||
notifications: () =>
|
||||
import("@/components/settings/notifications-section").then(
|
||||
(m) => m.NotificationsSection,
|
||||
),
|
||||
layout: () =>
|
||||
import("@/components/settings/layout-section").then((m) => m.LayoutSection),
|
||||
environment: () =>
|
||||
import("@/components/settings/environment-section").then(
|
||||
(m) => m.EnvironmentSection,
|
||||
),
|
||||
anamnesis: () =>
|
||||
import("@/components/settings/anamnesis-section").then(
|
||||
(m) => m.AnamnesisSection,
|
||||
),
|
||||
score: () =>
|
||||
import("@/components/settings/score-section").then((m) => m.ScoreSection),
|
||||
thresholds: () =>
|
||||
import("@/components/settings/thresholds-section").then(
|
||||
(m) => m.ThresholdsSection,
|
||||
),
|
||||
api: () =>
|
||||
import("@/components/settings/api-section").then((m) => m.ApiSection),
|
||||
mcp: () =>
|
||||
import("@/components/settings/mcp-section").then((m) => m.McpSection),
|
||||
gesundheitsakte: () =>
|
||||
import("@/components/settings/gesundheitsakte-section").then(
|
||||
(m) => m.GesundheitsakteSection,
|
||||
),
|
||||
export: () =>
|
||||
import("@/components/settings/export-section").then((m) => m.ExportSection),
|
||||
advanced: () =>
|
||||
import("@/components/settings/advanced-section").then(
|
||||
(m) => m.AdvancedSection,
|
||||
),
|
||||
privacy: () =>
|
||||
import("@/components/settings/privacy-section").then(
|
||||
(m) => m.PrivacySection,
|
||||
),
|
||||
};
|
||||
|
||||
const SECTIONS: Record<SettingsSectionSlug, ComponentType<SectionProps>> = {
|
||||
account: dynamic(LOADERS.account, { loading: () => <SectionLoading /> }),
|
||||
access: dynamic(LOADERS.access, { loading: () => <SectionLoading /> }),
|
||||
security: dynamic(LOADERS.security, { loading: () => <SectionLoading /> }),
|
||||
modules: dynamic(LOADERS.modules, { loading: () => <SectionLoading /> }),
|
||||
about: dynamic(LOADERS.about, { loading: () => <SectionLoading /> }),
|
||||
ai: dynamic(LOADERS.ai, { loading: () => <SectionLoading /> }),
|
||||
coach: dynamic(LOADERS.coach, { loading: () => <SectionLoading /> }),
|
||||
integrations: dynamic(LOADERS.integrations, {
|
||||
loading: () => <SectionLoading />,
|
||||
}),
|
||||
sources: dynamic(LOADERS.sources, { loading: () => <SectionLoading /> }),
|
||||
notifications: dynamic(LOADERS.notifications, {
|
||||
loading: () => <SectionLoading />,
|
||||
}),
|
||||
layout: dynamic(LOADERS.layout, { loading: () => <SectionLoading /> }),
|
||||
environment: dynamic(LOADERS.environment, {
|
||||
loading: () => <SectionLoading />,
|
||||
}),
|
||||
anamnesis: dynamic(LOADERS.anamnesis, { loading: () => <SectionLoading /> }),
|
||||
score: dynamic(LOADERS.score, { loading: () => <SectionLoading /> }),
|
||||
thresholds: dynamic(LOADERS.thresholds, {
|
||||
loading: () => <SectionLoading />,
|
||||
}),
|
||||
api: dynamic(LOADERS.api, { loading: () => <SectionLoading /> }),
|
||||
mcp: dynamic(LOADERS.mcp, { loading: () => <SectionLoading /> }),
|
||||
gesundheitsakte: dynamic(LOADERS.gesundheitsakte, {
|
||||
loading: () => <SectionLoading />,
|
||||
}),
|
||||
export: dynamic(LOADERS.export, { loading: () => <SectionLoading /> }),
|
||||
advanced: dynamic(LOADERS.advanced, { loading: () => <SectionLoading /> }),
|
||||
privacy: dynamic(LOADERS.privacy, { loading: () => <SectionLoading /> }),
|
||||
};
|
||||
|
||||
/**
|
||||
* The section mounts only once the record gate has the account
|
||||
* (`RecordSettingsSectionGate`), so without a head start its chunk would be
|
||||
* fetched after that and the reader would see two loading states in a row.
|
||||
* This starts the fetch at hydration, beside the gate's own read; the module
|
||||
* cache makes the later lazy render of the same file free.
|
||||
*/
|
||||
export function SettingsSectionPreload({
|
||||
section,
|
||||
}: {
|
||||
section: SettingsSectionSlug;
|
||||
}) {
|
||||
useEffect(() => {
|
||||
void LOADERS[section]().catch(() => {
|
||||
// The section's own lazy import retries and reports; a failed head
|
||||
// start changes nothing.
|
||||
});
|
||||
}, [section]);
|
||||
return null;
|
||||
}
|
||||
|
||||
export function SettingsSectionLoader({
|
||||
section,
|
||||
callbackUrls,
|
||||
}: {
|
||||
section: SettingsSectionSlug;
|
||||
callbackUrls?: IntegrationCallbackUrls;
|
||||
}) {
|
||||
const Section = SECTIONS[section];
|
||||
return <Section callbackUrls={callbackUrls} />;
|
||||
}
|
||||
@@ -37,7 +37,7 @@ import {
|
||||
settleRefusedRecordSessionTransition,
|
||||
} from "@/lib/query-keys/record-session-transition";
|
||||
import type { AccountAccess } from "@/lib/sharing/account-access-view";
|
||||
import { parseAccountAccess } from "@/lib/sharing/account-access-schema";
|
||||
import { parseAccountAccess } from "@/lib/sharing/account-access-parse";
|
||||
|
||||
/**
|
||||
* v1.36.0 — an account with no sharing at all. The shape `fetchMe` falls back
|
||||
|
||||
@@ -0,0 +1,129 @@
|
||||
/**
|
||||
* What the admin card shows about the measurement table maintenance: the
|
||||
* newest run and where it stands, whether the compaction-tombstone purge is
|
||||
* still at work (the run refuses to start while it is), and the table's size
|
||||
* right now.
|
||||
*
|
||||
* Read straight from `pgboss.job`, the same source `job-failures.ts` reads,
|
||||
* and fail-soft the same way: a web-only deployment has no `pgboss` schema,
|
||||
* and the card says "no background worker" instead of failing.
|
||||
*/
|
||||
import { prisma } from "@/lib/db";
|
||||
import { MEASUREMENT_MAINTENANCE_QUEUE } from "@/lib/jobs/measurement-maintenance";
|
||||
|
||||
const PURGE_QUEUE = "compaction-tombstone-purge";
|
||||
|
||||
/** Where the newest run stands, in the words the card uses. */
|
||||
export type MaintenanceRunState = "queued" | "running" | "completed" | "failed";
|
||||
|
||||
/**
|
||||
* How a finished run ended. `completed` did the work; the two refusals
|
||||
* finished without touching the table.
|
||||
*/
|
||||
export type MaintenanceRunOutcome =
|
||||
"completed" | "refused_purge_running" | "already_running";
|
||||
|
||||
export interface MeasurementMaintenanceStatus {
|
||||
/** False when there is no background queue to ask (web-only deployment). */
|
||||
available: boolean;
|
||||
/** The purge is queued or running; a new run would refuse to start. */
|
||||
purgePending: boolean;
|
||||
run: {
|
||||
state: MaintenanceRunState;
|
||||
requestedAt: string;
|
||||
startedAt: string | null;
|
||||
finishedAt: string | null;
|
||||
/** Only for `completed`: what the finished run actually did. */
|
||||
outcome: MaintenanceRunOutcome | null;
|
||||
} | null;
|
||||
/** `measurements` with its indexes and TOAST, and its indexes alone. */
|
||||
sizes: { tableBytes: number; indexBytes: number } | null;
|
||||
}
|
||||
|
||||
const OUTCOMES: ReadonlySet<string> = new Set([
|
||||
"completed",
|
||||
"refused_purge_running",
|
||||
"already_running",
|
||||
]);
|
||||
|
||||
/** pg-boss's row state → the card's run state. */
|
||||
export function runStateOf(state: string): MaintenanceRunState {
|
||||
if (state === "created" || state === "retry") return "queued";
|
||||
if (state === "active") return "running";
|
||||
if (state === "completed") return "completed";
|
||||
return "failed";
|
||||
}
|
||||
|
||||
async function readSizes(): Promise<MeasurementMaintenanceStatus["sizes"]> {
|
||||
try {
|
||||
const rows = await prisma.$queryRaw<
|
||||
Array<{ table_bytes: bigint; index_bytes: bigint }>
|
||||
>`
|
||||
SELECT pg_total_relation_size('measurements'::regclass) AS table_bytes,
|
||||
pg_indexes_size('measurements'::regclass) AS index_bytes
|
||||
`;
|
||||
const row = rows[0];
|
||||
if (!row) return null;
|
||||
return {
|
||||
tableBytes: Number(row.table_bytes),
|
||||
indexBytes: Number(row.index_bytes),
|
||||
};
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export async function readMeasurementMaintenanceStatus(): Promise<MeasurementMaintenanceStatus> {
|
||||
const sizes = await readSizes();
|
||||
try {
|
||||
const [runs, purge] = await Promise.all([
|
||||
prisma.$queryRaw<
|
||||
Array<{
|
||||
state: string;
|
||||
created_on: Date;
|
||||
started_on: Date | null;
|
||||
completed_on: Date | null;
|
||||
outcome: string | null;
|
||||
}>
|
||||
>`
|
||||
SELECT state, created_on, started_on, completed_on,
|
||||
output->'did'->>'outcome' AS outcome
|
||||
FROM pgboss.job
|
||||
WHERE name = ${MEASUREMENT_MAINTENANCE_QUEUE}
|
||||
ORDER BY created_on DESC
|
||||
LIMIT 1
|
||||
`,
|
||||
prisma.$queryRaw<Array<{ n: number }>>`
|
||||
SELECT count(*)::int AS n
|
||||
FROM pgboss.job
|
||||
WHERE name = ${PURGE_QUEUE}
|
||||
AND state IN ('created', 'retry', 'active')
|
||||
`,
|
||||
]);
|
||||
const row = runs[0];
|
||||
const state = row ? runStateOf(row.state) : null;
|
||||
return {
|
||||
available: true,
|
||||
purgePending: (purge[0]?.n ?? 0) > 0,
|
||||
run:
|
||||
row && state
|
||||
? {
|
||||
state,
|
||||
requestedAt: row.created_on.toISOString(),
|
||||
startedAt: row.started_on?.toISOString() ?? null,
|
||||
finishedAt: row.completed_on?.toISOString() ?? null,
|
||||
outcome:
|
||||
state === "completed" &&
|
||||
row.outcome !== null &&
|
||||
OUTCOMES.has(row.outcome)
|
||||
? (row.outcome as MaintenanceRunOutcome)
|
||||
: null,
|
||||
}
|
||||
: null,
|
||||
sizes,
|
||||
};
|
||||
} catch {
|
||||
// No `pgboss` schema (web-only deployment) or no permission to read it.
|
||||
return { available: false, purgePending: false, run: null, sizes };
|
||||
}
|
||||
}
|
||||
@@ -66,7 +66,7 @@ import {
|
||||
resolveGlucoseUnit,
|
||||
type GlucoseUnit,
|
||||
} from "@/lib/glucose";
|
||||
import { getUnitForType } from "@/lib/validations/measurement";
|
||||
import { getUnitForType } from "@/lib/measurements/unit-map";
|
||||
|
||||
export type UnitPreference = "metric" | "imperial";
|
||||
|
||||
|
||||
@@ -0,0 +1,163 @@
|
||||
/**
|
||||
* The canonical storage unit of every measurement type.
|
||||
*
|
||||
* Its own module, with no imports, because both ends read it: the write
|
||||
* validation in `validations/measurement.ts` and the client's display
|
||||
* transform (`display-transform.ts`), which runs on nearly every chart and
|
||||
* tile. Living inside the validation module it dragged Zod into each of those
|
||||
* routes for one table lookup. `validations/measurement.ts` re-exports
|
||||
* `getUnitForType`, so server callers keep their import.
|
||||
*/
|
||||
|
||||
const unitMap: Record<string, string> = {
|
||||
WEIGHT: "kg",
|
||||
BLOOD_PRESSURE_SYS: "mmHg",
|
||||
BLOOD_PRESSURE_DIA: "mmHg",
|
||||
PULSE: "bpm",
|
||||
BODY_FAT: "%",
|
||||
// v1.4.23 — sleep duration shifted from hours to minutes so HealthKit
|
||||
// category-sample stages can be stored without precision loss. Older
|
||||
// surfaces that need hours convert at read time (`minutes / 60`).
|
||||
SLEEP_DURATION: "minutes",
|
||||
ACTIVITY_STEPS: "steps",
|
||||
BLOOD_GLUCOSE: "mg/dL",
|
||||
TOTAL_BODY_WATER: "kg",
|
||||
BONE_MASS: "kg",
|
||||
OXYGEN_SATURATION: "%",
|
||||
// ── v1.4.23 Apple Health canonical units ──
|
||||
HEART_RATE_VARIABILITY: "ms",
|
||||
RESTING_HEART_RATE: "bpm",
|
||||
ACTIVE_ENERGY_BURNED: "kcal",
|
||||
FLIGHTS_CLIMBED: "flights",
|
||||
WALKING_RUNNING_DISTANCE: "m",
|
||||
VO2_MAX: "mL/(kg·min)",
|
||||
BODY_TEMPERATURE: "celsius",
|
||||
// ── v1.4.25 W5d Withings full coverage ──
|
||||
FAT_FREE_MASS: "kg",
|
||||
FAT_MASS: "kg",
|
||||
MUSCLE_MASS: "kg",
|
||||
// Distinct from BODY_TEMPERATURE — surface temps run ~32 °C; sharing
|
||||
// the bucket would corrupt analytics. Same canonical unit (°C).
|
||||
SKIN_TEMPERATURE: "celsius",
|
||||
PULSE_WAVE_VELOCITY: "m/s",
|
||||
VASCULAR_AGE: "years",
|
||||
// Withings reports visceral fat as a 1–12 rating, not a percent. The
|
||||
// string mirrors what Withings prints in Health Mate.
|
||||
VISCERAL_FAT: "rating",
|
||||
// ── v1.4.25 W8d Apple Health server-prep ──
|
||||
// Sound-pressure level — A-weighted decibels (dBA). HealthKit reports
|
||||
// both audio-exposure metrics in dBASPL; we store the unweighted "dBA"
|
||||
// label because the A-weighting is implicit (every HealthKit audio
|
||||
// sample carries it). 30 dBA = quiet bedroom; 140 dBA = pain threshold.
|
||||
AUDIO_EXPOSURE_ENV: "dBA",
|
||||
AUDIO_EXPOSURE_HEADPHONE: "dBA",
|
||||
// Daily-rollup pattern (one sample = one day's outdoor-light minutes).
|
||||
// 0–1440 covers the 24-hour day; in practice indoor users sit near 0
|
||||
// and outdoor athletes accumulate a few hours.
|
||||
TIME_IN_DAYLIGHT: "minutes",
|
||||
// ── v1.5.5 iOS-coord additions ──
|
||||
// Respiratory rate breaths-per-minute — the HK identifier ships as
|
||||
// `count/min`; we keep the more conventional clinical label.
|
||||
RESPIRATORY_RATE: "breaths/min",
|
||||
// BMI kg/m² — HealthKit ships the unitless ratio; the canonical
|
||||
// display string mirrors clinical convention.
|
||||
BODY_MASS_INDEX: "kg/m²",
|
||||
// Lean body mass kg — body-composition partner to FAT_MASS.
|
||||
LEAN_BODY_MASS: "kg",
|
||||
// Walking heart rate average bpm — daily rollup; distinct from
|
||||
// RESTING_HEART_RATE (sleep-window minimum) and spot PULSE.
|
||||
WALKING_HEART_RATE_AVERAGE: "bpm",
|
||||
// Walking gait percent (0-100 after server-side ×100 scaling).
|
||||
// Same convention as WALKING_STEADINESS / BODY_FAT / OXYGEN_SATURATION
|
||||
// — see the project convention block in `apple-health-mapping.ts`.
|
||||
WALKING_ASYMMETRY: "%",
|
||||
WALKING_DOUBLE_SUPPORT: "%",
|
||||
// ── v1.5.5 iOS-coord follow-up — raw-SI gait pair ──
|
||||
// Step length is metres; speed is metres per second. Both flow
|
||||
// raw on the wire — no server-side scaling. The unit strings
|
||||
// match HealthKit's `m` / `m/s` defaults.
|
||||
WALKING_STEP_LENGTH: "m",
|
||||
WALKING_SPEED: "m/s",
|
||||
// ── v1.10.0 — additive HealthKit signals (WX-A) ──
|
||||
// Cardio recovery is the bpm drop one minute after peak exercise.
|
||||
CARDIO_RECOVERY: "bpm",
|
||||
// Overnight wrist temperature in °C (absolute reading; Apple's own
|
||||
// display frames it as a baseline deviation, we store the reading).
|
||||
WRIST_TEMPERATURE: "celsius",
|
||||
// Hard-fall detections — a plain count.
|
||||
FALL_COUNT: "count",
|
||||
// Apple's estimated six-minute-walk-test distance in metres.
|
||||
SIX_MINUTE_WALK_DISTANCE: "m",
|
||||
// Stair gait speeds — raw metres-per-second (no scaling).
|
||||
STAIR_ASCENT_SPEED: "m/s",
|
||||
STAIR_DESCENT_SPEED: "m/s",
|
||||
// Per-night breathing-disturbance index — a unitless count Apple
|
||||
// classifies as NotElevated / Elevated.
|
||||
BREATHING_DISTURBANCES: "count",
|
||||
// ── v1.10.0 — categorical events (WX-B) ──
|
||||
// EVENT rows are dimensionless occurrences (value is always 1). The
|
||||
// canonical unit is the bare "event" so any accidental numeric surfacing
|
||||
// reads sensibly; the awareness timeline never displays the value.
|
||||
IRREGULAR_RHYTHM_NOTIFICATION: "event",
|
||||
HIGH_HEART_RATE_EVENT: "event",
|
||||
LOW_HEART_RATE_EVENT: "event",
|
||||
WALKING_STEADINESS_EVENT: "event",
|
||||
BREATHING_DISTURBANCE_EVENT: "event",
|
||||
// ── v1.10.0 — computed scores (WX-C) ──
|
||||
// Server-derived 0–100 wellness scores. The canonical unit is the bare
|
||||
// "score" so the value reads sensibly anywhere it surfaces.
|
||||
RECOVERY_SCORE: "score",
|
||||
STRESS_SCORE: "score",
|
||||
STRAIN_SCORE: "score",
|
||||
// ── v1.11.0 — WHOOP-native score classes ──
|
||||
// RMSSD HRV is in milliseconds, same canonical unit as the SDNN
|
||||
// HEART_RATE_VARIABILITY (different estimator, same dimension).
|
||||
HRV_RMSSD: "ms",
|
||||
// Day / workout strain ride WHOOP's bounded 0–21 scale; the bare "score"
|
||||
// unit reads sensibly wherever the value surfaces (distinct from the
|
||||
// 0–100 COMPUTED STRAIN_SCORE).
|
||||
DAY_STRAIN: "score",
|
||||
WORKOUT_STRAIN: "score",
|
||||
// Sleep quality percentages (0–100).
|
||||
SLEEP_PERFORMANCE: "%",
|
||||
SLEEP_EFFICIENCY: "%",
|
||||
SLEEP_CONSISTENCY: "%",
|
||||
// Recommended sleep duration in minutes (WHOOP reports ms; mapper ÷60000).
|
||||
SLEEP_NEED: "minutes",
|
||||
// Day energy expenditure in kilojoules (WHOOP-native; kept in kJ so the
|
||||
// device value round-trips rather than being converted to kcal).
|
||||
ENERGY_EXPENDITURE_KJ: "kJ",
|
||||
// ── v1.12.8 — WHOOP cycle + sleep coverage completion ──
|
||||
// Daily average / max heart rate bpm — whole-cycle aggregates, distinct
|
||||
// from the spot PULSE / RESTING_HEART_RATE / WALKING_HEART_RATE_AVERAGE.
|
||||
AVERAGE_HEART_RATE: "bpm",
|
||||
MAX_HEART_RATE: "bpm",
|
||||
// Per-night sleep disturbance tally — a plain integer count.
|
||||
SLEEP_DISTURBANCE_COUNT: "count",
|
||||
// ── v1.17.1 — Polar Nightly Recharge + Training Load Pro components ──
|
||||
ANS_CHARGE: "score",
|
||||
CARDIO_LOAD: "score",
|
||||
// ── v1.17.1 — Oura coverage completion ──
|
||||
// Oura's headline 0–100 Sleep Score — bare "score" like the other 0–100 scores.
|
||||
SLEEP_SCORE: "score",
|
||||
// Signed body-temperature deviation in °C (Oura nightly baseline offset).
|
||||
BODY_TEMPERATURE_DEVIATION: "celsius",
|
||||
// ── v1.19.0 — Oura resilience ──
|
||||
// Ordinal level scale (1=limited … 5=exceptional) — the categorical band
|
||||
// encoded into the numeric value. See RESILIENCE_LEVELS in src/lib/oura/client.
|
||||
RESILIENCE: "level",
|
||||
// ── v1.25 — clinical-signals wave ──
|
||||
PHQ9_SCORE: "score",
|
||||
GAD7_SCORE: "score",
|
||||
// ── v1.27.9 — screening scores ──
|
||||
WHO5_SCORE: "score",
|
||||
SCI_SCORE: "score",
|
||||
GRIP_STRENGTH: "kg",
|
||||
PAIN_NRS: "score",
|
||||
WAIST_CIRCUMFERENCE: "cm",
|
||||
WAIST_TO_HEIGHT: "ratio",
|
||||
};
|
||||
|
||||
export function getUnitForType(type: string): string {
|
||||
return unitMap[type] ?? "unknown";
|
||||
}
|
||||
@@ -275,8 +275,69 @@ const adminMeasurementMaintenanceResult = z
|
||||
})
|
||||
.meta({ id: "AdminMeasurementMaintenanceResult" });
|
||||
|
||||
const adminMeasurementMaintenanceStatus = z
|
||||
.object({
|
||||
available: z
|
||||
.boolean()
|
||||
.describe(
|
||||
"False when there is no background queue to ask (a web-only deployment); nothing can be started then.",
|
||||
),
|
||||
purgePending: z
|
||||
.boolean()
|
||||
.describe(
|
||||
"The compaction-tombstone purge is queued or running. A run started now finishes at once with `refused_purge_running`.",
|
||||
),
|
||||
run: z
|
||||
.object({
|
||||
state: z.enum(["queued", "running", "completed", "failed"]),
|
||||
requestedAt: z.iso.datetime(),
|
||||
startedAt: z.iso.datetime().nullable(),
|
||||
finishedAt: z.iso.datetime().nullable(),
|
||||
outcome: z
|
||||
.enum(["completed", "refused_purge_running", "already_running"])
|
||||
.nullable()
|
||||
.describe(
|
||||
"How a `completed` run ended: `completed` did the work, the two refusals finished without touching the table. Null for any other state.",
|
||||
),
|
||||
})
|
||||
.nullable()
|
||||
.describe(
|
||||
"The newest run pg-boss still keeps (terminal rows live seven days), or null when there is none.",
|
||||
),
|
||||
sizes: z
|
||||
.object({
|
||||
tableBytes: z
|
||||
.number()
|
||||
.int()
|
||||
.describe("`measurements` with its indexes and TOAST."),
|
||||
indexBytes: z.number().int().describe("Its indexes alone."),
|
||||
})
|
||||
.nullable(),
|
||||
})
|
||||
.meta({ id: "AdminMeasurementMaintenanceStatus" });
|
||||
|
||||
export const adminDiagnosticPaths: NonNullable<ZodOpenApiObject["paths"]> = {
|
||||
"/api/admin/maintenance/measurements": {
|
||||
get: {
|
||||
tags: ["Admin"],
|
||||
summary: "Where measurement table maintenance stands",
|
||||
description:
|
||||
"The newest maintenance run and its state, whether the compaction-tombstone purge is still at work, and the table's size right now. The admin card polls it while a run is queued or running. Cookie auth only: `requireAdmin()` refuses every Bearer caller.",
|
||||
responses: {
|
||||
"200": {
|
||||
description: "The status.",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: dataEnvelope(
|
||||
adminMeasurementMaintenanceStatus,
|
||||
"AdminMeasurementMaintenanceStatusResponse",
|
||||
),
|
||||
},
|
||||
},
|
||||
},
|
||||
...stdResponses,
|
||||
},
|
||||
},
|
||||
post: {
|
||||
tags: ["Admin"],
|
||||
summary: "Queue measurement table maintenance",
|
||||
|
||||
@@ -48,6 +48,8 @@ export const adminKeys = {
|
||||
adminBackupRestores: () => ["admin", "backup-restores"] as const,
|
||||
/** v1.23 — encryption coverage + rotation progress (admin Encryption section). */
|
||||
adminEncryptionStatus: () => ["admin", "encryption", "status"] as const,
|
||||
adminMeasurementMaintenance: () =>
|
||||
["admin", "maintenance", "measurements"] as const,
|
||||
/** v1.40.0 — the "Back up your encryption key" step (admin + dashboard banner). */
|
||||
adminEncryptionKeyBackup: () =>
|
||||
["admin", "encryption", "key-backup"] as const,
|
||||
|
||||
+110
-1
@@ -1,6 +1,10 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { parseAccountAccess } from "../account-access-schema";
|
||||
import {
|
||||
parseAccountAccess,
|
||||
validateAccountAccessBlock,
|
||||
} from "../account-access-parse";
|
||||
import { accountAccessBlockSchema } from "../account-access-schema";
|
||||
|
||||
const sharedEntry = {
|
||||
accountId: "record-a",
|
||||
@@ -220,3 +224,108 @@ describe("parseAccountAccess", () => {
|
||||
).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* The browser parse carries no Zod (it rides the shell every route shares),
|
||||
* so it is a second statement of the published schema. This holds the two to
|
||||
* one verdict: over every fixture below, and every single-field breakage of
|
||||
* each, the hand-written check accepts exactly what the schema accepts and
|
||||
* returns the same stripped value. Change one without the other and this
|
||||
* fails with the input that tells them apart.
|
||||
*/
|
||||
describe("validateAccountAccessBlock agrees with the published schema", () => {
|
||||
const BAD_VALUES: unknown[] = [
|
||||
undefined,
|
||||
null,
|
||||
0,
|
||||
1,
|
||||
true,
|
||||
"",
|
||||
"bogus",
|
||||
[],
|
||||
["labs"],
|
||||
["labs", "labs"],
|
||||
["nope"],
|
||||
{},
|
||||
];
|
||||
|
||||
const managerEntry = {
|
||||
...sharedEntry,
|
||||
accountId: "record-m",
|
||||
username: "record-m",
|
||||
access: "write",
|
||||
level: "manage",
|
||||
recordKind: "managed",
|
||||
sections: null,
|
||||
canWrite: true,
|
||||
writableDomains: ["measurements", "mind"],
|
||||
manageableDomains: ["measurements"],
|
||||
};
|
||||
const writerEntry = {
|
||||
...sharedEntry,
|
||||
accountId: "record-w",
|
||||
username: "record-w",
|
||||
access: "write",
|
||||
level: "write",
|
||||
sections: ["labs", "measurements"],
|
||||
canWrite: true,
|
||||
writableDomains: ["measurements"],
|
||||
};
|
||||
|
||||
const bases: Record<string, unknown>[] = [
|
||||
accessBlock(),
|
||||
{ accounts: [], active: null, recordKind: "self", canSwitch: false },
|
||||
{
|
||||
accounts: [managerEntry, writerEntry],
|
||||
active: null,
|
||||
recordKind: "self",
|
||||
canSwitch: true,
|
||||
},
|
||||
{
|
||||
accounts: [writerEntry],
|
||||
active: { ...writerEntry, extra: "stripped" },
|
||||
recordKind: "shared",
|
||||
canSwitch: true,
|
||||
},
|
||||
];
|
||||
|
||||
function variants(): unknown[] {
|
||||
const out: unknown[] = [...bases, null, "x", [], 42];
|
||||
for (const base of bases) {
|
||||
for (const key of Object.keys(base)) {
|
||||
for (const bad of BAD_VALUES) out.push({ ...base, [key]: bad });
|
||||
}
|
||||
const accounts = base.accounts as Record<string, unknown>[];
|
||||
accounts.forEach((entry, index) => {
|
||||
for (const key of [...Object.keys(entry), "unknownKey"]) {
|
||||
for (const bad of BAD_VALUES) {
|
||||
const broken = { ...entry, [key]: bad };
|
||||
const next = [...accounts];
|
||||
next[index] = broken;
|
||||
out.push({ ...base, accounts: next });
|
||||
if (base.active) out.push({ ...base, active: broken });
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
it("accepts and rejects the same blocks, with the same output", () => {
|
||||
const corpus = variants();
|
||||
expect(corpus.length).toBeGreaterThan(500);
|
||||
let accepted = 0;
|
||||
for (const input of corpus) {
|
||||
const zod = accountAccessBlockSchema.safeParse(input);
|
||||
const mine = validateAccountAccessBlock(input);
|
||||
expect(mine !== null, JSON.stringify(input)).toBe(zod.success);
|
||||
if (zod.success) {
|
||||
expect(mine, JSON.stringify(input)).toEqual(zod.data);
|
||||
accepted += 1;
|
||||
}
|
||||
}
|
||||
// Both arms exercised: the corpus is not all-reject or all-accept.
|
||||
expect(accepted).toBeGreaterThan(10);
|
||||
expect(accepted).toBeLessThan(corpus.length - 100);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,212 @@
|
||||
/**
|
||||
* The browser's parse of the account-access block on `GET /api/auth/me`.
|
||||
*
|
||||
* Plain TypeScript, no Zod, on purpose. `use-auth.ts` runs this parse on every
|
||||
* app boot, so whatever it imports lands in the shell every route shares, and
|
||||
* this parse was the shell's only Zod import: about 60 KB gzip on every first
|
||||
* paint for one structural check. A tree-shaken `zod/mini` build measured
|
||||
* almost the same under Turbopack, because the shared core comes along either
|
||||
* way.
|
||||
*
|
||||
* The Zod schema stays the contract: `account-access-schema.ts` publishes it
|
||||
* to OpenAPI, and `account-access-parse.test.ts` runs both over the same
|
||||
* corpus of good and broken blocks and fails the moment they disagree on
|
||||
* whether a block is accepted or on what comes out of it.
|
||||
*/
|
||||
import type {
|
||||
AccountAccess,
|
||||
AccountAccessEntry,
|
||||
} from "@/lib/sharing/account-access-view";
|
||||
import { SHARE_DOMAINS, type ShareDomain } from "@/lib/sharing/scope";
|
||||
|
||||
const DOMAINS: ReadonlySet<string> = new Set(SHARE_DOMAINS);
|
||||
|
||||
type Raw = Record<string, unknown>;
|
||||
|
||||
function isRecord(value: unknown): value is Raw {
|
||||
return typeof value === "object" && value !== null && !Array.isArray(value);
|
||||
}
|
||||
|
||||
function isNonEmptyString(value: unknown): value is string {
|
||||
return typeof value === "string" && value.length >= 1;
|
||||
}
|
||||
|
||||
function isNullableString(value: unknown): value is string | null {
|
||||
return value === null || typeof value === "string";
|
||||
}
|
||||
|
||||
function domainList(value: unknown): ShareDomain[] | null {
|
||||
if (!Array.isArray(value)) return null;
|
||||
for (const domain of value) {
|
||||
if (typeof domain !== "string" || !DOMAINS.has(domain)) return null;
|
||||
}
|
||||
return [...(value as ShareDomain[])];
|
||||
}
|
||||
|
||||
function hasRepeat(domains: readonly string[]): boolean {
|
||||
return new Set(domains).size !== domains.length;
|
||||
}
|
||||
|
||||
function isSubset(
|
||||
inner: readonly string[],
|
||||
outer: readonly string[] | null,
|
||||
): boolean {
|
||||
if (outer === null) return true;
|
||||
const set = new Set(outer);
|
||||
return inner.every((domain) => set.has(domain));
|
||||
}
|
||||
|
||||
/**
|
||||
* One entry, checked field by field and copied out with only the fields the
|
||||
* contract names (unknown keys are dropped, as the schema strips them), or
|
||||
* null. The invariants after the field checks are the schema's refinements.
|
||||
*/
|
||||
function parseEntry(value: unknown): AccountAccessEntry | null {
|
||||
if (!isRecord(value)) return null;
|
||||
const {
|
||||
accountId,
|
||||
username,
|
||||
displayName,
|
||||
fullName,
|
||||
access,
|
||||
level,
|
||||
recordKind,
|
||||
sections: rawSections,
|
||||
canWrite,
|
||||
writableDomains: rawWritable,
|
||||
manageableDomains: rawManageable,
|
||||
} = value;
|
||||
if (!isNonEmptyString(accountId) || !isNonEmptyString(username)) return null;
|
||||
if (!isNullableString(displayName) || !isNullableString(fullName)) {
|
||||
return null;
|
||||
}
|
||||
if (access !== "read" && access !== "write") return null;
|
||||
if (level !== "read" && level !== "write" && level !== "manage") return null;
|
||||
if (recordKind !== "shared" && recordKind !== "managed") return null;
|
||||
const sections = rawSections === null ? null : domainList(rawSections);
|
||||
if (rawSections !== null && sections === null) return null;
|
||||
if (typeof canWrite !== "boolean") return null;
|
||||
const writableDomains = domainList(rawWritable);
|
||||
const manageableDomains = domainList(rawManageable);
|
||||
if (writableDomains === null || manageableDomains === null) return null;
|
||||
|
||||
const isManage = level === "manage";
|
||||
if (sections !== null && hasRepeat(sections)) return null;
|
||||
if (isManage && (access !== "write" || sections !== null)) return null;
|
||||
if (canWrite !== (level !== "read")) return null;
|
||||
if (!isManage && access !== level) return null;
|
||||
if (hasRepeat(writableDomains) || hasRepeat(manageableDomains)) return null;
|
||||
if (
|
||||
!isSubset(writableDomains, sections) ||
|
||||
!isSubset(manageableDomains, sections)
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
if (!isSubset(manageableDomains, writableDomains)) return null;
|
||||
if (level === "read" && writableDomains.length > 0) return null;
|
||||
if (!isManage && manageableDomains.length > 0) return null;
|
||||
|
||||
return {
|
||||
accountId,
|
||||
username,
|
||||
displayName,
|
||||
fullName,
|
||||
access,
|
||||
level,
|
||||
recordKind,
|
||||
sections,
|
||||
canWrite,
|
||||
writableDomains,
|
||||
manageableDomains,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* The block's structure, without the cross-entry checks: what the schema
|
||||
* accepts, and the same stripped value it returns. Exported for the
|
||||
* equivalence test; the app calls {@link parseAccountAccess}.
|
||||
*/
|
||||
export function validateAccountAccessBlock(
|
||||
value: unknown,
|
||||
): AccountAccess | null {
|
||||
if (!isRecord(value)) return null;
|
||||
const {
|
||||
accounts: rawAccounts,
|
||||
active: rawActive,
|
||||
recordKind,
|
||||
canSwitch,
|
||||
} = value;
|
||||
if (!Array.isArray(rawAccounts)) return null;
|
||||
const accounts: AccountAccessEntry[] = [];
|
||||
for (const raw of rawAccounts) {
|
||||
const entry = parseEntry(raw);
|
||||
if (!entry) return null;
|
||||
accounts.push(entry);
|
||||
}
|
||||
const active = rawActive === null ? null : parseEntry(rawActive);
|
||||
if (rawActive !== null && active === null) return null;
|
||||
if (
|
||||
recordKind !== "self" &&
|
||||
recordKind !== "shared" &&
|
||||
recordKind !== "managed"
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
if (typeof canSwitch !== "boolean") return null;
|
||||
return { accounts, active, recordKind, canSwitch };
|
||||
}
|
||||
|
||||
/** Both published views of the active record must name the same resolved grant. */
|
||||
function sameEntry(a: AccountAccessEntry, b: AccountAccessEntry): boolean {
|
||||
const sameList = (x: readonly string[], y: readonly string[]) =>
|
||||
x.length === y.length && x.every((domain, index) => domain === y[index]);
|
||||
const sameSections =
|
||||
a.sections === null || b.sections === null
|
||||
? a.sections === b.sections
|
||||
: sameList(a.sections, b.sections);
|
||||
return (
|
||||
sameList(a.writableDomains, b.writableDomains) &&
|
||||
sameList(a.manageableDomains, b.manageableDomains) &&
|
||||
a.accountId === b.accountId &&
|
||||
a.username === b.username &&
|
||||
a.displayName === b.displayName &&
|
||||
a.fullName === b.fullName &&
|
||||
a.access === b.access &&
|
||||
a.level === b.level &&
|
||||
a.recordKind === b.recordKind &&
|
||||
a.canWrite === b.canWrite &&
|
||||
sameSections
|
||||
);
|
||||
}
|
||||
|
||||
/** Parse the server-resolved access block as a closed presentation contract. */
|
||||
export function parseAccountAccess(value: unknown): AccountAccess | null {
|
||||
const block = validateAccountAccessBlock(value);
|
||||
if (!block) return null;
|
||||
|
||||
const { accounts, active, recordKind, canSwitch } = block;
|
||||
const accountIds = new Set(accounts.map((entry) => entry.accountId));
|
||||
if (
|
||||
accountIds.size !== accounts.length ||
|
||||
canSwitch !== accounts.length > 0
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (active === null) {
|
||||
return recordKind === "self"
|
||||
? { accounts, active: null, recordKind, canSwitch }
|
||||
: null;
|
||||
}
|
||||
|
||||
if (!accountIds.has(active.accountId) || recordKind !== active.recordKind) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const canonicalActive = accounts.find(
|
||||
(entry) => entry.accountId === active.accountId,
|
||||
);
|
||||
if (!canonicalActive || !sameEntry(active, canonicalActive)) return null;
|
||||
|
||||
return { accounts, active: canonicalActive, recordKind, canSwitch };
|
||||
}
|
||||
@@ -1,6 +1,11 @@
|
||||
/**
|
||||
* The account-access block's contract as a Zod schema, for the OpenAPI
|
||||
* document. The browser does not import it: its parse is the Zod-free
|
||||
* `account-access-parse.ts`, kept out of the shell every route shares, and
|
||||
* `account-access-parse.test.ts` holds the two to the same verdicts.
|
||||
*/
|
||||
import { z } from "zod/v4";
|
||||
|
||||
import type { AccountAccess } from "./account-access-view";
|
||||
import { SHARE_DOMAINS } from "./scope";
|
||||
|
||||
const accountAccessLevelSchema = z.enum(["read", "write", "manage"]);
|
||||
@@ -21,7 +26,7 @@ function isSubset(
|
||||
return inner.every((domain) => set.has(domain));
|
||||
}
|
||||
|
||||
/** The browser's resolved account-access payload, shared with OpenAPI. */
|
||||
/** One account-access entry, as OpenAPI publishes it. */
|
||||
export const accountAccessEntrySchema = z
|
||||
.object({
|
||||
accountId: z.string().min(1),
|
||||
@@ -125,73 +130,3 @@ export const accountAccessBlockSchema = z.object({
|
||||
recordKind: z.enum(["self", "shared", "managed"]),
|
||||
canSwitch: z.boolean(),
|
||||
});
|
||||
|
||||
/** Both published views of the active record must name the same resolved grant. */
|
||||
function matchesCanonicalAccountAccessEntry(
|
||||
active: z.infer<typeof accountAccessEntrySchema>,
|
||||
canonical: z.infer<typeof accountAccessEntrySchema>,
|
||||
): boolean {
|
||||
const activeSections = active.sections;
|
||||
const canonicalSections = canonical.sections;
|
||||
const sameSections =
|
||||
activeSections === null || canonicalSections === null
|
||||
? activeSections === canonicalSections
|
||||
: activeSections.length === canonicalSections.length &&
|
||||
activeSections.every(
|
||||
(section, index) => section === canonicalSections[index],
|
||||
);
|
||||
|
||||
const sameList = (a: readonly string[], b: readonly string[]) =>
|
||||
a.length === b.length && a.every((domain, index) => domain === b[index]);
|
||||
|
||||
return (
|
||||
sameList(active.writableDomains, canonical.writableDomains) &&
|
||||
sameList(active.manageableDomains, canonical.manageableDomains) &&
|
||||
active.accountId === canonical.accountId &&
|
||||
active.username === canonical.username &&
|
||||
active.displayName === canonical.displayName &&
|
||||
active.fullName === canonical.fullName &&
|
||||
active.access === canonical.access &&
|
||||
active.level === canonical.level &&
|
||||
active.recordKind === canonical.recordKind &&
|
||||
active.canWrite === canonical.canWrite &&
|
||||
sameSections
|
||||
);
|
||||
}
|
||||
|
||||
/** Parse the server-resolved access block as a closed presentation contract. */
|
||||
export function parseAccountAccess(value: unknown): AccountAccess | null {
|
||||
const parsed = accountAccessBlockSchema.safeParse(value);
|
||||
if (!parsed.success) return null;
|
||||
|
||||
const { accounts, active, recordKind, canSwitch } = parsed.data;
|
||||
const accountIds = new Set(accounts.map((entry) => entry.accountId));
|
||||
if (
|
||||
accountIds.size !== accounts.length ||
|
||||
canSwitch !== accounts.length > 0
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (active === null) {
|
||||
return recordKind === "self"
|
||||
? { accounts, active: null, recordKind, canSwitch }
|
||||
: null;
|
||||
}
|
||||
|
||||
if (!accountIds.has(active.accountId) || recordKind !== active.recordKind) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const canonicalActive = accounts.find(
|
||||
(entry) => entry.accountId === active.accountId,
|
||||
);
|
||||
if (
|
||||
!canonicalActive ||
|
||||
!matchesCanonicalAccountAccessEntry(active, canonicalActive)
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return { accounts, active: canonicalActive, recordKind, canSwitch };
|
||||
}
|
||||
@@ -298,158 +298,9 @@ export const USER_CORRECTABLE_MEASUREMENT_SOURCES = [
|
||||
EXTERNAL_SOURCE,
|
||||
] as const;
|
||||
|
||||
const unitMap: Record<string, string> = {
|
||||
WEIGHT: "kg",
|
||||
BLOOD_PRESSURE_SYS: "mmHg",
|
||||
BLOOD_PRESSURE_DIA: "mmHg",
|
||||
PULSE: "bpm",
|
||||
BODY_FAT: "%",
|
||||
// v1.4.23 — sleep duration shifted from hours to minutes so HealthKit
|
||||
// category-sample stages can be stored without precision loss. Older
|
||||
// surfaces that need hours convert at read time (`minutes / 60`).
|
||||
SLEEP_DURATION: "minutes",
|
||||
ACTIVITY_STEPS: "steps",
|
||||
BLOOD_GLUCOSE: "mg/dL",
|
||||
TOTAL_BODY_WATER: "kg",
|
||||
BONE_MASS: "kg",
|
||||
OXYGEN_SATURATION: "%",
|
||||
// ── v1.4.23 Apple Health canonical units ──
|
||||
HEART_RATE_VARIABILITY: "ms",
|
||||
RESTING_HEART_RATE: "bpm",
|
||||
ACTIVE_ENERGY_BURNED: "kcal",
|
||||
FLIGHTS_CLIMBED: "flights",
|
||||
WALKING_RUNNING_DISTANCE: "m",
|
||||
VO2_MAX: "mL/(kg·min)",
|
||||
BODY_TEMPERATURE: "celsius",
|
||||
// ── v1.4.25 W5d Withings full coverage ──
|
||||
FAT_FREE_MASS: "kg",
|
||||
FAT_MASS: "kg",
|
||||
MUSCLE_MASS: "kg",
|
||||
// Distinct from BODY_TEMPERATURE — surface temps run ~32 °C; sharing
|
||||
// the bucket would corrupt analytics. Same canonical unit (°C).
|
||||
SKIN_TEMPERATURE: "celsius",
|
||||
PULSE_WAVE_VELOCITY: "m/s",
|
||||
VASCULAR_AGE: "years",
|
||||
// Withings reports visceral fat as a 1–12 rating, not a percent. The
|
||||
// string mirrors what Withings prints in Health Mate.
|
||||
VISCERAL_FAT: "rating",
|
||||
// ── v1.4.25 W8d Apple Health server-prep ──
|
||||
// Sound-pressure level — A-weighted decibels (dBA). HealthKit reports
|
||||
// both audio-exposure metrics in dBASPL; we store the unweighted "dBA"
|
||||
// label because the A-weighting is implicit (every HealthKit audio
|
||||
// sample carries it). 30 dBA = quiet bedroom; 140 dBA = pain threshold.
|
||||
AUDIO_EXPOSURE_ENV: "dBA",
|
||||
AUDIO_EXPOSURE_HEADPHONE: "dBA",
|
||||
// Daily-rollup pattern (one sample = one day's outdoor-light minutes).
|
||||
// 0–1440 covers the 24-hour day; in practice indoor users sit near 0
|
||||
// and outdoor athletes accumulate a few hours.
|
||||
TIME_IN_DAYLIGHT: "minutes",
|
||||
// ── v1.5.5 iOS-coord additions ──
|
||||
// Respiratory rate breaths-per-minute — the HK identifier ships as
|
||||
// `count/min`; we keep the more conventional clinical label.
|
||||
RESPIRATORY_RATE: "breaths/min",
|
||||
// BMI kg/m² — HealthKit ships the unitless ratio; the canonical
|
||||
// display string mirrors clinical convention.
|
||||
BODY_MASS_INDEX: "kg/m²",
|
||||
// Lean body mass kg — body-composition partner to FAT_MASS.
|
||||
LEAN_BODY_MASS: "kg",
|
||||
// Walking heart rate average bpm — daily rollup; distinct from
|
||||
// RESTING_HEART_RATE (sleep-window minimum) and spot PULSE.
|
||||
WALKING_HEART_RATE_AVERAGE: "bpm",
|
||||
// Walking gait percent (0-100 after server-side ×100 scaling).
|
||||
// Same convention as WALKING_STEADINESS / BODY_FAT / OXYGEN_SATURATION
|
||||
// — see the project convention block in `apple-health-mapping.ts`.
|
||||
WALKING_ASYMMETRY: "%",
|
||||
WALKING_DOUBLE_SUPPORT: "%",
|
||||
// ── v1.5.5 iOS-coord follow-up — raw-SI gait pair ──
|
||||
// Step length is metres; speed is metres per second. Both flow
|
||||
// raw on the wire — no server-side scaling. The unit strings
|
||||
// match HealthKit's `m` / `m/s` defaults.
|
||||
WALKING_STEP_LENGTH: "m",
|
||||
WALKING_SPEED: "m/s",
|
||||
// ── v1.10.0 — additive HealthKit signals (WX-A) ──
|
||||
// Cardio recovery is the bpm drop one minute after peak exercise.
|
||||
CARDIO_RECOVERY: "bpm",
|
||||
// Overnight wrist temperature in °C (absolute reading; Apple's own
|
||||
// display frames it as a baseline deviation, we store the reading).
|
||||
WRIST_TEMPERATURE: "celsius",
|
||||
// Hard-fall detections — a plain count.
|
||||
FALL_COUNT: "count",
|
||||
// Apple's estimated six-minute-walk-test distance in metres.
|
||||
SIX_MINUTE_WALK_DISTANCE: "m",
|
||||
// Stair gait speeds — raw metres-per-second (no scaling).
|
||||
STAIR_ASCENT_SPEED: "m/s",
|
||||
STAIR_DESCENT_SPEED: "m/s",
|
||||
// Per-night breathing-disturbance index — a unitless count Apple
|
||||
// classifies as NotElevated / Elevated.
|
||||
BREATHING_DISTURBANCES: "count",
|
||||
// ── v1.10.0 — categorical events (WX-B) ──
|
||||
// EVENT rows are dimensionless occurrences (value is always 1). The
|
||||
// canonical unit is the bare "event" so any accidental numeric surfacing
|
||||
// reads sensibly; the awareness timeline never displays the value.
|
||||
IRREGULAR_RHYTHM_NOTIFICATION: "event",
|
||||
HIGH_HEART_RATE_EVENT: "event",
|
||||
LOW_HEART_RATE_EVENT: "event",
|
||||
WALKING_STEADINESS_EVENT: "event",
|
||||
BREATHING_DISTURBANCE_EVENT: "event",
|
||||
// ── v1.10.0 — computed scores (WX-C) ──
|
||||
// Server-derived 0–100 wellness scores. The canonical unit is the bare
|
||||
// "score" so the value reads sensibly anywhere it surfaces.
|
||||
RECOVERY_SCORE: "score",
|
||||
STRESS_SCORE: "score",
|
||||
STRAIN_SCORE: "score",
|
||||
// ── v1.11.0 — WHOOP-native score classes ──
|
||||
// RMSSD HRV is in milliseconds, same canonical unit as the SDNN
|
||||
// HEART_RATE_VARIABILITY (different estimator, same dimension).
|
||||
HRV_RMSSD: "ms",
|
||||
// Day / workout strain ride WHOOP's bounded 0–21 scale; the bare "score"
|
||||
// unit reads sensibly wherever the value surfaces (distinct from the
|
||||
// 0–100 COMPUTED STRAIN_SCORE).
|
||||
DAY_STRAIN: "score",
|
||||
WORKOUT_STRAIN: "score",
|
||||
// Sleep quality percentages (0–100).
|
||||
SLEEP_PERFORMANCE: "%",
|
||||
SLEEP_EFFICIENCY: "%",
|
||||
SLEEP_CONSISTENCY: "%",
|
||||
// Recommended sleep duration in minutes (WHOOP reports ms; mapper ÷60000).
|
||||
SLEEP_NEED: "minutes",
|
||||
// Day energy expenditure in kilojoules (WHOOP-native; kept in kJ so the
|
||||
// device value round-trips rather than being converted to kcal).
|
||||
ENERGY_EXPENDITURE_KJ: "kJ",
|
||||
// ── v1.12.8 — WHOOP cycle + sleep coverage completion ──
|
||||
// Daily average / max heart rate bpm — whole-cycle aggregates, distinct
|
||||
// from the spot PULSE / RESTING_HEART_RATE / WALKING_HEART_RATE_AVERAGE.
|
||||
AVERAGE_HEART_RATE: "bpm",
|
||||
MAX_HEART_RATE: "bpm",
|
||||
// Per-night sleep disturbance tally — a plain integer count.
|
||||
SLEEP_DISTURBANCE_COUNT: "count",
|
||||
// ── v1.17.1 — Polar Nightly Recharge + Training Load Pro components ──
|
||||
ANS_CHARGE: "score",
|
||||
CARDIO_LOAD: "score",
|
||||
// ── v1.17.1 — Oura coverage completion ──
|
||||
// Oura's headline 0–100 Sleep Score — bare "score" like the other 0–100 scores.
|
||||
SLEEP_SCORE: "score",
|
||||
// Signed body-temperature deviation in °C (Oura nightly baseline offset).
|
||||
BODY_TEMPERATURE_DEVIATION: "celsius",
|
||||
// ── v1.19.0 — Oura resilience ──
|
||||
// Ordinal level scale (1=limited … 5=exceptional) — the categorical band
|
||||
// encoded into the numeric value. See RESILIENCE_LEVELS in src/lib/oura/client.
|
||||
RESILIENCE: "level",
|
||||
// ── v1.25 — clinical-signals wave ──
|
||||
PHQ9_SCORE: "score",
|
||||
GAD7_SCORE: "score",
|
||||
// ── v1.27.9 — screening scores ──
|
||||
WHO5_SCORE: "score",
|
||||
SCI_SCORE: "score",
|
||||
GRIP_STRENGTH: "kg",
|
||||
PAIN_NRS: "score",
|
||||
WAIST_CIRCUMFERENCE: "cm",
|
||||
WAIST_TO_HEIGHT: "ratio",
|
||||
};
|
||||
|
||||
export function getUnitForType(type: string): string {
|
||||
return unitMap[type] ?? "unknown";
|
||||
}
|
||||
// The canonical unit per type lives in a Zod-free module so the client's
|
||||
// display transform can read it without pulling Zod into every chart route.
|
||||
export { getUnitForType } from "@/lib/measurements/unit-map";
|
||||
|
||||
// Plausible ranges per measurement type
|
||||
export const VALUE_RANGES: Record<string, { min: number; max: number }> = {
|
||||
|
||||
Reference in new issue
Block a user