From fcd1801aabbd5b510aacdf129b1236eba24ef5cc Mon Sep 17 00:00:00 2001 From: maziggy Date: Mon, 18 May 2026 12:53:07 +0200 Subject: [PATCH] feat(inventory): sort-by-colour toggle in label-print modal (#1410) Reporter asked for an option to order printed label sheets by colour instead of spool number so multi-colour rolls group related colours together physically on the sheet. Backend (labels.py) already preserves caller order, so this is frontend-only. LabelTemplatePickerModal gains a "Sort: By ID / By colour" chip pair next to the material filter. Colour mode converts each spool's rgba to HSL: chromatic colours (s >= 0.1) cluster in bucket 0 ordered by hue 0..360, achromatic colours go in bucket 1 ordered by lightness so neutrals trail the rainbow black -> white. Stable tiebreaker on spool ID. Also fixes a latent issue exposed by the same code: the submit was always re-sorting selected IDs ascending, which would have clobbered any frontend order. Submit now uses sortedSpools.filter().map() so the visible order flows through to the PDF. Session-only state; toggle resets to "By ID" each time the modal opens. 3 new i18n keys translated across all 8 locales (parity 4852 leaves). 2 new modal tests pin the colour-sort payload order and the unchanged ID-default. 17 modal tests + i18n parity + build all green. --- CHANGELOG.md | 2 + .../LabelTemplatePickerModal.test.tsx | 60 +++++++++++ .../components/LabelTemplatePickerModal.tsx | 99 ++++++++++++++++++- frontend/src/i18n/locales/de.ts | 5 + frontend/src/i18n/locales/en.ts | 5 + frontend/src/i18n/locales/fr.ts | 5 + frontend/src/i18n/locales/it.ts | 5 + frontend/src/i18n/locales/ja.ts | 5 + frontend/src/i18n/locales/pt-BR.ts | 5 + frontend/src/i18n/locales/zh-CN.ts | 5 + frontend/src/i18n/locales/zh-TW.ts | 5 + 11 files changed, 196 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index c19ad1da0..4159edea1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,8 @@ All notable changes to Bambuddy will be documented in this file. ## [0.2.5b1] - Unreleased ### Added +- **Print labels: sort by colour as an alternative to spool-ID order (#1410, requested by @elit3ge)** — Reporter asked for an option to order the printed label sheet by colour instead of spool number so a multi-colour roll of Avery sheets / box labels groups related colours together physically. The label-render backend (`labels.py`) already honoured caller order — both `POST /inventory/labels` and `POST /spoolman/labels` preserve the order of `spool_ids` in the request body and pass it straight to the PDF renderer — so the fix is frontend-only. `LabelTemplatePickerModal` gains a small `sortMode` toggle ("By ID" / "By colour") rendered as a chip pair next to the material-filter row. The "by colour" mode converts each spool's `rgba` to HSL and returns a `[bucket, position]` sort key: chromatic colours (saturation ≥ 0.1) go in bucket 0 ordered by hue 0..360 so the sheet reads as a continuous rainbow; achromatic colours (greys, blacks, whites, plus missing/invalid rgba) go in bucket 1 ordered by lightness so the neutrals trail the rainbow black → white. Multi-colour spools sort on their primary `rgba` — the secondary `extra_colors` stripe still renders on the printed label but doesn't drive the sort, since multi-tone sorting would need a perceptual-distance model the use case doesn't justify. Stable tiebreaker on spool ID keeps identical-colour spools in a deterministic order across renders. The previous `[...selectedIds].sort((a, b) => a - b)` at submit time was forcing every PDF to ID order regardless of any frontend sorting — that's been replaced with `sortedSpools.filter(s => selectedIds.has(s.id)).map(s => s.id)` so the visible order flows through to the wire. Session-only state — toggle resets to "By ID" each time the modal opens, no persisted setting (label printing is a rare action and the user picks what they want every time). i18n: 3 new keys (`inventory.labels.sortBy.{label, id, color}`) translated across all 8 locales (en/de/fr/it/ja/pt-BR/zh-CN/zh-TW); parity check holds at 4852 leaves per locale. Tests: two new in `LabelTemplatePickerModal.test.tsx` — one asserts the "By colour" toggle reorders the submit payload to `[Red, Ivory, Blue, Black]` (hue 0° / 33° / 240° then neutral with lightness 0) using the existing 4-spool fixture, the other guards the default "By ID" path so adding the toggle didn't quietly regress users who never click it. 17 modal tests green; frontend build clean. + - **Camera: in-app diagnostic for "Connection lost" (#1395 follow-up)** — Second step of the camera architecture overhaul. When the camera viewer hits its error state, a new **Diagnose** button next to **Retry** runs a staged check against the printer and renders the result inline: which stage failed, how long it took, and a translated remediation hint. Cuts off the "user opens a 'camera broken' ticket → wait days → ask for the support bundle → finally figure out it was their reverse proxy / LAN-only toggle / wrong access code" loop at the user's screen. **Backend** ships `backend/app/services/camera_diagnose.py` (orchestrator) and a new `POST /printers/{id}/camera/diagnose` route in `camera.py`. Stages: (1) `tcp_reachable` — opens a TCP socket to the camera port (322 RTSPS / 6000 chamber image) with a 3-second timeout; distinguishes timeout (`tcp_timeout` → "printer not reachable, check IP/network/power") from refused (`tcp_refused` → "camera port closed, check LAN-only and developer mode") from host-unreachable (`tcp_unreachable` → "printer not reachable"). (2) `first_frame` — captures one JPEG end-to-end via the existing `capture_camera_frame_bytes` pipeline (15-second timeout, same code that powers `/camera/snapshot`); auth, RTSP handshake, and first keyframe collapse into one stage because the user-facing answer is the same regardless of which sub-layer failed. **Live-stream shortcut**: when a viewer is currently watching the printer's camera AND the buffered last-frame timestamp is fresher than 10 seconds, the diagnostic skips the real test and returns `live_stream_active_healthy` — opening a fresh socket would kick the live viewer off on single-camera-connection firmwares (the #1348 reconnect-storm trigger), so we trust the real-world evidence instead. Response includes structured metadata for support triage: `protocol` (rtsp / chamber_image), `port`, `profile` (`default` or the model name with an override — currently only `P2S`), per-stage duration in ms, and the machine-readable summary code. **Frontend** adds `CameraDiagnoseModal.tsx` that fires the API call on mount, renders one row per stage with green-check / red-X / grey-skipped icons, and shows the summary remediation message in a bordered banner styled by overall status. The metadata line at the bottom (protocol / port / profile) lets support triage ask "what does your modal say?" instead of "send the support bundle". A **Run again** button re-runs the diagnostic without dismissing the modal. **EmbeddedCameraViewer** error state grows the Diagnose button (kept "Retry" as the primary action; Diagnose is the escape hatch for users who can't see what's wrong). A small stethoscope icon also lives in the viewer's always-visible control bar between **Refresh** and **Fullscreen**, so pre-flight testing ("did my firmware update break the camera?", "is the camera up before I send a print?") doesn't require waiting for the stream to fail first. Also lifted the previously-hard-coded "Camera unavailable" / "Retry" strings into `camera.unavailable` / `camera.retry` so the error UI is properly translated alongside the new keys. **i18n**: 16 new keys (`unavailable`, `retry`, plus `diagnose.{button,modalTitle,running,runFailed,retry,stage.*,summary.*,meta.*}`) translated across all 8 locales (en/de/fr/it/ja/pt-BR/zh-CN/zh-TW). German "Diagnose" is a real cognate — added to `IDENTICAL_TO_EN_ALLOWED.de` rather than translated to a synthetic. Parity check holds at 4849 leaves per locale. **Tests**: 11 backend unit tests in `test_camera_diagnose.py` cover the live-stream shortcut (skip when fresh, run when stale), the three TCP failure modes (timeout / refused / OSError) → distinct summary codes, the first-frame stage (no-frame and capture-exception cases), the all-OK path, and the result metadata (P2S → P2S profile / rtsp / 322; A1 → default / chamber_image / 6000; X1C → default / rtsp / 322). 1 backend integration test pins the route's response shape end-to-end. 3 frontend tests in `CameraDiagnoseModal.test.tsx` (mounted → API call, failure → translated remediation, Run again → re-call). 5021 backend tests + 1905 frontend tests green; ruff clean; build clean; i18n parity clean. ### Fixed diff --git a/frontend/src/__tests__/components/LabelTemplatePickerModal.test.tsx b/frontend/src/__tests__/components/LabelTemplatePickerModal.test.tsx index 049d13885..b04796a0f 100644 --- a/frontend/src/__tests__/components/LabelTemplatePickerModal.test.tsx +++ b/frontend/src/__tests__/components/LabelTemplatePickerModal.test.tsx @@ -319,4 +319,64 @@ describe('LabelTemplatePickerModal', () => { expect(spoolListScroller!.className).toContain('min-h-0'); expect(spoolListScroller!.className).not.toMatch(/min-h-\[\d/); }); + + // #1410: an "ID | colour" sort toggle in the modal must flow through to the + // PDF — the backend (labels.py) prints in the order it receives spool_ids, + // so the modal's "submit in ID order" default was forcing every PDF to + // appear in spool-number order regardless of user choice. Toggling to + // colour mode must reorder both the visible list AND the payload so the + // printed sheet groups colours together. + it('sorts the submit payload by HSL hue when sort mode is "By colour" (#1410)', async () => { + vi.mocked(api.printSpoolLabels).mockResolvedValue(PDF_BLOB); + render( + , + ); + + // Default is ID-sorted; flip to colour. + fireEvent.click(screen.getByRole('button', { name: 'By colour' })); + fireEvent.click(screen.getByText(/Box label \(62 × 29 mm\)/i)); + + await waitFor(() => { + // Expected colour-sort order for the SPOOLS fixture: + // Red (1) — hue 0° — chromatic + // Ivory (4) — hue ≈34° — chromatic + // Blue (2) — hue 240° — chromatic + // Black (3) — saturation ≈0 → neutrals bucket, lightness 0 → last + // Rainbow first, then neutrals (dark→light) per design choice for #1410. + expect(api.printSpoolLabels).toHaveBeenCalledWith({ + spool_ids: [1, 4, 2, 3], + template: 'box_62x29', + }); + }); + }); + + it('keeps ID-order submission by default (#1410 regression guard)', async () => { + // Adding the sort toggle must NOT change the default behaviour — IDs go + // in ascending order unless the user explicitly clicks "By colour". + vi.mocked(api.printSpoolLabels).mockResolvedValue(PDF_BLOB); + render( + , + ); + + fireEvent.click(screen.getByText(/Box label \(40 × 30 mm\)/i)); + + await waitFor(() => { + expect(api.printSpoolLabels).toHaveBeenCalledWith({ + spool_ids: [1, 2, 3, 4], + template: 'box_40x30', + }); + }); + }); }); diff --git a/frontend/src/components/LabelTemplatePickerModal.tsx b/frontend/src/components/LabelTemplatePickerModal.tsx index 784b24b31..3c3082f83 100644 --- a/frontend/src/components/LabelTemplatePickerModal.tsx +++ b/frontend/src/components/LabelTemplatePickerModal.tsx @@ -98,6 +98,50 @@ function searchableText(s: SpoolForLabel): string { .toLowerCase(); } +type SortMode = 'id' | 'color'; + +/** Sort key for the "by colour" mode (#1410). + * + * Returns a 2-tuple so JS array compare does the right thing without us having + * to spell out a comparator: ``[bucket, position]``. Chromatic colours + * (saturation above the threshold) go in bucket 0 ordered by HSL hue, so the + * sheet reads as a continuous rainbow. Achromatic colours (white / grey / + * black, plus missing/invalid rgba) go in bucket 1 ordered by lightness so the + * neutrals trail at the end of the rainbow going dark → light. Multi-colour + * spools sort on their primary ``rgba``; their ``extra_colors`` stripe is + * still rendered on the label itself but doesn't drive the sort. + */ +function colorSortKey(rgba: string | null | undefined): [number, number] { + if (!rgba) return [1, 0]; // unknown colour — bucket with the neutrals at black + const cleaned = rgba.replace(/^#/, '').slice(0, 6); + if (cleaned.length !== 6) return [1, 0]; + const r = parseInt(cleaned.slice(0, 2), 16); + const g = parseInt(cleaned.slice(2, 4), 16); + const b = parseInt(cleaned.slice(4, 6), 16); + if ([r, g, b].some(Number.isNaN)) return [1, 0]; + + const rn = r / 255; + const gn = g / 255; + const bn = b / 255; + const max = Math.max(rn, gn, bn); + const min = Math.min(rn, gn, bn); + const l = (max + min) / 2; + const delta = max - min; + // Saturation in the HSL definition. Achromatic cutoff at 0.1 is generous — + // matches what feels "grey enough" to a user picking colours, without + // sending dark muted colours like deep navy into the neutrals bucket. + const s = delta === 0 ? 0 : delta / (1 - Math.abs(2 * l - 1)); + if (s < 0.1) return [1, l]; // neutrals: ordered black → white + + let h = 0; + if (max === rn) h = ((gn - bn) / delta) % 6; + else if (max === gn) h = (bn - rn) / delta + 2; + else h = (rn - gn) / delta + 4; + h = h * 60; + if (h < 0) h += 360; + return [0, h]; // chromatic: ordered by hue 0..360 +} + export function LabelTemplatePickerModal({ isOpen, onClose, @@ -111,6 +155,7 @@ export function LabelTemplatePickerModal({ const [selectedIds, setSelectedIds] = useState>(new Set()); const [search, setSearch] = useState(''); const [materialFilter, setMaterialFilter] = useState(''); + const [sortMode, setSortMode] = useState('id'); // Sync from caller and reset transient state on open. Intentionally not // reactive to props while open — once the user starts editing we don't want @@ -121,15 +166,29 @@ export function LabelTemplatePickerModal({ setSelectedIds(new Set(initialSelectedIds.filter((id) => allowed.has(id)))); setSearch(''); setMaterialFilter(''); + setSortMode('id'); setPending(null); } // eslint-disable-next-line react-hooks/exhaustive-deps }, [isOpen]); - const sortedSpools = useMemo( - () => [...availableSpools].sort((a, b) => a.id - b.id), - [availableSpools], - ); + const sortedSpools = useMemo(() => { + const copy = [...availableSpools]; + if (sortMode === 'color') { + copy.sort((a, b) => { + const ka = colorSortKey(a.rgba); + const kb = colorSortKey(b.rgba); + if (ka[0] !== kb[0]) return ka[0] - kb[0]; + if (ka[1] !== kb[1]) return ka[1] - kb[1]; + // Stable tiebreaker on ID so identical colours print in a deterministic + // order across renders. + return a.id - b.id; + }); + return copy; + } + copy.sort((a, b) => a.id - b.id); + return copy; + }, [availableSpools, sortMode]); // Material chips are derived from the *full* available set so they stay // stable when search/material filter narrows the visible list. @@ -189,7 +248,10 @@ export function LabelTemplatePickerModal({ async function handlePick(template: SpoolLabelTemplate) { if (noSelection || pending) return; - const ids = [...selectedIds].sort((a, b) => a - b); + // Order matters: the backend (labels.py) prints labels in the same order + // we send IDs. Use the sorted list so a "by colour" sort flows through to + // the PDF instead of being clobbered by an ascending-ID re-sort. + const ids = sortedSpools.filter((s) => selectedIds.has(s.id)).map((s) => s.id); setPending(template); try { const blob = spoolmanMode @@ -282,6 +344,33 @@ export function LabelTemplatePickerModal({ ))} )} +
+ + {t('inventory.labels.sortBy.label')} + + + +
{/* Action bar */} diff --git a/frontend/src/i18n/locales/de.ts b/frontend/src/i18n/locales/de.ts index 9e899405d..4896903e4 100644 --- a/frontend/src/i18n/locales/de.ts +++ b/frontend/src/i18n/locales/de.ts @@ -3536,6 +3536,11 @@ export default { bulkTitle: 'Spulen aus den aktuell angezeigten {{count}} zum Etikettieren auswählen', noSpoolsTitle: 'Keine Spulen zum Etikettieren', error: 'Etiketten konnten nicht erstellt werden: {{msg}}', + sortBy: { + label: 'Sortieren:', + id: 'Nach ID', + color: 'Nach Farbe', + }, templates: { ams: { label: 'AMS holder (30 × 15 mm)', diff --git a/frontend/src/i18n/locales/en.ts b/frontend/src/i18n/locales/en.ts index e1a4e87dd..fa0f76410 100644 --- a/frontend/src/i18n/locales/en.ts +++ b/frontend/src/i18n/locales/en.ts @@ -3539,6 +3539,11 @@ export default { bulkTitle: 'Pick spools to print labels for from the {{count}} currently shown', noSpoolsTitle: 'No spools to label', error: 'Could not generate labels: {{msg}}', + sortBy: { + label: 'Sort:', + id: 'By ID', + color: 'By colour', + }, templates: { ams: { label: 'AMS holder (30 × 15 mm)', diff --git a/frontend/src/i18n/locales/fr.ts b/frontend/src/i18n/locales/fr.ts index b927dcde2..c51031f02 100644 --- a/frontend/src/i18n/locales/fr.ts +++ b/frontend/src/i18n/locales/fr.ts @@ -3525,6 +3525,11 @@ export default { bulkTitle: 'Choisissez les bobines à étiqueter parmi les {{count}} affichées', noSpoolsTitle: 'Aucune bobine à étiqueter', error: 'Impossible de générer les étiquettes : {{msg}}', + sortBy: { + label: 'Trier :', + id: 'Par ID', + color: 'Par couleur', + }, templates: { ams: { label: 'AMS holder (30 × 15 mm)', diff --git a/frontend/src/i18n/locales/it.ts b/frontend/src/i18n/locales/it.ts index fcdbfa6ec..eaf8cffae 100644 --- a/frontend/src/i18n/locales/it.ts +++ b/frontend/src/i18n/locales/it.ts @@ -3524,6 +3524,11 @@ export default { bulkTitle: 'Scegli le bobine da etichettare tra le {{count}} mostrate', noSpoolsTitle: 'Nessuna bobina da etichettare', error: 'Impossibile generare etichette: {{msg}}', + sortBy: { + label: 'Ordina:', + id: 'Per ID', + color: 'Per colore', + }, templates: { ams: { label: 'AMS holder (30 × 15 mm)', diff --git a/frontend/src/i18n/locales/ja.ts b/frontend/src/i18n/locales/ja.ts index 6964e14f2..1dda654b8 100644 --- a/frontend/src/i18n/locales/ja.ts +++ b/frontend/src/i18n/locales/ja.ts @@ -3536,6 +3536,11 @@ export default { bulkTitle: '現在表示中の{{count}}件からラベルを印刷するスプールを選択', noSpoolsTitle: 'ラベル付けするスプールなし', error: 'ラベルを生成できませんでした: {{msg}}', + sortBy: { + label: '並べ替え:', + id: 'ID順', + color: '色順', + }, templates: { ams: { label: 'AMS holder (30 × 15 mm)', diff --git a/frontend/src/i18n/locales/pt-BR.ts b/frontend/src/i18n/locales/pt-BR.ts index ad017cd7c..7cd9ab799 100644 --- a/frontend/src/i18n/locales/pt-BR.ts +++ b/frontend/src/i18n/locales/pt-BR.ts @@ -3524,6 +3524,11 @@ export default { bulkTitle: 'Escolha bobinas para etiquetar entre as {{count}} exibidas', noSpoolsTitle: 'Nenhuma bobina para rotular', error: 'Não foi possível gerar etiquetas: {{msg}}', + sortBy: { + label: 'Ordenar:', + id: 'Por ID', + color: 'Por cor', + }, templates: { ams: { label: 'AMS holder (30 × 15 mm)', diff --git a/frontend/src/i18n/locales/zh-CN.ts b/frontend/src/i18n/locales/zh-CN.ts index ad466843c..98d276620 100644 --- a/frontend/src/i18n/locales/zh-CN.ts +++ b/frontend/src/i18n/locales/zh-CN.ts @@ -3524,6 +3524,11 @@ export default { bulkTitle: '从当前显示的 {{count}} 个线材中选择要打印标签的', noSpoolsTitle: '没有要打标签的线材', error: '无法生成标签:{{msg}}', + sortBy: { + label: '排序:', + id: '按 ID', + color: '按颜色', + }, templates: { ams: { label: 'AMS holder (30 × 15 mm)', diff --git a/frontend/src/i18n/locales/zh-TW.ts b/frontend/src/i18n/locales/zh-TW.ts index fcfb38512..015d79281 100644 --- a/frontend/src/i18n/locales/zh-TW.ts +++ b/frontend/src/i18n/locales/zh-TW.ts @@ -3524,6 +3524,11 @@ export default { bulkTitle: '從目前顯示的 {{count}} 個線材中選擇要列印標籤的', noSpoolsTitle: '沒有要貼標籤的線材', error: '無法產生標籤:{{msg}}', + sortBy: { + label: '排序:', + id: '按 ID', + color: '按顏色', + }, templates: { ams: { label: 'AMS holder (30 × 15 mm)',