mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-14 06:40:50 +00:00
feat(documents): client upload queue with progress and duplicate handling
One multipart XHR POST per file (real upload-progress events, no new dependency), concurrency capped at 3 to match the server's memory envelope, an Idempotency-Key per file so a retried blip cannot double-store, and a canvas downscale to 3000 px longest edge for raster photos only — PDFs, Office files, and GIFs upload verbatim. Entries appear synchronously before any network work; an over-limit file fails locally against the configured cap while the batch continues; a duplicate resolves to the existing row and hands its id to the timeline for a brief highlight. Plan-Task: W2-T4 (manager half)
This commit is contained in:
1 parent
37e9d933e2
commit
5536749985
1 file changed
+282
@@ -0,0 +1,282 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* The vault's upload manager: a client-side queue with concurrency 3, one
|
||||
* multipart XHR POST per file (XHR because it exposes real upload-progress
|
||||
* events with zero dependencies — fetch upload streaming is not portable),
|
||||
* client-side image downscaling, and per-file terminal states.
|
||||
*
|
||||
* Perceived-speed contract: `enqueue()` adds pending entries synchronously
|
||||
* (< 100 ms — before any network work), each carrying live progress. A
|
||||
* finished file leaves the queue and the list/usage queries refetch; a
|
||||
* failed file stays as an inline error card until dismissed (an over-limit
|
||||
* file fails individually, the batch continues).
|
||||
*
|
||||
* Duplicate contract (§3.2): a same-user re-upload returns HTTP 200 with
|
||||
* envelope-level `meta.duplicate: true` and the EXISTING row — the manager
|
||||
* toasts "already stored" and hands the row id to the timeline for a brief
|
||||
* highlight. This is the one call site that must read success-side `meta`;
|
||||
* `parseUploadResponse` in `vault-utils.ts` owns that parse.
|
||||
*/
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import { toast } from "sonner";
|
||||
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { invalidateKeys, queryKeys } from "@/lib/query-keys";
|
||||
import {
|
||||
parseUploadResponse,
|
||||
type UploadFailure,
|
||||
type UploadResult,
|
||||
} from "./vault-utils";
|
||||
|
||||
/** Client-side parallelism — matches the plan's NAS memory envelope. */
|
||||
const CONCURRENCY = 3;
|
||||
|
||||
/** Longest-edge ceiling for the client-side image downscale. */
|
||||
const IMAGE_MAX_EDGE = 3000;
|
||||
|
||||
/** How long a duplicate-highlight ring stays on the existing row. */
|
||||
const HIGHLIGHT_MS = 2600;
|
||||
|
||||
/** Only raster photo formats are re-encoded — never PDFs/Office, never GIF
|
||||
* (re-encoding would flatten an animation and GIF photos don't exist). */
|
||||
const DOWNSCALE_TYPES = new Set(["image/jpeg", "image/png", "image/webp"]);
|
||||
|
||||
export interface UploadQueueItem {
|
||||
/** Local id — NOT a server id; stable for the item's queue lifetime. */
|
||||
localId: string;
|
||||
fileName: string;
|
||||
byteSize: number;
|
||||
status: "uploading" | "error";
|
||||
/** 0..1 upload progress (transport bytes, not server processing). */
|
||||
progress: number;
|
||||
/** Terminal failure detail, present when `status === "error"`. */
|
||||
failure?: UploadFailure;
|
||||
}
|
||||
|
||||
export interface EnqueueOptions {
|
||||
/** Pre-link every uploaded document to this episode (deep-link uploads). */
|
||||
episodeId?: string;
|
||||
}
|
||||
|
||||
interface UploadLimits {
|
||||
maxFileBytes: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Downscale a raster photo to ≤ 3000 px longest edge (canvas, JPEG 0.85).
|
||||
* Returns the original file untouched when the type is not a raster photo,
|
||||
* the image is already small enough, or anything about decoding fails —
|
||||
* a downscale must never block an upload.
|
||||
*/
|
||||
async function downscaleImage(file: File): Promise<File> {
|
||||
if (!DOWNSCALE_TYPES.has(file.type)) return file;
|
||||
try {
|
||||
const bitmap = await createImageBitmap(file);
|
||||
const longest = Math.max(bitmap.width, bitmap.height);
|
||||
if (longest <= IMAGE_MAX_EDGE) {
|
||||
bitmap.close();
|
||||
return file;
|
||||
}
|
||||
const scale = IMAGE_MAX_EDGE / longest;
|
||||
const width = Math.round(bitmap.width * scale);
|
||||
const height = Math.round(bitmap.height * scale);
|
||||
const canvas = document.createElement("canvas");
|
||||
canvas.width = width;
|
||||
canvas.height = height;
|
||||
const ctx = canvas.getContext("2d");
|
||||
if (!ctx) {
|
||||
bitmap.close();
|
||||
return file;
|
||||
}
|
||||
ctx.drawImage(bitmap, 0, 0, width, height);
|
||||
bitmap.close();
|
||||
const blob = await new Promise<Blob | null>((resolve) =>
|
||||
canvas.toBlob(resolve, "image/jpeg", 0.85),
|
||||
);
|
||||
if (!blob) return file;
|
||||
const stem = file.name.replace(/\.[^.]+$/u, "") || "photo";
|
||||
return new File([blob], `${stem}.jpg`, { type: "image/jpeg" });
|
||||
} catch {
|
||||
return file;
|
||||
}
|
||||
}
|
||||
|
||||
/** One multipart POST via XHR, reporting upload progress. */
|
||||
function uploadViaXhr(
|
||||
file: File,
|
||||
options: EnqueueOptions,
|
||||
onProgress: (fraction: number) => void,
|
||||
): Promise<UploadResult> {
|
||||
return new Promise((resolve) => {
|
||||
const xhr = new XMLHttpRequest();
|
||||
xhr.open("POST", "/api/documents/inbound");
|
||||
// Retry-safe by construction: the server honours Idempotency-Key, so a
|
||||
// network blip that DID land server-side cannot double-store on retry.
|
||||
xhr.setRequestHeader("Idempotency-Key", crypto.randomUUID());
|
||||
xhr.upload.addEventListener("progress", (event) => {
|
||||
if (event.lengthComputable && event.total > 0) {
|
||||
onProgress(event.loaded / event.total);
|
||||
}
|
||||
});
|
||||
xhr.addEventListener("load", () => {
|
||||
resolve(parseUploadResponse(xhr.status, xhr.responseText));
|
||||
});
|
||||
xhr.addEventListener("error", () =>
|
||||
resolve({ ok: false, reason: "generic" }),
|
||||
);
|
||||
xhr.addEventListener("abort", () =>
|
||||
resolve({ ok: false, reason: "generic" }),
|
||||
);
|
||||
const fd = new FormData();
|
||||
fd.append("file", file);
|
||||
if (options.episodeId) fd.append("episodeIds", options.episodeId);
|
||||
xhr.send(fd);
|
||||
});
|
||||
}
|
||||
|
||||
export interface DocumentUploadManager {
|
||||
/** Live queue — uploading entries first-in-first, then error entries. */
|
||||
items: UploadQueueItem[];
|
||||
/** Add files to the queue; returns immediately (optimistic entries). */
|
||||
enqueue: (files: File[], options?: EnqueueOptions) => void;
|
||||
/** Remove a terminal (error) entry from the queue. */
|
||||
dismiss: (localId: string) => void;
|
||||
/** Row id to briefly highlight (duplicate upload → the existing row). */
|
||||
highlightId: string | null;
|
||||
}
|
||||
|
||||
export function useDocumentUpload(
|
||||
limits: UploadLimits | undefined,
|
||||
): DocumentUploadManager {
|
||||
const { t } = useTranslations();
|
||||
const queryClient = useQueryClient();
|
||||
const [items, setItems] = useState<UploadQueueItem[]>([]);
|
||||
const [highlightId, setHighlightId] = useState<string | null>(null);
|
||||
|
||||
const activeRef = useRef(0);
|
||||
const pendingRef = useRef<
|
||||
{ localId: string; file: File; options: EnqueueOptions }[]
|
||||
>([]);
|
||||
const mountedRef = useRef(true);
|
||||
const highlightTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
mountedRef.current = true;
|
||||
return () => {
|
||||
mountedRef.current = false;
|
||||
if (highlightTimer.current) clearTimeout(highlightTimer.current);
|
||||
};
|
||||
}, []);
|
||||
|
||||
const patchItem = useCallback(
|
||||
(localId: string, patch: Partial<UploadQueueItem>) => {
|
||||
if (!mountedRef.current) return;
|
||||
setItems((prev) =>
|
||||
prev.map((it) => (it.localId === localId ? { ...it, ...patch } : it)),
|
||||
);
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const removeItem = useCallback((localId: string) => {
|
||||
if (!mountedRef.current) return;
|
||||
setItems((prev) => prev.filter((it) => it.localId !== localId));
|
||||
}, []);
|
||||
|
||||
const flashHighlight = useCallback((id: string) => {
|
||||
if (!mountedRef.current) return;
|
||||
setHighlightId(id);
|
||||
if (highlightTimer.current) clearTimeout(highlightTimer.current);
|
||||
highlightTimer.current = setTimeout(() => {
|
||||
if (mountedRef.current) setHighlightId(null);
|
||||
}, HIGHLIGHT_MS);
|
||||
}, []);
|
||||
|
||||
const settle = useCallback(
|
||||
(localId: string, result: UploadResult) => {
|
||||
if (result.ok) {
|
||||
removeItem(localId);
|
||||
void invalidateKeys(queryClient, [queryKeys.documents()]);
|
||||
if (result.duplicate) {
|
||||
toast.info(t("documents.toast.duplicate"));
|
||||
flashHighlight(result.document.id);
|
||||
}
|
||||
return;
|
||||
}
|
||||
patchItem(localId, { status: "error", failure: result, progress: 0 });
|
||||
},
|
||||
[flashHighlight, patchItem, queryClient, removeItem, t],
|
||||
);
|
||||
|
||||
const pump = useCallback(() => {
|
||||
while (
|
||||
activeRef.current < CONCURRENCY &&
|
||||
pendingRef.current.length > 0 &&
|
||||
mountedRef.current
|
||||
) {
|
||||
const next = pendingRef.current.shift();
|
||||
if (!next) return;
|
||||
activeRef.current += 1;
|
||||
void (async () => {
|
||||
try {
|
||||
const prepared = await downscaleImage(next.file);
|
||||
const result = await uploadViaXhr(prepared, next.options, (f) =>
|
||||
patchItem(next.localId, { progress: f }),
|
||||
);
|
||||
settle(next.localId, result);
|
||||
} finally {
|
||||
activeRef.current -= 1;
|
||||
pump();
|
||||
}
|
||||
})();
|
||||
}
|
||||
}, [patchItem, settle]);
|
||||
|
||||
const enqueue = useCallback(
|
||||
(files: File[], options: EnqueueOptions = {}) => {
|
||||
if (files.length === 0) return;
|
||||
const fresh: UploadQueueItem[] = [];
|
||||
for (const file of files) {
|
||||
const localId = crypto.randomUUID();
|
||||
// Client pre-flight against the server's configured cap: an obvious
|
||||
// giant (a CD image) fails instantly and locally — friendly reason,
|
||||
// no wasted transfer. The server re-enforces regardless.
|
||||
if (limits && file.size > limits.maxFileBytes) {
|
||||
fresh.push({
|
||||
localId,
|
||||
fileName: file.name,
|
||||
byteSize: file.size,
|
||||
status: "error",
|
||||
progress: 0,
|
||||
failure: {
|
||||
ok: false,
|
||||
reason: "fileTooLarge",
|
||||
maxFileBytes: limits.maxFileBytes,
|
||||
},
|
||||
});
|
||||
continue;
|
||||
}
|
||||
fresh.push({
|
||||
localId,
|
||||
fileName: file.name,
|
||||
byteSize: file.size,
|
||||
status: "uploading",
|
||||
progress: 0,
|
||||
});
|
||||
pendingRef.current.push({ localId, file, options });
|
||||
}
|
||||
setItems((prev) => [...prev, ...fresh]);
|
||||
pump();
|
||||
},
|
||||
[limits, pump],
|
||||
);
|
||||
|
||||
const dismiss = useCallback(
|
||||
(localId: string) => removeItem(localId),
|
||||
[removeItem],
|
||||
);
|
||||
|
||||
return { items, enqueue, dismiss, highlightId };
|
||||
}
|
||||
Reference in new issue
Block a user