import { adjustStockEntry, getStockEntry, lookupItemDetails, patchStockItem, useStockItem, } from '../../api/stock.js'; import { BrowserMultiFormatReader } from '@zxing/browser'; import { formatPrintErrorMessage, printItemLabel } from '../../api/labels.js'; import { fetchLocations } from '../../api/locations.js'; import { getRouteContext } from '../../app/router.js'; import { createAsyncState, runAsyncState } from '../shared/ui-state.js'; import { formatDate } from '../shared/date-utils.js'; function todayAtMidnight() { const now = new Date(); return new Date(now.getFullYear(), now.getMonth(), now.getDate()); } function parseDateValue(value) { if (!value) { return null; } const [year, month, day] = String(value).split('-').map(Number); if (!year || !month || !day) { return null; } return new Date(year, month - 1, day); } function normalizeIdentifierCode(value) { return String(value || '').replace(/\s+/g, '').trim(); } function expirationInfo(entry) { if (!entry?.expire_date) { return { key: 'none', label: 'No expiration date', detail: 'No expiration date', }; } const expireDate = parseDateValue(entry.expire_date); const expireIn = typeof entry.expire_in === 'number' ? entry.expire_in : expireDate ? Math.round((expireDate - todayAtMidnight()) / (24 * 60 * 60 * 1000)) : null; if (expireIn === null) { return { key: 'none', label: 'No expiration date', detail: 'No expiration date', }; } if (expireIn < 0) { return { key: 'expired', label: 'Expired', detail: `Expired ${Math.abs(expireIn)} day${Math.abs(expireIn) === 1 ? '' : 's'} ago`, }; } if (expireIn <= 2) { return { key: 'use-first', label: expireIn === 0 ? 'Use today' : 'Use first', detail: expireIn === 0 ? 'Expires today' : `Expires in ${expireIn} day${expireIn === 1 ? '' : 's'}`, }; } if (expireIn <= 7) { return { key: 'upcoming', label: 'Upcoming expiration', detail: `Expires in ${expireIn} days`, }; } return { key: 'within-date', label: 'Within date', detail: `Expires in ${expireIn} days`, }; } export function renderStockDetailPage() { return `
← Back to stock

Stock detail

Inspect the entry and update its quantity without leaving the workflow.

Scan barcode

Point your camera at the barcode to fill the identifier field.

Starting camera...
`; } export function stockDetailPageData(store) { return { state: createAsyncState(), adjustmentState: createAsyncState(), printState: createAsyncState(), identifierState: createAsyncState(), scannerReader: null, scannerControls: null, scannerState: { isOpen: false, isLoading: false, hasCamera: false, error: '', lastDetectedCode: '', }, lookupDetailsState: createAsyncState(), printFeedback: { type: '', message: '', }, offLookupFeedback: { type: '', message: '', }, entry: null, locationPathByUuid: {}, identifierDraft: '', adjustment: { mode: 'increment', quantity: '1', level: 'plenty', }, async init() { this.scannerState.hasCamera = this.canUseCameraScanner(); if (!store.isConnected) { return; } const { params } = getRouteContext(); await runAsyncState(this.state, async () => { const [entry, locations] = await Promise.all([ getStockEntry(store, params.id), fetchLocations(store).catch(() => ({ flat: [] })), ]); this.entry = entry; this.identifierDraft = normalizeIdentifierCode(entry?.identifier_code); this.locationPathByUuid = Object.fromEntries( (locations.flat || []) .filter((location) => location.uuid_b64) .map((location) => [location.uuid_b64, location.pathLabel || location.name]), ); this.adjustment.level = this.entry?.level || 'plenty'; }).catch(() => {}); }, destroy() { this.stopScanner(); }, canUseCameraScanner() { return Boolean( typeof navigator !== 'undefined' && navigator.mediaDevices && typeof navigator.mediaDevices.getUserMedia === 'function', ); }, normalizeScannerError(error) { const message = String(error?.message || ''); const normalized = message.toLowerCase(); if (error?.name === 'NotAllowedError' || normalized.includes('permission')) { return 'Camera access was denied. Allow access to scan, or enter the code manually.'; } if (error?.name === 'NotFoundError' || normalized.includes('requested device not found')) { return 'No camera was found on this device. Enter the identifier code manually.'; } if (error?.name === 'NotReadableError' || normalized.includes('could not start video source')) { return 'Camera is busy in another app. Close it there and try scanning again.'; } return 'Could not start barcode scanning. Enter the identifier code manually.'; }, async openScanner() { this.scannerState.error = ''; this.scannerState.lastDetectedCode = ''; this.scannerState.isOpen = true; await this.$nextTick(); await this.startScanner(); }, async startScanner() { this.scannerState.error = ''; this.scannerState.lastDetectedCode = ''; if (!this.canUseCameraScanner()) { this.scannerState.hasCamera = false; this.scannerState.error = 'Camera scanning is not supported in this browser. Enter the identifier code manually.'; return; } const videoElement = this.$refs.scannerVideo; if (!videoElement) { this.scannerState.error = 'Scanner video element is unavailable. Close and reopen scanner.'; return; } this.stopScanner(); this.scannerState.isLoading = true; const shouldLogDecodeErrors = import.meta.env.DEV; let lastDecodeErrorName = ''; let lastDecodeErrorAt = 0; try { if (!this.scannerReader) { this.scannerReader = new BrowserMultiFormatReader(); } this.scannerControls = await this.scannerReader.decodeFromConstraints( { audio: false, video: { facingMode: { ideal: 'environment' }, }, }, videoElement, (result, error) => { if (result) { this.onBarcodeDetected(result.getText?.() || ''); return; } if (error) { // Continuous decode emits expected per-frame misses/errors before a valid barcode is found. // Keep the modal quiet and only surface startup failures from the outer catch block. if (shouldLogDecodeErrors) { const errorName = String(error?.name || 'UnknownError'); const now = Date.now(); if (errorName !== lastDecodeErrorName || now - lastDecodeErrorAt > 2000) { console.debug('[scanner] Ignoring frame decode error while scanning:', errorName, error?.message || ''); lastDecodeErrorName = errorName; lastDecodeErrorAt = now; } } return; } }, ); } catch (error) { this.scannerState.error = this.normalizeScannerError(error); } finally { this.scannerState.isLoading = false; } }, stopScanner() { try { this.scannerControls?.stop?.(); } catch { // Ignore cleanup errors when scanner is already stopped. } this.scannerControls = null; try { this.scannerReader?.reset?.(); } catch { // Ignore cleanup errors from stale reader state. } const videoElement = this.$refs.scannerVideo; const stream = videoElement?.srcObject; if (stream && typeof stream.getTracks === 'function') { stream.getTracks().forEach((track) => track.stop()); } if (videoElement) { videoElement.srcObject = null; } }, closeScanner() { this.stopScanner(); this.scannerState.isOpen = false; this.scannerState.isLoading = false; this.scannerState.error = ''; }, onBarcodeDetected(rawCode) { const code = normalizeIdentifierCode(rawCode); if (!code || !this.scannerState.isOpen) { return; } this.identifierDraft = code; this.scannerState.lastDetectedCode = code; this.closeScanner(); store.addAlert({ type: 'success', message: `Scanned identifier code: ${code}`, }); }, async saveIdentifierCode() { if (!this.entry?.uuid_b64) { return; } this.identifierState.error = ''; await runAsyncState(this.identifierState, async () => { const identifierCode = normalizeIdentifierCode(this.identifierDraft); const updated = await patchStockItem(store, this.entry.uuid_b64, { identifier_code: identifierCode || null, }); this.entry = updated; this.identifierDraft = normalizeIdentifierCode(updated?.identifier_code || identifierCode); store.addAlert({ type: 'success', message: identifierCode ? `Identifier code saved for ${this.entry.name}.` : `Identifier code cleared for ${this.entry.name}.`, }); }).catch(() => {}); }, normalizedIdentifierDraft() { return normalizeIdentifierCode(this.identifierDraft); }, hasIdentifierCode() { return Boolean(this.normalizedIdentifierDraft()); }, async reloadEntry(uuidB64) { const refreshed = await getStockEntry(store, uuidB64); this.entry = refreshed; this.identifierDraft = normalizeIdentifierCode(refreshed?.identifier_code); this.adjustment.level = this.entry?.level || 'plenty'; }, itemLookupStatusMessage(response) { const retryAfter = Number.isInteger(response?.retryAfterSeconds) && response.retryAfterSeconds > 0 ? ` Retry in ${response.retryAfterSeconds}s.` : ''; if (response?.status === 'missing_identifier') { return 'Save an identifier code before running lookup.'; } if (response?.status === 'not_found') { return `No OpenFoodFacts result found for code ${this.normalizedIdentifierDraft() || 'unknown'}.`; } if (response?.status === 'rate_limited') { return `OpenFoodFacts lookup is temporarily rate-limited.${retryAfter}`; } if (response?.status === 'lookup_failed') { return 'OpenFoodFacts lookup failed. Try again shortly or continue manually.'; } return 'Lookup response could not be applied.'; }, itemLookupSuccessMessage(response) { const parts = [ response?.update ? 'Applied missing fields from OpenFoodFacts.' : 'Fetched OpenFoodFacts details preview.', ]; const source = response?.item?.external_source || this.entry?.external_source; if (source) { parts.push(`Source: ${source}.`); } if (Array.isArray(response?.updatedFields) && response.updatedFields.length) { parts.push(`Updated: ${response.updatedFields.join(', ')}.`); } if (response?.staleCache) { parts.push('Using stale cache data.'); } else { parts.push('Cache freshness: current.'); } if (response?.offPayloadFetchedAt) { const fetchedAt = new Date(response.offPayloadFetchedAt); parts.push( `Fetched at: ${ Number.isNaN(fetchedAt.getTime()) ? response.offPayloadFetchedAt : fetchedAt.toLocaleString() }.`, ); } return parts.join(' '); }, async runItemLookup(update) { if (!this.entry?.uuid_b64) { return; } const identifierCode = this.normalizedIdentifierDraft(); if (!identifierCode) { this.offLookupFeedback = { type: 'warning', message: 'Save an identifier code before running lookup refresh.', }; return; } this.lookupDetailsState.error = ''; await runAsyncState(this.lookupDetailsState, async () => { const response = await lookupItemDetails(store, this.entry.uuid_b64, { update }); if (response.status !== 'ok') { const message = this.itemLookupStatusMessage(response); this.offLookupFeedback = { type: 'warning', message, }; store.addAlert({ type: 'warning', message }); return; } if (update) { await this.reloadEntry(this.entry.uuid_b64); } else if (response.item) { this.entry = response.item; this.identifierDraft = normalizeIdentifierCode(response.item.identifier_code || identifierCode); } const message = this.itemLookupSuccessMessage(response); this.offLookupFeedback = { type: 'success', message, }; store.addAlert({ type: 'success', message, }); }).catch((error) => { this.offLookupFeedback = { type: 'warning', message: error?.message || 'OpenFoodFacts lookup failed.', }; }); }, async submitMeasuredAdjustment() { if (!this.entry) { return; } await runAsyncState(this.adjustmentState, async () => { const requestedQuantity = Number(this.adjustment.quantity); if (Number.isNaN(requestedQuantity) || requestedQuantity < 0) { throw new Error('Enter a valid quantity first.'); } const currentQuantity = Number(this.entry.quantity || 0); const exactQuantity = this.adjustment.mode === 'increment' ? currentQuantity + requestedQuantity : this.adjustment.mode === 'decrement' ? Math.max(currentQuantity - requestedQuantity, 0) : requestedQuantity; this.entry = await adjustStockEntry(store, this.entry.uuid_b64, { quantity: exactQuantity, }); this.identifierDraft = normalizeIdentifierCode(this.entry?.identifier_code); store.addAlert({ type: 'success', message: 'Stock quantity updated.' }); }).catch(() => {}); }, async submitLevelAdjustment() { if (!this.entry) { return; } await runAsyncState(this.adjustmentState, async () => { if (this.adjustment.level === 'gone') { const entryName = this.entry.name; await useStockItem(store, this.entry.uuid_b64); store.addAlert({ type: 'success', message: `${entryName} was marked gone.` }); window.__loncApp.navigate('/stock'); return; } this.entry = await adjustStockEntry(store, this.entry.uuid_b64, { level: this.adjustment.level, }); this.identifierDraft = normalizeIdentifierCode(this.entry?.identifier_code); store.addAlert({ type: 'success', message: 'Stock level updated.' }); }).catch(() => {}); }, async markGone() { if (!this.entry) { return; } await runAsyncState(this.adjustmentState, async () => { const result = await useStockItem(store, this.entry.uuid_b64); const alreadyGone = result.status === 'already_gone'; store.addAlert({ type: alreadyGone ? 'info' : 'success', message: alreadyGone ? `${this.entry.name} was already out of stock.` : `${this.entry.name} was marked gone.`, }); window.__loncApp.navigate('/stock'); }).catch(() => {}); }, async printLabel() { if (!this.entry?.uuid_b64) { return; } this.printFeedback = { type: '', message: '', }; await runAsyncState(this.printState, async () => { try { await printItemLabel(store, this.entry.uuid_b64); this.printFeedback = { type: 'success', message: 'Label printed successfully.', }; store.addAlert({ type: 'success', message: `${this.entry.name} label sent to printer.`, }); } catch (error) { const parsed = formatPrintErrorMessage(error); this.printFeedback = { type: 'warning', message: parsed, }; store.addAlert({ type: 'warning', message: `Could not print ${this.entry.name} label: ${parsed}`, }); } }).catch(() => {}); }, quickAdjust(step) { const current = Number(this.adjustment.quantity || 0); this.adjustment.quantity = String(Math.max(current + step, 0)); }, formatDate, expirationFor(entry) { return expirationInfo(entry); }, expirationBadgeClass(entry) { const key = this.expirationFor(entry).key; if (key === 'expired') { return 'text-bg-danger'; } if (key === 'use-first') { return 'text-bg-warning'; } if (key === 'upcoming') { return 'text-bg-secondary'; } if (key === 'within-date') { return 'text-bg-success'; } return 'text-bg-light border'; }, locationLabel(entry) { const locationUuid = entry?.location_initial_uuid_b64; if (!locationUuid) { return 'Unassigned'; } return this.locationPathByUuid[locationUuid] || 'Location not resolved'; }, nutriScoreLabel(entry) { const value = entry?.nutriscore_grade; if (!value) { return 'Not available'; } return String(value).toUpperCase(); }, nutritionFactsRows(entry) { const facts = entry?.nutrition_facts; if (!facts || typeof facts !== 'object' || Array.isArray(facts)) { return []; } const preferredOrder = [ 'per', 'serving_size', 'energy_kj', 'energy_kcal', 'fat', 'saturated_fat', 'carbohydrates', 'sugars', 'fibers', 'proteins', 'salt', 'sodium', ]; const rankByKey = new Map(preferredOrder.map((key, index) => [key, index])); return Object.entries(facts) .sort(([leftKey], [rightKey]) => { const leftRank = rankByKey.has(leftKey) ? rankByKey.get(leftKey) : Number.POSITIVE_INFINITY; const rightRank = rankByKey.has(rightKey) ? rankByKey.get(rightKey) : Number.POSITIVE_INFINITY; if (leftRank !== rightRank) { return leftRank - rightRank; } return leftKey.localeCompare(rightKey); }) .map(([key, value]) => ({ key, label: this.nutritionLabel(key), value: this.formatNutritionValue(value), })); }, nutritionLabel(key) { const labels = { per: 'Per', serving_size: 'Serving size', energy_kj: 'Energy (kJ)', energy_kcal: 'Energy (kcal)', fat: 'Fat', saturated_fat: 'Saturated fat', carbohydrates: 'Carbohydrates', sugars: 'Sugars', fibers: 'Fibers', proteins: 'Proteins', salt: 'Salt', sodium: 'Sodium', }; return labels[key] || key.replace(/_/g, ' '); }, formatNutritionValue(value) { if (value === null || value === undefined || value === '') { return 'n/a'; } return String(value); }, formatQuantity(entry) { return `${entry.quantity ?? 0} ${entry.uom_symbol || ''}`.trim(); }, }; }