mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-13 22:32:04 +00:00
fix(documents): reconcile library quality-of-life passes
- Reword the upload toasts to the store-only reality ("stored", not "read"),
so the success message no longer claims AI ran and the panel it opens no
longer contradicts it.
- Classify upload failures (too large / wrong type / rate-limited / invalid
metadata) into specific messages; add a client-side 12 MB pre-check and show
the accepted formats and size ceiling beside the file field.
- Clear filters, search, and sort on a successful upload so the new document is
in the list the detail panel opens against, and add a "Clear filters" action
on the no-results state.
- List approved facts read-only after review so the link between a document and
the records it contributed stays visible instead of "Nothing left to review".
- Format date-group headers through the app's own date formatter, so they honour
the user's date-format preference instead of a textual medium style.
- Stack the toolbar until md and let it wrap; wrap the fact badge row on narrow
screens.
- Tie the disabled Approve to a plain-language reason, label the confidence
percentage, and render it through the locale percent formatter.
- Treat a fully-confirmed re-extract (422) as the calm already-reviewed note,
matching the partly-confirmed case.
- German: unify on "Angaben" for the extracted items and drop the anglicism.
This commit is contained in:
1 parent
4702546cdb
commit
e7683c9f54
10 files changed
+423
-95
No files matched your search
@@ -128,6 +128,8 @@ describe("<InboundDocumentsView> library", () => {
|
||||
expect(html).toContain("Load more");
|
||||
// A11Y-M2: the row's aria-controls points at the detail region id.
|
||||
expect(html).toContain('aria-controls="documents-detail-d1"');
|
||||
// QoL: accepted formats + size ceiling are shown by the file field.
|
||||
expect(html).toContain("JPEG, PNG, WebP or PDF · up to 12 MB");
|
||||
});
|
||||
|
||||
it("disables the upload button and shows a hint until a file is chosen", () => {
|
||||
@@ -287,6 +289,41 @@ describe("<DocumentDetail>", () => {
|
||||
expect(html).toContain("Imaging");
|
||||
});
|
||||
|
||||
it("shows the committed facts summary once approved, not the empty note", () => {
|
||||
const approved: ExtractedFactDto = { ...fact("f1"), status: "APPROVED" };
|
||||
const html = render(
|
||||
<DocumentDetail documentId="d1" onClosed={() => {}} />,
|
||||
(qc) => {
|
||||
qc.setQueryData(
|
||||
queryKeys.inboundDocument("d1"),
|
||||
detail({ status: "CONFIRMED", factCount: 1, facts: [approved] }),
|
||||
);
|
||||
},
|
||||
);
|
||||
// M6: provenance stays visible — the approved facts are listed read-only…
|
||||
expect(html).toContain('data-slot="documents-committed"');
|
||||
expect(html).toContain("Approved from this document");
|
||||
expect(html).toContain("LDL Cholesterol");
|
||||
// …instead of stranding the user on "Nothing left to review."
|
||||
expect(html).not.toContain("Nothing left to review.");
|
||||
});
|
||||
|
||||
it("explains why a low-confidence fact can't be approved yet", () => {
|
||||
const lowConf: ExtractedFactDto = { ...fact("f1"), needsReview: true };
|
||||
const html = render(
|
||||
<DocumentDetail documentId="d1" onClosed={() => {}} />,
|
||||
(qc) => {
|
||||
qc.setQueryData(
|
||||
queryKeys.inboundDocument("d1"),
|
||||
detail({ facts: [lowConf] }),
|
||||
);
|
||||
},
|
||||
);
|
||||
// M7: the disabled Approve is tied to a plain-language reason.
|
||||
expect(html).toContain("Edit this fact to approve it.");
|
||||
expect(html).toContain("Needs review");
|
||||
});
|
||||
|
||||
it("offers a confirm step before discarding", () => {
|
||||
const html = render(
|
||||
<DocumentDetail documentId="d1" onClosed={() => {}} />,
|
||||
|
||||
@@ -4,7 +4,9 @@ import { ApiError } from "@/lib/api/api-fetch";
|
||||
import type { InboundDocumentDto } from "@/lib/validations/inbound-documents";
|
||||
import {
|
||||
buildDocumentListSearch,
|
||||
classifyUploadError,
|
||||
documentDateKey,
|
||||
formatDateGroupLabel,
|
||||
groupDocumentsByDate,
|
||||
isAlreadyConfirmedError,
|
||||
isProviderUnsupportedError,
|
||||
@@ -126,13 +128,20 @@ describe("isProviderUnsupportedError", () => {
|
||||
});
|
||||
|
||||
describe("isAlreadyConfirmedError", () => {
|
||||
it("matches the extract 422 already-confirmed signal", () => {
|
||||
const err = new ApiError("already confirmed", 422, {
|
||||
it("matches the extract 409 partly-confirmed signal", () => {
|
||||
const err = new ApiError("already confirmed", 409, {
|
||||
errorCode: "documents.inbound.alreadyPartlyConfirmed",
|
||||
});
|
||||
expect(isAlreadyConfirmedError(err)).toBe(true);
|
||||
});
|
||||
|
||||
it("matches the extract 422 fully-confirmed signal", () => {
|
||||
const err = new ApiError("already confirmed", 422, {
|
||||
errorCode: "documents.inbound.alreadyConfirmed",
|
||||
});
|
||||
expect(isAlreadyConfirmedError(err)).toBe(true);
|
||||
});
|
||||
|
||||
it("rejects other API errors and non-errors", () => {
|
||||
expect(
|
||||
isAlreadyConfirmedError(
|
||||
@@ -145,3 +154,50 @@ describe("isAlreadyConfirmedError", () => {
|
||||
expect(isAlreadyConfirmedError(null)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("classifyUploadError", () => {
|
||||
it("maps each upload error code to its kind", () => {
|
||||
const code = (c: string) =>
|
||||
classifyUploadError(new ApiError("x", 400, { errorCode: c }));
|
||||
expect(code("documents.inbound.fileTooLarge")).toBe("tooLarge");
|
||||
expect(code("documents.inbound.fileType")).toBe("fileType");
|
||||
expect(code("documents.inbound.rateLimited")).toBe("rateLimited");
|
||||
expect(code("documents.inbound.invalidMetadata")).toBe("invalidMetadata");
|
||||
});
|
||||
|
||||
it("falls back to the HTTP status when the body carries no error code", () => {
|
||||
expect(classifyUploadError(new ApiError("too big", 413))).toBe("tooLarge");
|
||||
expect(classifyUploadError(new ApiError("wrong type", 415))).toBe(
|
||||
"fileType",
|
||||
);
|
||||
expect(classifyUploadError(new ApiError("slow down", 429))).toBe(
|
||||
"rateLimited",
|
||||
);
|
||||
});
|
||||
|
||||
it("returns generic for unknown errors and non-API errors", () => {
|
||||
expect(classifyUploadError(new ApiError("boom", 500))).toBe("generic");
|
||||
expect(classifyUploadError(new Error("network"))).toBe("generic");
|
||||
expect(classifyUploadError(null)).toBe("generic");
|
||||
});
|
||||
});
|
||||
|
||||
describe("formatDateGroupLabel", () => {
|
||||
it("delegates the calendar day to the supplied app formatter", () => {
|
||||
const seen: string[] = [];
|
||||
const formatDate = (value: string) => {
|
||||
seen.push(value);
|
||||
return "FORMATTED";
|
||||
};
|
||||
expect(formatDateGroupLabel("2026-06-20", formatDate)).toBe("FORMATTED");
|
||||
// Handed over as a noon-UTC instant so the day never shifts under the tz.
|
||||
expect(seen).toEqual(["2026-06-20T12:00:00.000Z"]);
|
||||
});
|
||||
|
||||
it("returns a malformed key unchanged without calling the formatter", () => {
|
||||
const formatDate = () => {
|
||||
throw new Error("should not be called");
|
||||
};
|
||||
expect(formatDateGroupLabel("not-a-date", formatDate)).toBe("not-a-date");
|
||||
});
|
||||
});
|
||||
@@ -67,7 +67,7 @@ import { NativeSelect } from "@/components/ui/native-select";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { apiDelete, apiFetch, apiPatch, apiPost } from "@/lib/api/api-fetch";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { useFormatters, useTranslations } from "@/lib/i18n/context";
|
||||
import { invalidateKeys, queryKeys } from "@/lib/query-keys";
|
||||
import type { DocumentListParams } from "@/lib/query-keys/documents";
|
||||
import {
|
||||
@@ -84,10 +84,12 @@ import {
|
||||
} from "@/lib/validations/inbound-documents";
|
||||
import {
|
||||
buildDocumentListSearch,
|
||||
classifyUploadError,
|
||||
formatDateGroupLabel,
|
||||
groupDocumentsByDate,
|
||||
isAlreadyConfirmedError,
|
||||
isProviderUnsupportedError,
|
||||
MAX_UPLOAD_BYTES,
|
||||
} from "./library-utils";
|
||||
|
||||
type ListResponse = {
|
||||
@@ -97,7 +99,8 @@ type ListResponse = {
|
||||
type Decision = "approve" | "reject";
|
||||
|
||||
export function InboundDocumentsView() {
|
||||
const { t, locale } = useTranslations();
|
||||
const { t } = useTranslations();
|
||||
const format = useFormatters();
|
||||
const queryClient = useQueryClient();
|
||||
const fileRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
@@ -155,6 +158,17 @@ export function InboundDocumentsView() {
|
||||
filters.q || filters.kind || filters.from || filters.to,
|
||||
);
|
||||
|
||||
// Reset search + every filter to the default view. Used by the no-results
|
||||
// "Clear filters" affordance and after an upload (so the just-stored document
|
||||
// is in the list the detail panel opens against).
|
||||
const clearFilters = () => {
|
||||
setQ("");
|
||||
setDebouncedQ("");
|
||||
setKindFilter("");
|
||||
setFromDate("");
|
||||
setToDate("");
|
||||
};
|
||||
|
||||
const upload = useMutation({
|
||||
mutationFn: async (file: File) => {
|
||||
const fd = new FormData();
|
||||
@@ -170,6 +184,14 @@ export function InboundDocumentsView() {
|
||||
},
|
||||
onSuccess: (doc) => {
|
||||
toast.success(t("documents.toast.uploaded"));
|
||||
// Drop any active filter / search / sort so the just-stored document is
|
||||
// in the list — otherwise selecting it opens a detail panel for a row the
|
||||
// current filter excludes, and the user lands on a dead end. A fresh
|
||||
// upload files under today, so the default documentDate-desc view shows it
|
||||
// at the top.
|
||||
clearFilters();
|
||||
setSort("documentDate");
|
||||
setOrder("desc");
|
||||
void invalidateKeys(queryClient, [queryKeys.documents()]);
|
||||
setSelectedId(doc.id);
|
||||
setUploadTitle("");
|
||||
@@ -177,7 +199,24 @@ export function InboundDocumentsView() {
|
||||
setHasFile(false);
|
||||
if (fileRef.current) fileRef.current.value = "";
|
||||
},
|
||||
onError: () => toast.error(t("documents.toast.uploadFailed")),
|
||||
onError: (error) => {
|
||||
switch (classifyUploadError(error)) {
|
||||
case "tooLarge":
|
||||
toast.error(t("documents.toast.uploadTooLarge"));
|
||||
break;
|
||||
case "fileType":
|
||||
toast.error(t("documents.toast.uploadFileType"));
|
||||
break;
|
||||
case "rateLimited":
|
||||
toast.error(t("documents.toast.uploadRateLimited"));
|
||||
break;
|
||||
case "invalidMetadata":
|
||||
toast.error(t("documents.toast.uploadInvalidMetadata"));
|
||||
break;
|
||||
default:
|
||||
toast.error(t("documents.toast.uploadFailed"));
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
@@ -201,6 +240,27 @@ export function InboundDocumentsView() {
|
||||
</CardHeader>
|
||||
<CardContent className="flex flex-col gap-3">
|
||||
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
|
||||
{/* The one required field goes first and full-width, with the
|
||||
accepted formats + size ceiling beside it (so the limit is known
|
||||
before an upload fails). */}
|
||||
<div className="flex flex-col gap-1 sm:col-span-2">
|
||||
<Label htmlFor="doc-file">
|
||||
{t("documents.upload.fileLabel")}
|
||||
</Label>
|
||||
<Input
|
||||
id="doc-file"
|
||||
ref={fileRef}
|
||||
type="file"
|
||||
required
|
||||
accept="image/jpeg,image/png,image/webp,application/pdf"
|
||||
aria-describedby="doc-file-hint"
|
||||
disabled={upload.isPending}
|
||||
onChange={(e) => setHasFile(Boolean(e.target.files?.length))}
|
||||
/>
|
||||
<p id="doc-file-hint" className="text-muted-foreground text-xs">
|
||||
{t("documents.upload.accepts")}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1">
|
||||
<Label htmlFor="doc-title">
|
||||
{t("documents.upload.titleLabel")}
|
||||
@@ -244,25 +304,19 @@ export function InboundDocumentsView() {
|
||||
disabled={upload.isPending}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1">
|
||||
<Label htmlFor="doc-file">
|
||||
{t("documents.upload.fileLabel")}
|
||||
</Label>
|
||||
<Input
|
||||
id="doc-file"
|
||||
ref={fileRef}
|
||||
type="file"
|
||||
accept="image/jpeg,image/png,image/webp,application/pdf"
|
||||
disabled={upload.isPending}
|
||||
onChange={(e) => setHasFile(Boolean(e.target.files?.length))}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Button
|
||||
onClick={() => {
|
||||
const file = fileRef.current?.files?.[0];
|
||||
if (file) upload.mutate(file);
|
||||
if (!file) return;
|
||||
// Reject an oversized file before it is sent, so the user gets
|
||||
// the size message immediately instead of after a full upload.
|
||||
if (file.size > MAX_UPLOAD_BYTES) {
|
||||
toast.error(t("documents.toast.uploadTooLarge"));
|
||||
return;
|
||||
}
|
||||
upload.mutate(file);
|
||||
}}
|
||||
disabled={upload.isPending || !hasFile}
|
||||
>
|
||||
@@ -289,10 +343,10 @@ export function InboundDocumentsView() {
|
||||
|
||||
{/* Toolbar — search / category filter / sort. */}
|
||||
<div
|
||||
className="flex flex-col gap-2 sm:flex-row sm:items-end"
|
||||
className="flex flex-col gap-2 md:flex-row md:flex-wrap md:items-end"
|
||||
data-slot="documents-toolbar"
|
||||
>
|
||||
<div className="flex flex-1 flex-col gap-1">
|
||||
<div className="flex min-w-0 flex-1 flex-col gap-1">
|
||||
<Label htmlFor="doc-search">
|
||||
{t("documents.toolbar.searchLabel")}
|
||||
</Label>
|
||||
@@ -394,11 +448,23 @@ export function InboundDocumentsView() {
|
||||
))}
|
||||
</div>
|
||||
) : documents.length === 0 ? (
|
||||
<p className="text-muted-foreground text-sm">
|
||||
{isFiltered
|
||||
? t("documents.list.noResults")
|
||||
: t("documents.list.empty")}
|
||||
</p>
|
||||
isFiltered ? (
|
||||
<div
|
||||
className="flex flex-col items-start gap-2"
|
||||
data-slot="documents-no-results"
|
||||
>
|
||||
<p className="text-muted-foreground text-sm">
|
||||
{t("documents.list.noResults")}
|
||||
</p>
|
||||
<Button variant="outline" size="sm" onClick={clearFilters}>
|
||||
{t("documents.list.clearFilters")}
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-muted-foreground text-sm">
|
||||
{t("documents.list.empty")}
|
||||
</p>
|
||||
)
|
||||
) : (
|
||||
<>
|
||||
{groups.map((group) => (
|
||||
@@ -407,7 +473,7 @@ export function InboundDocumentsView() {
|
||||
className="text-muted-foreground text-xs font-medium tracking-wide uppercase"
|
||||
data-slot="documents-date-group"
|
||||
>
|
||||
{formatDateGroupLabel(group.key, locale)}
|
||||
{formatDateGroupLabel(group.key, format.date)}
|
||||
</h2>
|
||||
{group.documents.map((doc) => {
|
||||
const expanded = selectedId === doc.id;
|
||||
@@ -518,6 +584,7 @@ export function DocumentDetail({
|
||||
regionId?: string;
|
||||
}) {
|
||||
const { t } = useTranslations();
|
||||
const format = useFormatters();
|
||||
const queryClient = useQueryClient();
|
||||
const [decisions, setDecisions] = useState<Record<string, Decision>>({});
|
||||
const [editOpen, setEditOpen] = useState(false);
|
||||
@@ -593,6 +660,10 @@ export function DocumentDetail({
|
||||
|
||||
const doc = detail.data;
|
||||
const pending = doc.facts.filter((f) => f.status === "PENDING");
|
||||
// Facts already approved from this document and pushed to the structured
|
||||
// stores — surfaced read-only below so the document ↔ committed-record link
|
||||
// stays visible after review (instead of the panel going blank).
|
||||
const approved = doc.facts.filter((f) => f.status === "APPROVED");
|
||||
const decidedCount = Object.keys(decisions).length;
|
||||
// Mirror the backend's hard guard: extraction is gone once the document is
|
||||
// confirmed OR any fact on it has already been approved (re-extracting would
|
||||
@@ -732,13 +803,36 @@ export function DocumentDetail({
|
||||
: t("documents.review.confirm")}
|
||||
</Button>
|
||||
</>
|
||||
) : (
|
||||
) : approved.length === 0 ? (
|
||||
<p className="text-muted-foreground text-sm">
|
||||
{doc.factCount > 0
|
||||
? t("documents.empty.noFacts")
|
||||
: t("documents.detail.notExtracted")}
|
||||
</p>
|
||||
)}
|
||||
) : null}
|
||||
|
||||
{approved.length > 0 ? (
|
||||
<div className="flex flex-col gap-2" data-slot="documents-committed">
|
||||
<Separator />
|
||||
<p className="text-muted-foreground text-sm">
|
||||
{t("documents.committed.title")}
|
||||
</p>
|
||||
<ul className="flex flex-col gap-1">
|
||||
{approved.map((fact) => {
|
||||
const when = factDate(fact);
|
||||
return (
|
||||
<li
|
||||
key={fact.id}
|
||||
className="text-muted-foreground truncate text-sm"
|
||||
>
|
||||
{factSummary(fact)}
|
||||
{when ? ` · ${format.date(`${when}T12:00:00.000Z`)}` : ""}
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
</div>
|
||||
) : null}
|
||||
</CardContent>
|
||||
|
||||
<Dialog open={editOpen} onOpenChange={setEditOpen}>
|
||||
@@ -890,6 +984,7 @@ function FactCard({
|
||||
onDecision: (d: Decision | null) => void;
|
||||
}) {
|
||||
const { t } = useTranslations();
|
||||
const format = useFormatters();
|
||||
const queryClient = useQueryClient();
|
||||
const [editing, setEditing] = useState(false);
|
||||
|
||||
@@ -912,7 +1007,7 @@ function FactCard({
|
||||
<div className="rounded-md border p-3">
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<div className="min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Badge variant="outline">
|
||||
{t(`documents.factType.${fact.factType}`)}
|
||||
</Badge>
|
||||
@@ -927,8 +1022,14 @@ function FactCard({
|
||||
{t("documents.review.needsReview")}
|
||||
</Badge>
|
||||
) : null}
|
||||
<span className="text-muted-foreground text-xs">
|
||||
{Math.round(fact.confidence * 100)}%
|
||||
<span
|
||||
className="text-muted-foreground text-xs"
|
||||
title={t("documents.review.confidenceLabel")}
|
||||
>
|
||||
<span className="sr-only">
|
||||
{t("documents.review.confidenceLabel")}:{" "}
|
||||
</span>
|
||||
{format.percent(fact.confidence)}
|
||||
</span>
|
||||
</div>
|
||||
<p className="mt-1 truncate text-sm font-medium">{summary}</p>
|
||||
@@ -937,6 +1038,13 @@ function FactCard({
|
||||
“{fact.provenance.sourceText}”
|
||||
</p>
|
||||
) : null}
|
||||
{fact.needsReview ? (
|
||||
// Ties the disabled Approve button to the "Needs review" badge: a
|
||||
// low-confidence fact must be edited before it can be approved.
|
||||
<p className="text-muted-foreground mt-1 text-xs">
|
||||
{t("documents.review.needsReviewHint")}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="flex shrink-0 gap-1">
|
||||
<Button
|
||||
@@ -944,6 +1052,11 @@ function FactCard({
|
||||
size="sm"
|
||||
className="min-h-11 sm:min-h-9"
|
||||
disabled={fact.needsReview}
|
||||
title={
|
||||
fact.needsReview
|
||||
? t("documents.review.needsReviewHint")
|
||||
: undefined
|
||||
}
|
||||
onClick={() =>
|
||||
onDecision(decision === "approve" ? null : "approve")
|
||||
}
|
||||
@@ -1148,3 +1261,14 @@ function factSummary(fact: ExtractedFactDto): string {
|
||||
const d = fact.data as ConditionFactData;
|
||||
return d.label;
|
||||
}
|
||||
|
||||
/** The effective / onset date a staged fact carries, if any (YYYY-MM-DD). */
|
||||
function factDate(fact: ExtractedFactDto): string | null {
|
||||
if (fact.factType === "CONDITION") {
|
||||
return (fact.data as ConditionFactData).onsetDate;
|
||||
}
|
||||
if (fact.factType === "OBSERVATION") {
|
||||
return (fact.data as ObservationFactData).effectiveDate;
|
||||
}
|
||||
return (fact.data as MedicationStatementFactData).effectiveDate;
|
||||
}
|
||||
@@ -83,27 +83,72 @@ export function isProviderUnsupportedError(error: unknown): boolean {
|
||||
}
|
||||
|
||||
/**
|
||||
* True when an error is the extract route's 422 "this document has already been
|
||||
* confirmed" signal. Re-extraction is refused server-side once any fact has
|
||||
* been committed; the UI surfaces this as a calm inline note rather than a hard
|
||||
* error toast, and hides the Extract control to match.
|
||||
* True when an error is the extract route's "already confirmed" signal. Two
|
||||
* shapes reach here:
|
||||
* - 409 `alreadyPartlyConfirmed` — some facts approved, the rest still pending;
|
||||
* - 422 `alreadyConfirmed` — the whole document is CONFIRMED (a cross-tab race
|
||||
* can fire Extract against a document a second tab just finished confirming).
|
||||
* Re-extraction is refused server-side in both cases; the UI surfaces a calm
|
||||
* inline note rather than a hard error toast, and hides the Extract control to
|
||||
* match.
|
||||
*/
|
||||
export function isAlreadyConfirmedError(error: unknown): boolean {
|
||||
if (!(error instanceof ApiError)) return false;
|
||||
const code = error.meta?.errorCode;
|
||||
return (
|
||||
error instanceof ApiError &&
|
||||
error.meta?.errorCode === "documents.inbound.alreadyPartlyConfirmed"
|
||||
code === "documents.inbound.alreadyPartlyConfirmed" ||
|
||||
code === "documents.inbound.alreadyConfirmed"
|
||||
);
|
||||
}
|
||||
|
||||
/** Format a YYYY-MM-DD group key into a locale-aware medium date label. */
|
||||
export function formatDateGroupLabel(key: string, locale: string): string {
|
||||
const date = new Date(`${key}T00:00:00`);
|
||||
if (Number.isNaN(date.getTime())) return key;
|
||||
try {
|
||||
return new Intl.DateTimeFormat(locale, { dateStyle: "medium" }).format(
|
||||
date,
|
||||
);
|
||||
} catch {
|
||||
return key;
|
||||
/**
|
||||
* Client-side upload ceiling. Mirrors the server's `OCR_MAX_BYTES` (12 MB) so an
|
||||
* oversized file is rejected before it is sent, instead of after a full upload
|
||||
* round-trips and returns 413.
|
||||
*/
|
||||
export const MAX_UPLOAD_BYTES = 12 * 1024 * 1024;
|
||||
|
||||
/**
|
||||
* The class of an upload (`POST /api/documents/inbound`) failure. The route
|
||||
* reports precise, actionable codes the generic toast would throw away; this
|
||||
* maps them to a stable kind so the view can show the right message and keep
|
||||
* every `t()` key a literal call site.
|
||||
*/
|
||||
export type UploadErrorKind =
|
||||
"tooLarge" | "fileType" | "rateLimited" | "invalidMetadata" | "generic";
|
||||
|
||||
/** Classify an upload failure by the route's `errorCode`, then HTTP status. */
|
||||
export function classifyUploadError(error: unknown): UploadErrorKind {
|
||||
if (!(error instanceof ApiError)) return "generic";
|
||||
switch (error.meta?.errorCode) {
|
||||
case "documents.inbound.fileTooLarge":
|
||||
return "tooLarge";
|
||||
case "documents.inbound.fileType":
|
||||
return "fileType";
|
||||
case "documents.inbound.rateLimited":
|
||||
return "rateLimited";
|
||||
case "documents.inbound.invalidMetadata":
|
||||
return "invalidMetadata";
|
||||
}
|
||||
// A reverse proxy can strip the JSON body (and its `meta`) on some statuses;
|
||||
// fall back to the HTTP code so size / type / rate-limit still classify.
|
||||
if (error.status === 413) return "tooLarge";
|
||||
if (error.status === 415) return "fileType";
|
||||
if (error.status === 429) return "rateLimited";
|
||||
return "generic";
|
||||
}
|
||||
|
||||
/**
|
||||
* Format a YYYY-MM-DD group key into a date label via the app's own date
|
||||
* formatter (`useFormatters().date`), so the group headers follow the user's
|
||||
* date-format preference (numeric) like the rest of the app instead of a textual
|
||||
* `dateStyle: "medium"` month. The key is handed over as a noon-UTC instant so
|
||||
* the calendar day never shifts under the display timezone.
|
||||
*/
|
||||
export function formatDateGroupLabel(
|
||||
key: string,
|
||||
formatDate: (value: string) => string,
|
||||
): string {
|
||||
if (!/^\d{4}-\d{2}-\d{2}$/.test(key)) return key;
|
||||
return formatDate(`${key}T12:00:00.000Z`);
|
||||
}
|
||||
Reference in new issue
Block a user