mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-13 14:32:03 +00:00
Merge branch 'v143/fixdata' into release/v1.43.0
This commit is contained in:
commit
a806676a71
62 files changed
+2018
-209
No files matched your search
@@ -37,7 +37,12 @@ const RESULT = {
|
||||
const status = (
|
||||
lastDryRun: PulseDayMeanStatus["lastDryRun"],
|
||||
method: PulseDayMeanStatus["method"] = "hour_mean_v1",
|
||||
): PulseDayMeanStatus => ({ method, appliedAt: null, lastDryRun });
|
||||
): PulseDayMeanStatus => ({
|
||||
method,
|
||||
appliedAt: null,
|
||||
lastDryRun,
|
||||
lastRebuild: null,
|
||||
});
|
||||
|
||||
const done = (finishedAt: string) => ({
|
||||
status: "done" as const,
|
||||
@@ -113,4 +118,33 @@ describe("PulseDayMeanCard", () => {
|
||||
expect(de).toContain("Dünne Stunden gewichtet");
|
||||
expect(de).toContain("Probelauf");
|
||||
});
|
||||
|
||||
it("shows a stopped recalculation and offers it again without a dry run", () => {
|
||||
const failed: PulseDayMeanStatus = {
|
||||
...status(done("2026-10-01T10:00:00Z"), "weighted_hour_mean_v2"),
|
||||
appliedAt: "2026-10-01T11:00:00Z",
|
||||
lastRebuild: {
|
||||
status: "failed",
|
||||
startedAt: "2026-10-01T11:00:00Z",
|
||||
finishedAt: "2026-10-01T11:05:00Z",
|
||||
accounts: null,
|
||||
rows: null,
|
||||
},
|
||||
};
|
||||
const html = render(failed);
|
||||
expect(html).toContain('data-state="failed"');
|
||||
expect(html).toContain('data-testid="admin-pulse-day-mean-rebuild"');
|
||||
|
||||
const finished = render({
|
||||
...failed,
|
||||
lastRebuild: {
|
||||
...failed.lastRebuild!,
|
||||
status: "done",
|
||||
accounts: 3,
|
||||
rows: 900,
|
||||
},
|
||||
});
|
||||
expect(finished).not.toContain("admin-pulse-day-mean-rebuild");
|
||||
expect(finished).toContain('data-slot="pulse-day-mean-rebuild"');
|
||||
});
|
||||
});
|
||||
@@ -57,8 +57,13 @@ export function PulseDayMeanCard() {
|
||||
queryFn: () =>
|
||||
apiGet<PulseDayMeanStatus>(ENDPOINT, { credentials: "include" }),
|
||||
refetchInterval: (query) => {
|
||||
const state = query.state.data?.lastDryRun?.status;
|
||||
return state === "queued" || state === "running" ? POLL_MS : false;
|
||||
const states = [
|
||||
query.state.data?.lastDryRun?.status,
|
||||
query.state.data?.lastRebuild?.status,
|
||||
];
|
||||
return states.some((state) => state === "queued" || state === "running")
|
||||
? POLL_MS
|
||||
: false;
|
||||
},
|
||||
});
|
||||
|
||||
@@ -102,7 +107,11 @@ export function PulseDayMeanCard() {
|
||||
: t("admin.section.pulseDayMean.appliedNoRebuild"),
|
||||
);
|
||||
} else {
|
||||
toast.success(t("admin.section.pulseDayMean.dryRunStarted"));
|
||||
toast.success(
|
||||
action === "rebuild"
|
||||
? t("admin.section.pulseDayMean.rebuildStarted")
|
||||
: t("admin.section.pulseDayMean.dryRunStarted"),
|
||||
);
|
||||
}
|
||||
void queryClient.invalidateQueries({
|
||||
queryKey: queryKeys.adminPulseDayMean(),
|
||||
@@ -125,9 +134,16 @@ export function PulseDayMeanCard() {
|
||||
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 rebuild = s?.lastRebuild ?? null;
|
||||
const rebuilding =
|
||||
rebuild?.status === "queued" || rebuild?.status === "running";
|
||||
const active =
|
||||
dryRun?.status === "queued" || dryRun?.status === "running" || rebuilding;
|
||||
const weighted = s?.method === "weighted_hour_mean_v2";
|
||||
const canApply = !!s && !active && hasFreshDryRun(s);
|
||||
// A stopped recalculation is run again from here; the method is already
|
||||
// switched, so it needs no new dry run.
|
||||
const canRebuild = !!s?.appliedAt && rebuild?.status === "failed";
|
||||
|
||||
return (
|
||||
<SettingsCard data-testid="admin-pulse-day-mean">
|
||||
@@ -252,6 +268,25 @@ export function PulseDayMeanCard() {
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
{rebuild ? (
|
||||
<p
|
||||
className={
|
||||
rebuild.status === "failed"
|
||||
? "text-destructive text-sm"
|
||||
: "text-muted-foreground text-xs"
|
||||
}
|
||||
data-slot="pulse-day-mean-rebuild"
|
||||
data-state={rebuild.status}
|
||||
>
|
||||
{rebuild.status === "done"
|
||||
? t("admin.section.pulseDayMean.rebuild.done", {
|
||||
at: fmt.dateTime(rebuild.finishedAt ?? rebuild.startedAt),
|
||||
accounts: fmt.number(rebuild.accounts ?? 0),
|
||||
})
|
||||
: t(`admin.section.pulseDayMean.rebuild.${rebuild.status}`)}
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
<SettingsCardActions>
|
||||
<Button
|
||||
size="sm"
|
||||
@@ -264,6 +299,18 @@ export function PulseDayMeanCard() {
|
||||
<FlaskConical className="size-3.5" aria-hidden />
|
||||
{t("admin.section.pulseDayMean.dryRun")}
|
||||
</Button>
|
||||
{canRebuild ? (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className="min-h-11 sm:min-h-9"
|
||||
disabled={run.isPending}
|
||||
onClick={() => run.mutate("rebuild")}
|
||||
data-testid="admin-pulse-day-mean-rebuild"
|
||||
>
|
||||
{t("admin.section.pulseDayMean.rebuildAgain")}
|
||||
</Button>
|
||||
) : null}
|
||||
<AlertDialog>
|
||||
<AlertDialogTrigger asChild>
|
||||
<Button
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
|
||||
const state = vi.hoisted(() => ({
|
||||
facts: null as unknown,
|
||||
}));
|
||||
|
||||
vi.mock("@tanstack/react-query", () => ({
|
||||
useQuery: () => ({ data: state.facts }),
|
||||
useQueryClient: () => ({ invalidateQueries: vi.fn() }),
|
||||
useMutation: () => ({ mutate: vi.fn(), isPending: false }),
|
||||
}));
|
||||
vi.mock("@/lib/api/api-fetch", () => ({
|
||||
apiGet: vi.fn(),
|
||||
apiPost: vi.fn(),
|
||||
apiDelete: vi.fn(),
|
||||
}));
|
||||
vi.mock("@/components/outcome/outcome-toast", () => ({
|
||||
toastWrittenOutcome: vi.fn(),
|
||||
}));
|
||||
|
||||
import { I18nProvider } from "@/lib/i18n/context";
|
||||
import { auditCInstrument } from "@/lib/mental-health/audit-c";
|
||||
import { AuditCPanel } from "../audit-c-panel";
|
||||
|
||||
function render() {
|
||||
return renderToStaticMarkup(
|
||||
<I18nProvider initialLocale="en">
|
||||
<AuditCPanel
|
||||
data={{
|
||||
latest: null,
|
||||
history: [],
|
||||
offerDue: true,
|
||||
instrument: auditCInstrument("en"),
|
||||
}}
|
||||
onChanged={() => {}}
|
||||
/>
|
||||
</I18nProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
state.facts = null;
|
||||
});
|
||||
|
||||
describe("the old alcohol pattern in the alcohol section", () => {
|
||||
it("lists the stored pattern with a way to remove it", () => {
|
||||
state.facts = {
|
||||
current: {
|
||||
SMOKING_STATUS: null,
|
||||
SHIFT_SCHEDULE: null,
|
||||
ALCOHOL_PATTERN: { id: "fact-1", value: "WEEKLY" },
|
||||
},
|
||||
history: [],
|
||||
};
|
||||
const html = render();
|
||||
expect(html).toContain('data-slot="audit-c-legacy-pattern"');
|
||||
expect(html).toContain("Earlier answer: Weekly");
|
||||
expect(html).toContain("audit-c-legacy-remove");
|
||||
});
|
||||
|
||||
it("shows nothing when there is no stored pattern", () => {
|
||||
state.facts = {
|
||||
current: {
|
||||
SMOKING_STATUS: null,
|
||||
SHIFT_SCHEDULE: null,
|
||||
ALCOHOL_PATTERN: null,
|
||||
},
|
||||
history: [],
|
||||
};
|
||||
expect(render()).not.toContain("audit-c-legacy-pattern");
|
||||
});
|
||||
});
|
||||
@@ -10,7 +10,7 @@
|
||||
* and its source are the server's too; nothing is scored here.
|
||||
*/
|
||||
import { useState } from "react";
|
||||
import { useMutation } from "@tanstack/react-query";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { Loader2, Trash2 } from "lucide-react";
|
||||
|
||||
import { toastWrittenOutcome } from "@/components/outcome/outcome-toast";
|
||||
@@ -18,9 +18,10 @@ import { SettingsCardActions } from "@/components/settings/_card-actions";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { ConfirmButton } from "@/components/ui/confirm-button";
|
||||
import { ResponsiveSheet } from "@/components/ui/responsive-sheet";
|
||||
import { apiDelete, apiPost } from "@/lib/api/api-fetch";
|
||||
import { apiDelete, apiGet, apiPost } from "@/lib/api/api-fetch";
|
||||
import { showErrorToast } from "@/lib/errors/error-toast";
|
||||
import { useFormatters, useTranslations } from "@/lib/i18n/context";
|
||||
import type { HealthProfileFactsPayload } from "@/lib/profile/health-facts";
|
||||
import type { LifeCircumstancesDto } from "@/lib/profile/life-circumstances";
|
||||
import { queryKeys } from "@/lib/query-keys";
|
||||
|
||||
@@ -76,6 +77,34 @@ export function AuditCPanel({
|
||||
|
||||
const complete = answers.every((answer) => answer !== null);
|
||||
|
||||
// The one-word alcohol pattern asked before AUDIT-C. It still lives in the
|
||||
// record, so it is shown here, where alcohol now lives, with a way out.
|
||||
// The Coach stops reading it once an AUDIT-C entry exists.
|
||||
const queryClient = useQueryClient();
|
||||
const facts = useQuery({
|
||||
queryKey: queryKeys.healthProfileFacts(),
|
||||
queryFn: () => apiGet<HealthProfileFactsPayload>("/api/anamnesis/facts"),
|
||||
});
|
||||
const legacy = facts.data?.current.ALCOHOL_PATTERN ?? null;
|
||||
const removeLegacy = useMutation({
|
||||
mutationKey: queryKeys.healthProfileFacts(),
|
||||
mutationFn: (id: string) => apiDelete(`/api/anamnesis/facts/${id}`),
|
||||
onSuccess: () => {
|
||||
toastWrittenOutcome(
|
||||
"success",
|
||||
t("records.life.auditC.legacyRemovedToast"),
|
||||
);
|
||||
void queryClient.invalidateQueries({
|
||||
queryKey: queryKeys.healthProfileFacts(),
|
||||
});
|
||||
onChanged();
|
||||
},
|
||||
onError: (error) =>
|
||||
showErrorToast(t("records.life.auditC.legacyRemoveError"), {
|
||||
cause: error,
|
||||
}),
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="space-y-4" data-slot="audit-c-panel">
|
||||
<p className="text-sm">{t("records.life.auditC.intro")}</p>
|
||||
@@ -154,6 +183,41 @@ export function AuditCPanel({
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{legacy?.value ? (
|
||||
<div
|
||||
className="flex items-start justify-between gap-2"
|
||||
data-slot="audit-c-legacy-pattern"
|
||||
>
|
||||
<div className="space-y-1">
|
||||
<p className="text-sm">
|
||||
{t("records.life.auditC.legacyLabel", {
|
||||
value: t(
|
||||
`records.profileFacts.values.ALCOHOL_PATTERN.${legacy.value}`,
|
||||
),
|
||||
})}
|
||||
</p>
|
||||
<p className="text-muted-foreground text-xs">
|
||||
{t("records.life.auditC.legacyHint")}
|
||||
</p>
|
||||
</div>
|
||||
<ConfirmButton
|
||||
label={t("records.life.auditC.legacyRemove")}
|
||||
ariaLabel={t("records.life.auditC.legacyRemove")}
|
||||
title={t("records.life.auditC.legacyRemoveConfirmTitle")}
|
||||
body={t("records.life.auditC.legacyRemoveConfirmBody")}
|
||||
confirmLabel={t("records.life.auditC.legacyRemove")}
|
||||
onConfirm={() => removeLegacy.mutate(legacy.id)}
|
||||
pending={removeLegacy.isPending}
|
||||
disabled={removeLegacy.isPending}
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="text-destructive min-h-11 sm:min-h-9"
|
||||
icon={<Trash2 className="size-4" aria-hidden />}
|
||||
slot="audit-c-legacy-remove"
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{data.offerDue ? (
|
||||
<p className="text-muted-foreground text-xs">
|
||||
{t("records.life.auditC.offerDue")}
|
||||
|
||||
Reference in new issue
Block a user