WFDASHDRIVER-APP/lib/image.ts
Dave de16082ff7 feat: one invoice photo per PO, one-tap PO PDF, save lock, sharper photos
- Confirm Collection locks while saving ("Saving…") and the page drops a
  second collect for an item already in flight. Repeat taps on slow data
  re-ran the save and re-uploaded the photo (7 identical copies on one
  item on 2026-10-16).
- PO headers (and a single-PO supplier's list) get an Invoice button:
  photograph the supplier invoice once per PO, then "add pg 2", "pg 3".
  Pages go onto one customer line of the PO as invoice-<PO>-p<N>.jpg;
  WFDASH Collection Confirm already gathers photos per PO card. The page
  count is also kept on the device so it survives lines dropping off.
- PO button opens the PO PDF in one tap (shared buildPoFileUrl in
  lib/poDocs.ts, also used by the item detail modal).
- Collect modal says whether the PO's invoice is already uploaded, so
  the per-item photo is only for problems with the item.
- Photos now upload at 2000px / q0.8 (was 1200px / q0.7) so small print
  on invoices stays readable for the AI checks.
- Supplier header counts and detailed view now include items in PO
  grouping mode (they showed 0 items before).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-17 17:43:35 +02:00

124 lines
No EOL
4.9 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* Client-side photo downscaling for pickup uploads.
*
* Why this exists: the driver app used to PUT the raw camera file
* straight at PocketBase. PocketBase's request logs show what that
* cost us — on 2026-10-12 every single attempt came back `400
* unexpected EOF` (86 of 86, median 7.6s, max 15.6s). That error is
* Go's multipart parser hitting the end of the request body before
* the declared Content-Length arrived: a modern Android camera
* produces a 3–8 MB file, and on mobile data the transfer is getting
* cut off partway through. Not a PocketBase mime or schema problem —
* `order_item_attachements` has `mimeTypes=null`, so nothing was
* rejecting the format.
*
* Size choice (2026-10-17): these photos are mostly supplier invoices
* that the WFDASH AI reads on Collection Confirm. At 1200px / q70 a
* landscape invoice shot sideways left stock codes too blurry to read
* (the AI turned "051315" into "0515315"). 2000px / q80 keeps small
* print legible and still lands around 400–600 KB — an order of
* magnitude below the multi-MB camera files that failed in transit.
*/
/** Longest edge, in pixels, of the uploaded image. */
export const MAX_PHOTO_EDGE = 2000;
/** JPEG encoder quality, 0–1. */
export const PHOTO_QUALITY = 0.8;
/**
* Below this, the file is already small enough that re-encoding costs
* the driver time (and a visible spinner) for no meaningful saving.
* 150 KB is comfortably inside a reliable mobile upload.
*/
export const SKIP_BELOW_BYTES = 150 * 1024;
/**
* Formats a canvas can decode and re-encode for us. HEIC/HEIF is
* excluded deliberately: iPhone-origin files land here via `accept`
* when a driver picks from the library rather than the camera, and
* Chrome cannot decode them into a canvas. They fall through to
* `compressPhoto` returning the original untouched.
*/
const REENCODABLE_TYPES = new Set(['image/jpeg', 'image/png', 'image/webp']);
function loadImage(url: string): Promise<HTMLImageElement> {
return new Promise((resolve, reject) => {
const img = new Image();
img.onload = () => resolve(img);
img.onerror = () => reject(new Error('decode failed'));
img.src = url;
});
}
/**
* Downscale + re-encode `file` for upload.
*
* Never throws and never returns null: on any failure it hands back the
* original file. Compression here is an optimisation, not a
* correctness requirement — turning a photo that might have uploaded
* fine into an error would be strictly worse than trying and failing
* the way we do today. Callers can treat the return value as
* "the best file we can produce", so the happy path stays simple.
*/
export async function compressPhoto(file: File): Promise<File> {
if (file.size <= SKIP_BELOW_BYTES) return file;
if (!REENCODABLE_TYPES.has(file.type)) return file;
// Not available during SSR. Only ever called from a click handler in
// the browser, but a guard is cheaper than a confusing crash.
if (typeof document === 'undefined') return file;
let objectUrl: string | null = null;
try {
objectUrl = URL.createObjectURL(file);
const img = await loadImage(objectUrl);
// Guard against absurd intrinsic sizes before allocating a canvas;
// some devices report bogus dimensions for corrupt files and a
// canvas at 99999px would take the tab down with it.
if (!img.naturalWidth || !img.naturalHeight) return file;
if (img.naturalWidth > 20000 || img.naturalHeight > 20000) return file;
const longest = Math.max(img.naturalWidth, img.naturalHeight);
// Only shrink. Upscaling a small photo wastes bytes and detail.
if (longest <= MAX_PHOTO_EDGE) return file;
const scale = MAX_PHOTO_EDGE / longest;
const width = Math.max(1, Math.round(img.naturalWidth * scale));
const height = Math.max(1, Math.round(img.naturalHeight * scale));
const canvas = document.createElement('canvas');
canvas.width = width;
canvas.height = height;
const ctx = canvas.getContext('2d');
if (!ctx) return file;
// JPEG has no alpha channel; without this a transparent PNG comes
// out with black backgrounds where the alpha was.
ctx.fillStyle = '#ffffff';
ctx.fillRect(0, 0, width, height);
ctx.drawImage(img, 0, 0, width, height);
const blob = await new Promise<Blob | null>((resolve) =>
canvas.toBlob(resolve, 'image/jpeg', PHOTO_QUALITY),
);
if (!blob) return file;
// If re-encoding somehow produced something bigger, keep the
// original rather than making the upload worse.
if (blob.size >= file.size) return file;
const stem = file.name.replace(/\.[^.]+$/, '') || 'pickup';
return new File([blob], `${stem}.jpg`, {
type: 'image/jpeg',
lastModified: Date.now(),
});
} catch {
// Decode failure, canvas unavailable, out of memory — all handled
// the same way: try anyway with what we were given.
return file;
} finally {
if (objectUrl) URL.revokeObjectURL(objectUrl);
}
}