mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-11 22:02:16 +00:00
feat(admin): pulse day-mean dry run, apply step and admin card
This commit is contained in:
1 parent
877920f5f7
commit
9f17cef8e4
17 files changed
+915
-16
No files matched your search
@@ -127,6 +127,8 @@ const ALLOWED_SQL: Record<string, string> = {
|
||||
"Canonical-source blood pressure and weight over 90 days: sparse types, a few rows a day.",
|
||||
"lib/measurements/reconcile-external-measurement.ts::1":
|
||||
"Row locks (`FOR UPDATE`) on an id list the caller already read; returns those ids only.",
|
||||
"lib/jobs/pulse-day-mean.ts::1":
|
||||
"Selects one row per account; `measurements` is read only inside the MIN / MAX scalar subqueries of each row.",
|
||||
"lib/jobs/pr-detection.ts::1":
|
||||
"Selects account ids; `measurements` is read inside `EXISTS`, one row per account at most.",
|
||||
"lib/rollups/measurement-coverage.ts::1":
|
||||
|
||||
@@ -201,6 +201,11 @@ const ALLOWED: Readonly<Record<string, { count: number; reason: string }>> = {
|
||||
reason:
|
||||
"A size probe for an admin card: null shows no figure, and the status read must not fail over it.",
|
||||
},
|
||||
"lib/jobs/pulse-day-mean.ts::isPulseDayMeanRunActive": {
|
||||
count: 1,
|
||||
reason:
|
||||
"No pgboss schema on a web-only deployment, so no run can be active; the route answers 503 there before it asks.",
|
||||
},
|
||||
"lib/jobs/job-observer.ts::emitJobLine": {
|
||||
count: 1,
|
||||
reason:
|
||||
|
||||
@@ -107,6 +107,11 @@ const PINNED_AFFORDANCES: Record<
|
||||
"src/components/admin/measurement-maintenance-card.tsx": {
|
||||
"toast.success": 1,
|
||||
},
|
||||
// Fires only on a 202: the dry run was queued, or the method was applied.
|
||||
// The dry run's outcome itself is the status line.
|
||||
"src/components/admin/pulse-day-mean-card.tsx": {
|
||||
"toast.success": 2,
|
||||
},
|
||||
"src/components/admin/invite-tokens-section.tsx": {
|
||||
"text-success": 2,
|
||||
"toast.success": 3,
|
||||
|
||||
@@ -41,6 +41,7 @@ import { SupportReportCard } from "@/components/admin/support-report-card";
|
||||
import { RevealWhenSettled } from "@/components/ui/reveal-when-settled";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { MeasurementMaintenanceCard } from "@/components/admin/measurement-maintenance-card";
|
||||
import { PulseDayMeanCard } from "@/components/admin/pulse-day-mean-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";
|
||||
@@ -75,6 +76,8 @@ export function AdminSectionRenderer({
|
||||
<SupportReportCard />
|
||||
{/* v1.42 — the table maintenance after the tombstone purge. */}
|
||||
<MeasurementMaintenanceCard />
|
||||
{/* v1.43 — the pulse day-mean method, dry run before apply. */}
|
||||
<PulseDayMeanCard />
|
||||
</SectionFrame>
|
||||
</RevealWhenSettled>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,275 @@
|
||||
/**
|
||||
* `GET/POST /api/admin/maintenance/pulse-day-mean` — the pulse day-mean
|
||||
* method's dry run and apply step. Admin only; apply needs a finished dry run
|
||||
* from the last seven days, and nothing starts while a run is active.
|
||||
*/
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { NextRequest } from "next/server";
|
||||
|
||||
vi.mock("@/lib/db", () => ({
|
||||
prisma: {
|
||||
$queryRaw: vi.fn(),
|
||||
appSettings: { findUnique: vi.fn(), upsert: vi.fn() },
|
||||
},
|
||||
}));
|
||||
vi.mock("@/lib/auth/session", () => ({ getSession: vi.fn() }));
|
||||
vi.mock("@/lib/auth/audit", () => ({
|
||||
auditLog: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
vi.mock("@/lib/jobs/boss-instance", () => ({ getGlobalBoss: vi.fn() }));
|
||||
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, POST } from "../route";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { auditLog } from "@/lib/auth/audit";
|
||||
import { getSession } from "@/lib/auth/session";
|
||||
import { getGlobalBoss } from "@/lib/jobs/boss-instance";
|
||||
import {
|
||||
activePulseDayMeanMethod,
|
||||
setActivePulseDayMeanMethod,
|
||||
} from "@/lib/measurements/day-statistic";
|
||||
|
||||
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 URL = "http://localhost/api/admin/maintenance/pulse-day-mean";
|
||||
const get = () =>
|
||||
(GET as unknown as (r: NextRequest) => Promise<Response>)(
|
||||
new NextRequest(URL),
|
||||
);
|
||||
const post = (body: unknown) =>
|
||||
(POST as unknown as (r: NextRequest) => Promise<Response>)(
|
||||
new NextRequest(URL, {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify(body),
|
||||
}),
|
||||
);
|
||||
|
||||
const NOW = new Date("2026-10-16T12:00:00Z");
|
||||
const send = vi.fn();
|
||||
|
||||
function settings(dryRun: unknown, method = "hour_mean_v1") {
|
||||
vi.mocked(prisma.appSettings.findUnique).mockResolvedValue({
|
||||
pulseDayMeanMethod: method,
|
||||
pulseDayMeanAppliedAt: null,
|
||||
pulseDayMeanDryRunJson: dryRun,
|
||||
} as never);
|
||||
}
|
||||
|
||||
function activeJobs(n: number) {
|
||||
vi.mocked(prisma.$queryRaw).mockResolvedValue([{ n }] as never);
|
||||
}
|
||||
|
||||
const RESULT = {
|
||||
accounts: 1,
|
||||
daysCompared: 30,
|
||||
daysChangedOver1Bpm: 2,
|
||||
maxChangeBpm: 3.4,
|
||||
distribution: [
|
||||
{ bucket: "0-1", days: 28 },
|
||||
{ bucket: "1-2", days: 1 },
|
||||
{ bucket: "2-5", days: 1 },
|
||||
{ bucket: "5+", days: 0 },
|
||||
],
|
||||
perAccount: [{ account: "#1", days: 30, changedOver1Bpm: 2 }],
|
||||
};
|
||||
|
||||
function doneAt(iso: string) {
|
||||
return {
|
||||
status: "done",
|
||||
startedAt: iso,
|
||||
finishedAt: iso,
|
||||
result: RESULT,
|
||||
};
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.resetAllMocks();
|
||||
vi.useFakeTimers({ toFake: ["Date"] });
|
||||
vi.setSystemTime(NOW);
|
||||
setActivePulseDayMeanMethod("hour_mean_v1");
|
||||
vi.mocked(getSession).mockResolvedValue(ADMIN as never);
|
||||
vi.mocked(getGlobalBoss).mockReturnValue({ send } as never);
|
||||
vi.mocked(prisma.appSettings.upsert).mockResolvedValue({} as never);
|
||||
send.mockResolvedValue("job-1");
|
||||
activeJobs(0);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
setActivePulseDayMeanMethod("hour_mean_v1");
|
||||
});
|
||||
|
||||
describe("admin only", () => {
|
||||
it("refuses anyone but an admin, before reading anything", async () => {
|
||||
vi.mocked(getSession).mockResolvedValue(USER as never);
|
||||
expect((await get()).status).toBe(403);
|
||||
expect((await post({ action: "dry_run" })).status).toBe(403);
|
||||
expect(prisma.appSettings.findUnique).not.toHaveBeenCalled();
|
||||
expect(send).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("GET", () => {
|
||||
it("answers the method and the last dry run", async () => {
|
||||
settings(doneAt("2026-10-15T08:00:00.000Z"));
|
||||
const body = await (await get()).json();
|
||||
expect(body.data).toEqual({
|
||||
method: "hour_mean_v1",
|
||||
appliedAt: null,
|
||||
lastDryRun: doneAt("2026-10-15T08:00:00.000Z"),
|
||||
});
|
||||
});
|
||||
|
||||
it("reads a run marked running with no live job behind it as failed", async () => {
|
||||
settings({
|
||||
status: "running",
|
||||
startedAt: "2026-10-16T11:00:00.000Z",
|
||||
finishedAt: null,
|
||||
result: null,
|
||||
});
|
||||
activeJobs(0);
|
||||
const body = await (await get()).json();
|
||||
expect(body.data.lastDryRun.status).toBe("failed");
|
||||
});
|
||||
|
||||
it("drops a stored value of the wrong shape instead of passing it on", async () => {
|
||||
settings({ status: "done", userId: "u-1" });
|
||||
const body = await (await get()).json();
|
||||
expect(body.data.lastDryRun).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("POST dry_run", () => {
|
||||
it("marks the dry run queued and enqueues it", async () => {
|
||||
settings(null);
|
||||
const res = await post({ action: "dry_run" });
|
||||
expect(res.status).toBe(202);
|
||||
expect((await res.json()).data).toEqual({ enqueued: true });
|
||||
expect(send).toHaveBeenCalledWith(
|
||||
"pulse-day-mean",
|
||||
{ action: "dry_run" },
|
||||
expect.objectContaining({ retryLimit: 0, singletonKey: "pulse-day-mean" }),
|
||||
);
|
||||
expect(prisma.appSettings.upsert).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
update: {
|
||||
pulseDayMeanDryRunJson: expect.objectContaining({ status: "queued" }),
|
||||
},
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("answers 409 busy while a run is active, and starts nothing", async () => {
|
||||
activeJobs(1);
|
||||
const res = await post({ action: "dry_run" });
|
||||
expect(res.status).toBe(409);
|
||||
expect((await res.json()).meta.errorCode).toBe(
|
||||
"maintenance.pulseDayMean.busy",
|
||||
);
|
||||
expect(send).not.toHaveBeenCalled();
|
||||
expect(prisma.appSettings.upsert).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("puts the last dry run back when the send loses the singleton", async () => {
|
||||
const previous = doneAt("2026-10-15T08:00:00.000Z");
|
||||
settings(previous);
|
||||
send.mockResolvedValue(null);
|
||||
const res = await post({ action: "dry_run" });
|
||||
expect(res.status).toBe(409);
|
||||
expect(vi.mocked(prisma.appSettings.upsert).mock.lastCall?.[0]).toEqual(
|
||||
expect.objectContaining({ update: { pulseDayMeanDryRunJson: previous } }),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("POST apply", () => {
|
||||
it("is refused without a dry run", async () => {
|
||||
settings(null);
|
||||
const res = await post({ action: "apply" });
|
||||
expect(res.status).toBe(409);
|
||||
expect((await res.json()).meta.errorCode).toBe(
|
||||
"maintenance.pulseDayMean.dryRunRequired",
|
||||
);
|
||||
expect(prisma.appSettings.upsert).not.toHaveBeenCalled();
|
||||
expect(send).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("is refused with a dry run older than seven days", async () => {
|
||||
settings(doneAt("2026-10-09T11:59:00.000Z"));
|
||||
expect((await post({ action: "apply" })).status).toBe(409);
|
||||
});
|
||||
|
||||
it("is refused with a dry run that failed", async () => {
|
||||
settings({ ...doneAt("2026-10-15T08:00:00.000Z"), status: "failed" });
|
||||
expect((await post({ action: "apply" })).status).toBe(409);
|
||||
});
|
||||
|
||||
it("is refused 409 busy while a run is active", async () => {
|
||||
settings(doneAt("2026-10-15T08:00:00.000Z"));
|
||||
activeJobs(1);
|
||||
const res = await post({ action: "apply" });
|
||||
expect((await res.json()).meta.errorCode).toBe(
|
||||
"maintenance.pulseDayMean.busy",
|
||||
);
|
||||
});
|
||||
|
||||
it("switches the method, enqueues the rebuild and audits it", async () => {
|
||||
settings(doneAt("2026-10-15T08:00:00.000Z"));
|
||||
const res = await post({ action: "apply" });
|
||||
expect(res.status).toBe(202);
|
||||
expect((await res.json()).data).toEqual({ enqueued: true });
|
||||
expect(prisma.appSettings.upsert).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
update: {
|
||||
pulseDayMeanMethod: "weighted_hour_mean_v2",
|
||||
pulseDayMeanAppliedAt: NOW,
|
||||
},
|
||||
}),
|
||||
);
|
||||
expect(send).toHaveBeenCalledWith(
|
||||
"pulse-day-mean",
|
||||
{ action: "rebuild" },
|
||||
expect.anything(),
|
||||
);
|
||||
expect(activePulseDayMeanMethod()).toBe("weighted_hour_mean_v2");
|
||||
expect(auditLog).toHaveBeenCalledWith(
|
||||
"admin.pulseDayMean.apply",
|
||||
expect.objectContaining({
|
||||
userId: "admin-1",
|
||||
details: { method: "weighted_hour_mean_v2", rebuildEnqueued: true },
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("POST body", () => {
|
||||
it("is strict", async () => {
|
||||
expect((await post({ action: "apply", force: true })).status).toBe(422);
|
||||
expect((await post({ action: "rebuild" })).status).toBe(422);
|
||||
expect(send).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("answers 503 without a background worker", async () => {
|
||||
vi.mocked(getGlobalBoss).mockReturnValue(null as never);
|
||||
expect((await post({ action: "dry_run" })).status).toBe(503);
|
||||
});
|
||||
});
|
||||
@@ -26,7 +26,6 @@ import { auditLog } from "@/lib/auth/audit";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { getGlobalBoss } from "@/lib/jobs/boss-instance";
|
||||
import {
|
||||
hasFreshDryRun,
|
||||
isPulseDayMeanRunActive,
|
||||
PULSE_DAY_MEAN_QUEUE,
|
||||
PULSE_DAY_MEAN_SEND_OPTIONS,
|
||||
@@ -39,7 +38,10 @@ import {
|
||||
setActivePulseDayMeanMethod,
|
||||
type PulseDayMeanMethod,
|
||||
} from "@/lib/measurements/day-statistic";
|
||||
import { pulseDayMeanActionSchema } from "@/lib/validations/pulse-day-mean";
|
||||
import {
|
||||
hasFreshDryRun,
|
||||
pulseDayMeanActionSchema,
|
||||
} from "@/lib/validations/pulse-day-mean";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
|
||||
@@ -0,0 +1,306 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* `<PulseDayMeanCard>` — switch the pulse day mean to weigh sparse hours, with
|
||||
* a dry run first (#1107 follow-up).
|
||||
*
|
||||
* The card shows the active method, starts a dry run, shows what it found
|
||||
* (how many days would move by more than 1 bpm, the largest move, the spread,
|
||||
* and the same per account under an ordinal), and applies the new method once
|
||||
* a dry run from the last seven days has finished. It polls only while a dry
|
||||
* run is queued or running. The route enforces every rule the buttons mirror.
|
||||
*/
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { FlaskConical, HeartPulse, Loader2 } 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 { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { QueryErrorCard } from "@/components/ui/query-error-card";
|
||||
import { apiFetchRaw, apiGet } from "@/lib/api/api-fetch";
|
||||
import { showErrorToast } from "@/lib/errors/error-toast";
|
||||
import { useFormatters, useTranslations } from "@/lib/i18n/context";
|
||||
import { queryKeys } from "@/lib/query-keys";
|
||||
import {
|
||||
hasFreshDryRun,
|
||||
type PulseDayMeanActionInput,
|
||||
type PulseDayMeanStatus,
|
||||
} from "@/lib/validations/pulse-day-mean";
|
||||
|
||||
const ENDPOINT = "/api/admin/maintenance/pulse-day-mean";
|
||||
|
||||
/** How often the card asks while a dry run is waiting or working. */
|
||||
const POLL_MS = 10_000;
|
||||
|
||||
export function PulseDayMeanCard() {
|
||||
const { t } = useTranslations();
|
||||
const fmt = useFormatters();
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const status = useQuery({
|
||||
queryKey: queryKeys.adminPulseDayMean(),
|
||||
queryFn: () =>
|
||||
apiGet<PulseDayMeanStatus>(ENDPOINT, { credentials: "include" }),
|
||||
refetchInterval: (query) => {
|
||||
const state = query.state.data?.lastDryRun?.status;
|
||||
return state === "queued" || state === "running" ? POLL_MS : false;
|
||||
},
|
||||
});
|
||||
|
||||
const run = useMutation({
|
||||
mutationFn: async (
|
||||
action: PulseDayMeanActionInput["action"],
|
||||
): Promise<{ action: typeof action; enqueued: boolean }> => {
|
||||
const res = await apiFetchRaw(ENDPOINT, {
|
||||
method: "POST",
|
||||
credentials: "include",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({ action }),
|
||||
});
|
||||
if (!res.ok) {
|
||||
let code: string | undefined;
|
||||
try {
|
||||
const body = (await res.json()) as { meta?: { errorCode?: string } };
|
||||
code = body.meta?.errorCode;
|
||||
} catch {
|
||||
// No envelope: the generic message below.
|
||||
}
|
||||
// The route's two refusals each have their own message.
|
||||
throw new Error(
|
||||
code === "maintenance.pulseDayMean.busy"
|
||||
? t("admin.section.pulseDayMean.error.busy")
|
||||
: code === "maintenance.pulseDayMean.dryRunRequired"
|
||||
? t("admin.section.pulseDayMean.error.dryRunRequired")
|
||||
: t("admin.section.pulseDayMean.error.generic"),
|
||||
);
|
||||
}
|
||||
const { enqueued } = (
|
||||
(await res.json()) as { data: { enqueued: boolean } }
|
||||
).data;
|
||||
return { action, enqueued };
|
||||
},
|
||||
onSuccess: ({ action, enqueued }) => {
|
||||
if (action === "apply") {
|
||||
toast.success(
|
||||
enqueued
|
||||
? t("admin.section.pulseDayMean.applied")
|
||||
: t("admin.section.pulseDayMean.appliedNoRebuild"),
|
||||
);
|
||||
} else {
|
||||
toast.success(t("admin.section.pulseDayMean.dryRunStarted"));
|
||||
}
|
||||
void queryClient.invalidateQueries({
|
||||
queryKey: queryKeys.adminPulseDayMean(),
|
||||
});
|
||||
},
|
||||
onError: (err: Error) => {
|
||||
showErrorToast(err.message, { cause: err });
|
||||
},
|
||||
});
|
||||
|
||||
if (status.isError) {
|
||||
return (
|
||||
<QueryErrorCard
|
||||
title={t("admin.section.pulseDayMean.loadError")}
|
||||
onRetry={() => void status.refetch()}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const s = status.data;
|
||||
const dryRun = s?.lastDryRun ?? null;
|
||||
const result = dryRun?.status === "done" ? dryRun.result : null;
|
||||
const active = dryRun?.status === "queued" || dryRun?.status === "running";
|
||||
const weighted = s?.method === "weighted_hour_mean_v2";
|
||||
const canApply = !!s && !active && hasFreshDryRun(s);
|
||||
|
||||
return (
|
||||
<SettingsCard data-testid="admin-pulse-day-mean">
|
||||
<SettingsCardHeader
|
||||
icon={HeartPulse}
|
||||
title={t("admin.section.pulseDayMean.title")}
|
||||
description={t("admin.section.pulseDayMean.description")}
|
||||
status={
|
||||
s ? (
|
||||
<Badge variant="secondary" data-slot="pulse-day-mean-method">
|
||||
{weighted
|
||||
? t("admin.section.pulseDayMean.method.weighted")
|
||||
: t("admin.section.pulseDayMean.method.hourly")}
|
||||
</Badge>
|
||||
) : null
|
||||
}
|
||||
/>
|
||||
<p className="text-sm">{t("admin.section.pulseDayMean.body")}</p>
|
||||
|
||||
<div
|
||||
className="text-sm"
|
||||
role="status"
|
||||
aria-live="polite"
|
||||
data-slot="pulse-day-mean-state"
|
||||
data-state={status.isLoading ? "loading" : (dryRun?.status ?? "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.pulseDayMean.loading")}
|
||||
</span>
|
||||
) : active ? (
|
||||
<span className="flex items-center gap-2">
|
||||
<Loader2
|
||||
className="text-muted-foreground size-4 animate-spin motion-reduce:animate-none"
|
||||
aria-hidden
|
||||
/>
|
||||
{dryRun?.status === "queued"
|
||||
? t("admin.section.pulseDayMean.state.queued")
|
||||
: t("admin.section.pulseDayMean.state.running")}
|
||||
</span>
|
||||
) : dryRun?.status === "done" ? (
|
||||
<span>
|
||||
{t("admin.section.pulseDayMean.state.done", {
|
||||
at: fmt.dateTime(dryRun.finishedAt ?? dryRun.startedAt),
|
||||
})}
|
||||
</span>
|
||||
) : dryRun?.status === "failed" ? (
|
||||
<span className="text-destructive">
|
||||
{t("admin.section.pulseDayMean.state.failed")}
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-muted-foreground">
|
||||
{t("admin.section.pulseDayMean.state.idle")}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{result ? (
|
||||
<div className="space-y-3" data-slot="pulse-day-mean-result">
|
||||
<div className="grid gap-3 sm:grid-cols-3">
|
||||
<StatTile
|
||||
label={t("admin.section.pulseDayMean.result.days")}
|
||||
value={fmt.number(result.daysCompared)}
|
||||
/>
|
||||
<StatTile
|
||||
label={t("admin.section.pulseDayMean.result.changed")}
|
||||
value={fmt.number(result.daysChangedOver1Bpm)}
|
||||
/>
|
||||
<StatTile
|
||||
label={t("admin.section.pulseDayMean.result.max")}
|
||||
value={t("admin.section.pulseDayMean.result.maxValue", {
|
||||
value: fmt.number(result.maxChangeBpm, 1),
|
||||
})}
|
||||
/>
|
||||
</div>
|
||||
<dl className="grid grid-cols-2 gap-x-3 gap-y-2 text-sm sm:grid-cols-4">
|
||||
{result.distribution.map((d) => (
|
||||
<div key={d.bucket}>
|
||||
<dt className="text-muted-foreground text-xs">
|
||||
{t("admin.section.pulseDayMean.result.bucket", {
|
||||
range: d.bucket,
|
||||
})}
|
||||
</dt>
|
||||
<dd className="tabular-nums">{fmt.number(d.days)}</dd>
|
||||
</div>
|
||||
))}
|
||||
</dl>
|
||||
{result.perAccount.length > 0 ? (
|
||||
<ul className="space-y-1 text-sm">
|
||||
{result.perAccount.map((a) => (
|
||||
<li
|
||||
key={a.account}
|
||||
className="flex items-center justify-between gap-3"
|
||||
>
|
||||
<span>
|
||||
{t("admin.section.pulseDayMean.result.account", {
|
||||
account: a.account,
|
||||
})}
|
||||
</span>
|
||||
<span className="text-muted-foreground tabular-nums">
|
||||
{t("admin.section.pulseDayMean.result.accountDays", {
|
||||
changed: fmt.number(a.changedOver1Bpm),
|
||||
days: fmt.number(a.days),
|
||||
})}
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{s?.appliedAt ? (
|
||||
<p className="text-muted-foreground text-xs">
|
||||
{t("admin.section.pulseDayMean.appliedAt", {
|
||||
at: fmt.dateTime(s.appliedAt),
|
||||
})}
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
<SettingsCardActions>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className="min-h-11 sm:min-h-9"
|
||||
disabled={!s || active || run.isPending}
|
||||
onClick={() => run.mutate("dry_run")}
|
||||
data-testid="admin-pulse-day-mean-dry-run"
|
||||
>
|
||||
<FlaskConical className="size-3.5" aria-hidden />
|
||||
{t("admin.section.pulseDayMean.dryRun")}
|
||||
</Button>
|
||||
<AlertDialog>
|
||||
<AlertDialogTrigger asChild>
|
||||
<Button
|
||||
size="sm"
|
||||
className="min-h-11 sm:min-h-9"
|
||||
disabled={!canApply || run.isPending}
|
||||
data-testid="admin-pulse-day-mean-apply"
|
||||
>
|
||||
{run.isPending ? (
|
||||
<Loader2
|
||||
className="size-3.5 animate-spin motion-reduce:animate-none"
|
||||
aria-hidden
|
||||
/>
|
||||
) : null}
|
||||
{t("admin.section.pulseDayMean.apply")}
|
||||
</Button>
|
||||
</AlertDialogTrigger>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>
|
||||
{t("admin.section.pulseDayMean.confirmTitle")}
|
||||
</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
{t("admin.section.pulseDayMean.confirmBody")}
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>
|
||||
{t("admin.section.pulseDayMean.cancel")}
|
||||
</AlertDialogCancel>
|
||||
<AlertDialogAction onClick={() => run.mutate("apply")}>
|
||||
{t("admin.section.pulseDayMean.confirm")}
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</SettingsCardActions>
|
||||
</SettingsCard>
|
||||
);
|
||||
}
|
||||
@@ -63,9 +63,6 @@ export interface PulseDayMeanPayload {
|
||||
action: "dry_run" | "rebuild";
|
||||
}
|
||||
|
||||
/** How old a finished dry run may be for the apply step to accept it. */
|
||||
export const DRY_RUN_FRESH_MS = 7 * 24 * 60 * 60 * 1000;
|
||||
|
||||
const DAY_MS = 24 * 60 * 60 * 1000;
|
||||
/** Days of one account's pulse a dry-run statement covers. */
|
||||
const DRY_RUN_WINDOW_DAYS = 90;
|
||||
@@ -131,17 +128,6 @@ export async function readPulseDayMeanStatus(): Promise<PulseDayMeanStatus> {
|
||||
};
|
||||
}
|
||||
|
||||
/** A finished dry run from the last seven days, the apply step's condition. */
|
||||
export function hasFreshDryRun(
|
||||
status: PulseDayMeanStatus,
|
||||
now: Date = new Date(),
|
||||
): boolean {
|
||||
const run = status.lastDryRun;
|
||||
if (!run || run.status !== "done" || run.finishedAt === null) return false;
|
||||
const finished = Date.parse(run.finishedAt);
|
||||
return Number.isFinite(finished) && now.getTime() - finished <= DRY_RUN_FRESH_MS;
|
||||
}
|
||||
|
||||
/** The only write a dry run makes. */
|
||||
export async function writeDryRun(run: PulseDayMeanDryRun): Promise<void> {
|
||||
await prisma.appSettings.upsert({
|
||||
|
||||
@@ -53,6 +53,8 @@ export const adminKeys = {
|
||||
adminEncryptionStatus: () => ["admin", "encryption", "status"] as const,
|
||||
adminMeasurementMaintenance: () =>
|
||||
["admin", "maintenance", "measurements"] as const,
|
||||
/** v1.43 — the pulse day-mean method, its dry run and apply step. */
|
||||
adminPulseDayMean: () => ["admin", "maintenance", "pulse-day-mean"] as const,
|
||||
/** v1.40.0 — the "Back up your encryption key" step (admin + dashboard banner). */
|
||||
adminEncryptionKeyBackup: () =>
|
||||
["admin", "encryption", "key-backup"] as const,
|
||||
|
||||
@@ -56,3 +56,22 @@ export interface PulseDayMeanStatus {
|
||||
appliedAt: string | null;
|
||||
lastDryRun: PulseDayMeanDryRun | null;
|
||||
}
|
||||
|
||||
/** How old a finished dry run may be for the apply step to accept it. */
|
||||
const DRY_RUN_FRESH_MS = 7 * 24 * 60 * 60 * 1000;
|
||||
|
||||
/**
|
||||
* A finished dry run from the last seven days: the apply step's condition,
|
||||
* enforced by the route and mirrored by the admin card's button.
|
||||
*/
|
||||
export function hasFreshDryRun(
|
||||
status: Pick<PulseDayMeanStatus, "lastDryRun">,
|
||||
now: Date = new Date(),
|
||||
): boolean {
|
||||
const run = status.lastDryRun;
|
||||
if (!run || run.status !== "done" || run.finishedAt === null) return false;
|
||||
const finished = Date.parse(run.finishedAt);
|
||||
return (
|
||||
Number.isFinite(finished) && now.getTime() - finished <= DRY_RUN_FRESH_MS
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user