Merge branch 'release/v1.47.3' into release/v1.47.4

# Conflicts:
#	src/__tests__/baselines/source-file-lines.json
#	src/__tests__/baselines/source-function-lines.json
#	src/__tests__/baselines/test-as-never.json
#	src/__tests__/baselines/version-stamp-comments.json
This commit is contained in:
Marc-André Bombeck committed 2026-11-12 13:27:52 +01:00
commit 031379d966
384 files changed
+7908 -5710

No files matched your search

+4 -19
View File
@@ -12,6 +12,7 @@ import { useTranslations } from "@/lib/i18n/context";
import { queryKeys } from "@/lib/query-keys";
import { PasswordInput as SharedPasswordInput } from "@/components/ui/password-input";
import { apiGet, apiPut } from "@/lib/api/api-fetch";
import { localizedApiError } from "@/lib/api/localized-error";
/**
* Re-export the canonical password input so admin sections have access to
@@ -323,29 +324,13 @@ export function useUpdateSettings() {
toast.success(t("common.saved"));
},
onError: (err) => {
showErrorToast(
err instanceof Error && err.message
? err.message
: t("admin.settingsSaveError"),
{ cause: err },
);
showErrorToast(localizedApiError(err, t, "admin.settingsSaveError"), {
cause: err,
});
},
});
}
export async function getApiErrorMessage(response: Response): Promise<string> {
const fallback = `HTTP ${response.status}`;
try {
const json = (await response.json()) as { error?: string };
if (typeof json?.error === "string" && json.error.trim().length > 0) {
return json.error;
}
} catch {
return fallback;
}
return fallback;
}
/**
* Shared `auth.*` audit-action label map. Two surfaces consume it
* today (`/admin/login-overview` and the dashboard recent-activity
@@ -21,6 +21,7 @@ import {
queryKeys,
} from "@/lib/query-keys";
import { apiGet, apiPut } from "@/lib/api/api-fetch";
import { localizedApiError } from "@/lib/api/localized-error";
import { PasswordInput } from "./_shared";
/**
@@ -90,10 +91,9 @@ export function AiServerKeySection() {
toast.success(t("admin.aiServerKey.saved"));
},
onError: (error: unknown) => {
showErrorToast(
error instanceof Error ? error.message : t("admin.aiServerKey.error"),
{ cause: error },
);
showErrorToast(localizedApiError(error, t, "admin.aiServerKey.error"), {
cause: error,
});
},
});
+4 -6
View File
@@ -35,6 +35,7 @@ import {
type AiUsageLevel,
} from "@/lib/ai/usage-level";
import { apiGet, apiPut } from "@/lib/api/api-fetch";
import { localizedApiError } from "@/lib/api/localized-error";
import { useTranslations } from "@/lib/i18n/context";
import {
aiInputDependentKeys,
@@ -122,12 +123,9 @@ function AiUsageForm({ bounds }: { bounds: AiUsageBounds }) {
toast.success(t("common.saved"));
},
onError: (err) => {
showErrorToast(
err instanceof Error && err.message
? err.message
: t("admin.settingsSaveError"),
{ cause: err },
);
showErrorToast(localizedApiError(err, t, "admin.settingsSaveError"), {
cause: err,
});
},
});
+4 -6
View File
@@ -15,6 +15,7 @@ import {
} from "@/lib/query-keys";
import { SettingsToggle } from "./_shared";
import { apiGet, apiPut } from "@/lib/api/api-fetch";
import { localizedApiError } from "@/lib/api/localized-error";
import { Label } from "@/components/ui/label";
import { NativeSelect } from "@/components/ui/native-select";
import {
@@ -105,12 +106,9 @@ function useUpdateAssistantFlags() {
toast.success(t("common.saved"));
},
onError: (err) => {
showErrorToast(
err instanceof Error && err.message
? err.message
: t("admin.settingsSaveError"),
{ cause: err },
);
showErrorToast(localizedApiError(err, t, "admin.settingsSaveError"), {
cause: err,
});
client.invalidateQueries({
queryKey: queryKeys.adminAssistantFlags(),
});
@@ -7,14 +7,13 @@ import {
ReproofCancelledError,
throwIfReproofRequired,
} from "@/components/settings/security-section/use-recent-proof";
import { apiFetchRaw, apiPost } from "@/lib/api/api-fetch";
import { apiFetchRaw, apiPost, throwApiError } from "@/lib/api/api-fetch";
import { localizedApiError } from "@/lib/api/localized-error";
import { showErrorToast } from "@/lib/errors/error-toast";
import { useTranslations } from "@/lib/i18n/context";
import { queryKeys } from "@/lib/query-keys";
import type { BackupRow } from "@/types/backups";
import { getApiErrorMessage } from "../_shared";
/** "Run backup now": enqueues the pg-boss `data-backup` job. */
export function useRunBackup() {
const { t } = useTranslations();
@@ -34,9 +33,7 @@ export function useRunBackup() {
},
onError: (err) => {
showErrorToast(
err instanceof Error && err.message
? err.message
: t("admin.section.backups.runFailed"),
localizedApiError(err, t, "admin.section.backups.runFailed"),
{ cause: err },
);
},
@@ -62,9 +59,7 @@ export function useBackupDownload(
await apiFetchRaw(`/api/admin/backups/${row.id}/download`),
),
);
if (!res.ok) {
throw new Error(await getApiErrorMessage(res));
}
if (!res.ok) await throwApiError(res);
// Use the server-provided Content-Disposition filename if present,
// otherwise fall back to a deterministic client-side name. The
// server filename is more accurate (uses the actual createdAt).
@@ -86,9 +81,7 @@ export function useBackupDownload(
} catch (err) {
if (err instanceof ReproofCancelledError) return;
showErrorToast(
err instanceof Error && err.message
? err.message
: t("admin.section.backups.downloadFailed"),
localizedApiError(err, t, "admin.section.backups.downloadFailed"),
{ cause: err },
);
} finally {
@@ -5,6 +5,7 @@ import { toast } from "sonner";
import type { useRecentProof } from "@/components/settings/security-section/use-recent-proof";
import { ReproofCancelledError } from "@/components/settings/security-section/use-recent-proof";
import { ApiError, apiGet, apiPost } from "@/lib/api/api-fetch";
import { localizedApiError } from "@/lib/api/localized-error";
import { showErrorToast } from "@/lib/errors/error-toast";
import type {
MissingBackupSection,
@@ -188,9 +189,7 @@ export function useBackupRestores(
}
setRefusedSections([]);
showErrorToast(
err instanceof Error && err.message
? err.message
: t("admin.section.backups.restoreFailed"),
localizedApiError(err, t, "admin.section.backups.restoreFailed"),
{ cause: err },
);
},
@@ -5,6 +5,7 @@ import { toast } from "sonner";
import type { useRecentProof } from "@/components/settings/security-section/use-recent-proof";
import { ReproofCancelledError } from "@/components/settings/security-section/use-recent-proof";
import { ApiError, apiFetch, apiGet } from "@/lib/api/api-fetch";
import { localizedApiError } from "@/lib/api/localized-error";
import { showErrorToast } from "@/lib/errors/error-toast";
import { useTranslations } from "@/lib/i18n/context";
import { queryKeys } from "@/lib/query-keys";
@@ -167,9 +168,7 @@ export function useBackupUpload(
return;
}
showErrorToast(
err instanceof Error && err.message
? err.message
: t("admin.section.backups.uploadFailed"),
localizedApiError(err, t, "admin.section.backups.uploadFailed"),
{ cause: err },
);
},
+24 -36
View File
@@ -28,7 +28,8 @@ import { Button } from "@/components/ui/button";
import { ConfirmButton } from "@/components/ui/confirm-button";
import { SettingsCard } from "@/components/settings/settings-card";
import { SettingsCardHeader } from "@/components/settings/_card-header";
import { apiFetchRaw, apiGet } from "@/lib/api/api-fetch";
import { apiDelete, apiGet, apiPost } from "@/lib/api/api-fetch";
import { localizedApiError } from "@/lib/api/localized-error";
import { formatDateTime } from "@/lib/format";
import { useTranslations } from "@/lib/i18n/context";
import {
@@ -66,21 +67,20 @@ export function CentralCodexSection() {
async function handleConnect() {
setDevicePolling(true);
try {
const res = await apiFetchRaw("/api/admin/central-codex/device-start", {
method: "POST",
});
const json = await res.json();
if (!res.ok) throw new Error(json.error || t("admin.centralCodex.error"));
const data = await apiPost<{
userCode: string;
verificationUrl: string;
intervalSeconds: number;
}>("/api/admin/central-codex/device-start");
setDeviceCode({
userCode: json.data.userCode,
verificationUrl: json.data.verificationUrl,
intervalSeconds: json.data.intervalSeconds,
userCode: data.userCode,
verificationUrl: data.verificationUrl,
intervalSeconds: data.intervalSeconds,
});
} catch (err) {
showErrorToast(
err instanceof Error ? err.message : t("admin.centralCodex.error"),
{ cause: err },
);
showErrorToast(localizedApiError(err, t, "admin.centralCodex.error"), {
cause: err,
});
setDevicePolling(false);
}
}
@@ -92,15 +92,11 @@ export function CentralCodexSection() {
async function tick() {
try {
const res = await apiFetchRaw("/api/admin/central-codex/device-poll", {
method: "POST",
});
const json = await res.json();
const data = await apiPost<{ status?: string } | null>(
"/api/admin/central-codex/device-poll",
);
if (cancelled) return;
if (!res.ok) {
throw new Error(json.error || t("admin.centralCodex.error"));
}
if (json.data?.status === "connected") {
if (data?.status === "connected") {
setDeviceCode(null);
setDevicePolling(false);
toast.success(t("admin.centralCodex.connected"));
@@ -113,10 +109,9 @@ export function CentralCodexSection() {
if (!cancelled) setTimeout(tick, intervalMs);
} catch (err) {
if (cancelled) return;
showErrorToast(
err instanceof Error ? err.message : t("admin.centralCodex.error"),
{ cause: err },
);
showErrorToast(localizedApiError(err, t, "admin.centralCodex.error"), {
cause: err,
});
setDeviceCode(null);
setDevicePolling(false);
}
@@ -133,23 +128,16 @@ export function CentralCodexSection() {
async function handleDisconnect() {
setDisconnecting(true);
try {
const res = await apiFetchRaw("/api/admin/central-codex", {
method: "DELETE",
});
if (!res.ok) {
const json = await res.json();
throw new Error(json.error || t("admin.centralCodex.error"));
}
await apiDelete("/api/admin/central-codex");
toast.success(t("admin.centralCodex.disconnected"));
queryClient.invalidateQueries({
queryKey: queryKeys.adminCentralCodex(),
});
void invalidateKeys(queryClient, aiInputDependentKeys);
} catch (err) {
showErrorToast(
err instanceof Error ? err.message : t("admin.centralCodex.error"),
{ cause: err },
);
showErrorToast(localizedApiError(err, t, "admin.centralCodex.error"), {
cause: err,
});
} finally {
setDisconnecting(false);
}
+5 -2
View File
@@ -22,6 +22,7 @@ import { SettingsCard } from "@/components/settings/settings-card";
import { SettingsCardHeader } from "@/components/settings/_card-header";
import { useTranslations } from "@/lib/i18n/context";
import { apiDelete } from "@/lib/api/api-fetch";
import { localizedApiError } from "@/lib/api/localized-error";
import { randomId } from "@/lib/random-id";
import {
recentProofErrorMessage,
@@ -81,8 +82,10 @@ export function DangerZoneSection() {
}),
);
},
onError: (err: Error) => {
setWipeMsg(recentProofErrorMessage(err, err.message));
onError: (err) => {
setWipeMsg(
recentProofErrorMessage(err, localizedApiError(err, t, "common.error")),
);
},
});
+2 -3
View File
@@ -7,6 +7,7 @@ import { ConfirmButton } from "@/components/ui/confirm-button";
import { toastWrittenOutcome } from "@/components/outcome/outcome-toast";
import { showErrorToast } from "@/lib/errors/error-toast";
import { apiDelete } from "@/lib/api/api-fetch";
import { localizedApiError } from "@/lib/api/localized-error";
import { formatDate } from "@/lib/format";
import { useTranslations } from "@/lib/i18n/context";
import { queryKeys } from "@/lib/query-keys";
@@ -90,9 +91,7 @@ function DeleteUserButton({ user }: { user: AdminUser }) {
onError: (err) => {
const message = recentProofErrorMessage(
err,
err instanceof Error && err.message
? err.message
: t("admin.section.users.deleteUserFailed"),
localizedApiError(err, t, "admin.section.users.deleteUserFailed"),
);
if (message) showErrorToast(message, { cause: err });
},
+11 -9
View File
@@ -43,8 +43,13 @@ import { SettingsCard } from "@/components/settings/settings-card";
import { SettingsCardHeader } from "@/components/settings/_card-header";
import { useFormatters, useTranslations } from "@/lib/i18n/context";
import { queryKeys } from "@/lib/query-keys";
import { apiFetchRaw, apiGet } from "@/lib/api/api-fetch";
import { getApiErrorMessage } from "./_shared";
import {
ApiError,
apiFetchRaw,
apiGet,
throwApiError,
} from "@/lib/api/api-fetch";
import { localizedApiError } from "@/lib/api/localized-error";
import {
recentProofErrorMessage,
throwIfReproofRequired,
@@ -133,12 +138,7 @@ export function EncryptionSection() {
}),
),
);
if (!res.ok) {
if (res.status === 401) {
throw new Error(t("admin.section.encryption.stepUpNeeded"));
}
throw new Error(await getApiErrorMessage(res));
}
if (!res.ok) await throwApiError(res);
return res.json();
},
onSuccess: () => {
@@ -150,7 +150,9 @@ export function EncryptionSection() {
onError: (err: Error) => {
const message = recentProofErrorMessage(
err,
err.message || t("admin.section.encryption.rotateFailed"),
err instanceof ApiError && err.status === 401
? t("admin.section.encryption.stepUpNeeded")
: localizedApiError(err, t, "admin.section.encryption.rotateFailed"),
);
if (message === null) return;
showErrorToast(message, { cause: err });
+2 -3
View File
@@ -20,6 +20,7 @@ import {
ConfiguredBadge,
} from "./_shared";
import { apiPost } from "@/lib/api/api-fetch";
import { localizedApiError } from "@/lib/api/localized-error";
import { queryKeys } from "@/lib/query-keys";
export function GlitchtipSection() {
@@ -113,9 +114,7 @@ export function GlitchtipSection() {
},
onError: (error) => {
showErrorToast(
error instanceof Error
? error.message
: t("admin.monitoringTestFailed"),
localizedApiError(error, t, "admin.monitoringTestFailed"),
{ cause: error },
);
void queryClient.invalidateQueries({
@@ -33,12 +33,12 @@ import {
} 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 { apiFetchRaw, apiGet, throwApiError } from "@/lib/api/api-fetch";
import { localizedApiError } from "@/lib/api/localized-error";
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";
@@ -70,7 +70,7 @@ export function MeasurementMaintenanceCard() {
headers: { "content-type": "application/json" },
body: JSON.stringify({ vacuum: true, reindex: true }),
});
if (!res.ok) throw new Error(await getApiErrorMessage(res));
if (!res.ok) await throwApiError(res);
return ((await res.json()) as { data: { enqueued: boolean } }).data;
},
onSuccess: ({ enqueued }) => {
@@ -80,9 +80,9 @@ export function MeasurementMaintenanceCard() {
queryKey: queryKeys.adminMeasurementMaintenance(),
});
},
onError: (err: Error) => {
onError: (err) => {
showErrorToast(
err.message || t("admin.section.maintenance.startFailed"),
localizedApiError(err, t, "admin.section.maintenance.startFailed"),
{ cause: err },
);
},
@@ -23,6 +23,7 @@ import {
import type { ModuleKey } from "@/lib/modules/registry";
import { SettingsToggle } from "./_shared";
import { apiGet, apiPatch } from "@/lib/api/api-fetch";
import { localizedApiError } from "@/lib/api/localized-error";
import { InlineAction } from "@/components/ui/inline-action";
/**
@@ -76,12 +77,9 @@ function useUpdateModuleAvailability() {
toast.success(t("common.saved"));
},
onError: (err) => {
showErrorToast(
err instanceof Error && err.message
? err.message
: t("admin.modules.saveError"),
{ cause: err },
);
showErrorToast(localizedApiError(err, t, "admin.modules.saveError"), {
cause: err,
});
client.invalidateQueries({
queryKey: queryKeys.adminModuleAvailability(),
});
@@ -8,6 +8,7 @@ import { Badge } from "@/components/ui/badge";
import { ConfirmButton } from "@/components/ui/confirm-button";
import { showErrorToast } from "@/lib/errors/error-toast";
import { apiPost } from "@/lib/api/api-fetch";
import { localizedApiError } from "@/lib/api/localized-error";
import { formatDate } from "@/lib/format";
import { useTranslations } from "@/lib/i18n/context";
import { queryKeys } from "@/lib/query-keys";
@@ -41,9 +42,7 @@ export function PendingDeletionControls({ user }: { user: AdminUser }) {
},
onError: (err) => {
showErrorToast(
err instanceof Error && err.message
? err.message
: t("admin.section.users.undoDeletionFailed"),
localizedApiError(err, t, "admin.section.users.undoDeletionFailed"),
{ cause: err },
);
},
+23 -20
View File
@@ -32,7 +32,13 @@ import {
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 {
ApiError,
apiFetchRaw,
apiGet,
throwApiError,
} from "@/lib/api/api-fetch";
import { localizedApiError } from "@/lib/api/localized-error";
import { showErrorToast } from "@/lib/errors/error-toast";
import { useFormatters, useTranslations } from "@/lib/i18n/context";
import { queryKeys } from "@/lib/query-keys";
@@ -77,23 +83,7 @@ export function PulseDayMeanCard() {
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"),
);
}
if (!res.ok) await throwApiError(res);
const { enqueued } = (
(await res.json()) as { data: { enqueued: boolean } }
).data;
@@ -117,8 +107,21 @@ export function PulseDayMeanCard() {
queryKey: queryKeys.adminPulseDayMean(),
});
},
onError: (err: Error) => {
showErrorToast(err.message, { cause: err });
// The route's two refusals each have their own message.
onError: (err) => {
const code = err instanceof ApiError ? err.meta?.errorCode : undefined;
showErrorToast(
code === "maintenance.pulseDayMean.busy"
? t("admin.section.pulseDayMean.error.busy")
: code === "maintenance.pulseDayMean.dryRunRequired"
? t("admin.section.pulseDayMean.error.dryRunRequired")
: localizedApiError(
err,
t,
"admin.section.pulseDayMean.error.generic",
),
{ cause: err },
);
},
});
+12 -7
View File
@@ -32,6 +32,7 @@ import { NativeSelect } from "@/components/ui/native-select";
import { QueryErrorRow } from "@/components/ui/query-error-row";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { ApiError, apiFetch, apiPost } from "@/lib/api/api-fetch";
import { localizedApiError } from "@/lib/api/localized-error";
import { showErrorToast } from "@/lib/errors/error-toast";
import { useFormatters, useTranslations } from "@/lib/i18n/context";
import { queryKeys } from "@/lib/query-keys";
@@ -264,11 +265,14 @@ function CheckMyCopy({ kit }: { kit: RecoveryKit }) {
setCheckResult(data.matches);
setCandidate("");
},
onError: (err: Error) => {
onError: (err) => {
setCheckResult(null);
showErrorToast(err.message || t("admin.recoveryKit.checkFailed"), {
cause: err,
});
showErrorToast(
localizedApiError(err, t, "admin.recoveryKit.checkFailed"),
{
cause: err,
},
);
},
});
@@ -395,9 +399,10 @@ export function RecoveryKitCard() {
await invalidate();
return;
}
showErrorToast(err.message || t("admin.recoveryKit.confirmFailed"), {
cause: err,
});
showErrorToast(
localizedApiError(err, t, "admin.recoveryKit.confirmFailed"),
{ cause: err },
);
},
});
+5 -7
View File
@@ -23,6 +23,7 @@ import { useTranslations } from "@/lib/i18n/context";
import { queryKeys } from "@/lib/query-keys";
import { useAdminSettings, useUpdateSettings } from "./_shared";
import { apiGet, apiPost } from "@/lib/api/api-fetch";
import { localizedApiError } from "@/lib/api/localized-error";
import {
describeChannelTestFailures,
type ChannelTestResult,
@@ -163,9 +164,7 @@ export function RemindersSection() {
},
onError: (error) => {
showErrorToast(
error instanceof Error
? error.message
: t("admin.notificationTestFailed"),
localizedApiError(error, t, "admin.notificationTestFailed"),
{ cause: error },
);
},
@@ -201,10 +200,9 @@ export function RemindersSection() {
toast.success(data?.message ?? t("admin.reminderCheckSuccess"));
},
onError: (error) => {
showErrorToast(
error instanceof Error ? error.message : t("admin.reminderCheckFailed"),
{ cause: error },
);
showErrorToast(localizedApiError(error, t, "admin.reminderCheckFailed"), {
cause: error,
});
},
});
+2 -3
View File
@@ -19,6 +19,7 @@ import {
ConfiguredBadge,
} from "./_shared";
import { apiPost } from "@/lib/api/api-fetch";
import { localizedApiError } from "@/lib/api/localized-error";
export function UmamiSection() {
const { t } = useTranslations();
@@ -52,9 +53,7 @@ export function UmamiSection() {
},
onError: (error) => {
showErrorToast(
error instanceof Error
? error.message
: t("admin.monitoringTestFailed"),
localizedApiError(error, t, "admin.monitoringTestFailed"),
{ cause: error },
);
},
@@ -41,7 +41,8 @@ import { queryKeys } from "@/lib/query-keys";
import { type AdminUser, PasswordInput } from "./_shared";
import { PendingDeletionControls } from "./pending-deletion-controls";
import { DeleteUserControl } from "./delete-user-control";
import { apiGet, apiPost, apiPut } from "@/lib/api/api-fetch";
import { ApiError, apiGet, apiPost, apiPut } from "@/lib/api/api-fetch";
import { localizedApiError } from "@/lib/api/localized-error";
import {
recentProofErrorMessage,
useRecentProof,
@@ -109,12 +110,9 @@ export function UserManagementSection() {
toast.success(t("common.saved"));
},
onError: (err) => {
showErrorToast(
err instanceof Error && err.message
? err.message
: t("admin.settingsSaveError"),
{ cause: err },
);
showErrorToast(localizedApiError(err, t, "admin.settingsSaveError"), {
cause: err,
});
},
});
@@ -132,8 +130,17 @@ export function UserManagementSection() {
setResetMsg(t("admin.passwordReset"));
setResetPassword("");
},
onError: (err: Error) => {
setResetMsg(recentProofErrorMessage(err, err.message));
// The server words its password verdict (too weak, found in a breach) in
// the account's language; any other refusal reads through the catalogue.
onError: (err) => {
setResetMsg(
recentProofErrorMessage(
err,
err instanceof ApiError && err.status === 422
? err.message
: localizedApiError(err, t, "common.error"),
),
);
},
});
@@ -156,9 +163,7 @@ export function UserManagementSection() {
},
onError: (err) => {
showErrorToast(
err instanceof Error && err.message
? err.message
: t("admin.section.users.forceLogoutFailed"),
localizedApiError(err, t, "admin.section.users.forceLogoutFailed"),
{ cause: err },
);
},
@@ -560,7 +565,11 @@ export function UserManagementSection() {
</div>
{updateUser.isError && (
<p className="text-destructive text-sm">
{(updateUser.error as Error).message}
{localizedApiError(
updateUser.error,
t,
"admin.settingsSaveError",
)}
</p>
)}
<SettingsCardActions>
@@ -0,0 +1,59 @@
/**
* The BMI chart divides the weights on screen by the height of the record on
* screen. It used to know only the viewer's height, so inside another
* person's record it drew nothing at all.
*/
import { beforeEach, describe, expect, it, vi } from "vitest";
const h = vi.hoisted(() => ({
inSharedRecord: false,
recordHeightCm: 120 as number | null,
enabled: [] as boolean[],
}));
vi.mock("@/hooks/use-auth", () => ({
useAuth: () => ({ isAuthenticated: true, user: { heightCm: 180 } }),
}));
vi.mock("@/hooks/use-record-capabilities", () => ({
useRecordCapabilities: () => ({ inSharedRecord: h.inSharedRecord }),
}));
vi.mock("@tanstack/react-query", () => ({
useQuery: (options: { enabled: boolean }) => {
h.enabled.push(options.enabled);
return options.enabled
? { data: { profile: { heightCm: h.recordHeightCm } } }
: { data: undefined };
},
}));
import { useBmiDivisor } from "../health-chart/use-bmi-divisor";
describe("useBmiDivisor", () => {
beforeEach(() => {
h.inSharedRecord = false;
h.recordHeightCm = 120;
h.enabled.length = 0;
});
it("uses the viewer's own height in their own record", () => {
expect(useBmiDivisor("bmi")).toBeCloseTo(3.24);
expect(h.enabled).toEqual([false]);
});
it("uses the record's height inside somebody else's record", () => {
h.inSharedRecord = true;
expect(useBmiDivisor("bmi")).toBeCloseTo(1.44);
});
it("draws no BMI when the record has no height", () => {
h.inSharedRecord = true;
h.recordHeightCm = null;
expect(useBmiDivisor("bmi")).toBeNull();
});
it("asks for nothing outside the BMI view", () => {
h.inSharedRecord = true;
expect(useBmiDivisor("raw")).toBeNull();
expect(h.enabled).toEqual([false]);
});
});
+2 -10
View File
@@ -19,7 +19,6 @@ import {
import { Skeleton } from "@/components/ui/skeleton";
import { useAuth } from "@/hooks/use-auth";
import { useChartOverlayPrefs } from "@/hooks/use-chart-overlay-prefs";
import { useRecordCapabilities } from "@/hooks/use-record-capabilities";
import { useViewportWidth } from "@/hooks/use-viewport-width";
import { axisTickUnitSuffix } from "@/lib/charts/axis-unit";
import { makeBucketLabelFormatters } from "@/lib/charts/bucket-label";
@@ -61,6 +60,7 @@ import {
useHealthChartLabels,
useHealthChartOverlays,
} from "./health-chart/use-health-chart-overlays";
import { useBmiDivisor } from "./health-chart/use-bmi-divisor";
import { useHealthChartSeries } from "./health-chart/use-health-chart-series";
import { useHealthChartView } from "./health-chart/use-health-chart-view";
import { resolveMiniRangePoints } from "./mini-window";
@@ -157,15 +157,7 @@ export function HealthChart({
// Viewport-aware tick density: 4 to 10 labels, whatever the point count.
const viewportWidth = useViewportWidth();
// `useAuth()` is the person at the keyboard, and under a switch that is the
// delegate: dividing the owner's weights by the delegate's height squared
// gives a plausible BMI that is somebody else's. Inside a shared record the
// chart draws no BMI rather than a wrong one.
const { inSharedRecord } = useRecordCapabilities();
const bmiDivisor =
valueMode === "bmi" && !inSharedRecord && user?.heightCm
? (user.heightCm / 100) ** 2
: null;
const bmiDivisor = useBmiDivisor(valueMode);
const { data, isLoading, isError, error, isPlaceholderData, refetch } =
useHealthChartSeries({
@@ -0,0 +1,36 @@
"use client";
import { useQuery } from "@tanstack/react-query";
import { useAuth } from "@/hooks/use-auth";
import { useRecordCapabilities } from "@/hooks/use-record-capabilities";
import { apiGet } from "@/lib/api/api-fetch";
import { queryKeys } from "@/lib/query-keys";
/**
* The BMI view's divisor: the height of the record on screen, in metres,
* squared. `useAuth()` is the person at the keyboard, and under a switch that
* is the delegate, so inside somebody else's record the height comes from
* `/api/insights/targets`, the route that knows which record is on screen
* (the BMI page and the reference panel read the same cell). No height, no
* BMI.
*/
export function useBmiDivisor(
valueMode: "raw" | "bmi" | undefined,
): number | null {
const { isAuthenticated, user } = useAuth();
const { inSharedRecord } = useRecordCapabilities();
const { data: targets } = useQuery({
queryKey: queryKeys.insightsTargets(),
queryFn: () =>
apiGet<{ profile?: { heightCm: number | null } }>(
"/api/insights/targets",
),
enabled: isAuthenticated && valueMode === "bmi" && inSharedRecord,
});
if (valueMode !== "bmi") return null;
const heightCm = inSharedRecord
? (targets?.profile?.heightCm ?? null)
: (user?.heightCm ?? null);
return heightCm ? (heightCm / 100) ** 2 : null;
}
@@ -42,11 +42,10 @@ const FULL_RECORD = {
end: "2026-01-31T00:00:00.000Z",
},
patient: {
username: "shared-account",
name: "A. Patient",
dateOfBirth: "1980-01-01T00:00:00.000Z",
gender: "FEMALE",
heightCm: 170,
fullName: "A. Patient",
},
practiceName: null,
measurements: {},
@@ -27,7 +27,7 @@ function makeReport(
end: "2026-01-31T00:00:00.000Z",
},
patient: {
username: "tester",
name: "tester",
dateOfBirth: null,
gender: null,
heightCm: null,
@@ -72,6 +72,7 @@ function render(
| "selection"
| "timeFormat"
| "dateFormat"
| "timezone"
>
>,
) {
@@ -501,6 +502,45 @@ describe("<ClinicianView>", () => {
expect(html).not.toContain('<img src="/c/hls_tok/d/doc-b"');
expect(html).toContain("Referral letter");
});
it("prints the date of birth on its own day west of UTC", () => {
const html = render(
makeReport({
patient: {
name: "A. Patient",
// Stored at UTC midnight: 13 May, 20:00 in New York.
dateOfBirth: "1960-05-14T00:00:00.000Z",
gender: null,
heightCm: null,
},
}),
{ timezone: "America/New_York", locale: "en" },
);
expect(html).toContain("05/14/1960");
expect(html).not.toContain("05/13/1960");
});
it("dates a vaccination on its own day west of UTC", () => {
const html = render(
makeReport({
immunizations: [
{
// A dose date at UTC midnight: the evening before in New York.
occurredAt: "2026-04-11T00:00:00.000Z",
antigenSlug: null,
vaccineName: "Influenza",
lotNumber: null,
site: null,
practitionerName: null,
series: [],
},
],
}),
{ timezone: "America/New_York", locale: "en" },
);
expect(html).toContain("04/11/2026");
expect(html).not.toContain("04/10/2026");
});
});
/**
+12 -2
View File
@@ -30,6 +30,7 @@
* document list in `./documents-list`, the downloads in `./download-actions`.
*/
import type { DoctorReportData } from "@/lib/doctor-report-data";
import { formatDate } from "@/lib/date-format";
import { formatLabValue, type LabNumberFormat } from "@/lib/labs/format-value";
import {
makeFormatters,
@@ -133,6 +134,15 @@ interface ClinicianViewProps {
dateFormat: DateFormatPreference;
}
/**
* A date-only value (a date of birth, a dose date) as its calendar date in
* the owner's date order, the same in every zone. Read as an instant in the
* owner's zone it fell on the previous day west of UTC.
*/
function calendarDateIn(dateFormat: DateFormatPreference, locale: Locale) {
return (iso: string) => formatDate(iso.slice(0, 10), dateFormat, locale);
}
export function ClinicianView({
t,
label,
@@ -199,7 +209,7 @@ export function ClinicianView({
t={t}
report={report}
scope={scope}
fmtDate={fmtDate}
fmtCalendarDate={calendarDateIn(dateFormat, locale)}
/>
{/* ── measurements ─────────────────────────────────────── */}
@@ -298,7 +308,7 @@ export function ClinicianView({
t={t}
report={report}
scope={scope}
fmtDate={fmtDate}
fmtCalendarDate={calendarDateIn(dateFormat, locale)}
/>
<AllergiesSection t={t} report={report} scope={scope} />
@@ -302,12 +302,13 @@ export function ImmunizationsSection({
t,
report,
scope,
fmtDate,
fmtCalendarDate,
}: {
t: Translate;
report: DoctorReportData;
scope: LeafScope;
fmtDate: (iso: string) => string;
/** A dose date is a calendar date at UTC midnight, never an instant. */
fmtCalendarDate: (iso: string) => string;
}) {
const doses = report.immunizations ?? [];
@@ -342,7 +343,7 @@ export function ImmunizationsSection({
: (dose.vaccineName ?? "—")
}
value={compose([
fmtDate(dose.occurredAt),
fmtCalendarDate(dose.occurredAt),
doseDisplay(dose.series),
dose.lotNumber
? `${t("doctorReport.immunizationsColLot")} ${dose.lotNumber}`
@@ -48,15 +48,15 @@ export function PatientIdentitySection({
t,
report,
scope,
fmtDate,
fmtCalendarDate,
}: {
t: Translate;
report: DoctorReportData;
scope: LeafScope;
fmtDate: (iso: string) => string;
fmtCalendarDate: (iso: string) => string;
}) {
const patient = report.patient;
const name = patient.fullName ?? patient.username ?? null;
const name = patient.name;
const genderKey = patient.gender
? GENDER_LABEL_KEYS[patient.gender]
: undefined;
@@ -67,7 +67,7 @@ export function PatientIdentitySection({
if (patient.dateOfBirth) {
rows.push({
label: t("doctorReport.dateOfBirth"),
value: fmtDate(patient.dateOfBirth),
value: fmtCalendarDate(patient.dateOfBirth),
});
}
if (genderKey) {
@@ -11,7 +11,8 @@ import { DateTimeField } from "@/components/ui/date-time-field";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Textarea } from "@/components/ui/textarea";
import { ApiError, apiPost } from "@/lib/api/api-fetch";
import { localizedApiError } from "@/lib/api/localized-error";
import { useIdempotentPost } from "@/lib/api/use-idempotent-post";
import { useTranslations } from "@/lib/i18n/context";
import { queryKeys } from "@/lib/query-keys";
@@ -55,6 +56,7 @@ export function CustomMetricEntryForm({
const { t } = useTranslations();
const formId = useId();
const queryClient = useQueryClient();
const post = useIdempotentPost();
const [value, setValue] = useState("");
const [measuredAt, setMeasuredAt] = useState(() => nowLocal());
@@ -85,7 +87,7 @@ export function CustomMetricEntryForm({
setSubmitting(true);
try {
await apiPost<CustomMetricEntryDto>(
await post<CustomMetricEntryDto>(
`/api/custom-metrics/${customMetricId}/entries`,
body,
);
@@ -99,11 +101,7 @@ export function CustomMetricEntryForm({
});
onSuccess?.();
} catch (err) {
const message =
err instanceof ApiError
? err.message
: t("customMetrics.entry.saveError");
setError(message);
setError(localizedApiError(err, t, "customMetrics.entry.saveError"));
} finally {
setSubmitting(false);
}
@@ -10,7 +10,9 @@ import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Switch } from "@/components/ui/switch";
import { Textarea } from "@/components/ui/textarea";
import { ApiError, apiPatch, apiPost } from "@/lib/api/api-fetch";
import { ApiError, apiPatch } from "@/lib/api/api-fetch";
import { useIdempotentPost } from "@/lib/api/use-idempotent-post";
import { localizedApiError } from "@/lib/api/localized-error";
import { useTranslations } from "@/lib/i18n/context";
import type { CustomMetricDto } from "./types";
@@ -33,6 +35,18 @@ function parseInteger(raw: string): number | null {
return Number.isInteger(n) && n >= 0 ? n : null;
}
/**
* Update the metric, or define a new one under one Idempotency-Key until the
* create lands, so a retry after a timeout is not a second metric.
*/
function useSaveCustomMetric(existing: CustomMetricDto | undefined) {
const post = useIdempotentPost();
return (body: unknown) =>
existing
? apiPatch<CustomMetricDto>(`/api/custom-metrics/${existing.id}`, body)
: post<CustomMetricDto>("/api/custom-metrics", body);
}
interface CustomMetricFormProps {
/** When set the form edits this metric; otherwise it defines a new one. */
existing?: CustomMetricDto;
@@ -46,6 +60,17 @@ interface CustomMetricFormProps {
footerSlot?: HTMLElement | null;
}
/**
* The sentence for a refused save. A name clash is read by its code: a 409
* can also be the first save still running under the same key.
*/
function saveRefusal(err: unknown, t: (key: string) => string): string {
return err instanceof ApiError &&
err.meta?.errorCode === "customMetric.name_taken"
? t("customMetrics.form.nameTaken")
: localizedApiError(err, t, "customMetrics.form.saveError");
}
/**
* v1.25.5 — define or edit a custom metric.
*
@@ -79,6 +104,7 @@ export function CustomMetricForm({
);
const [submitting, setSubmitting] = useState(false);
const [error, setError] = useState<string | null>(null);
const save = useSaveCustomMetric(existing);
async function handleSubmit(e: React.FormEvent) {
e.preventDefault();
@@ -109,12 +135,7 @@ export function CustomMetricForm({
setSubmitting(true);
try {
const saved = existing
? await apiPatch<CustomMetricDto>(
`/api/custom-metrics/${existing.id}`,
body,
)
: await apiPost<CustomMetricDto>("/api/custom-metrics", body);
const saved = await save(body);
toast.success(
existing
? t("customMetrics.form.updatedToast")
@@ -122,11 +143,7 @@ export function CustomMetricForm({
);
onSuccess?.(saved);
} catch (err) {
const message =
err instanceof ApiError
? err.message
: t("customMetrics.form.saveError");
setError(message);
setError(saveRefusal(err, t));
} finally {
setSubmitting(false);
}
@@ -22,7 +22,8 @@ import {
} from "@/components/ui/table";
import { Textarea } from "@/components/ui/textarea";
import { useTableSort } from "@/hooks/use-table-sort";
import { ApiError, apiDelete, apiPatch, apiPost } from "@/lib/api/api-fetch";
import { apiDelete, apiPatch, apiPost } from "@/lib/api/api-fetch";
import { localizedApiError } from "@/lib/api/localized-error";
import { formatDateShort } from "@/lib/format";
import { useFormatters, useTranslations } from "@/lib/i18n/context";
import { queryKeys } from "@/lib/query-keys";
@@ -140,11 +141,7 @@ export function CustomMetricHistoryList({
closeEdit();
},
onError: (err) => {
setEditError(
err instanceof ApiError
? err.message
: t("customMetrics.entry.editError"),
);
setEditError(localizedApiError(err, t, "customMetrics.entry.editError"));
},
});
@@ -42,7 +42,11 @@ const idleQuery = {
const mutationStub = { mutate: vi.fn(), isPending: false, isError: false };
vi.mock("../use-cycle", () => ({
localYmd: () => "2026-07-01",
useCycleWindow: () => ({
today: "2026-07-01",
from: "2026-04-02",
to: "2026-12-28",
}),
useCycleCalendar: () => errorQuery,
useCycleHistory: () => idleQuery,
useCycleInsights: () => idleQuery,
@@ -95,7 +95,7 @@ const idleQuery = {
const mutationStub = { mutate: vi.fn(), isPending: false, isError: false };
vi.mock("../use-cycle", () => ({
localYmd: () => TODAY,
useCycleWindow: () => ({ today: TODAY, from: TODAY, to: TODAY }),
useCycleCalendar: () => calendarQuery,
useCycleHistory: () => idleQuery,
useCycleInsights: () => idleQuery,
@@ -16,14 +16,14 @@ import { describe, expect, it } from "vitest";
import { ApiError } from "@/lib/api/api-fetch";
import { queryKeys } from "@/lib/query-keys";
import { localYmd, useCycleRingDial } from "../use-cycle";
import { DEFAULT_TIMEZONE, shiftDateKey, userDayKey } from "@/lib/tz/format";
import { useCycleRingDial } from "../use-cycle";
/** The ring's window, around today in the display zone (Berlin when unset). */
function window(): [string, string] {
const from = new Date();
from.setDate(from.getDate() - 90);
const to = new Date();
to.setDate(to.getDate() + 180);
return [localYmd(from), localYmd(to)];
const today = userDayKey(new Date(), DEFAULT_TIMEZONE);
return [shiftDateKey(today, -90), shiftDateKey(today, 180)];
}
function Probe({ enabled }: { enabled: boolean }) {
@@ -0,0 +1,33 @@
/**
* The cycle surfaces take "today" from the record's display zone, the zone
* the server keys cycle days in. They used to read the device clock, so with
* a phone in another zone than the profile the highlighted day and the day a
* log lands on sat one apart from the server's around midnight.
*/
import { afterEach, describe, expect, it, vi } from "vitest";
import { renderToStaticMarkup } from "react-dom/server";
vi.mock("@/lib/i18n/context", async (importOriginal) => ({
...(await importOriginal<typeof import("@/lib/i18n/context")>()),
// UTC+14: already the next calendar day for most of the world's afternoon.
useDisplayTimezone: () => "Pacific/Kiritimati",
}));
import { useCycleWindow } from "../use-cycle";
function Probe() {
const { today, from, to } = useCycleWindow();
return <span>{`${from}|${today}|${to}`}</span>;
}
afterEach(() => vi.useRealTimers());
describe("useCycleWindow", () => {
it("dates today in the display zone, not on the device clock", () => {
vi.useFakeTimers({ toFake: ["Date"] });
vi.setSystemTime(new Date("2026-07-01T12:00:00.000Z"));
expect(renderToStaticMarkup(<Probe />)).toContain(
"2026-04-03|2026-07-02|2026-12-29",
);
});
});
@@ -1,6 +1,5 @@
"use client";
import { useMemo } from "react";
import Link from "next/link";
import { ArrowRight, Droplets } from "lucide-react";
@@ -8,7 +7,11 @@ import { useTranslations } from "@/lib/i18n/context";
import { SectionHeading } from "@/components/ui/section-heading";
import { CyclePhaseHeadline } from "./cycle-phase-crosstab";
import { PHASE_HUE } from "./phase-tokens";
import { localYmd, useCycleCalendar, useCycleInsights } from "./use-cycle";
import {
useCycleCalendar,
useCycleInsights,
useCycleWindow,
} from "./use-cycle";
import { QueryErrorRow } from "@/components/ui/query-error-row";
/**
@@ -37,20 +40,12 @@ import { QueryErrorRow } from "@/components/ui/query-error-row";
* a broken or empty-framed state on the overview.
*/
/** YYYY-MM-DD for `n` days from now in the local tz (shares `localYmd`). */
function shiftToday(days: number): string {
const d = new Date();
d.setDate(d.getDate() + days);
return localYmd(d);
}
export function CycleInsightSummaryCard() {
const { t } = useTranslations();
// Mirror the cycle-view window so the cache key is shared (no extra request
// when the user later opens the cycle page in the same session).
const from = useMemo(() => shiftToday(-90), []);
const to = useMemo(() => shiftToday(180), []);
const { from, to } = useCycleWindow();
const calendar = useCycleCalendar(from, to);
const insights = useCycleInsights();
+2 -11
View File
@@ -27,7 +27,7 @@ import { CycleSettingsTab } from "./cycle-settings-tab";
import { PHASE_HUE } from "./phase-tokens";
import { monthOf, monthWindowOutside } from "./calendar-window";
import {
localYmd,
useCycleWindow,
useCycleCalendar,
useCycleHistory,
useCycleInsights,
@@ -50,13 +50,6 @@ import { ListCapNotice } from "@/components/ui/list-cap-notice";
* re-triggers the moment.
*/
/** YYYY-MM-DD for `n` days from now in the local tz (shares `localYmd`). */
function shiftToday(days: number): string {
const d = new Date();
d.setDate(d.getDate() + days);
return localYmd(d);
}
/** The four cycle tabs, in render order. The deep-link value is validated
* against this set so a junk `?tab=` falls back to the calendar tab. */
// The BBT chart is the only recharts consumer on /cycle — load it through
@@ -114,9 +107,7 @@ export function CycleView() {
// a later param change does not force-switch a tab the user is reading).
const [tab, setTab] = useState<CycleTab>(initialTab);
const today = useMemo(() => shiftToday(0), []);
const from = useMemo(() => shiftToday(-90), []);
const to = useMemo(() => shiftToday(180), []);
const { today, from, to } = useCycleWindow();
const calendar = useCycleCalendar(from, to);
const history = useCycleHistory();
+20 -23
View File
@@ -21,7 +21,8 @@ import {
apiPatch,
apiPost,
} from "@/lib/api/api-fetch";
import { useTranslations } from "@/lib/i18n/context";
import { useDisplayTimezone, useTranslations } from "@/lib/i18n/context";
import { shiftDateKey, userDayKey } from "@/lib/tz/format";
import { randomId } from "@/lib/random-id";
import { toastWrittenOutcome } from "@/components/outcome/outcome-toast";
import { useCalendarDate } from "@/hooks/use-calendar-date";
@@ -72,16 +73,23 @@ export interface CycleInsightsResponse {
}
/**
* The local-timezone `YYYY-MM-DD` for a Date (default: now). Cycle day-keys
* are tz-anchored on the server; the client must derive the key from the
* user's wall-clock day, never from `toISOString()` (which is UTC and rolls
* the date over near midnight).
* Today, and the window the cycle page, its ring and its summary card read
* (90 days back, 180 ahead), in the record's display zone. The server keys
* cycle days, "today" included, in the profile zone; the device clock put
* the highlighted day and the day a log lands on one apart from it when a
* phone sat in another zone near midnight. One window, so the three readers
* share one cache entry.
*/
export function localYmd(d: Date = new Date()): string {
const y = d.getFullYear();
const m = String(d.getMonth() + 1).padStart(2, "0");
const day = String(d.getDate()).padStart(2, "0");
return `${y}-${m}-${day}`;
export function useCycleWindow(): { today: string; from: string; to: string } {
const timeZone = useDisplayTimezone();
return useMemo(() => {
const today = userDayKey(new Date(), timeZone);
return {
today,
from: shiftDateKey(today, -90),
to: shiftDateKey(today, 180),
};
}, [timeZone]);
}
/**
@@ -115,20 +123,9 @@ export function useCycleCalendar(from: string, to: string, enabled = true) {
});
}
/** YYYY-MM-DD for `days` from today in the local zone. */
function localYmdFromToday(days: number): string {
const d = new Date();
d.setDate(d.getDate() + days);
return localYmd(d);
}
/**
* The calendar window the cycle ring reads: the cycle page's own window, so
* the ring, the page and the summary card share one cache entry.
*/
/** The calendar window the cycle ring reads: the cycle page's own. */
export function useCycleRingCalendar(enabled = true) {
const from = useMemo(() => localYmdFromToday(-90), []);
const to = useMemo(() => localYmdFromToday(180), []);
const { from, to } = useCycleWindow();
return useCycleCalendar(from, to, enabled);
}
@@ -393,21 +393,22 @@ describe("submit invalidation — the quick-add is a capture-picker surface, not
);
it("routes the intake submit through the blessed invalidateMedicationReads (source pin)", () => {
expect(componentSrc).toContain(
"await invalidateMedicationReads(queryClient);",
);
expect(componentSrc).not.toContain(
"invalidateKeys(queryClient, medicationDependentKeys)",
);
// The submit handler pairs the blessed helper with the inline
// compliance-chart fan-out AFTER the intake POST landed.
// AFTER the intake POST landed, and after the sheet was told it is done:
// the save does not wait for the refetches before it closes.
expect(componentSrc).toMatch(
/async function handleSubmit\([\s\S]*?apiPost[\s\S]*?await invalidateMedicationReads\(queryClient\);[\s\S]*?complianceChartInline\(\)/,
/async function handleSubmit\([\s\S]*?await post<[\s\S]*?onSuccess\?\.\(\);\s*(?:\/\/[^\n]*\n\s*)*void invalidateMedicationReads\(queryClient\);/,
);
// The bundle carries every inline compliance chart, so no second sweep.
expect(medicationDependentKeys).toContainEqual(
queryKeys.complianceChartInline(),
);
});
it("forces the inactive Today hero + dashboard reads to refetch after submit", async () => {
// The helper the submit path awaits — asserted directly, same shape as
// The helper the submit path calls, asserted directly, same shape as
// the take-all-due suite: the bundle fans out (N keys), then the two
// daily reads are invalidated with `refetchType: "inactive"`, which is
// what actually refetches them while unmounted.
@@ -27,7 +27,9 @@ import {
} from "@/lib/medications/default-medication";
import { useAuth } from "@/hooks/use-auth";
import { useActiveRecordName } from "@/hooks/use-record-capabilities";
import { ApiError, apiGet, apiPost } from "@/lib/api/api-fetch";
import { apiGet } from "@/lib/api/api-fetch";
import { useIdempotentPost } from "@/lib/api/use-idempotent-post";
import { localizedApiError } from "@/lib/api/localized-error";
import {
intakeToastOptions,
runUndoIntake,
@@ -118,6 +120,7 @@ export function MedicationIntakeQuickAdd({
}: MedicationIntakeQuickAddProps) {
const { t } = useTranslations();
const queryClient = useQueryClient();
const post = useIdempotentPost();
// v1.16.9 — the auto-pick reasons in the PROFILE timezone (the same
// source the cards use); Berlin stays the last-resort fallback so
// logged-out mounts behave unchanged.
@@ -225,7 +228,7 @@ export function MedicationIntakeQuickAdd({
// v1.30.1 — the route returns the created event
// (`apiSuccess(event, 201)`), same shape the card's take/skip path
// already reads; the id drives the new Undo action below.
const created = await apiPost<{ id?: string } | undefined>(
const created = await post<{ id?: string } | undefined>(
`/api/medications/${medicationId}/intake`,
{
takenAt: timestamp,
@@ -235,13 +238,6 @@ export function MedicationIntakeQuickAdd({
);
const eventId = created?.id;
await invalidateMedicationReads(queryClient);
// Fan-out to every inline compliance chart key (one per medication)
// so the detail-page tile refreshes when the user re-enters it.
await queryClient.invalidateQueries({
queryKey: queryKeys.complianceChartInline(),
});
// v1.36.x — the shared decision, not a fourth copy of it. This surface
// was left out of the collapse that took the other three: it offered a
// delegate an Undo hitting `DELETE /api/medications/{id}/intake/{id}`,
@@ -272,11 +268,16 @@ export function MedicationIntakeQuickAdd({
toast.success(t("common.saved"));
}
onSuccess?.();
// The dose is stored; the sheet does not wait for the refetches. The
// bundle covers every inline compliance chart as well.
void invalidateMedicationReads(queryClient);
} catch (err) {
setError(
err instanceof ApiError && err.message
? err.message
: t("dashboard.medicationIntakeQuickAdd.saveError"),
localizedApiError(
err,
t,
"dashboard.medicationIntakeQuickAdd.saveError",
),
);
} finally {
setLoading(false);
@@ -0,0 +1,34 @@
/**
* Confirming a document's facts creates medications, conditions and lab
* values. The confirm used to evict only the roots of those lists, so the
* dashboard, the Today digest, adherence, nutrients, the day and the timeline
* kept showing the record without them.
*/
import { QueryClient } from "@tanstack/react-query";
import { afterEach, describe, expect, it } from "vitest";
import { invalidateConfirmedFactReads } from "../use-document-facts";
import { queryKeys } from "@/lib/query-keys";
const client = new QueryClient();
afterEach(() => client.clear());
describe("invalidateConfirmedFactReads", () => {
it.each([
["the dashboard snapshot", queryKeys.dashboardSnapshot()],
["the Today digest", queryKeys.dailyDigest()],
["the nutrient reads", queryKeys.nutrientsRoot()],
["an open day", queryKeys.day("2026-11-10")],
["the timeline", queryKeys.timelineRoot()],
["the lab results", queryKeys.labResults()],
["the condition journal", queryKeys.illness()],
["the document", queryKeys.inboundDocument("doc-1")],
])("marks %s stale", async (_label, key) => {
client.setQueryData(key, { seeded: true });
await invalidateConfirmedFactReads(client, "doc-1");
expect(client.getQueryState(key)?.isInvalidated).toBe(true);
});
});
@@ -0,0 +1,84 @@
/**
* The document picker reads one page of the vault, the newest fifty. With a
* longer vault it listed those and said nothing, so an older report seemed
* not to exist unless one already knew to search for it.
*
* ResponsiveSheet portals its body, which static markup does not render, so it
* is mocked down to a wrapper that always shows its children.
*/
import { describe, expect, it, vi } from "vitest";
import { renderToStaticMarkup } from "react-dom/server";
const page = vi.hoisted(() => ({ nextCursor: null as string | null }));
vi.mock("@/components/ui/responsive-sheet", () => ({
ResponsiveSheet: ({ children }: { children: React.ReactNode }) => (
<div data-slot="mock-sheet">{children}</div>
),
}));
vi.mock("@tanstack/react-query", () => ({
useQuery: () => ({
data: {
// A cut page is a full one.
documents: Array.from({ length: page.nextCursor ? 50 : 3 }, (_, i) => ({
id: `d${i}`,
kind: "LAB_RESULT",
title: `Blood panel ${i}`,
filename: null,
documentDate: "2026-10-01",
createdAt: "2026-10-01T08:00:00.000Z",
})),
nextCursor: page.nextCursor,
},
isPending: false,
isError: false,
}),
}));
import { I18nProvider } from "@/lib/i18n/context";
import {
DocumentPickerCapNotice,
DocumentPickerSheet,
} from "../document-picker-sheet";
function render() {
return renderToStaticMarkup(
<I18nProvider initialLocale="en">
<DocumentPickerSheet
open
onOpenChange={() => undefined}
title="Link documents"
description="Pick documents"
searchPlaceholder="Search"
emptyLabel="No documents"
isPicked={() => false}
onToggle={() => undefined}
/>
</I18nProvider>,
);
}
describe("DocumentPickerSheet", () => {
it("says the list is cut, and how to reach the rest", () => {
page.nextCursor = "d49";
const html = render();
expect(html).toContain('data-slot="list-cap-notice"');
expect(html).toContain("Showing the newest 50");
});
it("says nothing when every document is listed", () => {
page.nextCursor = null;
expect(render()).not.toContain('data-slot="list-cap-notice"');
});
it("asks for a narrower search, not a search, while one runs", () => {
const html = renderToStaticMarkup(
<I18nProvider initialLocale="en">
<DocumentPickerCapNotice cut count={50} searching />
</I18nProvider>,
);
expect(html).toContain("Narrow the search");
expect(html).not.toContain("Search to find");
});
});
@@ -18,7 +18,11 @@ import {
import { DOCUMENT_PURGE_GRACE_DAYS } from "@/lib/documents/inbound-constants";
import { showErrorToast } from "@/lib/errors/error-toast";
import { useTranslations } from "@/lib/i18n/context";
import { invalidateKeys, queryKeys } from "@/lib/query-keys";
import {
documentDependentKeys,
invalidateKeys,
queryKeys,
} from "@/lib/query-keys";
import type {
DocumentSourceSystemValue,
InboundDocumentDetailDto,
@@ -265,7 +269,7 @@ function useDocumentWrites({
// A link written from this side changes what the visit's and the
// dose's own views list, so their reads refresh with the vault's.
void invalidateKeys(queryClient, [
queryKeys.documents(),
...documentDependentKeys,
...(input.encounterIds !== undefined ? [queryKeys.encounters()] : []),
...(input.vaccinationIds !== undefined
? [queryKeys.vaccinations()]
@@ -282,7 +286,7 @@ function useDocumentWrites({
mutationFn: (id: string) =>
apiPost(`/api/documents/inbound/${id}/restore`, {}),
onSuccess: () => {
void invalidateKeys(queryClient, [queryKeys.documents()]);
void invalidateKeys(queryClient, documentDependentKeys);
},
onError: (error) => {
const reason =
@@ -303,7 +307,7 @@ function useDocumentWrites({
mutationFn: (id: string) => apiDelete(`/api/documents/inbound/${id}`),
onSuccess: (_data, id) => {
onOpenChange(false);
void invalidateKeys(queryClient, [queryKeys.documents()]);
void invalidateKeys(queryClient, documentDependentKeys);
toast.success(t("documents.toast.deleted"), {
description: t("documents.toast.deletedHint", {
days: DOCUMENT_PURGE_GRACE_DAYS,
@@ -2,9 +2,10 @@
/**
* The vault document picker sheet: a `ResponsiveSheet` (bottom sheet on
* phones) with a debounced search over the person's own vault — the same
* recall path as the vault page, one page (50) of matches, the search input
* as the way to an older document — and one toggle row per document.
* phones) with a debounced search over the person's own vault (the same
* recall path as the vault page, one page of 50 matches with a line that
* says when more exist, the search input as the way to an older document)
* and one toggle row per document.
*
* Two pickers used to carry this whole body as copies: linking documents to
* an illness episode (each toggle writes at once) and choosing documents for
@@ -17,6 +18,7 @@ import { Check, Search } from "lucide-react";
import { useEffect, useRef, useState, type ReactNode } from "react";
import { useDocumentDate } from "@/components/documents/use-document-date";
import { ListCapNotice } from "@/components/ui/list-cap-notice";
import { DOCUMENT_KIND_ICONS } from "@/components/documents/document-kind-meta";
import { EmptyState } from "@/components/ui/empty-state";
import { Input } from "@/components/ui/input";
@@ -97,6 +99,8 @@ export function DocumentPickerSheet({
});
const documents = list.data?.documents ?? [];
// More match than one page: say so, and that the search reaches the rest.
const cut = Boolean(list.data?.nextCursor);
return (
<ResponsiveSheet
@@ -188,7 +192,40 @@ export function DocumentPickerSheet({
})}
</ul>
)}
<DocumentPickerCapNotice
cut={cut && !list.isError}
count={documents.length}
searching={query !== ""}
/>
</div>
</ResponsiveSheet>
);
}
/**
* The line under a picker list cut at one page: the search reaches the rest,
* and while one runs, a narrower one does. Shared by the vault picker and
* the Coach's attachment picker.
*/
export function DocumentPickerCapNotice({
cut,
count,
searching,
}: {
cut: boolean;
count: number;
searching: boolean;
}) {
const { t } = useTranslations();
return (
<ListCapNotice
cap={{ truncated: cut, limit: count }}
message={t(
searching
? "documents.picker.cappedSearching"
: "documents.picker.capped",
{ count: String(count) },
)}
/>
);
}
@@ -46,6 +46,7 @@ import { useVaccinations } from "@/components/vaccinations/use-vaccinations";
import { useAuth } from "@/hooks/use-auth";
import { apiGet } from "@/lib/api/api-fetch";
import { useFormatters, useTranslations } from "@/lib/i18n/context";
import { useCalendarDate } from "@/hooks/use-calendar-date";
import { queryKeys } from "@/lib/query-keys";
import type { EncounterDTO, EncounterListDTO } from "@/lib/encounters/dto";
import type { EncounterKind } from "@/generated/prisma/client";
@@ -161,6 +162,7 @@ export function DocumentRecordLinks({
}) {
const { t } = useTranslations();
const format = useFormatters();
const calendarDate = useCalendarDate();
const { user } = useAuth();
const vaccinationsOn = user?.modules?.vaccinations !== false;
const anchor = doc.documentDate ?? doc.reportDate;
@@ -186,10 +188,7 @@ export function DocumentRecordLinks({
const [visitIds, setVisitIds] = useState<string[] | null>(null);
const [doseIds, setDoseIds] = useState<string[] | null>(null);
const labels = {
suggested: t("links.picker.suggested"),
date: (iso: string) => format.date(iso),
};
const suggested = t("links.picker.suggested");
const visitOptions = recordOptions(
[...encounters.items]
@@ -203,7 +202,7 @@ export function DocumentRecordLinks({
href: visitHref(visit.id),
})),
anchor,
labels,
{ suggested, date: format.date },
);
const doseOptions = recordOptions(
(vaccinations.data?.vaccinations ?? []).map((dose) => ({
@@ -213,7 +212,7 @@ export function DocumentRecordLinks({
href: doseHref(dose.id),
})),
anchor,
labels,
{ suggested, date: calendarDate }, // a dose date is a calendar date
);
const showDoses = vaccinationsOn && doc.vaccinationLinks !== null;
@@ -324,7 +323,7 @@ export function DocumentRecordLinks({
</span>
{link.occurredAt ? (
<span className="text-muted-foreground shrink-0">
{format.date(link.occurredAt)}
{calendarDate(link.occurredAt)}
</span>
) : null}
</Link>
@@ -71,7 +71,11 @@ import {
type DocumentSourceTagDto,
} from "@/lib/documents/sources/types";
import { useTranslations } from "@/lib/i18n/context";
import { invalidateKeys, queryKeys } from "@/lib/query-keys";
import {
documentDependentKeys,
invalidateKeys,
queryKeys,
} from "@/lib/query-keys";
import { cn } from "@/lib/utils";
import type { InboundDocumentKindValue } from "@/lib/validations/inbound-documents";
@@ -317,7 +321,7 @@ export function DocumentSourcePicker({
// The vault, its pickers and the record's own lists read documents
// through the `["documents"]` prefix; the picker's states through its own.
void invalidateKeys(queryClient, [
queryKeys.documents(),
...documentDependentKeys,
queryKeys.documentSources(),
]);
if (handed.length > 0) onImported?.(handed);
@@ -10,7 +10,11 @@ import { ApiError, apiGetList, apiPost } from "@/lib/api/api-fetch";
import { DOCUMENT_BULK_MAX_IDS } from "@/lib/documents/inbound-constants";
import { showErrorToast } from "@/lib/errors/error-toast";
import { useTranslations } from "@/lib/i18n/context";
import { invalidateKeys, queryKeys } from "@/lib/query-keys";
import {
documentDependentKeys,
invalidateKeys,
queryKeys,
} from "@/lib/query-keys";
import type {
DeletedDocumentListDto,
DocumentBulkResultDto,
@@ -21,7 +25,8 @@ import type {
* document back (one at a time, or all through the bulk route); "Delete now"
* removes one for good ahead of the grace window. Every write invalidates the
* `["documents"]` prefix, so the vault, its usage figures and this list repaint
* together.
* together, and the day and timeline roots, which place a document on its
* date.
*/
export function useRecentlyDeleted({ enabled }: { enabled: boolean }) {
const { t } = useTranslations();
@@ -36,7 +41,7 @@ export function useRecentlyDeleted({ enabled }: { enabled: boolean }) {
});
const refresh = useCallback(
() => void invalidateKeys(queryClient, [queryKeys.documents()]),
() => void invalidateKeys(queryClient, documentDependentKeys),
[queryClient],
);
+39 -14
View File
@@ -13,7 +13,11 @@
* approve/reject decisions the person made; a fact without a decision stays
* pending on the server.
*/
import { useMutation, useQueryClient } from "@tanstack/react-query";
import {
useMutation,
useQueryClient,
type QueryClient,
} from "@tanstack/react-query";
import { resolveAiRun, useAiRunPhase } from "@/hooks/use-ai-run";
import { apiPatch, apiPost } from "@/lib/api/api-fetch";
@@ -21,7 +25,15 @@ import type {
AiRunAccepted,
DocumentExtractRunResult,
} from "@/lib/documents/ai-runs/types";
import { invalidateKeys, queryKeys } from "@/lib/query-keys";
import {
documentDependentKeys,
illnessDependentKeys,
invalidateKeys,
labDependentKeys,
medicationDependentKeys,
queryKeys,
refetchInactiveDailyReads,
} from "@/lib/query-keys";
import type {
ExtractedFactDto,
InboundFactEdit,
@@ -101,11 +113,32 @@ export function useEditFact() {
});
}
/**
* Every read a confirm can touch: the medications, conditions and lab values
* it creates, with what derives from them, and the document itself (status
* and fact states). A new medication or condition also reaches the dashboard
* and the Today digest, which are unmounted while the person reviews.
*/
export function invalidateConfirmedFactReads(
queryClient: QueryClient,
documentId: string,
) {
return Promise.all([
invalidateKeys(queryClient, [
...medicationDependentKeys,
...illnessDependentKeys,
...labDependentKeys,
...documentDependentKeys,
queryKeys.inboundDocument(documentId),
]),
refetchInactiveDailyReads(queryClient),
]);
}
/**
* Commit the review decisions. Approved facts land in their structured
* stores (labs / conditions / medications) through the server's existing
* field-by-field commit; rejected facts are discarded. Invalidates every
* read the commit can touch, plus the document itself (status + fact states).
* field-by-field commit; rejected facts are discarded.
*/
export function useConfirmFacts() {
const queryClient = useQueryClient();
@@ -127,15 +160,7 @@ export function useConfirmFacts() {
`/api/documents/inbound/${documentId}/confirm`,
{ decisions },
),
onSuccess: (_data, { documentId }) => {
void invalidateKeys(queryClient, [
queryKeys.inboundDocument(documentId),
queryKeys.documents(),
queryKeys.labResults(),
queryKeys.biomarkers(),
queryKeys.illness(),
queryKeys.medications(),
]);
},
onSuccess: (_data, { documentId }) =>
void invalidateConfirmedFactReads(queryClient, documentId),
});
}
@@ -32,7 +32,7 @@ import { toast } from "sonner";
import { useTranslations } from "@/lib/i18n/context";
import { DOCUMENT_UPLOAD_PER_USER_CONCURRENCY } from "@/lib/documents/inbound-constants";
import { randomId } from "@/lib/random-id";
import { invalidateKeys, queryKeys } from "@/lib/query-keys";
import { documentDependentKeys, invalidateKeys } from "@/lib/query-keys";
import { uploadWithBusyRetry } from "./upload-busy-retry";
import {
parseUploadResponse,
@@ -261,7 +261,7 @@ export function useDocumentUpload(
}
const decision = settleDecision(result);
removeItem(localId);
void invalidateKeys(queryClient, [queryKeys.documents()]);
void invalidateKeys(queryClient, documentDependentKeys);
if (decision.toastDuplicate) {
toast.info(t("documents.toast.duplicate"));
}
@@ -11,7 +11,7 @@ import {
} from "@/lib/documents/inbound-constants";
import { showErrorToast } from "@/lib/errors/error-toast";
import { useTranslations } from "@/lib/i18n/context";
import { invalidateKeys, queryKeys } from "@/lib/query-keys";
import { documentDependentKeys, invalidateKeys } from "@/lib/query-keys";
import type {
DocumentBulkAction,
DocumentBulkResultDto,
@@ -64,7 +64,7 @@ export function useVaultBulk({
return results;
},
onSettled: () => {
void invalidateKeys(queryClient, [queryKeys.documents()]);
void invalidateKeys(queryClient, documentDependentKeys);
},
});
+23 -5
View File
@@ -33,6 +33,7 @@
*/
import { useState } from "react";
import { Trash2 } from "lucide-react";
import { toast } from "sonner";
import { showErrorToast } from "@/lib/errors/error-toast";
import {
@@ -49,6 +50,7 @@ import {
import { Button } from "@/components/ui/button";
import { ResponsiveSheet } from "@/components/ui/responsive-sheet";
import { toastWrittenOutcome } from "@/components/outcome/outcome-toast";
import { localizedApiError } from "@/lib/api/localized-error";
import { useTranslations } from "@/lib/i18n/context";
import { useEncounterMutations, type Encounter } from "@/hooks/use-encounters";
import {
@@ -84,7 +86,7 @@ export function EncounterSheet({
onSaved?: (saved: Encounter) => void;
}) {
const { t } = useTranslations();
const { create, update, remove } = useEncounterMutations();
const { create, update, remove, restore } = useEncounterMutations();
const [confirmingDelete, setConfirmingDelete] = useState(false);
// Seeded once, at mount. The caller remounts this component on every open by
// varying its `key`, so a second edit never shows the first one's values and
@@ -128,8 +130,7 @@ export function EncounterSheet({
onSaved?.(saved);
onOpenChange(false);
} catch (err) {
const message =
err instanceof Error ? err.message : t("encounters.saveFailed");
const message = localizedApiError(err, t, "encounters.saveFailed");
setError(message);
showErrorToast(message, { cause: err });
}
@@ -210,8 +211,25 @@ export function EncounterSheet({
<AlertDialogAction
variant="destructive"
onClick={() => {
remove.mutate(encounter.id, {
onSuccess: () => onOpenChange(false),
const id = encounter.id;
remove.mutate(id, {
onSuccess: () => {
onOpenChange(false);
// The delete is soft. The Undo runs after this sheet
// closed, so it awaits the restore itself rather than
// passing callbacks a closed sheet never receives.
toast.success(t("encounters.deleted"), {
action: {
label: t("common.undo"),
onClick: () =>
void restore.mutateAsync(id).catch((failure) =>
showErrorToast(t("encounters.restoreFailed"), {
cause: failure,
}),
),
},
});
},
onError: (failure) =>
showErrorToast(t("encounters.deleteFailed"), {
cause: failure,
@@ -0,0 +1,87 @@
/**
* An episode save settles once the server stored it, and stores it once.
*
* Every episode hook's `onSuccess` used to return the refresh of the illness
* reads and the dashboard. A mutation waits for what `onSuccess` returns, so
* the episode sheet kept its spinner until each of those reads had refetched.
* And a create retried after a timeout went out under a new key, so the
* server stored the episode twice.
*/
import { describe, expect, it, vi } from "vitest";
const client = vi.hoisted(() => ({
// A refetch that never answers, as on a slow connection.
invalidateQueries: vi.fn(() => new Promise(() => {})),
refetchQueries: vi.fn(() => new Promise(() => {})),
getQueryCache: () => ({ findAll: () => [] }),
}));
vi.mock("react", async (importOriginal) => ({
...(await importOriginal<typeof import("react")>()),
useRef: <T>(initial: T) => ({ current: initial }),
}));
vi.mock("@tanstack/react-query", () => ({
// The hook's options are what is under test; return them as they are.
useQuery: (options: unknown) => options,
useMutation: (options: unknown) => options,
useQueryClient: () => client,
}));
vi.mock("@/lib/api/api-fetch", () => ({
apiGet: vi.fn(),
apiGetList: vi.fn(),
apiPost: vi.fn(),
apiPatch: vi.fn(),
apiDelete: vi.fn(),
}));
import { apiPost } from "@/lib/api/api-fetch";
import {
useCreateEpisode,
useDeleteEpisode,
useResolveEpisode,
useRestoreEpisode,
useUpdateEpisode,
useUpsertDayLog,
} from "../use-illness";
describe("episode saves", () => {
it.each([
["create", () => useCreateEpisode()],
["update", () => useUpdateEpisode()],
["resolve", () => useResolveEpisode()],
["delete", () => useDeleteEpisode()],
["restore", () => useRestoreEpisode()],
["day log", () => useUpsertDayLog("e1")],
])("%s starts the refresh and does not wait for it", (_, hook) => {
client.invalidateQueries.mockClear();
const options = hook() as unknown as { onSuccess: () => unknown };
expect(options.onSuccess()).toBeUndefined();
expect(client.invalidateQueries).toHaveBeenCalled();
});
});
describe("creating an episode", () => {
it("retries under the same Idempotency-Key and starts the next one fresh", async () => {
const create = useCreateEpisode() as unknown as {
mutationFn: (input: unknown) => Promise<unknown>;
};
const post = vi.mocked(apiPost);
post.mockRejectedValueOnce(new TypeError("timed out"));
post.mockResolvedValue({ id: "e1" });
const keyOf = (call: number) =>
new Headers((post.mock.calls[call]![2] as RequestInit).headers).get(
"Idempotency-Key",
);
await expect(create.mutationFn({ name: "Flu" })).rejects.toThrow();
await create.mutationFn({ name: "Flu" });
await create.mutationFn({ name: "Cold" });
expect(keyOf(0)).toBeTruthy();
expect(keyOf(1)).toBe(keyOf(0));
expect(keyOf(2)).not.toBe(keyOf(0));
});
});
+11 -4
View File
@@ -17,6 +17,7 @@ import {
apiPatch,
apiPost,
} from "@/lib/api/api-fetch";
import { useIdempotentPost } from "@/lib/api/use-idempotent-post";
import {
illnessDependentKeys,
invalidateKeys,
@@ -152,18 +153,24 @@ function useInvalidateIllness() {
// the Today digest — both unmounted from here, so the bundle invalidation
// is paired with the forced inactive refetch. The write routes hard-evict
// the server analytics bucket, so the refetch returns post-write context.
return async () => {
await invalidateKeys(qc, illnessDependentKeys);
await refetchInactiveDailyReads(qc);
// Not returned: a mutation waits for what `onSuccess` returns, and the
// sheet would stay open until every read had refetched.
return () => {
void invalidateKeys(qc, illnessDependentKeys).then(() =>
refetchInactiveDailyReads(qc),
);
};
}
/** Create an episode. */
export function useCreateEpisode() {
const invalidate = useInvalidateIllness();
// One key until a save lands, so a retry after a timeout is not a second
// episode.
const post = useIdempotentPost();
return useMutation({
mutationFn: (input: IllnessEpisodeCreateInput) =>
apiPost<IllnessEpisodeDTO>("/api/illness/episodes", input),
post<IllnessEpisodeDTO>("/api/illness/episodes", input),
onSuccess: invalidate,
});
}
@@ -3,12 +3,21 @@
* phone it is the only control on the tile a thumb has to hit, so it carries
* the 44 px floor every other mobile action does (`size-11`), stepping down
* from `sm` like the rest of the app.
*
* The hint is also an invitation, so it stays away from a person who switched
* both the Coach and AI analysis off.
*/
import { renderToStaticMarkup } from "react-dom/server";
import { describe, expect, it, vi } from "vitest";
import { afterEach, describe, expect, it, vi } from "vitest";
const auth = vi.hoisted(() => ({
modules: {} as Record<string, boolean>,
}));
vi.mock("@/hooks/use-auth", () => ({
useAuth: () => ({ user: { id: "u1" }, isAuthenticated: true }),
useAuth: () => ({
user: { id: "u1", modules: auth.modules },
isAuthenticated: true,
}),
}));
vi.mock("@/hooks/use-mounted", () => ({ useMounted: () => true }));
vi.mock("@/hooks/use-ai-capability", () => ({
@@ -22,16 +31,21 @@ vi.mock("@/hooks/use-ai-capability", () => ({
import { I18nProvider } from "@/lib/i18n/context";
import { AiSetupHint } from "../ai-setup-hint";
function dismissButton(): string {
function render(surface: "insights" | "settings" = "insights"): string {
vi.stubGlobal("window", {
localStorage: { getItem: () => null, setItem: () => undefined },
});
const html = renderToStaticMarkup(
<I18nProvider initialLocale="en">
<AiSetupHint surface="insights" />
<AiSetupHint surface={surface} />
</I18nProvider>,
);
vi.unstubAllGlobals();
return html;
}
function dismissButton(): string {
const html = render();
const match = html.match(
/<button[^>]*data-slot="ai-setup-hint-dismiss"[^>]*>/,
);
@@ -46,3 +60,22 @@ describe("the setup hint's dismiss button", () => {
expect(cls).toContain("size-11");
});
});
describe("the setup hint after an opt-out", () => {
afterEach(() => {
auth.modules = {};
});
it("is gone on both surfaces once the Coach and AI analysis are off", () => {
auth.modules = { coach: false, insights: false };
expect(render("insights")).not.toContain('data-slot="ai-setup-hint"');
expect(render("settings")).not.toContain('data-slot="ai-setup-hint"');
});
it("stays while either one is still on", () => {
auth.modules = { coach: false, insights: true };
expect(render("insights")).toContain('data-slot="ai-setup-hint"');
auth.modules = { coach: true, insights: false };
expect(render("settings")).toContain('data-slot="ai-setup-hint"');
});
});
@@ -7,9 +7,9 @@ import type { CorrelationResult } from "@/lib/insights/correlations";
* v1.4.20 — `<CorrelationCard>` rendering.
*
* Tests cover:
* 1. OK status renders title + interpretation + source chip + disabled CTA.
* 1. OK status renders title + interpretation + source chip.
* 2. Insufficient status renders the empty-state instead.
* 3. CTA is `disabled` and exposes the "Coming soon" tooltip via `title`.
* 3. The card offers no placeholder action, only ones that work.
* 4. Confidence chip surfaces high / moderate / low.
* 5. Locale-aware copy (EN / DE).
*/
@@ -81,18 +81,15 @@ describe("<CorrelationCard>", () => {
expect(html).toContain("High confidence");
});
it("renders the disabled experiment CTA with the Coming-soon tooltip", () => {
it("offers no placeholder action, only ones that work", () => {
const html = render(<CorrelationCard result={okResult} />);
expect(html).toMatch(/data-slot="correlation-card-cta"/);
expect(html).toContain("Try a 7-day experiment");
expect(html).toMatch(/disabled/);
expect(html).toContain("Coming soon");
expect(html).not.toMatch(/data-slot="correlation-card-cta"/);
expect(html).not.toMatch(/<button[^>]*disabled/);
});
it("renders the empty-state when status=insufficient", () => {
const html = render(<CorrelationCard result={insufficient} />);
expect(html).toContain("Need more data to see this relationship");
expect(html).not.toContain("Try a 7-day experiment");
});
it("tags the wrapper with the correlation kind", () => {
@@ -103,7 +100,7 @@ describe("<CorrelationCard>", () => {
it("renders German copy when locale=de", () => {
const html = render(<CorrelationCard result={okResult} />, "de");
expect(html).toContain("BD × Therapietreue");
expect(html).toContain("7-Tage-Experiment");
expect(html).toContain("basierend auf 28 gepaarten Messungen");
});
it("never surfaces causal language in the interpretation slot", () => {
@@ -0,0 +1,60 @@
/**
* The card prints a lab figure the way the labs page does: in the reader's
* notation and with the digits the reading holds. It used to print
* `String(value)`, so a German reader saw "4.6" beside a labs page that says
* "4,6", and a converted reading showed every digit of the conversion.
*/
import { describe, expect, it, vi } from "vitest";
import { renderToStaticMarkup } from "react-dom/server";
vi.mock("@tanstack/react-query", () => ({
useQuery: () => ({
data: {
present: true,
latestDate: "2026-06-01",
previousDate: "2026-03-01",
generatedAt: "2026-06-02T00:00:00.000Z",
changes: [
{
analyte: "TSH",
unit: "mIU/L",
latest: 0.009,
previous: 0.004,
delta: 0.005,
direction: "up",
status: "unknown",
},
{
analyte: "Potassium",
unit: "mmol/L",
latest: 4.6,
previous: 4.9,
delta: -0.3,
direction: "down",
status: "unknown",
},
],
},
}),
}));
vi.mock("@/hooks/use-auth", () => ({
useAuth: () => ({ isAuthenticated: true }),
}));
import { I18nProvider } from "@/lib/i18n/context";
import { LabsChangesCard } from "../labs-changes-card";
describe("<LabsChangesCard>", () => {
it("prints values and deltas in the reader's notation", () => {
const html = renderToStaticMarkup(
<I18nProvider initialLocale="de">
<LabsChangesCard />
</I18nProvider>,
);
expect(html).toContain("0,009 mIU/L");
expect(html).toContain("+0,005 mIU/L");
expect(html).toContain("4,6 mmol/L");
expect(html).toContain("-0,3 mmol/L");
});
});
@@ -1,109 +0,0 @@
/**
* v1.4.25 — `<PersonalRecordBadge>` rendering.
*
* The badge renders a small "PR" pill when the user achieved an
* all-time best for the metric in the last 30 days. Tests cover:
* 1. Renders the pill when a fresh PR exists.
* 2. Stays silent when no PR exists for the metric.
* 3. Stays silent when the only PR is older than 30 days.
* 4. Surfaces metric-type and PR-value via data-attrs / tooltip.
* 5. EN + DE locale parity.
*/
import { describe, it, expect, vi } from "vitest";
import { renderToStaticMarkup } from "react-dom/server";
import { I18nProvider } from "@/lib/i18n/context";
// React-Query is mocked the same way the other insights tests do it —
// we control the `data` field per-test so the SSR tree is deterministic.
type Row = {
id: string;
metricType: string;
metricSlot: string | null;
value: number;
unit: string;
achievedAt: string;
};
let mockRows: Row[] = [];
vi.mock("@tanstack/react-query", () => ({
// `dataUpdatedAt` mirrors what TanStack Query produces after a
// successful refetch — `Date.now()` is fine inside the test
// (the React-purity rule is component-scoped).
useQuery: () => ({
data: mockRows,
isLoading: false,
dataUpdatedAt: Date.now(),
}),
}));
import { PersonalRecordBadge } from "../personal-record-badge";
function render(node: React.ReactNode, locale: "en" | "de" = "en") {
return renderToStaticMarkup(
<I18nProvider initialLocale={locale}>{node}</I18nProvider>,
);
}
function freshPR(over: Partial<Row> = {}): Row {
return {
id: "pr-1",
metricType: "ACTIVITY_STEPS",
metricSlot: null,
value: 18432,
unit: "count",
achievedAt: new Date(Date.now() - 24 * 60 * 60 * 1000).toISOString(),
...over,
};
}
describe("<PersonalRecordBadge>", () => {
it("renders the PR pill when an all-time best lands in the last 30 days", () => {
mockRows = [freshPR()];
const html = render(
<PersonalRecordBadge metricType="ACTIVITY_STEPS" withTooltip={false} />,
);
expect(html).toMatch(/data-slot="insights-pr-badge"/);
expect(html).toMatch(/data-metric-type="ACTIVITY_STEPS"/);
expect(html).toContain("PR");
});
it("renders nothing when no PR exists for the metric", () => {
mockRows = [];
const html = render(
<PersonalRecordBadge metricType="ACTIVITY_STEPS" withTooltip={false} />,
);
expect(html).not.toMatch(/data-slot="insights-pr-badge"/);
});
it("stays silent when the PR is older than 30 days", () => {
const stale = new Date(Date.now() - 45 * 24 * 60 * 60 * 1000).toISOString();
mockRows = [freshPR({ achievedAt: stale })];
const html = render(
<PersonalRecordBadge metricType="ACTIVITY_STEPS" withTooltip={false} />,
);
expect(html).not.toMatch(/data-slot="insights-pr-badge"/);
});
it("ignores workout-slot rows on the plain-metric badge", () => {
mockRows = [freshPR({ metricSlot: "longest_run_duration" })];
const html = render(
<PersonalRecordBadge metricType="ACTIVITY_STEPS" withTooltip={false} />,
);
expect(html).not.toMatch(/data-slot="insights-pr-badge"/);
});
it("uses the same 'PR' literal across EN and DE locales", () => {
mockRows = [freshPR()];
const en = render(
<PersonalRecordBadge metricType="ACTIVITY_STEPS" withTooltip={false} />,
"en",
);
const de = render(
<PersonalRecordBadge metricType="ACTIVITY_STEPS" withTooltip={false} />,
"de",
);
expect(en).toContain("PR");
expect(de).toContain("PR");
});
});
@@ -10,6 +10,12 @@ vi.mock("next/navigation", () => ({
usePathname: () => "/insights/weight",
}));
// The target gear asks whether this is somebody else's record; the shell
// renders here without the session it would read that from.
vi.mock("@/hooks/use-record-capabilities", () => ({
useRecordCapabilities: () => ({ inSharedRecord: false }),
}));
/**
* v1.4.25 — `<SubPageShell>` unit tests.
*
@@ -28,6 +28,11 @@ vi.mock("@/lib/insights/target-adjust-context", () => ({
useTargetAdjust: () => useTargetAdjustMock(),
}));
const capabilities = { inSharedRecord: false };
vi.mock("@/hooks/use-record-capabilities", () => ({
useRecordCapabilities: () => capabilities,
}));
const { TargetAdjustButton } = await import("../target-adjust-button");
function render(node: React.ReactNode, locale: "en" | "de" = "en") {
@@ -53,6 +58,22 @@ describe("<TargetAdjustButton>", () => {
expect(html).toBe("");
});
it("renders nothing inside somebody else's record", () => {
// The editor reads and writes the signed-in account's own targets, which
// the server refuses under a switch: the gear would open a dead sheet.
useTargetAdjustMock.mockReturnValue({
canAdjust: true,
requestAdjust: vi.fn(),
register: vi.fn(),
});
capabilities.inSharedRecord = true;
try {
expect(render(<TargetAdjustButton />)).toBe("");
} finally {
capabilities.inSharedRecord = false;
}
});
it("renders the gear once an editable target is registered", () => {
useTargetAdjustMock.mockReturnValue({
canAdjust: true,
+5 -2
View File
@@ -13,6 +13,7 @@ import { SettingsCardActions } from "@/components/settings/_card-actions";
import { useAiProviderState } from "@/hooks/use-ai-capability";
import { useAuth } from "@/hooks/use-auth";
import { useMounted } from "@/hooks/use-mounted";
import { hasOptedOutOfAi } from "@/lib/ai/capabilities/types";
import { useTranslations } from "@/lib/i18n/context";
/**
@@ -26,8 +27,9 @@ import { useTranslations } from "@/lib/i18n/context";
* It shows only while no provider is configured AND the person could set one
* up here (`provider.canConfigure`: false inside somebody else's record and
* while the operator has AI switched off, where the hint would point at a
* setting nobody on this screen can change). Dismissing it is remembered per
* viewer in this browser; the hint stays gone on both surfaces.
* setting nobody on this screen can change) AND they have not switched both
* the Coach and AI analysis off (`hasOptedOutOfAi`). Dismissing it is
* remembered per viewer in this browser; the hint stays gone on both surfaces.
*/
const AI_SETUP_HINT_STORAGE_PREFIX = "healthlog.ai-setup-hint.dismissed";
@@ -63,6 +65,7 @@ export function AiSetupHint({
const viewerId = user?.id ?? null;
if (!mounted || viewerId === null) return null;
if (provider.configured || !provider.canConfigure) return null;
if (hasOptedOutOfAi(user?.modules)) return null;
if (dismissedNow || readDismissed(viewerId)) return null;
const dismiss = () => {
@@ -0,0 +1,71 @@
/**
* The Coach's "Choose from documents" picker reads one page of the vault, the
* newest fifty. With a longer vault it listed those and said nothing, so an
* older report seemed not to exist unless one already knew to search for it.
*
* ResponsiveSheet portals its body, which static markup does not render, so it
* is mocked down to a wrapper that always shows its children.
*/
import { describe, expect, it, vi } from "vitest";
import { renderToStaticMarkup } from "react-dom/server";
const page = vi.hoisted(() => ({ nextCursor: null as string | null }));
vi.mock("@/components/ui/responsive-sheet", () => ({
ResponsiveSheet: ({ children }: { children: React.ReactNode }) => (
<div data-slot="mock-sheet">{children}</div>
),
}));
vi.mock("@tanstack/react-query", () => ({
useQuery: () => ({
data: {
// A cut page is a full one.
documents: Array.from({ length: page.nextCursor ? 50 : 3 }, (_, i) => ({
id: `d${i}`,
kind: "LAB_RESULT",
title: `Blood panel ${i}`,
filename: null,
hasContentIndex: true,
})),
nextCursor: page.nextCursor,
},
isPending: false,
isError: false,
}),
}));
import { I18nProvider } from "@/lib/i18n/context";
import { AttachmentPicker } from "../attachment-picker";
function render() {
return renderToStaticMarkup(
<I18nProvider initialLocale="en">
<AttachmentPicker
open
onOpenChange={() => undefined}
remainingSlots={3}
excludeIds={[]}
onConfirm={() => undefined}
/>
</I18nProvider>,
);
}
describe("AttachmentPicker", () => {
it("says the list is cut, and how to reach the rest", () => {
page.nextCursor = "d49";
const html = render();
expect(html).toContain('data-slot="list-cap-notice"');
expect(html).toContain("Showing the newest 50");
});
it("says nothing when every document is listed", () => {
page.nextCursor = null;
const html = render();
expect(html).not.toContain('data-slot="list-cap-notice"');
expect(
html.match(/data-slot="coach-attachment-picker-item"/g),
).toHaveLength(3);
});
});
@@ -0,0 +1,52 @@
import { describe, it, expect, vi } from "vitest";
import { renderToStaticMarkup } from "react-dom/server";
import { I18nProvider } from "@/lib/i18n/context";
/**
* The person's bubble falls back to initials of the name the app shows, like
* the top bar, never the login: a managed record's login is a generated
* handle, and "MA" for `managed-…` is nobody's initials.
*/
const auth = vi.hoisted(() => ({
user: {
id: "u1",
username: "managed-1f2e3d",
displayName: "Alex Example" as string | null,
role: "USER",
avatarUrl: null,
},
}));
vi.mock("@/hooks/use-auth", () => ({
useAuth: () => ({ user: auth.user, isAuthenticated: true, isLoading: false }),
}));
vi.mock("@tanstack/react-query", () => ({
useQuery: () => ({ data: undefined }),
useMutation: () => ({ mutate: vi.fn(), isPending: false, isError: false }),
useQueryClient: () => ({ invalidateQueries: vi.fn() }),
}));
import { ChatBubble } from "../chat-bubble";
function userBubble(): string {
return renderToStaticMarkup(
<I18nProvider initialLocale="en">
<ChatBubble role="user" content="How did I sleep?" />
</I18nProvider>,
);
}
describe("coach bubble initials", () => {
it("spells the display name, not the login", () => {
const html = userBubble();
expect(html).toContain(">AE<");
expect(html).not.toContain(">MA<");
});
it("falls back to the login when there is no display name", () => {
auth.user.displayName = null;
auth.user.username = "sam";
expect(userBubble()).toContain(">S<");
});
});
@@ -551,10 +551,11 @@ describe("<MessageThread>", () => {
});
it("falls back to initials when no avatar URL is present", () => {
// Default mock returns avatarUrl: null → initials path.
// Default mock returns avatarUrl: null → initials path, spelled like the
// top bar's: one word, one letter.
const html = render(<MessageThread conversation={baseConversation} />);
expect(html).toContain('data-slot="coach-bubble-user-avatar"');
expect(html).toMatch(/data-slot="coach-bubble-user-avatar"[^>]*>TE</);
expect(html).toMatch(/data-slot="coach-bubble-user-avatar"[^>]*>T</);
});
// v1.4.25 — optimistic user bubble appears before the "Thinking…"
@@ -15,6 +15,7 @@ import { cn } from "@/lib/utils";
import type { InboundDocumentDto } from "@/lib/validations/inbound-documents";
import { DOCUMENT_KIND_ICONS } from "@/components/documents/document-kind-meta";
import { DocumentPickerCapNotice } from "@/components/documents/document-picker-sheet";
import { QueryErrorRow } from "@/components/ui/query-error-row";
import { EmptyState } from "@/components/ui/empty-state";
@@ -45,6 +46,58 @@ export interface AttachmentPickerProps {
onConfirm: (ids: string[]) => void;
}
/** One document in the picker: a toggle, disabled until it is indexed. */
function PickerRow({
doc,
isSelected,
disabled,
onToggle,
}: {
doc: InboundDocumentDto;
isSelected: boolean;
disabled: boolean;
onToggle: () => void;
}) {
const { t } = useTranslations();
const title = doc.title ?? doc.filename ?? t("documents.card.untitled");
const Icon = DOCUMENT_KIND_ICONS[doc.kind];
return (
<li>
<button
type="button"
aria-pressed={isSelected}
disabled={disabled}
onClick={() => doc.hasContentIndex && onToggle()}
data-slot="coach-attachment-picker-item"
data-selected={isSelected ? "true" : undefined}
className={cn(
"border-border hover:bg-muted/50 flex w-full items-center gap-3 rounded-lg border p-3 text-left",
"focus-visible:ring-input-focus focus-visible:ring-[3px] focus-visible:outline-none",
isSelected && "border-primary/40 bg-primary/5",
disabled && "cursor-not-allowed opacity-60 hover:bg-transparent",
)}
>
<Icon className="text-foreground size-5 shrink-0" aria-hidden="true" />
<span className="min-w-0 flex-1">
<span className="block truncate text-sm font-medium">{title}</span>
{!doc.hasContentIndex ? (
<span className="text-muted-foreground block text-xs">
{t("insights.coach.attach.pickerNotIndexed")}
</span>
) : null}
</span>
<Check
className={cn(
"text-primary size-4 shrink-0",
isSelected ? "opacity-100" : "opacity-0",
)}
aria-hidden="true"
/>
</button>
</li>
);
}
export function AttachmentPicker({
open,
onOpenChange,
@@ -107,6 +160,8 @@ export function AttachmentPicker({
);
const capReached = selected.length >= remainingSlots;
// More match than one page: say so, and that the search reaches the rest.
const cut = Boolean(list.data?.nextCursor);
function toggle(id: string) {
setSelected((prev) => {
@@ -200,55 +255,23 @@ export function AttachmentPicker({
<ul className="max-h-[50vh] space-y-1.5 overflow-y-auto overscroll-contain">
{documents.map((doc) => {
const isSelected = selected.includes(doc.id);
const selectable = doc.hasContentIndex;
const disabled = !selectable || (!isSelected && capReached);
const title =
doc.title ?? doc.filename ?? t("documents.card.untitled");
const Icon = DOCUMENT_KIND_ICONS[doc.kind];
return (
<li key={doc.id}>
<button
type="button"
aria-pressed={isSelected}
disabled={disabled}
onClick={() => selectable && toggle(doc.id)}
data-slot="coach-attachment-picker-item"
data-selected={isSelected ? "true" : undefined}
className={cn(
"border-border hover:bg-muted/50 flex w-full items-center gap-3 rounded-lg border p-3 text-left",
"focus-visible:ring-input-focus focus-visible:ring-[3px] focus-visible:outline-none",
isSelected && "border-primary/40 bg-primary/5",
disabled &&
"cursor-not-allowed opacity-60 hover:bg-transparent",
)}
>
<Icon
className="text-foreground size-5 shrink-0"
aria-hidden="true"
/>
<span className="min-w-0 flex-1">
<span className="block truncate text-sm font-medium">
{title}
</span>
{!selectable ? (
<span className="text-muted-foreground block text-xs">
{t("insights.coach.attach.pickerNotIndexed")}
</span>
) : null}
</span>
<Check
className={cn(
"text-primary size-4 shrink-0",
isSelected ? "opacity-100" : "opacity-0",
)}
aria-hidden="true"
/>
</button>
</li>
<PickerRow
key={doc.id}
doc={doc}
isSelected={isSelected}
disabled={!doc.hasContentIndex || (!isSelected && capReached)}
onToggle={() => toggle(doc.id)}
/>
);
})}
</ul>
)}
<DocumentPickerCapNotice
cut={cut && !list.isError}
count={list.data?.documents.length ?? 0}
searching={query !== ""}
/>
</div>
</ResponsiveSheet>
);
@@ -9,6 +9,7 @@ import { cn } from "@/lib/utils";
import { Button } from "@/components/ui/button";
import { useTranslations } from "@/lib/i18n/context";
import { useAuth } from "@/hooks/use-auth";
import { accountName, nameInitials } from "@/lib/account-name";
import {
parseChartTokens,
tokenToMetric,
@@ -388,12 +389,10 @@ function ChatBubbleImpl({
if (role === "user") {
// v1.5.5 — pull the user's self-hosted avatar so the user
// bubble matches the Coach avatar in size and visual weight.
// Falls back to initials when the user has not uploaded an
// avatar. Replaces the Gravatar leak (v1.4.22 B3).
// Falls back to the display name's initials, as the top bar does, when
// the user has not uploaded an avatar.
const avatarUrl = user?.avatarUrl ?? null;
const initials = user?.username
? user.username.slice(0, 2).toUpperCase()
: null;
const initials = user ? nameInitials(accountName(user)) || null : null;
return (
<div
data-slot="coach-bubble-user"
@@ -20,6 +20,7 @@ import {
type UserAIProvider,
} from "@/components/settings/ai/shared";
import { apiGet, apiPatch, apiPut } from "@/lib/api/api-fetch";
import { localizedApiError } from "@/lib/api/localized-error";
import { useTranslations } from "@/lib/i18n/context";
import {
aiInputDependentKeys,
@@ -155,11 +156,7 @@ export function CoachModelPicker() {
void invalidateKeys(queryClient, aiInputDependentKeys);
};
const onFailed = (e: unknown) =>
setError(
e instanceof Error && e.message
? e.message
: t("insights.coach.frame.saveFailed"),
);
setError(localizedApiError(e, t, "insights.coach.frame.saveFailed"));
const switchProvider = useMutation({
mutationFn: async (provider: ProviderType) => {
+2 -23
View File
@@ -1,8 +1,7 @@
"use client";
import dynamic from "next/dynamic";
import { Activity, Sparkles } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Activity } from "lucide-react";
import { Card, CardContent, CardHeader } from "@/components/ui/card";
import { TileHeader } from "@/components/insights/tile-header";
import { EmptyState } from "@/components/ui/empty-state";
@@ -177,27 +176,7 @@ export function CorrelationCard({ result }: CorrelationCardProps) {
})}
</p>
<div className="flex flex-wrap items-center gap-2">
<Button
type="button"
size="sm"
// Outline variant so the disabled placeholder reads as
// "this is on the roadmap" rather than "the system is
// broken right now". Flip back to default when the
// 7-day experiment feature ships in v1.5.
variant="outline"
disabled
data-slot="correlation-card-cta"
className="gap-1.5"
title={t("insights.correlationRow.experimentTooltip")}
>
<Sparkles className="h-3.5 w-3.5" aria-hidden="true" />
<span>{t("insights.correlationRow.experimentCta")}</span>
<span className="sr-only">
, {t("insights.correlationRow.experimentTooltip")}
</span>
</Button>
{/* v1.21.0 — the live Coach hand-off sits beside the
roadmap CTA: it scopes to both sources in the pair. */}
{/* The Coach hand-off scopes to both sources in the pair. */}
<AskCoachAction
question={t(COACH_QUESTION_BY_KIND[result.kind])}
scope={coachScope}
@@ -12,6 +12,7 @@ import { SectionHeading } from "@/components/ui/section-heading";
import type { LabChange } from "@/lib/insights/labs-changes";
import { InsightSectionCard } from "./insight-section-card";
import { withLabUnit } from "@/lib/labs/dimensionless";
import { useLabNumber } from "@/components/labs/use-lab-format";
/**
* v1.25 — "what changed since your last panel" card.
@@ -55,6 +56,7 @@ export function LabsChangesCard({
}: LabsChangesCardProps) {
const { t } = useTranslations();
const { isAuthenticated } = useAuth();
const labNumber = useLabNumber();
const { data } = useQuery({
queryKey: queryKeys.insightsLabsChanges(),
@@ -82,7 +84,10 @@ export function LabsChangesCard({
const signedDelta =
c.direction === "flat"
? t("insights.labsChanges.directionFlat")
: withLabUnit(`${c.delta > 0 ? "+" : ""}${c.delta}`, c.unit);
: withLabUnit(
`${c.delta > 0 ? "+" : ""}${labNumber(c.delta)}`,
c.unit,
);
return (
<div
key={c.analyte}
@@ -94,7 +99,7 @@ export function LabsChangesCard({
{c.analyte}
</p>
<p className="text-muted-foreground text-xs">
{withLabUnit(String(c.latest), c.unit)}
{withLabUnit(labNumber(c.latest), c.unit)}
{labelKey ? ` · ${t(labelKey)}` : ""}
</p>
</div>
@@ -1,171 +0,0 @@
"use client";
/**
* v1.4.25 — Personal-record "PR" pill.
*
* Renders a small, text-only "PR" tag next to a metric label when the
* given metric reached an all-time best in the last 30 days. Pulls
* directly from `GET /api/personal-records?metricType=<type>` so the
* server's `PersonalRecord` rows are the single source of truth — no
* client-side recomputation, no risk of drift against the doctor
* report's PR list.
*
* Design choices:
* - Text-only (no celebratory emoji / no animation). The badge is
* informational and the same Recharts-restrained aesthetic the
* rest of the trend tiles use.
* - Tooltip is opt-in via the `withTooltip` prop. The dashboard's
* tile strip already runs a `TooltipProvider`, so the badge can
* drop straight in. Standalone uses (correlation cards, doctor
* report) pass `withTooltip={false}` to keep the DOM minimal.
* - Query is per-metric — 14 PR-trackable metrics × ~1 KB response =
* small enough that 14 idle queries on the dashboard cost less
* than one Recharts re-render. TanStack Query coalesces.
*/
import { useQuery } from "@tanstack/react-query";
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from "@/components/ui/tooltip";
import { useFormatters, useTranslations } from "@/lib/i18n/context";
import { apiGet } from "@/lib/api/api-fetch";
import { queryKeys } from "@/lib/query-keys";
import type { MeasurementType } from "@/generated/prisma/client";
import { cn } from "@/lib/utils";
import { ScorePill } from "@/components/ui/score-pill";
interface PersonalRecordRow {
id: string;
metricType: MeasurementType;
metricSlot: string | null;
value: number;
unit: string;
achievedAt: string;
/**
* v1.43 — the cited band for the age the record was set at, resolved on
* the server; null (or absent on an older server) when none applies.
*/
benchmark?: {
low: number;
high: number;
unit: string;
source: string;
sourceLabel: string;
} | null;
}
interface PersonalRecordBadgeProps {
metricType: MeasurementType;
/** Render a wrapping Tooltip with the PR value + date. Default true. */
withTooltip?: boolean;
/** Optional className passthrough so callers can position the pill. */
className?: string;
}
const THIRTY_DAYS_MS = 30 * 24 * 60 * 60 * 1000;
/**
* Pure freshness check kept out of the component body so the React
* purity linter doesn't trip on the `Date.now()` reference; callers
* pass `now` from the surrounding query refetch boundary where one
* stale-time tick = one new clock reading.
*/
function isPRWithin30Days(achievedAtIso: string, now: number): boolean {
const ts = new Date(achievedAtIso).getTime();
return Number.isFinite(ts) && now - ts <= THIRTY_DAYS_MS;
}
export function PersonalRecordBadge({
metricType,
withTooltip = true,
className,
}: PersonalRecordBadgeProps) {
const { t } = useTranslations();
const fmt = useFormatters();
const { data, dataUpdatedAt } = useQuery({
// Per-metric queryKey so two tiles for two different metrics never
// share a cache slot (HealthLog convention — see
// `feedback_react_query_key_collision`).
queryKey: queryKeys.personalRecordsByMetric(metricType),
queryFn: async () => {
try {
const data = await apiGet<PersonalRecordRow[] | null>(
`/api/personal-records?metricType=${encodeURIComponent(metricType)}`,
);
return data ?? [];
} catch {
// The badge is decorative — degrade to "no records", as before.
return [] as PersonalRecordRow[];
}
},
// Per-tile data: stale-while-revalidate cadence matches the
// dashboard's existing trend queries. 5 minutes is enough to
// absorb the worker's 30-minute fallback cadence without
// surfacing stale state for long.
staleTime: 5 * 60 * 1000,
});
// `dataUpdatedAt` is TanStack Query's per-query refetch timestamp
// — a deterministic clock reading that React's purity linter
// accepts because it's hook-derived. Each refetch advances it,
// shifting the freshness window forward by exactly the stale time.
// On the very first render (before the query has resolved)
// `dataUpdatedAt` is 0, so the freshness filter rejects everything
// until the response lands — desired behaviour for the SSR path.
const fresh = (data ?? []).find(
(row) =>
row.metricSlot === null &&
isPRWithin30Days(row.achievedAt, dataUpdatedAt),
);
if (!fresh) return null;
const pill = (
<ScorePill
tone="success"
numeric
data-slot="insights-pr-badge"
data-metric-type={metricType}
className={cn("tracking-wide uppercase", className)}
aria-label={t("insights.personalRecord.badge")}
>
{t("insights.personalRecord.badge")}
</ScorePill>
);
if (!withTooltip) return pill;
const formattedDate = fmt.date(new Date(fresh.achievedAt));
const tooltipText = t("insights.personalRecord.tooltip", {
value: fmt.number(fresh.value, 1),
unit: fresh.unit,
date: formattedDate,
});
return (
<Tooltip>
<TooltipTrigger asChild>{pill}</TooltipTrigger>
<TooltipContent
className="bg-muted border-border text-foreground"
data-slot="insights-pr-badge-tooltip"
>
<span className="text-xs">{tooltipText}</span>
{fresh.benchmark ? (
<span
className="text-muted-foreground block text-xs"
data-slot="insights-pr-badge-benchmark"
>
{t("insights.personalRecord.benchmark", {
low: fmt.number(fresh.benchmark.low, 1),
high: fmt.number(fresh.benchmark.high, 1),
unit: fresh.benchmark.unit,
source: fresh.benchmark.sourceLabel,
})}
</span>
) : null}
</TooltipContent>
</Tooltip>
);
}
@@ -6,6 +6,7 @@ import { Button } from "@/components/ui/button";
import { cn } from "@/lib/utils";
import { useTranslations } from "@/lib/i18n/context";
import { useTargetAdjust } from "@/lib/insights/target-adjust-context";
import { useRecordCapabilities } from "@/hooks/use-record-capabilities";
/**
* Header gear that opens the per-metric target editor.
@@ -20,6 +21,10 @@ import { useTargetAdjust } from "@/lib/insights/target-adjust-context";
* has registered for the page (e.g. a metric without a numeric
* target band, where the card paints nothing), so the header never
* shows a dead control;
* - renders nothing inside somebody else's record: the editor loads and
* saves the signed-in account's own targets (`/api/user/thresholds`),
* which refuses under a switch, so the gear would open a sheet that
* can do neither;
* - opens the primary registered target's `<TargetEditSheet>` on click.
*
* Sized to match `<CoachLaunchButton variant="icon">` so the gear + the
@@ -29,9 +34,11 @@ import { useTargetAdjust } from "@/lib/insights/target-adjust-context";
export function TargetAdjustButton({ className }: { className?: string }) {
const { t } = useTranslations();
const adjust = useTargetAdjust();
const { inSharedRecord } = useRecordCapabilities();
// No provider, or no editable target registered for this page.
if (!adjust || !adjust.canAdjust) return null;
// No provider, no editable target registered for this page, or a record
// whose targets this editor cannot reach.
if (!adjust || !adjust.canAdjust || inSharedRecord) return null;
// Reuse the action's existing copy ("Adjust target range" /
// "Zielbereich anpassen") — the gear performs exactly what the old
@@ -91,7 +91,6 @@ function render(
mode={options.mode ?? "vision"}
pdfSupported={options.pdfSupported ?? true}
consentRequired={options.consentRequired}
onCommitted={() => {}}
/>
</I18nProvider>
</QueryClientProvider>,
+3 -5
View File
@@ -16,7 +16,7 @@ import { Skeleton } from "@/components/ui/skeleton";
import { apiDelete, apiGet, apiPut } from "@/lib/api/api-fetch";
import { formatReferenceRange } from "@/lib/labs/reference-range";
import { useTranslations } from "@/lib/i18n/context";
import { queryKeys } from "@/lib/query-keys";
import { invalidateKeys, labDependentKeys, queryKeys } from "@/lib/query-keys";
import { useRecordCapabilities } from "@/hooks/use-record-capabilities";
@@ -68,8 +68,7 @@ export function BiomarkerManager() {
mutationFn: (id: string) => apiDelete(`/api/biomarkers/${id}`),
onSuccess: () => {
toast.success(t("labs.biomarker.deletedToast"));
queryClient.invalidateQueries({ queryKey: queryKeys.biomarkers() });
queryClient.invalidateQueries({ queryKey: queryKeys.labResults() });
void invalidateKeys(queryClient, labDependentKeys);
},
onError: (failure) =>
showErrorToast(t("labs.biomarker.deleteError"), { cause: failure }),
@@ -103,8 +102,7 @@ export function BiomarkerManager() {
function afterSave() {
setFormOpen(false);
setEditing(null);
queryClient.invalidateQueries({ queryKey: queryKeys.biomarkers() });
queryClient.invalidateQueries({ queryKey: queryKeys.labResults() });
void invalidateKeys(queryClient, labDependentKeys);
}
const markers = data?.biomarkers ?? [];
@@ -25,7 +25,7 @@ import { ResponsiveSheet } from "@/components/ui/responsive-sheet";
import { TagChip } from "@/components/ui/tag-chip";
import { apiPost } from "@/lib/api/api-fetch";
import { useTranslations } from "@/lib/i18n/context";
import { queryKeys } from "@/lib/query-keys";
import { invalidateKeys, labDependentKeys } from "@/lib/query-keys";
import type { BiomarkerDto, BiomarkerMergeResultDto } from "./types";
import { labUnitLabel } from "@/lib/labs/dimensionless";
@@ -79,8 +79,7 @@ export function BiomarkerMergeSheet({
name: result.target.name,
}),
);
queryClient.invalidateQueries({ queryKey: queryKeys.biomarkers() });
queryClient.invalidateQueries({ queryKey: queryKeys.labResults() });
void invalidateKeys(queryClient, labDependentKeys);
reset();
onOpenChange(false);
},
+10 -15
View File
@@ -44,7 +44,7 @@ import {
type MessageBundle,
} from "@/lib/i18n/load-locale";
import { resolveKey } from "@/lib/i18n/resolve-key";
import { queryKeys } from "@/lib/query-keys";
import { invalidateKeys, labDependentKeys, queryKeys } from "@/lib/query-keys";
import dynamic from "next/dynamic";
import type { ComponentProps } from "react";
@@ -259,13 +259,10 @@ export function LabBiomarkerDetail({ biomarkerId }: { biomarkerId: string }) {
// lapsed; bust them alongside the result list so the assessment repaints as
// soon as the reading lands.
function invalidateAfterReadingWrite() {
queryClient.invalidateQueries({ queryKey: queryKeys.labResults() });
queryClient.invalidateQueries({
queryKey: queryKeys.insightsBiomarkerAssessment(biomarkerId, locale),
});
queryClient.invalidateQueries({
queryKey: queryKeys.insightsLabsChanges(),
});
void invalidateKeys(queryClient, [
...labDependentKeys,
queryKeys.insightsBiomarkerAssessment(biomarkerId, locale),
]);
}
function afterAdd() {
@@ -282,12 +279,11 @@ export function LabBiomarkerDetail({ biomarkerId }: { biomarkerId: string }) {
function afterEditMarker() {
setEditOpen(false);
queryClient.invalidateQueries({
queryKey: queryKeys.biomarkerDetail(biomarkerId),
});
queryClient.invalidateQueries({ queryKey: queryKeys.biomarkers() });
// Resolved name / unit / range on every reading derives from the marker.
queryClient.invalidateQueries({ queryKey: queryKeys.labResults() });
void invalidateKeys(queryClient, [
...labDependentKeys,
queryKeys.biomarkerDetail(biomarkerId),
]);
}
// v1.18.9 (#41/#3) — delete the biomarker directly from its detail page so
@@ -301,8 +297,7 @@ export function LabBiomarkerDetail({ biomarkerId }: { biomarkerId: string }) {
mutationFn: () => apiDelete(`/api/biomarkers/${biomarkerId}`),
onSuccess: () => {
toast.success(t("labs.biomarker.deletedToast"));
queryClient.invalidateQueries({ queryKey: queryKeys.biomarkers() });
queryClient.invalidateQueries({ queryKey: queryKeys.labResults() });
void invalidateKeys(queryClient, labDependentKeys);
router.push("/labs");
},
onError: (failure) =>
+4 -2
View File
@@ -21,7 +21,8 @@ import {
SelectValue,
} from "@/components/ui/select";
import { Textarea } from "@/components/ui/textarea";
import { apiGet, apiPost } from "@/lib/api/api-fetch";
import { apiGet } from "@/lib/api/api-fetch";
import { useIdempotentPost } from "@/lib/api/use-idempotent-post";
import { localizedApiError } from "@/lib/api/localized-error";
import {
isUnreadableRange,
@@ -144,6 +145,7 @@ export function LabForm({
const labNumber = useLabNumber();
const recordName = useActiveRecordName();
const queryClient = useQueryClient();
const post = useIdempotentPost();
const formId = useId();
const { data: catalog, isLoading: catalogLoading } = useQuery({
@@ -265,7 +267,7 @@ export function LabForm({
const printed = isQualitative
? null
: parseReferenceRange(sourceRange, selected?.unit);
const created = await apiPost<LabResultDto>("/api/labs", {
const created = await post<LabResultDto>("/api/labs", {
biomarkerId,
...(isQualitative
? { valueText: qualitativeValue }
+2 -9
View File
@@ -35,7 +35,7 @@ import { LabConvertedNote } from "./lab-converted-note";
import { resolveNoteForUpdate } from "@/lib/labs/note-update";
import { resolveValueForUpdate, valueEditSeed } from "@/lib/labs/value-update";
import { useTranslations } from "@/lib/i18n/context";
import { queryKeys } from "@/lib/query-keys";
import { invalidateKeys, labDependentKeys, queryKeys } from "@/lib/query-keys";
import { ReferenceRangeBadge } from "./reference-range-badge";
import { LabReferenceRangeBar } from "./lab-reference-range-bar";
@@ -147,14 +147,7 @@ export function LabHistoryList({ readings }: { readings: LabResultDto[] }) {
const [hasNote, setHasNote] = useState(false);
function invalidate() {
queryClient.invalidateQueries({ queryKey: queryKeys.labResults() });
// v1.32.19 — a lab reading edit / delete / restore shifts the two most
// recent panels the Insights "what changed since your last panel" card
// reads, so bust it too instead of leaving it on its pre-write deltas
// until the 60 s stale window lapses.
queryClient.invalidateQueries({
queryKey: queryKeys.insightsLabsChanges(),
});
void invalidateKeys(queryClient, labDependentKeys);
}
async function restore(id: string) {
@@ -142,7 +142,6 @@ export function OcrReviewDialog({
mode,
pdfSupported,
consentRequired = false,
onCommitted,
}: {
open: boolean;
onOpenChange: (open: boolean) => void;
@@ -155,7 +154,6 @@ export function OcrReviewDialog({
* granted `/api/auth/me` refreshes and the picker appears.
*/
consentRequired?: boolean;
onCommitted: () => void;
}) {
const { t } = useTranslations();
const pickerId = useId();
@@ -335,7 +333,6 @@ export function OcrReviewDialog({
})
: t("labs.ocr.savedEmptyToast"),
);
onCommitted();
handleClose(false);
},
onError: (failure) =>
+7 -6
View File
@@ -29,7 +29,9 @@ import type { AiRunAccepted } from "@/lib/documents/ai-runs/types";
import { ocrImageToText } from "@/lib/labs/local-ocr";
import {
aiInputDependentKeys,
documentDependentKeys,
invalidateKeys,
labDependentKeys,
queryKeys,
} from "@/lib/query-keys";
import type {
@@ -259,12 +261,11 @@ export function useOcrCommit() {
});
},
onSuccess: (_result, { file }) => {
queryClient.invalidateQueries({ queryKey: queryKeys.labResults() });
queryClient.invalidateQueries({ queryKey: queryKeys.biomarkers() });
// A scan filed into the vault adds a document — refresh its lists.
if (file) {
queryClient.invalidateQueries({ queryKey: queryKeys.documents() });
}
// A scan filed into the vault adds a document as well.
void invalidateKeys(queryClient, [
...labDependentKeys,
...(file ? documentDependentKeys : []),
]);
},
});
}
+4 -12
View File
@@ -40,7 +40,7 @@ import {
import { cn } from "@/lib/utils";
import { Logo } from "@/components/ui/logo";
import { useAuth, useLogout } from "@/hooks/use-auth";
import { accountName } from "@/lib/account-name";
import { accountName, nameInitials } from "@/lib/account-name";
import { useIsMobile } from "@/hooks/use-is-mobile";
import { useNavModules } from "@/hooks/use-nav-modules";
import { useMounted } from "@/hooks/use-mounted";
@@ -98,14 +98,6 @@ export function writeSidebarCollapsedPref(collapsed: boolean): void {
}
}
function getInitials(name: string): string {
return name
.split(/[\s._-]+/)
.slice(0, 2)
.map((s) => s[0]?.toUpperCase() ?? "")
.join("");
}
/**
* The sidebar footer's name-and-avatar block.
*
@@ -170,7 +162,7 @@ function SidebarUserSection({ collapsed }: { collapsed: boolean }) {
<Avatar className="h-9 w-9 shrink-0">
{avatarUrl && <AvatarImage src={avatarUrl} alt={name} />}
<AvatarFallback className="bg-primary/15 text-primary text-xs font-medium">
{getInitials(name)}
{nameInitials(name)}
</AvatarFallback>
</Avatar>
<div className="min-w-0 flex-1">
@@ -286,7 +278,7 @@ function SidebarUserSection({ collapsed }: { collapsed: boolean }) {
<Avatar className="h-8 w-8" aria-hidden="true">
{avatarUrl && <AvatarImage src={avatarUrl} alt={name} />}
<AvatarFallback className="bg-primary/15 text-primary text-xs font-medium">
{getInitials(name)}
{nameInitials(name)}
</AvatarFallback>
</Avatar>
</DropdownMenuTrigger>
@@ -313,7 +305,7 @@ function SidebarUserSection({ collapsed }: { collapsed: boolean }) {
<Avatar className="h-8 w-8 shrink-0" aria-hidden="true">
{avatarUrl && <AvatarImage src={avatarUrl} alt={name} />}
<AvatarFallback className="bg-primary/15 text-primary text-xs font-medium">
{getInitials(name)}
{nameInitials(name)}
</AvatarFallback>
</Avatar>
<div className="min-w-0 flex-1">
+2 -10
View File
@@ -26,7 +26,7 @@ import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
import { Skeleton } from "@/components/ui/skeleton";
import Link from "next/link";
import { useAuth, useLogout } from "@/hooks/use-auth";
import { accountName } from "@/lib/account-name";
import { accountName, nameInitials } from "@/lib/account-name";
import { useRecordCapabilities } from "@/hooks/use-record-capabilities";
import { useTheme } from "@/components/providers";
import { useTranslations } from "@/lib/i18n/context";
@@ -41,14 +41,6 @@ import {
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
function getInitials(name: string): string {
return name
.split(/[\s._-]+/)
.slice(0, 2)
.map((s) => s[0]?.toUpperCase() ?? "")
.join("");
}
export function TopBar() {
const { user, isLoading } = useAuth();
// v1.36.0 — acting on somebody else's record; see the menu below. Read from
@@ -147,7 +139,7 @@ export function TopBar() {
<AvatarImage src={avatarUrl} alt={accountName(user)} />
)}
<AvatarFallback className="bg-primary/15 text-primary text-xs font-medium">
{getInitials(accountName(user))}
{nameInitials(accountName(user))}
</AvatarFallback>
</Avatar>
<span className="hidden sm:inline">{accountName(user)}</span>
+10 -6
View File
@@ -37,6 +37,7 @@ import {
type EntityLinkOption,
} from "@/components/links/entity-link-picker";
import { useVaultDocumentOptions } from "@/components/links/vault-document-options";
import { useCalendarDate } from "@/hooks/use-calendar-date";
import {
pickerPivot,
useLinkPickerPages,
@@ -133,6 +134,7 @@ export function RelatedRecordsList({
}) {
const { t } = useTranslations();
const format = useFormatters();
const calendarDate = useCalendarDate();
const rows = (related ?? []).filter((row) => !omitKinds.includes(row.kind));
if (rows.length === 0) return null;
return (
@@ -142,7 +144,12 @@ export function RelatedRecordsList({
const via = viaText(t, row.via);
const meta = [
kindText(t, row.kind),
row.date ? format.date(row.date) : null,
row.date
? // A dose date is a calendar date at UTC midnight.
row.kind === "vaccination"
? calendarDate(row.date)
: format.date(row.date)
: null,
via,
]
.filter(Boolean)
@@ -381,7 +388,7 @@ function MedicationPicker({ selected, onChange, known }: PickerProps) {
function DosePicker({ selected, onChange, anchor, known }: PickerProps) {
const { t } = useTranslations();
const format = useFormatters();
const calendarDate = useCalendarDate();
const doses = useVaccinations(null, true);
const options = recordOptions(
(doses.data?.vaccinations ?? []).map((dose) => ({
@@ -393,10 +400,7 @@ function DosePicker({ selected, onChange, anchor, known }: PickerProps) {
href: `/vaccinations?dose=${encodeURIComponent(dose.id)}`,
})),
anchor,
{
suggested: t("links.picker.suggested"),
date: (iso: string) => format.date(iso),
},
{ suggested: t("links.picker.suggested"), date: calendarDate },
);
return (
<EntityLinkPicker
@@ -0,0 +1,74 @@
/**
* The edit sheet refuses an implausible value at the value field, before the
* request, in the unit the input shows. The server checks the same band and
* used to be the only one that did: its 422 reached the sheet as the English
* banner "Validation failed", in every locale, with no field marked.
*/
import { describe, expect, it } from "vitest";
import type { UnitDisplay } from "@/hooks/use-unit-display";
import { getUnitForType } from "@/lib/measurements/unit-map";
import { editValueRange } from "../list/use-measurement-edit";
import { rowDisplayFor } from "../list/use-measurement-row-display";
const KG_PER_LB = 0.45359237;
function display(
preference: "metric" | "imperial",
glucose: "mg/dL" | "mmol/L",
) {
const imperial = preference === "imperial";
const unitDisplay = {
preference,
isTransformed: (type: string) => imperial && type === "WEIGHT",
toDisplay: (type: string, v: number) =>
imperial && type === "WEIGHT" ? v / KG_PER_LB : v,
fromDisplay: (type: string, v: number) =>
imperial && type === "WEIGHT" ? v * KG_PER_LB : v,
unitFor: (type: string) =>
imperial && type === "WEIGHT" ? "lb" : getUnitForType(type),
} as unknown as UnitDisplay;
return rowDisplayFor(glucose, unitDisplay);
}
const row = (type: string, unit: string) => ({
id: "m1",
type,
value: 72,
unit,
source: "MANUAL",
measuredAt: "2026-11-10T08:00:00.000Z",
notes: null,
});
describe("editValueRange", () => {
it("names the band in the stored unit for a metric reader", () => {
expect(
editValueRange(display("metric", "mg/dL"), row("WEIGHT", "kg")),
).toEqual({ min: 1, max: 500, unit: "kg" });
});
it("names it in pounds for an imperial reader, who types pounds", () => {
const range = editValueRange(
display("imperial", "mg/dL"),
row("WEIGHT", "kg"),
);
expect(range?.unit).toBe("lb");
expect(range?.max).toBeCloseTo(1102.3, 1);
});
it("names it in mmol/L for a glucose edit in mmol/L", () => {
const range = editValueRange(
display("metric", "mmol/L"),
row("BLOOD_GLUCOSE", "mg/dL"),
);
expect(range?.unit).toBe("mmol/L");
expect(range!.max).toBeLessThan(100);
});
it("has nothing to say for a type without a band", () => {
expect(
editValueRange(display("metric", "mg/dL"), row("NO_SUCH_TYPE", "x")),
).toBeNull();
});
});
@@ -61,10 +61,15 @@ vi.mock("@/hooks/use-auth", () => ({
import { I18nProvider } from "@/lib/i18n/context";
import { MeasurementList } from "../measurement-list";
import type { Measurement } from "../list/list-model";
import type { UnitDisplay } from "@/hooks/use-unit-display";
import { convertGlucose, toCanonicalMgdl } from "@/lib/glucose";
import { getUnitForType } from "@/lib/measurements/unit-map";
import { editSeedValue, storedValueFor } from "../list/use-measurement-edit";
import {
editSeedValue,
outOfRangeMessage,
storedValueFor,
} from "../list/use-measurement-edit";
import { rowDisplayFor } from "../list/use-measurement-row-display";
/** A metric reader on the given glucose unit, as the list's row display. */
@@ -140,3 +145,35 @@ describe("MeasurementList — blood-glucose display unit", () => {
expect(storedValueFor(display, glucose(95.48), 95.48)).toBe(95.48);
});
});
describe("the out-of-range sentence", () => {
const t = ((key: string, params?: Record<string, unknown>) =>
`${key} ${JSON.stringify(params)}`) as unknown as Parameters<
typeof outOfRangeMessage
>[2];
const reading = (type: string, unit: string): Measurement => ({
id: "m1",
type,
value: 1,
unit,
source: "MANUAL",
measuredAt: "2026-11-10T12:00:00.000Z",
notes: null,
});
const shows = (unit: string) => ({
rowDisplay: (m: { value: number }) => ({ value: m.value, unit }),
});
it("names the unit where the type has one", () => {
expect(
outOfRangeMessage(shows("kg"), reading("WEIGHT", "kg"), t, String),
).toMatch(/^measurements\.valueOutOfRange \{.*"unit":"kg"/);
});
it("leaves the unit out for a score, which has no label", () => {
// "Enter a value between 0 and 10 ." read with a space before the stop.
expect(
outOfRangeMessage(shows(""), reading("PAIN_NRS", "score"), t, String),
).toBe('measurements.valueOutOfRangeNoUnit {"min":"0","max":"10"}');
});
});
@@ -0,0 +1,137 @@
/**
* A computed score offers neither edit nor delete in the measurement list,
* and no selection checkbox for the bulk delete; a reading of the person's
* own, or one a device or provider sent, keeps all three.
*/
import { describe, expect, it, vi } from "vitest";
import { renderToStaticMarkup } from "react-dom/server";
vi.mock("@/hooks/use-record-capabilities", () => ({
useRecordCapabilities: () => ({
canManageDomain: () => true,
inSharedRecord: false,
}),
}));
import { I18nProvider } from "@/lib/i18n/context";
import type { Measurement } from "../list-model";
import { MeasurementCardList } from "../measurement-card-list";
import { MeasurementTable } from "../measurement-table";
import {
MeasurementRowActions,
type MeasurementRowContext,
} from "../row-actions";
const rows: MeasurementRowContext = {
canManage: true,
selectedIds: new Set<string>(),
expandedDayKeys: new Set<string>(),
toggleExpand: () => {},
navigateForType: () => {},
startEdit: () => {},
onToggleRow: () => {},
onDelete: () => {},
// The row's value formatting; the actions never read it.
display: {} as MeasurementRowContext["display"],
};
function render(source: string): string {
const m: Measurement = {
id: "m1",
type: "RECOVERY_SCORE",
value: 85,
unit: "score",
source,
measuredAt: "2026-11-10T12:00:00.000Z",
notes: null,
};
return renderToStaticMarkup(
<I18nProvider initialLocale="en">
<MeasurementRowActions
m={m}
rows={rows}
isGrouped={false}
isExpanded={false}
drilldownId="d1"
layout="desktop"
/>
</I18nProvider>,
);
}
describe("MeasurementRowActions", () => {
it("offers no edit and no delete for a computed score", () => {
const html = render("COMPUTED");
expect(html).not.toContain('aria-label="Edit"');
expect(html).not.toContain('aria-label="Delete"');
});
it.each(["MANUAL", "WITHINGS", "APPLE_HEALTH"])(
"keeps edit and delete for a %s reading",
(source) => {
const html = render(source);
expect(html).toContain('aria-label="Edit"');
expect(html).toContain('aria-label="Delete"');
},
);
});
describe("the row checkbox", () => {
const listRows: MeasurementRowContext = {
...rows,
display: {
rowDisplay: (m: Measurement) => ({ value: m.value, unit: m.unit }),
rowIdentityAttrs: () => ({}),
} as unknown as MeasurementRowContext["display"],
};
const reading = (id: string, source: string): Measurement => ({
id,
type: "RECOVERY_SCORE",
value: 85,
unit: "score",
source,
measuredAt: "2026-11-10T12:00:00.000Z",
notes: null,
});
const checkboxes = (html: string) =>
html.split('aria-label="Select row"').length - 1;
it("is offered for a reading and not for a computed score, on both layouts", () => {
const measurements = [reading("m1", "MANUAL"), reading("m2", "COMPUTED")];
const wrap = (node: React.ReactNode) =>
renderToStaticMarkup(
<I18nProvider initialLocale="en">{node}</I18nProvider>,
);
expect(
checkboxes(
wrap(
<MeasurementCardList measurements={measurements} rows={listRows} />,
),
),
).toBe(1);
expect(
checkboxes(
wrap(
<MeasurementTable
measurements={measurements}
rows={listRows}
filters={
{
sortBy: "measuredAt",
sortDir: "desc",
toggleSort: () => {},
} as unknown as Parameters<typeof MeasurementTable>[0]["filters"]
}
list={
{
selectAll: "none",
pageIds: [],
onToggleSelectAll: () => {},
} as unknown as Parameters<typeof MeasurementTable>[0]["list"]
}
/>,
),
),
).toBe(1);
});
});
@@ -0,0 +1,98 @@
/**
* The list's bulk delete and its Undo answer once the server has done it.
*
* Both used to wait for every read a measurement touches (charts, insights,
* the dashboard) before the selection bar cleared or the toast showed, so on
* a slow connection a delete looked like it had not happened.
*/
import { beforeEach, describe, expect, it, vi } from "vitest";
const h = vi.hoisted(() => ({
client: {
// A refetch that never answers, as on a slow connection.
invalidateQueries: vi.fn(() => new Promise(() => {})),
getQueryCache: () => ({ findAll: () => [] }),
},
toastSuccess: vi.fn(),
apiPost: vi.fn(async () => ({ deleted: 2 })),
}));
vi.mock("react", async (importOriginal) => ({
...(await importOriginal<typeof import("react")>()),
useCallback: <T>(fn: T) => fn,
useEffect: () => {},
}));
vi.mock("@tanstack/react-query", () => ({
useQuery: () => ({ data: undefined, isLoading: false, isError: false }),
// The hook's options are what is under test; return them as they are.
useMutation: (options: unknown) => options,
useQueryClient: () => h.client,
}));
vi.mock("@/lib/api/api-fetch", () => ({
apiGet: vi.fn(),
apiDelete: vi.fn(),
apiPost: h.apiPost,
}));
vi.mock("@/lib/i18n/context", () => ({
useTranslations: () => ({ t: (key: string) => key }),
}));
vi.mock("sonner", () => ({ toast: { success: h.toastSuccess } }));
import type { MeasurementListFilters } from "../use-measurement-list-filters";
import { useMeasurementListData } from "../use-measurement-list-data";
function useBulkDelete(clearSelection = vi.fn()) {
const filters = {
typeFilter: "ALL",
selectedIds: new Set<string>(),
setSelectedIds: vi.fn(),
clearSelection,
} as unknown as MeasurementListFilters;
const data = useMeasurementListData({
filters,
enabled: true,
lockedType: undefined,
});
return data.bulkDeleteMutation as unknown as {
onSuccess: (deleted: number, ids: string[]) => unknown;
};
}
describe("measurement list deletes", () => {
beforeEach(() => {
vi.clearAllMocks();
});
it("clears the selection and says so without waiting for the refetch", () => {
const clearSelection = vi.fn();
const bulk = useBulkDelete(clearSelection);
expect(bulk.onSuccess(2, ["a", "b"])).toBeUndefined();
expect(clearSelection).toHaveBeenCalled();
expect(h.toastSuccess).toHaveBeenCalledWith(
"measurements.bulkDeleteSuccess",
expect.anything(),
);
});
it("confirms the Undo once the rows are back, before the refetch", async () => {
useBulkDelete().onSuccess(2, ["a", "b"]);
const undo = h.toastSuccess.mock.calls[0]![1] as {
action: { onClick: () => void };
};
undo.action.onClick();
await vi.waitFor(() =>
expect(h.toastSuccess).toHaveBeenLastCalledWith(
"measurements.restoredToast",
),
);
expect(h.apiPost).toHaveBeenLastCalledWith("/api/measurements/restore", {
ids: ["a", "b"],
});
});
});
@@ -18,6 +18,7 @@ import {
import { DayDrillDown, SleepNightCaption } from "./day-drill-down";
import {
MeasurementRowActions,
isServerComputed,
rowState,
typeLabel,
type MeasurementRowContext,
@@ -83,7 +84,7 @@ function MeasurementCard({
>
<div className="flex items-center justify-between">
<div className="flex min-w-0 flex-1 items-center gap-2">
{!isGrouped && canManage && (
{!isGrouped && !isServerComputed(m) && canManage && (
// The checkbox owns its 44 px hit area through an `after`
// hit-slop inside a plain layout `<div>`: a wrapper `<button>`
// would nest a button in a button, invalid markup that failed
@@ -3,6 +3,7 @@
import { createPortal } from "react-dom";
import { Loader2, MoreHorizontal, Trash2 } from "lucide-react";
import { FieldError } from "@/components/forms/field-error";
import { SeededFormDiscardDialog } from "@/components/forms/use-seeded-form-dismissal";
import {
AlertDialog,
@@ -57,6 +58,8 @@ export function MeasurementEditSheet({
setEditValue,
editError,
editErrorDescriptor,
editValueError,
editValueErrorId,
editMeasuredAt,
setEditMeasuredAt,
editNotes,
@@ -103,9 +106,12 @@ export function MeasurementEditSheet({
onChange={(e) => setEditValue(e.target.value)}
required
aria-required="true"
aria-invalid={!!editError || undefined}
aria-describedby={editErrorDescriptor}
aria-invalid={!!(editValueError || editError) || undefined}
aria-describedby={
editValueError ? editValueErrorId : editErrorDescriptor
}
/>
<FieldError id={editValueErrorId} message={editValueError} />
</div>
<div className="space-y-2">
@@ -23,6 +23,7 @@ import { MEASUREMENT_TYPE_COLORS as TYPE_COLORS } from "../measurement-list-meta
import { DayDrillDown, SleepNightCaption } from "./day-drill-down";
import {
MeasurementRowActions,
isServerComputed,
rowState,
typeLabel,
type MeasurementRowContext,
@@ -154,8 +155,9 @@ function MeasurementTableRow({
data-display-unit={isGrouped ? m.unit : rowDisplay(m).unit}
>
<TableCell className="pl-4">
{/* A grouped row is not individually deletable: no checkbox. */}
{!isGrouped && canManage && (
{/* A grouped row is not individually deletable, and a computed score
is the server's to rewrite: no checkbox for either. */}
{!isGrouped && !isServerComputed(m) && canManage && (
<Checkbox
checked={isSelected}
onCheckedChange={() => onToggleRow(m.id)}
@@ -43,10 +43,22 @@ export function rowState(m: Measurement, rows: MeasurementRowContext) {
};
}
/**
* A score the server computes (recovery, strain, stress) offers neither
* edit nor delete: its value is the engine's, the server refuses a changed
* one on save, and the edit sheet has nothing of the person's to correct.
* Readings from a device or a provider keep both: the server takes a note or
* time edit on them, and a wrong scale reading has to be removable.
*/
export function isServerComputed(m: Pick<Measurement, "source">): boolean {
return m.source === "COMPUTED";
}
/**
* A row's controls: the chevron into a day's or night's samples for a
* grouped row; edit (owner only) and delete for a reading. The table's
* buttons are 40 px, the phone list's the large icon size.
* grouped row; edit (owner only) and delete for a reading, neither for a
* computed score. The table's buttons are 40 px, the phone list's the large
* icon size.
*/
export function MeasurementRowActions({
m,
@@ -85,7 +97,7 @@ export function MeasurementRowActions({
className={`h-4 w-4 transition-transform ${isExpanded ? "rotate-180" : ""}`}
/>
</Button>
) : (
) : isServerComputed(m) ? null : (
<>
{canManage && (
<Button
@@ -5,9 +5,14 @@ import { useMutation, useQueryClient } from "@tanstack/react-query";
import { useSeededFormDismissal } from "@/components/forms/use-seeded-form-dismissal";
import { ApiError, apiPut } from "@/lib/api/api-fetch";
import { localizedApiError } from "@/lib/api/localized-error";
import { toCanonicalMgdl } from "@/lib/glucose";
import { useTranslations } from "@/lib/i18n/context";
import { useFormatters, useTranslations } from "@/lib/i18n/context";
import { getUnitForType } from "@/lib/measurements/unit-map";
import {
VALUE_RANGES,
validateMeasurementRange,
} from "@/lib/measurements/value-ranges";
import {
invalidateKeys,
measurementDependentKeys,
@@ -78,6 +83,58 @@ export function storedValueFor(
return parsedValue;
}
/**
* The stored value's plausible band for the row's type, in the unit the edit
* input shows, or null for a type without one. The same table the server
* checks the edit against.
*/
export function editValueRange(
{ rowDisplay }: Pick<MeasurementRowDisplay, "rowDisplay">,
measurement: Measurement,
): { min: number; max: number; unit: string } | null {
const range = VALUE_RANGES[measurement.type];
if (!range) return null;
const low = rowDisplay({ ...measurement, value: range.min });
const high = rowDisplay({ ...measurement, value: range.max });
const round = (n: number) => Math.round(n * 10) / 10;
return { min: round(low.value), max: round(high.value), unit: low.unit };
}
type Translate = ReturnType<typeof useTranslations>["t"];
/** The sentence for a value outside its band, in the unit the input shows. */
export function outOfRangeMessage(
display: Pick<MeasurementRowDisplay, "rowDisplay">,
measurement: Measurement,
t: Translate,
formatNumber: (value: number) => string,
): string {
const range = editValueRange(display, measurement);
if (!range) return t("measurements.invalidValue");
const bounds = { min: formatNumber(range.min), max: formatNumber(range.max) };
// A score, rating, count or ratio has no unit label, which left a space
// before the full stop.
return range.unit
? t("measurements.valueOutOfRange", { ...bounds, unit: range.unit })
: t("measurements.valueOutOfRangeNoUnit", bounds);
}
/**
* The banner for a refused save. A 409 names its reason in `meta.errorCode`:
* the edit collides with another reading's (type, time, source, stage), or
* the reading belongs to a server-owned source (connector, import, computed).
*/
function saveRefusal(err: unknown, t: Translate): string {
const code = err instanceof ApiError ? err.meta?.errorCode : undefined;
if (code === "measurement.duplicate_timestamp") {
return t("measurements.duplicateTimestamp");
}
if (code === "measurement.update.server_owned_source") {
return t("measurements.serverOwnedSource");
}
return localizedApiError(err, t, "measurements.saveError");
}
/**
* The edit sheet's state: the reading being edited, the draft seeded in the
* person's display unit, the save (converted back to the stored unit) and
@@ -94,6 +151,7 @@ export function useMeasurementEdit({
deleteMutation: MeasurementListData["deleteMutation"];
}) {
const { t } = useTranslations();
const fmt = useFormatters();
const queryClient = useQueryClient();
const [editing, setEditing] = useState<Measurement | null>(null);
const [editValue, setEditValue] = useState("");
@@ -105,10 +163,13 @@ export function useMeasurementEdit({
notes: "",
});
const [editError, setEditError] = useState<string | null>(null);
// A refused value is said under the value input, not in the banner.
const [editValueError, setEditValueError] = useState<string | null>(null);
const [editDeleteDialogOpen, setEditDeleteDialogOpen] = useState(false);
// The error banner is linked to the inputs through `aria-describedby`, so a
// screen reader announces why a submit was refused.
const editErrorId = useId();
const editValueErrorId = useId();
// The sheet's footer is a slot the form portals its buttons into.
const editFormId = useId();
const [editFooterEl, setEditFooterEl] = useState<HTMLDivElement | null>(null);
@@ -127,39 +188,30 @@ export function useMeasurementEdit({
}) => {
await apiPut(`/api/measurements/${id}`, { value, measuredAt, notes });
},
onSuccess: async () => {
await invalidateKeys(queryClient, measurementDependentKeys);
await refetchInactiveDailyReads(queryClient);
onSuccess: () => {
setEditSeed({
value: editValue,
measuredAt: editMeasuredAt,
notes: editNotes,
});
dismissEdit();
void invalidateKeys(queryClient, measurementDependentKeys);
void refetchInactiveDailyReads(queryClient);
},
onError: (err) => {
// A 409 names its reason in `meta.errorCode`: the edit collides with
// another reading's (type, time, source, stage), or the reading belongs
// to a server-owned source (connector, import, computed).
if (
err instanceof ApiError &&
err.meta?.errorCode === "measurement.duplicate_timestamp"
) {
setEditError(t("measurements.duplicateTimestamp"));
return;
if (err instanceof ApiError && err.issuePaths.includes("value")) {
setEditValueError(valueRefusal());
} else {
setEditError(saveRefusal(err, t));
}
if (
err instanceof ApiError &&
err.meta?.errorCode === "measurement.update.server_owned_source"
) {
setEditError(t("measurements.serverOwnedSource"));
return;
}
setEditError(
err instanceof Error ? err.message : t("measurements.saveError"),
);
},
});
function valueRefusal(): string {
return editing
? outOfRangeMessage(display, editing, t, fmt.number)
: t("measurements.invalidValue");
}
const editDismissal = useSeededFormDismissal({
seed: editSeed,
value: {
@@ -188,11 +240,13 @@ export function useMeasurementEdit({
setEditNotes(seed.notes);
setEditSeed(seed);
setEditError(null);
setEditValueError(null);
}
function dismissEdit() {
setEditing(null);
setEditError(null);
setEditValueError(null);
setEditDeleteDialogOpen(false);
}
@@ -216,9 +270,11 @@ export function useMeasurementEdit({
e.preventDefault();
if (!editing) return;
setEditError(null);
setEditValueError(null);
const parsedValue = Number(editValue);
if (!Number.isFinite(parsedValue)) {
setEditError(t("measurements.invalidValue"));
setEditValueError(t("measurements.invalidValue"));
return;
}
@@ -229,8 +285,16 @@ export function useMeasurementEdit({
}
const canonicalValue = storedValueFor(display, editing, parsedValue);
// The server refuses a changed value outside the type's band; say so at
// the field before sending, in the unit the input shows.
if (
editValue !== editSeed.value &&
validateMeasurementRange(editing.type, canonicalValue) !== null
) {
setEditValueError(valueRefusal());
return;
}
setEditError(null);
updateMutation.mutate({
id: editing.id,
value: canonicalValue,
@@ -250,6 +314,8 @@ export function useMeasurementEdit({
editError,
editErrorId,
editErrorDescriptor: editError ? editErrorId : undefined,
editValueError,
editValueErrorId,
editFormId,
editFooterEl,
setEditFooterEl,
@@ -22,6 +22,7 @@ import {
} from "@/lib/query-keys";
import { PAGE_SIZE, isGroupedRow, type Measurement } from "./list-model";
import { isServerComputed } from "./row-actions";
import type { MeasurementListFilters } from "./use-measurement-list-filters";
/**
@@ -114,9 +115,9 @@ export function useMeasurementListData({
async (ids: string[]) => {
try {
await apiPost("/api/measurements/restore", { ids });
await invalidateKeys(queryClient, measurementDependentKeys);
await refetchInactiveDailyReads(queryClient);
toast.success(t("measurements.restoredToast"));
void invalidateKeys(queryClient, measurementDependentKeys);
void refetchInactiveDailyReads(queryClient);
} catch (failure) {
showErrorToast(t("measurements.restoreError"), { cause: failure });
}
@@ -154,9 +155,11 @@ export function useMeasurementListData({
);
return data.deleted;
},
onSuccess: async (deleted, ids) => {
await invalidateKeys(queryClient, measurementDependentKeys);
await refetchInactiveDailyReads(queryClient);
// The refresh is started, not returned: the selection bar and the toast
// answer as soon as the rows are gone.
onSuccess: (deleted, ids) => {
void invalidateKeys(queryClient, measurementDependentKeys);
void refetchInactiveDailyReads(queryClient);
clearSelection();
toast.success(
deleted === 1
@@ -176,9 +179,10 @@ export function useMeasurementListData({
});
// Synthetic day and night rows collapse many raw rows behind a `dayKey`
// and are not individually deletable, so they are not selectable.
// and are not individually deletable, and a computed score is rewritten
// by the server, so neither is selectable.
const pageIds = (data?.measurements ?? [])
.filter((m) => !isGroupedRow(m))
.filter((m) => !isGroupedRow(m) && !isServerComputed(m))
.map((m) => m.id);
const selectAll = selectAllState(selectedIds, pageIds);
const selectedOnPage = selectedCountOnPage(selectedIds, pageIds);
@@ -33,8 +33,8 @@ import {
measurementDependentKeys,
refetchInactiveDailyReads,
} from "@/lib/query-keys";
import { apiPost } from "@/lib/api/api-fetch";
import { localizedApiError } from "@/lib/api/localized-error";
import { useIdempotentPost } from "@/lib/api/use-idempotent-post";
import { MEASUREMENT_NOTES_MAX_LENGTH } from "@/lib/measurements/notes-limit";
import {
getLastUsedMeasurementType,
@@ -327,6 +327,7 @@ export function MeasurementForm({
const { t } = useTranslations();
const recordName = useActiveRecordName();
const queryClient = useQueryClient();
const post = useIdempotentPost();
const unitDisplay = useUnitDisplay();
const { user } = useAuth();
// Glucose is its own display axis, separate from metric/imperial: it is
@@ -448,7 +449,7 @@ export function MeasurementForm({
});
}
await apiPost("/api/measurements", batch);
await post("/api/measurements", batch);
} else if (isKetoneMode) {
// One fingerstick, posted as one transaction (`ketone-fingerstick.ts`).
const batch = ketoneFingerstickBatch({
@@ -463,7 +464,7 @@ export function MeasurementForm({
setError(t("measurements.saveError"));
return;
}
await apiPost("/api/measurements", batch);
await post("/api/measurements", batch);
} else {
// Single measurement. Canonical storage stays SI: an imperial user
// types in lb / in / °F and we invert to the canonical unit at the
@@ -508,7 +509,7 @@ export function MeasurementForm({
: inverted;
}
canonicalValue = entryValueToCanonical(type, canonicalValue);
await apiPost("/api/measurements", {
await post("/api/measurements", {
type,
value: canonicalValue,
measuredAt: timestamp,
@@ -528,8 +529,6 @@ export function MeasurementForm({
setPulse("");
setPairedGlucose("");
setNotes("");
await invalidateKeys(queryClient, measurementDependentKeys);
await refetchInactiveDailyReads(queryClient);
// v1.36.x — a delegate's receipt names the record. "Saved" alone is
// the one confirmation somebody acting for another person does not
// need, and the reading has just left their own history for good.
@@ -544,6 +543,10 @@ export function MeasurementForm({
: undefined,
);
onSuccess?.();
// The reading is stored; the sheet does not wait for every read that
// shows it to come back.
void invalidateKeys(queryClient, measurementDependentKeys);
void refetchInactiveDailyReads(queryClient);
} catch (err) {
setError(localizedApiError(err, t, "measurements.saveError"));
} finally {
@@ -1,108 +0,0 @@
/**
* v1.5.5 — `<IntakeHistoryPreview>` smoke tests.
*
* The preview mounts in SSR with an empty selection so the bulk-
* delete toolbar stays hidden by default. Selection / dialog flips
* are interactive surfaces and are exercised by the e2e suite.
*
* The kebab + selection wiring is covered by
* `intake-history-list-v2-row-actions.test.tsx`; this file pins the
* wrapper-level contract (no toolbar by default, wired through the
* v1.5.5 endpoints).
*/
import { describe, expect, it, vi } from "vitest";
import { renderToStaticMarkup } from "react-dom/server";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { I18nProvider } from "@/lib/i18n/context";
import { IntakeHistoryPreview } from "@/components/medications/sections/intake-history-preview";
import { queryKeys } from "@/lib/query-keys";
vi.mock("@/hooks/use-auth", () => ({
useAuth: () => ({
user: { id: "test-user", username: "tester", role: "USER" },
isAuthenticated: true,
isLoading: false,
}),
}));
function makeClient() {
return new QueryClient({
defaultOptions: {
queries: {
retry: false,
staleTime: Number.POSITIVE_INFINITY,
refetchOnMount: false,
},
},
});
}
function seed(client: QueryClient) {
client.setQueryData(
queryKeys.medicationIntakeList("med-1", {
sortBy: "takenAt",
sortDir: "desc",
limit: 14,
offset: 0,
status: "completed",
}),
{
events: [
{
id: "evt-1",
medicationId: "med-1",
scheduledFor: "2026-05-15T08:00:00.000Z",
takenAt: "2026-05-15T08:02:00.000Z",
skipped: false,
source: "WEB",
createdAt: "2026-05-15T08:02:00.000Z",
},
],
meta: { total: 1, limit: 14, offset: 0 },
},
);
}
function render(node: React.ReactNode, client: QueryClient) {
return renderToStaticMarkup(
<I18nProvider initialLocale="en">
<QueryClientProvider client={client}>{node}</QueryClientProvider>
</I18nProvider>,
);
}
describe("<IntakeHistoryPreview> — F-1 C-2", () => {
it("hides the bulk-delete toolbar when nothing is selected (initial render)", () => {
const client = makeClient();
seed(client);
const html = render(
<IntakeHistoryPreview
medicationId="med-1"
importOpen={false}
onImportOpenChange={() => {}}
/>,
client,
);
expect(html).not.toContain("intake-history-bulk-delete-toolbar");
});
it("renders the row-action kebab and selection checkbox on every row", () => {
const client = makeClient();
seed(client);
const html = render(
<IntakeHistoryPreview
medicationId="med-1"
importOpen={false}
onImportOpenChange={() => {}}
/>,
client,
);
// Per-row affordances feature 15 + 16 — the row carries both.
expect(html).toContain("intake-history-row-kebab");
expect(html).toContain("intake-history-row-select");
// The import header CTA stays alongside the new affordances.
expect(html).toContain("intake-history-import");
});
});
@@ -366,7 +366,41 @@ describe("runLogIntake — manual backdated intake from the Add choice", () => {
takenAt: "2026-06-01T08:30:00.000Z",
});
expect(body).not.toHaveProperty("scheduledFor");
expect(queryClient.invalidateQueries).toHaveBeenCalled();
// Started, not awaited (the case below).
await vi.waitFor(() =>
expect(queryClient.invalidateQueries).toHaveBeenCalled(),
);
expect(toast.success).toHaveBeenCalledWith(
"medications.intakeToastTaken:Mounjaro",
);
});
it("answers once the dose is stored, without waiting for the refetches", async () => {
// The dialog closes on this answer; on a slow connection the reads it
// refreshes can take seconds.
vi.stubGlobal(
"fetch",
vi.fn().mockResolvedValue({
ok: true,
headers: new Headers(),
json: vi.fn(),
text: vi.fn().mockResolvedValue(""),
}),
);
const queryClient = {
invalidateQueries: vi.fn(() => new Promise(() => {})),
getQueryCache: () => ({ findAll: () => [] }),
} as unknown as QueryClient;
await expect(
runLogIntake({
medication,
skipped: false,
takenAt: "2026-06-01T08:30:00.000Z",
t,
queryClient,
}),
).resolves.toBe(true);
expect(toast.success).toHaveBeenCalledWith(
"medications.intakeToastTaken:Mounjaro",
);
@@ -193,16 +193,14 @@ export function LedgerAddDialog({
} else {
toast.success(message);
}
await invalidateKeys(queryClient, [
...medicationDependentKeys,
ledgerKey,
]);
// A ledger add can land a today-dated dose, so clear the unmounted
// Today hero + dashboard too (v1.32.19 — same class as the ledger's
// own log/edit/delete sites).
await refetchInactiveDailyReads(queryClient);
setNudgeOpen(false);
onOpenChange(false);
// Started, not awaited: the dialog closes once the dose is stored. A
// ledger add can land a today-dated dose, so clear the unmounted Today
// hero + dashboard too (same class as the ledger's own log/edit/delete
// sites).
void invalidateKeys(queryClient, [...medicationDependentKeys, ledgerKey]);
void refetchInactiveDailyReads(queryClient);
} catch (failure) {
showErrorToast(
t("medications.intakeToastFailed", { name: medicationName }),
@@ -130,12 +130,14 @@ function IntakeEditDialogBody({
body.takenAt = null;
}
await apiPut(`/api/medications/${medicationId}/intake/${event.id}`, body);
// Editing today's dose (un-skip, retime) changes its due/taken state —
// route through the blessed helper so the Today hero + dashboard clear
// at once even when they are unmounted behind the detail page.
await invalidateMedicationReads(queryClient);
toast.success(t("medications.detail.intake.edit.savedToast"));
onClose();
// Editing today's dose (un-skip, retime) changes its due/taken state —
// route through the blessed helper so the Today hero + dashboard clear
// at once even when they are unmounted behind the detail page. Started,
// not awaited: the edit names one row, so nothing here can be sent twice
// while the reads catch up.
void invalidateMedicationReads(queryClient);
} catch (failure) {
showErrorToast(t("medications.detail.intake.edit.failed"), {
cause: failure,
@@ -1,283 +0,0 @@
"use client";
/**
* v1.7.0 — shared editable intake-history surface.
*
* Extracted from `<IntakeHistoryPreview>` so the detail-page preview
* (14-row, `takenAt` sort) and the full-history view (25-row,
* `scheduledFor` sort) share one state machine: multi-select
* bulk-delete, per-row edit (`<IntakeEditDialog>`) and per-row delete
* (`<AlertDialog>`). The component owns the table + every editing
* dialog; it does NOT own the CSV-import dialog (that stays lifted to
* the hosting page so it mounts exactly once).
*
* Both surfaces render the same `<IntakeHistoryListV2>`; the wrapper
* just changes `pageSize` + `defaultSortBy`. The full-history route
* defaults to `scheduledFor desc` so skipped rows (`takenAt: null`)
* never float to the top of the table (O-1).
*/
import { useRecordCapabilities } from "@/hooks/use-record-capabilities";
import { useState } from "react";
import { useQueryClient } from "@tanstack/react-query";
import { Trash2 } from "lucide-react";
import { toast } from "sonner";
import { showErrorToast } from "@/lib/errors/error-toast";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "@/components/ui/alert-dialog";
import { Button } from "@/components/ui/button";
import { IntakeEditDialog } from "@/components/medications/intake-edit-dialog";
import {
IntakeHistoryListV2,
type IntakeEvent,
type IntakeHistorySortKey,
} from "@/components/medications/intake-history-list-v2";
import { useTranslations } from "@/lib/i18n/context";
import { invalidateMedicationReads } from "@/lib/query-keys";
import { apiDelete, apiPost } from "@/lib/api/api-fetch";
export interface IntakeHistoryEditableProps {
medicationId: string;
/** Override the default page size — preview 14, full history 25. */
pageSize?: number;
/**
* Initial sort column. The full-history view passes `"scheduledFor"`
* so the chronological order stays clean even for skipped rows
* (`takenAt: null`); the preview keeps the legacy `"takenAt"`.
*/
defaultSortBy?: IntakeHistorySortKey;
}
export function IntakeHistoryEditable({
medicationId,
pageSize,
defaultSortBy,
}: IntakeHistoryEditableProps) {
const { t } = useTranslations();
// v1.36.x — bulk-deleting recorded doses is the owner's alone.
const { canManageDomain } = useRecordCapabilities();
const canManageMedications = canManageDomain("medications");
const queryClient = useQueryClient();
const [selected, setSelected] = useState<Set<string>>(new Set());
const [editingEvent, setEditingEvent] = useState<IntakeEvent | null>(null);
const [pendingDeleteId, setPendingDeleteId] = useState<string | null>(null);
const [bulkConfirmOpen, setBulkConfirmOpen] = useState(false);
const [bulkBusy, setBulkBusy] = useState(false);
const [rowDeleteBusy, setRowDeleteBusy] = useState(false);
function onToggle(id: string) {
setSelected((prev) => {
const next = new Set(prev);
if (next.has(id)) {
next.delete(id);
} else {
next.add(id);
}
return next;
});
}
function clearSelection() {
setSelected(new Set());
}
async function confirmBulkDelete() {
if (bulkBusy || selected.size === 0) return;
setBulkBusy(true);
const ids = Array.from(selected);
try {
await apiPost(`/api/medications/${medicationId}/intake/bulk-delete`, {
eventIds: ids,
});
await invalidateMedicationReads(queryClient);
toast.success(t("medications.detail.intake.bulkDelete.toast"));
setBulkConfirmOpen(false);
clearSelection();
} catch (failure) {
showErrorToast(t("medications.detail.intake.bulkDelete.failed"), {
cause: failure,
});
} finally {
setBulkBusy(false);
}
}
async function confirmRowDelete() {
if (rowDeleteBusy || !pendingDeleteId) return;
setRowDeleteBusy(true);
const id = pendingDeleteId;
try {
await apiDelete(`/api/medications/${medicationId}/intake/${id}`);
await invalidateMedicationReads(queryClient);
toast.success(t("medications.detail.intake.deleteRow.toast"));
setPendingDeleteId(null);
// Drop the deleted row from the pending selection so the
// bulk-toolbar count stays honest.
setSelected((prev) => {
if (!prev.has(id)) return prev;
const next = new Set(prev);
next.delete(id);
return next;
});
} catch (failure) {
showErrorToast(t("medications.detail.intake.deleteRow.failed"), {
cause: failure,
});
} finally {
setRowDeleteBusy(false);
}
}
return (
<>
<div className="space-y-3" data-slot="intake-history-editable-body">
{canManageMedications && selected.size > 0 && (
<div
className="border-border bg-muted/40 flex flex-wrap items-center justify-between gap-2 rounded-md border p-2"
role="region"
aria-label={t(
"medications.detail.intake.bulkDelete.selectionCount",
{ count: selected.size },
)}
data-slot="intake-history-bulk-delete-toolbar"
>
<span className="text-foreground text-sm font-medium">
{t("medications.detail.intake.bulkDelete.selectionCount", {
count: selected.size,
})}
</span>
<div className="flex gap-2">
<Button
variant="outline"
size="sm"
onClick={clearSelection}
className="min-h-11 sm:min-h-9"
data-slot="intake-history-bulk-cancel"
>
{t("medications.detail.intake.bulkDelete.cancelButton")}
</Button>
<Button
variant="destructive"
size="sm"
onClick={() => setBulkConfirmOpen(true)}
className="min-h-11 sm:min-h-9"
data-slot="intake-history-bulk-delete"
>
<Trash2 aria-hidden="true" className="h-4 w-4" />
{t("medications.detail.intake.bulkDelete.deleteButton")}
</Button>
</div>
</div>
)}
<IntakeHistoryListV2
medicationId={medicationId}
pageSize={pageSize}
defaultSortBy={defaultSortBy}
onEditIntake={setEditingEvent}
onDeleteIntake={setPendingDeleteId}
selection={{
mode: "multi",
selected,
onToggle,
}}
/>
</div>
<IntakeEditDialog
medicationId={medicationId}
event={
editingEvent
? {
id: editingEvent.id,
takenAt: editingEvent.takenAt,
skipped: editingEvent.skipped,
scheduledFor: editingEvent.scheduledFor,
}
: null
}
onClose={() => setEditingEvent(null)}
/>
<AlertDialog
open={bulkConfirmOpen}
onOpenChange={(open) => {
if (!open) setBulkConfirmOpen(false);
}}
>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>
{t("medications.detail.intake.bulkDelete.confirmTitle")}
</AlertDialogTitle>
<AlertDialogDescription>
{t("medications.detail.intake.bulkDelete.confirmBody", {
count: selected.size,
})}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel disabled={bulkBusy}>
{t("medications.detail.intake.bulkDelete.cancelButton")}
</AlertDialogCancel>
<AlertDialogAction
onClick={(e) => {
e.preventDefault();
void confirmBulkDelete();
}}
disabled={bulkBusy}
aria-busy={bulkBusy || undefined}
className="bg-destructive dark:bg-destructive/60 hover:bg-destructive/90 text-white"
>
{t("medications.detail.intake.bulkDelete.confirmAction")}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
<AlertDialog
open={!!pendingDeleteId}
onOpenChange={(open) => {
if (!open) setPendingDeleteId(null);
}}
>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>
{t("medications.detail.intake.deleteRow.confirmTitle")}
</AlertDialogTitle>
<AlertDialogDescription>
{t("medications.detail.intake.deleteRow.confirmBody")}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel disabled={rowDeleteBusy}>
{t("medications.detail.intake.bulkDelete.cancelButton")}
</AlertDialogCancel>
<AlertDialogAction
onClick={(e) => {
e.preventDefault();
void confirmRowDelete();
}}
disabled={rowDeleteBusy}
aria-busy={rowDeleteBusy || undefined}
className="bg-destructive dark:bg-destructive/60 hover:bg-destructive/90 text-white"
>
{t("medications.detail.intake.deleteRow.confirmAction")}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</>
);
}
@@ -38,11 +38,9 @@ import { Label } from "@/components/ui/label";
import { Textarea } from "@/components/ui/textarea";
import { useTranslations } from "@/lib/i18n/context";
import { invalidateMedicationReads } from "@/lib/query-keys";
import { ApiError, apiPost } from "@/lib/api/api-fetch";
import {
MedicationIntakeImportError,
waitForMedicationIntakeImport,
} from "@/lib/medications/intake-import-poll";
import { apiPost } from "@/lib/api/api-fetch";
import { localizedApiError } from "@/lib/api/localized-error";
import { waitForMedicationIntakeImport } from "@/lib/medications/intake-import-poll";
export interface IntakeImportDialogProps {
medicationId: string | null;
@@ -132,19 +130,12 @@ export function IntakeImportDialog({
// import dialog on the detail page).
void invalidateMedicationReads(queryClient);
} catch (err) {
if (err instanceof MedicationIntakeImportError) {
setResult({
kind: "notice",
tone: "error",
text: t("medications.importFailed"),
});
return;
}
if (!(err instanceof ApiError)) throw err;
// A refusal reads through the API catalogue; a failed job's reason is
// the worker's English, so it reads as the plain failure.
setResult({
kind: "notice",
tone: "error",
text: err.message || t("medications.importFailed"),
text: localizedApiError(err, t, "medications.importFailed"),
});
}
} catch (err) {
@@ -95,10 +95,9 @@ type DialogError =
| { kind: "budget" }
| { kind: "consent" }
| { kind: "unavailable" }
| { kind: "network"; message?: string };
| { kind: "network" };
interface ExtractFailureBody {
error?: string | null;
meta?: { errorCode?: unknown } | null;
}
@@ -127,7 +126,7 @@ export function classifyExtractFailure(
}
if (status === 429) return { kind: "rateLimit" };
if (status === 503) return { kind: "noProvider" };
return { kind: "network", message: body?.error ?? undefined };
return { kind: "network" };
}
const MAX_TEXT_LENGTH = 2000;
@@ -200,7 +199,7 @@ export function NaturalLanguageExtractor({
error: string | null;
};
if (body.error || !body.data) {
setError({ kind: "network", message: body.error ?? undefined });
setError({ kind: "network" });
return;
}
onPrefill(body.data);
@@ -214,10 +213,7 @@ export function NaturalLanguageExtractor({
// Caller dismissed the dialog mid-flight; do not surface.
return;
}
setError({
kind: "network",
message: err instanceof Error ? err.message : undefined,
});
setError({ kind: "network" });
} finally {
setBusy(false);
abortRef.current = null;
@@ -37,7 +37,8 @@ import {
medicationDependentKeys,
queryKeys,
} from "@/lib/query-keys";
import { ApiError, apiGet, apiPut } from "@/lib/api/api-fetch";
import { apiGet, apiPut } from "@/lib/api/api-fetch";
import { localizedApiError } from "@/lib/api/localized-error";
export interface ApiTokensRowProps {
medicationId: string;
@@ -182,9 +183,7 @@ export function ApiTokensRow({
});
} catch (err) {
showErrorToast(
err instanceof ApiError && err.message
? err.message
: t("medications.detail.api.mintFailed"),
localizedApiError(err, t, "medications.detail.api.mintFailed"),
{ cause: err },
);
} finally {
Loaded 100 of 384 files, more files were not shown because too many files have changed in this diff. Show more