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