mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-13 06:32:03 +00:00
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:
commit
031379d966
384 files changed
+7908
-5710
No files matched your search
@@ -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,
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -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 },
|
||||
);
|
||||
},
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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")),
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -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 });
|
||||
},
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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 },
|
||||
);
|
||||
},
|
||||
|
||||
@@ -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 },
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -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 },
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -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]);
|
||||
});
|
||||
});
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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],
|
||||
);
|
||||
|
||||
|
||||
@@ -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);
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -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));
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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>,
|
||||
|
||||
@@ -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);
|
||||
},
|
||||
|
||||
@@ -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) =>
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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) =>
|
||||
|
||||
@@ -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 : []),
|
||||
]);
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
+98
@@ -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
Reference in new issue
Block a user