diff --git a/AGENTS.md b/AGENTS.md
index 3a45206..7803e4c 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -82,16 +82,20 @@ These are current project assumptions and should not be casually changed.
- `/{database}/user/application/`
- `/{database}/kitchen/kitchens`
- `/{database}/kitchen/items`
+- `/{database}/kitchen/items/upsert`
- `/{database}/kitchen/items/grouped`
- `/{database}/kitchen/items/{uuid_b64}`
- `/{database}/kitchen/items/{uuid_b64}/stock`
+- `/{database}/kitchen/items/{uuid_b64}/use`
+- `/{database}/kitchen/changes`
- `/{database}/kitchen/locations`
### Labels
- Preview uses label-preview flags
-- Create uses label-generation flags
-- Actual create currently also needs `print=1`
+- Submit/create flow uses upsert apply (`/kitchen/items/upsert?mode=apply`)
+- UI exposes a `Print` checkbox next to save (default on for current page session)
+- If `Print` is enabled and save succeeds, label printing uses `/kitchen/items/{uuid_b64}/print-label`
### Item-definition search for label creation
diff --git a/README.md b/README.md
index 3b36bd6..437fb22 100644
--- a/README.md
+++ b/README.md
@@ -147,8 +147,8 @@ Default endpoint placeholders live in [`src/app/config.js`](/Users/blaz/PycharmP
Expected shapes today:
-- Kitchen-scoped application resources use:
- `/{database}/kitchen/{kitchen_id}/{resource}`
+- Kitchen application resources use database-scoped routes:
+ `/{database}/kitchen/{resource}`
- User application key management uses:
`/{database}/user/application/`
@@ -165,14 +165,22 @@ Expected shapes today:
Returns the current stock review list.
- `GET /{database}/kitchen/items/{uuid_b64}`
Returns one item detail payload.
+- `GET /{database}/kitchen/changes`
+ Returns `{ since, next_cursor, changes }` feed payload for item/stock updates.
+- `POST /{database}/kitchen/items/upsert?mode=preview|apply`
+ Used by label submit flow for create-or-update behavior and conflict-safe matching.
- `POST /{database}/kitchen/items?label=1`
- Creates a stock item plus label-related output on the backend side.
+ Used for label image preview rendering.
- `POST /{database}/kitchen/items?label=1&preview=1`
Returns an image blob, `{ imageUrl }`, or `{ imageSvg }` for in-browser preview.
- `POST /{database}/kitchen/items/{uuid_b64}/stock`
Updates measured or descriptive stock state using `{ quantity }` or `{ level }`.
+- `POST /{database}/kitchen/items/{uuid_b64}/use`
+ Marks an item used up (`gone`) via stock-event semantics.
+- `POST /{database}/kitchen/items/{uuid_b64}/print-label`
+ Prints label for an existing item; called from the save flow when `Print` is enabled.
- `DELETE /{database}/kitchen/items/{uuid_b64}`
- Marks an individual stock item gone.
+ Compatibility fallback when `/use` is not available on the backend.
- `GET /{database}/kitchen/locations`
Returns a nested location tree.
@@ -181,4 +189,5 @@ Expected shapes today:
- Hash-based routing is used to keep static deployment simple.
- Local storage only keeps non-sensitive app config, session payload, active kitchen, and label draft state.
- Kitchen context now lives in the URL path instead of a custom header.
-- `includeKitchen: false` in the API client only removes the kitchen path segment; it does not disable bearer authentication.
+- The API client now builds database-scoped kitchen routes by default; it always keeps bearer authentication handling separate from URL shaping.
+- Label submit uses upsert-first apply semantics and an optional `Print` checkbox (default on for the current page session).
diff --git a/package-lock.json b/package-lock.json
index ac59e17..5878e7b 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -1,12 +1,12 @@
{
"name": "lonc-web",
- "version": "0.1.0",
+ "version": "0.1.2",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "lonc-web",
- "version": "0.1.0",
+ "version": "0.1.2",
"dependencies": {
"alpinejs": "^3.14.9",
"bootstrap": "^5.3.3"
diff --git a/package.json b/package.json
index 20563f2..7ad0a56 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "lonc-web",
- "version": "0.1.0",
+ "version": "0.1.2",
"private": true,
"type": "module",
"scripts": {
diff --git a/src/api/auth.js b/src/api/auth.js
index fd45a26..b912101 100644
--- a/src/api/auth.js
+++ b/src/api/auth.js
@@ -22,7 +22,6 @@ export async function login(store, credentials) {
user: credentials.userLogin,
application: TRYTON_APPLICATION,
},
- includeKitchen: false,
});
const applicationKey = extractKey(payload);
@@ -66,7 +65,6 @@ export async function logout(store) {
key: store.session.applicationKey,
application: TRYTON_APPLICATION,
},
- includeKitchen: false,
skipAuthFailureHandler: true,
});
}
diff --git a/src/api/client.js b/src/api/client.js
index 323aa2a..2e7ab66 100644
--- a/src/api/client.js
+++ b/src/api/client.js
@@ -20,7 +20,7 @@ function isSameOriginBaseUrl(baseUrl) {
}
}
-function buildPathname({ database, kitchenId, path, includeKitchen = true }) {
+function buildPathname({ database, path }) {
const encodedDatabase = encodeURIComponent(database);
const rawPath = String(path || '').replace(/^\/+/, '');
const keepTrailingSlash = rawPath.endsWith('/');
@@ -30,23 +30,17 @@ function buildPathname({ database, kitchenId, path, includeKitchen = true }) {
.map((segment) => encodeURIComponent(segment));
const segments = [encodedDatabase];
- if (includeKitchen && kitchenId) {
- segments.push('kitchen', encodeURIComponent(String(kitchenId)));
- }
-
segments.push(...encodedPathSegments);
const pathname = `/${segments.join('/')}`;
return keepTrailingSlash ? `${pathname}/` : pathname;
}
-function buildUrl({ baseUrl, database, kitchenId, path, query = {}, includeKitchen = true }) {
+function buildUrl({ baseUrl, database, path, query = {} }) {
const cleanBaseUrl = normalizeBaseUrl(baseUrl);
const pathname = buildPathname({
database,
- kitchenId,
path,
- includeKitchen,
});
const searchParams = new URLSearchParams();
@@ -191,6 +185,10 @@ function isKitchensPath(path) {
return String(path || '').replace(/^\/+/, '').replace(/\/+$/, '') === API_PATHS.kitchens;
}
+function isKitchenApiPath(path) {
+ return String(path || '').replace(/^\/+/, '').startsWith('kitchen/');
+}
+
function shouldInvalidateValidatedSession(store, path, options = {}) {
if (options.skipAuthFailureHandler) {
return false;
@@ -202,15 +200,16 @@ function shouldInvalidateValidatedSession(store, path, options = {}) {
return (
isKitchensPath(path) ||
- options.includeKitchen !== false ||
+ isKitchenApiPath(path) ||
path === API_PATHS.items ||
path === API_PATHS.locations ||
+ path === API_PATHS.changes ||
String(path || '').startsWith(`${API_PATHS.items}/`)
);
}
export async function apiRequest(store, path, options = {}) {
- const { config, session, activeKitchen } = store;
+ const { config, session } = store;
if (!config.database) {
throw new Error('Database name is required.');
@@ -220,10 +219,8 @@ export async function apiRequest(store, path, options = {}) {
const url = buildUrl({
baseUrl: config.baseUrl,
database: config.database,
- kitchenId: activeKitchen?.id,
path,
query: options.query,
- includeKitchen: options.includeKitchen !== false,
});
const headers = new Headers(options.headers || {});
headers.set('Accept', options.accept || 'application/json');
@@ -304,9 +301,7 @@ export function buildKitchenApiUrl(store, path, query = {}) {
return buildUrl({
baseUrl: store.config.baseUrl,
database: store.config.database,
- kitchenId: store.activeKitchen?.id,
path,
query,
- includeKitchen: true,
});
}
diff --git a/src/api/kitchens.js b/src/api/kitchens.js
index aba3bbf..a975fc0 100644
--- a/src/api/kitchens.js
+++ b/src/api/kitchens.js
@@ -1,9 +1,7 @@
import { apiRequest, getPath } from './client.js';
export async function listKitchens(store) {
- const payload = await apiRequest(store, getPath('kitchens'), {
- includeKitchen: false,
- });
+ const payload = await apiRequest(store, getPath('kitchens'));
if (Array.isArray(payload)) {
return payload;
}
diff --git a/src/api/labels.js b/src/api/labels.js
index 36fc865..514635f 100644
--- a/src/api/labels.js
+++ b/src/api/labels.js
@@ -42,7 +42,6 @@ export async function previewLabel(store, body) {
method: 'POST',
body,
accept: 'image/svg+xml, image/png, application/json',
- includeKitchen: false,
query: { label: 1, preview: 1 },
});
@@ -53,3 +52,57 @@ export async function previewLabel(store, body) {
throw new Error('Label preview response did not include an image.');
}
+
+export async function printItemLabel(store, uuidB64) {
+ return apiRequest(store, `${getPath('items')}/${uuidB64}/print-label`, {
+ method: 'POST',
+ });
+}
+
+function flattenDetails(details) {
+ if (!details) {
+ return '';
+ }
+
+ if (typeof details === 'string') {
+ return details;
+ }
+
+ if (Array.isArray(details)) {
+ return details
+ .map((entry) => (typeof entry === 'string' ? entry : JSON.stringify(entry)))
+ .join(' | ');
+ }
+
+ if (typeof details === 'object') {
+ return Object.entries(details)
+ .map(([key, value]) => `${key}: ${value}`)
+ .join(' | ');
+ }
+
+ return String(details);
+}
+
+export function formatPrintErrorMessage(error) {
+ const status = error?.status || error?.cause?.status;
+ const payload = error?.payload || error?.cause?.payload || {};
+ const code = String(payload?.code || '').toLowerCase();
+ const detailsText = flattenDetails(payload?.details || error?.details || error?.cause?.details);
+
+ let message;
+ if (code === 'printer_unavailable') {
+ message = 'Printer is unavailable.';
+ } else if (code === 'print_failed') {
+ message = 'Label printing failed.';
+ } else if (status === 503) {
+ message = 'Printer service is unavailable.';
+ } else if (status === 404) {
+ message = 'Saved item could not be found for printing.';
+ } else if (status === 400) {
+ message = 'Print request was invalid.';
+ } else {
+ message = error?.message || 'Printing failed.';
+ }
+
+ return detailsText ? `${message} (${detailsText})` : message;
+}
diff --git a/src/api/locations.js b/src/api/locations.js
index 987f4c3..02bbc46 100644
--- a/src/api/locations.js
+++ b/src/api/locations.js
@@ -42,9 +42,7 @@ export async function fetchLocations(store) {
return cached.value;
}
- const payload = await apiRequest(store, getPath('locations'), {
- includeKitchen: false,
- });
+ const payload = await apiRequest(store, getPath('locations'));
const tree = Array.isArray(payload)
? payload
: payload?.data || payload?.locations || [];
diff --git a/src/api/stock.js b/src/api/stock.js
index 4cd276f..f29e018 100644
--- a/src/api/stock.js
+++ b/src/api/stock.js
@@ -10,7 +10,6 @@ export async function searchItemDefinitions(store, query) {
}
const payload = await apiRequest(store, `${getPath('items')}/grouped`, {
- includeKitchen: false,
query: { search_name: query, expanded: 0 },
});
@@ -22,9 +21,7 @@ export async function searchItemDefinitions(store, query) {
}
export async function listStockEntries(store, filters = {}) {
- const payload = await apiRequest(store, getPath('items'), {
- includeKitchen: false,
- });
+ const payload = await apiRequest(store, getPath('items'));
if (Array.isArray(payload)) {
return payload;
@@ -35,7 +32,6 @@ export async function listStockEntries(store, filters = {}) {
export async function listGroupedStockEntries(store) {
const payload = await apiRequest(store, `${getPath('items')}/grouped`, {
- includeKitchen: false,
query: { expanded: 1 },
});
@@ -47,9 +43,7 @@ export async function listGroupedStockEntries(store) {
}
export async function getStockEntry(store, stockId) {
- const payload = await apiRequest(store, `${getPath('items')}/${stockId}`, {
- includeKitchen: false,
- });
+ const payload = await apiRequest(store, `${getPath('items')}/${stockId}`);
return unwrapEntryPayload(payload);
}
@@ -57,17 +51,47 @@ export async function createStockEntry(store, body) {
const payload = await apiRequest(store, getPath('items'), {
method: 'POST',
body,
- includeKitchen: false,
query: { label: 1, print: 1 },
});
return unwrapEntryPayload(payload);
}
+function normalizeUpsertResponse(payload) {
+ return {
+ status: payload?.status || null,
+ mode: payload?.mode || null,
+ operation: payload?.operation || null,
+ matchType: payload?.match_type || null,
+ matchedItem: payload?.matched_item || null,
+ item: payload?.item || null,
+ payload: payload?.payload || null,
+ };
+}
+
+export async function previewItemUpsert(store, body) {
+ const payload = await apiRequest(store, `${getPath('items')}/upsert`, {
+ method: 'POST',
+ body,
+ query: { mode: 'preview' },
+ });
+
+ return normalizeUpsertResponse(payload);
+}
+
+export async function applyItemUpsert(store, body) {
+ const payload = await apiRequest(store, `${getPath('items')}/upsert`, {
+ method: 'POST',
+ body,
+ query: { mode: 'apply' },
+ });
+
+ return normalizeUpsertResponse(payload);
+}
+
export async function updateStockItem(store, uuidB64, body) {
const payload = await apiRequest(store, `${getPath('items')}/${uuidB64}/stock`, {
method: 'POST',
body,
- includeKitchen: false,
});
return unwrapEntryPayload(payload);
}
@@ -75,16 +99,50 @@ export async function updateStockItem(store, uuidB64, body) {
export async function deleteStockItem(store, uuidB64) {
const payload = await apiRequest(store, `${getPath('items')}/${uuidB64}`, {
method: 'DELETE',
- includeKitchen: false,
});
return unwrapEntryPayload(payload);
}
+export async function useStockItem(store, uuidB64) {
+ try {
+ await apiRequest(store, `${getPath('items')}/${uuidB64}/use`, {
+ method: 'POST',
+ });
+ return { status: 'used' };
+ } catch (error) {
+ const status = error?.status || error?.cause?.status;
+ if (status === 409) {
+ return { status: 'already_gone' };
+ }
+
+ if (status === 404 || status === 405) {
+ await deleteStockItem(store, uuidB64);
+ return { status: 'fallback_delete' };
+ }
+
+ throw error;
+ }
+}
+
export async function adjustStockEntry(store, stockId, body) {
const payload = await apiRequest(store, `${getPath('items')}/${stockId}/stock`, {
method: 'POST',
body,
- includeKitchen: false,
});
return unwrapEntryPayload(payload);
}
+
+export async function listKitchenChanges(store, { since, limit = 10 } = {}) {
+ const payload = await apiRequest(store, getPath('changes'), {
+ query: {
+ since,
+ limit,
+ },
+ });
+
+ return {
+ since: payload?.since || null,
+ nextCursor: payload?.next_cursor || null,
+ changes: Array.isArray(payload?.changes) ? payload.changes : [],
+ };
+}
diff --git a/src/app/bootstrap.js b/src/app/bootstrap.js
index f58d23b..1d95127 100644
--- a/src/app/bootstrap.js
+++ b/src/app/bootstrap.js
@@ -2,7 +2,7 @@ import Alpine from 'alpinejs';
import { logout, restoreSession, verifyConnection } from '../api/auth.js';
import { listKitchens } from '../api/kitchens.js';
-import { APP_NAME } from './config.js';
+import { APP_NAME, APP_VERSION } from './config.js';
import { createRouter, navigate } from './router.js';
import { createAppStore } from './store.js';
import { appShell } from '../components/app-shell.js';
@@ -30,7 +30,11 @@ export function bootstrapApp() {
registerFeatureData(Alpine, store);
const appRoot = document.querySelector('#app');
- appRoot.innerHTML = appShell(APP_NAME);
+ appRoot.innerHTML = appShell(
+ APP_NAME,
+ APP_VERSION,
+ import.meta.env.DEV ? 'development' : 'production',
+ );
Alpine.initTree(appRoot);
const navRoot = document.querySelector('#app-nav');
diff --git a/src/app/config.js b/src/app/config.js
index 76e3b0a..26ecc8b 100644
--- a/src/app/config.js
+++ b/src/app/config.js
@@ -1,4 +1,5 @@
export const APP_NAME = 'Lonc';
+export const APP_VERSION = typeof __APP_VERSION__ !== 'undefined' ? __APP_VERSION__ : '0.1.2';
export const TRYTON_APPLICATION = 'kitchen';
export const CONNECTION_STATES = {
@@ -24,8 +25,8 @@ export const API_PATHS = {
userApplication: 'user/application/',
kitchens: 'kitchen/kitchens',
items: 'kitchen/items',
- stockEntries: 'stock',
locations: 'kitchen/locations',
+ changes: 'kitchen/changes',
};
export const ROUTES = {
diff --git a/src/components/app-shell.js b/src/components/app-shell.js
index e20218c..094811e 100644
--- a/src/components/app-shell.js
+++ b/src/components/app-shell.js
@@ -1,12 +1,22 @@
import { navBar } from './nav-bar.js';
-export function appShell(appName) {
+export function appShell(appName, appVersion, runtimeMode) {
+ const currentYear = new Date().getFullYear();
+
return `
${navBar(appName)}
+
diff --git a/src/features/dashboard/dashboard-page.js b/src/features/dashboard/dashboard-page.js
index a78cfc2..822ad70 100644
--- a/src/features/dashboard/dashboard-page.js
+++ b/src/features/dashboard/dashboard-page.js
@@ -1,6 +1,10 @@
+import { fetchLocations } from '../../api/locations.js';
+import { getStockEntry, listKitchenChanges } from '../../api/stock.js';
+import { createAsyncState, runAsyncState } from '../shared/ui-state.js';
+
export function renderDashboardPage() {
return `
-
+
@@ -80,6 +84,52 @@ export function renderDashboardPage() {
+
+
+
+
+
+
+
+
Recent changes
+
Latest item and stock updates from the kitchen change feed.
+
Saved means the backend created or updated a record.
+
+
+
+
+
+
+
+
+
+ Loading changes...
+
+
+
+ No recent changes yet.
+
+
+
+
+
+
+
+
+
`;
}
@@ -87,10 +137,191 @@ export function renderDashboardPage() {
export function dashboardPageData(store) {
return {
showKitchenPicker: false,
+ changesState: createAsyncState(),
+ recentChanges: [],
+ locationLabelByUuid: {},
+ itemByUuid: {},
+ async init() {
+ if (!store.isConnected) {
+ return;
+ }
+
+ await this.refreshChanges();
+ },
+ async refreshChanges() {
+ await runAsyncState(this.changesState, async () => {
+ const payload = await listKitchenChanges(store, { limit: 10 });
+ this.recentChanges = payload.changes;
+ await this.loadContextForChanges(payload.changes);
+ }).catch(() => {});
+ },
+ async loadContextForChanges(changes) {
+ const stockItemUuids = Array.from(new Set(
+ changes
+ .map((change) => change?.stock?.item_uuid_b64)
+ .filter(Boolean),
+ ));
+ const missingItemUuids = stockItemUuids.filter((uuid) => !this.itemByUuid[uuid]);
+
+ if (missingItemUuids.length) {
+ const results = await Promise.allSettled(
+ missingItemUuids.map((uuid) => getStockEntry(store, uuid)),
+ );
+
+ results.forEach((result) => {
+ if (result.status !== 'fulfilled' || !result.value?.uuid_b64) {
+ return;
+ }
+
+ this.itemByUuid[result.value.uuid_b64] = result.value;
+ });
+ }
+
+ if (Object.keys(this.locationLabelByUuid).length) {
+ return;
+ }
+
+ try {
+ const { flat } = await fetchLocations(store);
+ this.locationLabelByUuid = Object.fromEntries(
+ flat
+ .filter((location) => location.uuid_b64)
+ .map((location) => [location.uuid_b64, location.pathLabel || location.name]),
+ );
+ } catch {
+ this.locationLabelByUuid = {};
+ }
+ },
setKitchen(kitchen) {
store.setActiveKitchen(kitchen);
this.showKitchenPicker = false;
store.addAlert({ type: 'success', message: `Working in ${kitchen.name}.` });
+ this.locationLabelByUuid = {};
+ this.itemByUuid = {};
+ this.refreshChanges();
+ },
+ resolveItemForChange(change) {
+ if (change?.item?.uuid_b64) {
+ return change.item;
+ }
+
+ const stockItemUuid = change?.stock?.item_uuid_b64;
+ if (!stockItemUuid) {
+ return null;
+ }
+
+ return this.itemByUuid[stockItemUuid] || null;
+ },
+ humanStockType(value) {
+ if (!value) {
+ return null;
+ }
+
+ return value.charAt(0).toUpperCase() + value.slice(1);
+ },
+ formatQuantity(quantity, uomSymbol) {
+ if (quantity === null || quantity === undefined || quantity === '') {
+ return null;
+ }
+
+ return `${quantity}${uomSymbol ? ` ${uomSymbol}` : ''}`;
+ },
+ formatLevel(level) {
+ if (!level) {
+ return null;
+ }
+
+ return level.charAt(0).toUpperCase() + level.slice(1);
+ },
+ formatShortDate(value) {
+ if (!value) {
+ return null;
+ }
+
+ const date = new Date(value);
+ if (Number.isNaN(date.getTime())) {
+ return String(value);
+ }
+
+ return date.toLocaleDateString();
+ },
+ resolveLocationLabel(change, item) {
+ const locationUuid =
+ change?.stock?.location_uuid_b64 ||
+ item?.location_initial_uuid_b64 ||
+ null;
+ if (!locationUuid) {
+ return null;
+ }
+
+ return this.locationLabelByUuid[locationUuid] || locationUuid;
+ },
+ changeHeadline(change) {
+ const item = this.resolveItemForChange(change);
+ const itemName = item?.name || 'Unknown item';
+ const type = String(change?.type || 'change');
+ const action = String(change?.action || 'updated');
+
+ if (action === 'upsert' && type === 'item') {
+ return `Item saved: ${itemName}`;
+ }
+
+ if (action === 'upsert' && type === 'stock') {
+ return `Stock saved: ${itemName}`;
+ }
+
+ return `${type} ${action}: ${itemName}`;
+ },
+ changeStateLine(change) {
+ const item = this.resolveItemForChange(change);
+ const stock = change?.stock || {};
+ const state = [];
+
+ const stockType = this.humanStockType(item?.stock_type);
+ if (stockType) {
+ state.push(`Type: ${stockType}`);
+ }
+
+ const quantity = this.formatQuantity(
+ stock.quantity ?? item?.quantity,
+ stock.uom_symbol || item?.uom_symbol,
+ );
+ if (quantity) {
+ state.push(`Quantity: ${quantity}`);
+ }
+
+ const level = this.formatLevel(stock.level || item?.level);
+ if (level) {
+ state.push(`Level: ${level}`);
+ }
+
+ const expiry = this.formatShortDate(item?.expire_date);
+ if (expiry) {
+ state.push(`Expires: ${expiry}`);
+ }
+
+ const location = this.resolveLocationLabel(change, item);
+ if (location) {
+ state.push(`Location: ${location}`);
+ }
+
+ if (!state.length) {
+ return 'Saved (created or updated).';
+ }
+
+ return state.join(' • ');
+ },
+ formatChangeTimestamp(value) {
+ if (!value) {
+ return 'Unknown time';
+ }
+
+ const date = new Date(value);
+ if (Number.isNaN(date.getTime())) {
+ return String(value);
+ }
+
+ return date.toLocaleString();
},
};
}
diff --git a/src/features/labels/label-create-page.js b/src/features/labels/label-create-page.js
index f9cb2d8..a7c09e6 100644
--- a/src/features/labels/label-create-page.js
+++ b/src/features/labels/label-create-page.js
@@ -1,6 +1,14 @@
-import { createStockEntry, searchItemDefinitions } from '../../api/stock.js';
+import {
+ applyItemUpsert,
+ previewItemUpsert,
+ searchItemDefinitions,
+} from '../../api/stock.js';
import { fetchLocations } from '../../api/locations.js';
-import { previewLabel } from '../../api/labels.js';
+import {
+ formatPrintErrorMessage,
+ previewLabel,
+ printItemLabel,
+} from '../../api/labels.js';
import { STORAGE_KEYS } from '../../app/config.js';
import { debounce, normalizeValidationError } from '../shared/form-utils.js';
import { loadStoredValue, saveStoredValue } from '../shared/storage.js';
@@ -400,18 +408,36 @@ export function renderLabelCreatePage() {
-
-
-
@@ -83,12 +190,23 @@ export function renderStockDetailPage() {
+
+
+
Save quantity
Saving...
+
+ Print label
+ Printing...
+
Mark gone
Removing...
@@ -114,12 +232,23 @@ export function renderStockDetailPage() {
+
+
+
Save stock level
Saving...
+
+ Print label
+ Printing...
+
Mark gone
Removing...
@@ -137,11 +266,22 @@ export function renderStockDetailPage() {
+
+
+
Mark gone
Removing...
+
+ Print label
+ Printing...
+
@@ -157,7 +297,13 @@ export function stockDetailPageData(store) {
return {
state: createAsyncState(),
adjustmentState: createAsyncState(),
+ printState: createAsyncState(),
+ printFeedback: {
+ type: '',
+ message: '',
+ },
entry: null,
+ locationPathByUuid: {},
adjustment: {
mode: 'increment',
quantity: '1',
@@ -170,7 +316,16 @@ export function stockDetailPageData(store) {
const { params } = getRouteContext();
await runAsyncState(this.state, async () => {
- this.entry = await getStockEntry(store, params.id);
+ const [entry, locations] = await Promise.all([
+ getStockEntry(store, params.id),
+ fetchLocations(store).catch(() => ({ flat: [] })),
+ ]);
+ this.entry = entry;
+ 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(() => {});
},
@@ -207,7 +362,7 @@ export function stockDetailPageData(store) {
await runAsyncState(this.adjustmentState, async () => {
if (this.adjustment.level === 'gone') {
const entryName = this.entry.name;
- await deleteStockItem(store, this.entry.uuid_b64);
+ await useStockItem(store, this.entry.uuid_b64);
store.addAlert({ type: 'success', message: `${entryName} was marked gone.` });
window.__loncApp.navigate('/stock');
return;
@@ -225,16 +380,155 @@ export function stockDetailPageData(store) {
}
await runAsyncState(this.adjustmentState, async () => {
- await deleteStockItem(store, this.entry.uuid_b64);
- store.addAlert({ type: 'success', message: `${this.entry.name} was marked gone.` });
+ 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();
},
diff --git a/src/features/stock/stock-list-page.js b/src/features/stock/stock-list-page.js
index df5cd5c..677091e 100644
--- a/src/features/stock/stock-list-page.js
+++ b/src/features/stock/stock-list-page.js
@@ -1,8 +1,8 @@
import {
- deleteStockItem,
listGroupedStockEntries,
listStockEntries,
updateStockItem,
+ useStockItem,
} from '../../api/stock.js';
import { fetchLocations } from '../../api/locations.js';
import { createAsyncState, runAsyncState } from '../shared/ui-state.js';
@@ -660,7 +660,7 @@ export function renderStockListPage() {
@@ -1204,12 +1209,12 @@ export function stockListPageData(store) {
},
formatDate,
async updateBinary(entry, level) {
- await this.deleteEntry(entry);
+ await this.useEntry(entry);
},
async saveLevel(entry) {
const level = this.editForms[entry.id]?.level || 'plenty';
if (level === 'gone') {
- await this.deleteEntry(entry);
+ await this.useEntry(entry);
return;
}
@@ -1229,7 +1234,27 @@ export function stockListPageData(store) {
}, { quantity });
},
async markGone(entry) {
- await this.deleteEntry(entry);
+ await this.useEntry(entry);
+ },
+ async markGoneFromGroup(item, group) {
+ this.editErrors[item.id] = '';
+
+ try {
+ const result = await useStockItem(store, item.uuid_b64);
+ const alreadyGone = result.status === 'already_gone';
+ this.removeGroupedItem(group.id, item.id);
+ this.entries = this.entries.filter((candidate) => candidate.id !== item.id);
+ delete this.editForms[item.id];
+ delete this.editErrors[item.id];
+ store.addAlert({
+ type: alreadyGone ? 'info' : 'success',
+ message: alreadyGone
+ ? `${item.name} was already out of stock and removed from the group.`
+ : `${item.name} was marked gone and removed from the group.`,
+ });
+ } catch (error) {
+ this.editErrors[item.id] = error.message || 'Mark gone failed.';
+ }
},
async saveEntryUpdate(entry, payload, localPatch) {
this.editErrors[entry.id] = '';
@@ -1245,20 +1270,23 @@ export function stockListPageData(store) {
this.editErrors[entry.id] = error.message || 'Update failed.';
}
},
- async deleteEntry(entry) {
+ async useEntry(entry) {
this.editErrors[entry.id] = '';
try {
- await deleteStockItem(store, entry.uuid_b64);
+ const result = await useStockItem(store, entry.uuid_b64);
this.entries = this.entries.filter((candidate) => candidate.id !== entry.id);
delete this.editForms[entry.id];
delete this.editErrors[entry.id];
+ const alreadyGone = result.status === 'already_gone';
store.addAlert({
- type: 'success',
- message: `${entry.name} was marked gone and removed from the list.`,
+ type: alreadyGone ? 'info' : 'success',
+ message: alreadyGone
+ ? `${entry.name} was already out of stock and removed from the list.`
+ : `${entry.name} was marked gone and removed from the list.`,
});
} catch (error) {
- this.editErrors[entry.id] = error.message || 'Delete failed.';
+ this.editErrors[entry.id] = error.message || 'Mark gone failed.';
}
},
replaceEntry(entryId, nextEntry) {
@@ -1270,5 +1298,24 @@ export function stockListPageData(store) {
quantity: nextEntry.quantity ?? '',
};
},
+ removeGroupedItem(groupId, itemId) {
+ this.groupedEntries = this.groupedEntries
+ .map((group) => {
+ if (group.id !== groupId) {
+ return group;
+ }
+
+ const nextItems = (group.items || []).filter((candidate) => candidate.id !== itemId);
+ if (!nextItems.length) {
+ return null;
+ }
+
+ return {
+ ...group,
+ items: nextItems,
+ };
+ })
+ .filter(Boolean);
+ },
};
}
diff --git a/src/styles/app.css b/src/styles/app.css
index 9179731..28d0d38 100644
--- a/src/styles/app.css
+++ b/src/styles/app.css
@@ -26,6 +26,45 @@ body {
position: relative;
}
+.app-footer {
+ background: rgba(255, 255, 255, 0.8);
+ backdrop-filter: blur(4px);
+}
+
+.input-group-label-submit .input-group-text {
+ background: rgba(255, 255, 255, 0.9);
+ white-space: nowrap;
+}
+
+.label-actions-row .btn {
+ white-space: nowrap;
+}
+
+.input-group-label-submit {
+ width: auto;
+ flex: 0 0 auto;
+}
+
+.input-group-label-submit .btn {
+ white-space: nowrap;
+}
+
+.label-action-btn {
+ white-space: nowrap !important;
+ flex: 0 0 auto;
+ min-width: max-content;
+}
+
+@media (min-width: 768px) {
+ .label-actions-row {
+ flex-wrap: nowrap !important;
+ }
+
+ .label-actions-primary {
+ flex-wrap: nowrap !important;
+ }
+}
+
.brand-mark {
display: inline-grid;
place-items: center;
@@ -642,6 +681,11 @@ button.legend-card:focus-visible {
justify-content: flex-start;
}
+.grouped-stock-mark-gone {
+ align-self: center;
+ white-space: nowrap;
+}
+
.grouped-stock-item-subline {
display: flex;
flex-wrap: wrap;
diff --git a/tests/api/client.test.js b/tests/api/client.test.js
index 3e0f8c6..8e85094 100644
--- a/tests/api/client.test.js
+++ b/tests/api/client.test.js
@@ -48,17 +48,15 @@ describe('api/client', () => {
it('returns configured path constants', () => {
expect(getPath('items')).toBe('kitchen/items');
expect(getPath('userApplication')).toBe('user/application/');
+ expect(getPath('changes')).toBe('kitchen/changes');
});
- it('builds kitchen urls with encoded path segments and query values', () => {
+ it('builds database-scoped kitchen urls with encoded query values', () => {
const store = createStore({
config: {
baseUrl: 'https://api.example.com',
database: 'my db',
},
- activeKitchen: {
- id: 'kitchen/01',
- },
});
const url = buildKitchenApiUrl(store, 'kitchen/items/grouped', {
@@ -68,7 +66,7 @@ describe('api/client', () => {
});
expect(url).toBe(
- 'https://api.example.com/my%20db/kitchen/kitchen%2F01/kitchen/items/grouped?search_name=Milk+%2B+eggs&expanded=1',
+ 'https://api.example.com/my%20db/kitchen/items/grouped?search_name=Milk+%2B+eggs&expanded=1',
);
});
@@ -97,7 +95,7 @@ describe('api/client', () => {
expect(payload).toEqual({ ok: true });
const [url, request] = fetchSpy.mock.calls[0];
- expect(url).toBe('/kitchen-db/kitchen/kitchen-1/kitchen/items?label=1');
+ expect(url).toBe('/kitchen-db/kitchen/items?label=1');
expect(request.method).toBe('POST');
expect(request.body).toBe('{"name":"Rice"}');
expect(request.headers.get('Accept')).toBe('application/json');
diff --git a/tests/api/labels.test.js b/tests/api/labels.test.js
new file mode 100644
index 0000000..cf1e8b9
--- /dev/null
+++ b/tests/api/labels.test.js
@@ -0,0 +1,23 @@
+import { describe, expect, it } from 'vitest';
+
+import { formatPrintErrorMessage } from '../../src/api/labels.js';
+
+describe('api/labels formatPrintErrorMessage', () => {
+ it('maps printer_unavailable payload to user-friendly message', () => {
+ const message = formatPrintErrorMessage({
+ status: 503,
+ payload: {
+ code: 'printer_unavailable',
+ message: 'Backend says unavailable',
+ details: { printer: 'Office Zebra' },
+ },
+ });
+
+ expect(message).toBe('Printer is unavailable. (printer: Office Zebra)');
+ });
+
+ it('falls back to generic message when payload is missing', () => {
+ const message = formatPrintErrorMessage(new Error('Something failed'));
+ expect(message).toBe('Something failed');
+ });
+});
diff --git a/tests/api/stock.test.js b/tests/api/stock.test.js
new file mode 100644
index 0000000..131f559
--- /dev/null
+++ b/tests/api/stock.test.js
@@ -0,0 +1,161 @@
+import { beforeEach, describe, expect, it, vi } from 'vitest';
+
+const apiRequestMock = vi.fn();
+
+vi.mock('../../src/api/client.js', () => ({
+ getPath(key) {
+ const paths = {
+ items: 'kitchen/items',
+ changes: 'kitchen/changes',
+ };
+ return paths[key];
+ },
+ apiRequest: (...args) => apiRequestMock(...args),
+}));
+
+const {
+ applyItemUpsert,
+ listKitchenChanges,
+ previewItemUpsert,
+ useStockItem,
+} = await import('../../src/api/stock.js');
+
+describe('api/stock', () => {
+ beforeEach(() => {
+ apiRequestMock.mockReset();
+ });
+
+ it('listKitchenChanges returns normalized changes payload', async () => {
+ apiRequestMock.mockResolvedValueOnce({
+ since: 'cursor-1',
+ next_cursor: 'cursor-2',
+ changes: [{ type: 'item', action: 'upsert', timestamp: '2026-04-10T10:00:00Z' }],
+ });
+
+ const result = await listKitchenChanges({ config: { database: 'db' } }, { limit: 10 });
+
+ expect(result).toEqual({
+ since: 'cursor-1',
+ nextCursor: 'cursor-2',
+ changes: [{ type: 'item', action: 'upsert', timestamp: '2026-04-10T10:00:00Z' }],
+ });
+ expect(apiRequestMock).toHaveBeenCalledWith(
+ { config: { database: 'db' } },
+ 'kitchen/changes',
+ { query: { since: undefined, limit: 10 } },
+ );
+ });
+
+ it('listKitchenChanges falls back to empty shape when changes are missing', async () => {
+ apiRequestMock.mockResolvedValueOnce({});
+
+ const result = await listKitchenChanges({ config: { database: 'db' } }, {});
+
+ expect(result).toEqual({
+ since: null,
+ nextCursor: null,
+ changes: [],
+ });
+ });
+
+ it('previewItemUpsert normalizes preview response', async () => {
+ apiRequestMock.mockResolvedValueOnce({
+ status: 'ok',
+ mode: 'preview',
+ operation: 'update',
+ match_type: 'uuid_b64',
+ matched_item: { uuid_b64: 'abc', name: 'Rice' },
+ payload: { name: 'Rice' },
+ });
+
+ const response = await previewItemUpsert({ config: { database: 'db' } }, { item: { name: 'Rice' } });
+
+ expect(response).toEqual({
+ status: 'ok',
+ mode: 'preview',
+ operation: 'update',
+ matchType: 'uuid_b64',
+ matchedItem: { uuid_b64: 'abc', name: 'Rice' },
+ item: null,
+ payload: { name: 'Rice' },
+ });
+ expect(apiRequestMock).toHaveBeenCalledWith(
+ { config: { database: 'db' } },
+ 'kitchen/items/upsert',
+ {
+ method: 'POST',
+ body: { item: { name: 'Rice' } },
+ query: { mode: 'preview' },
+ },
+ );
+ });
+
+ it('applyItemUpsert normalizes apply response', async () => {
+ apiRequestMock.mockResolvedValueOnce({
+ status: 'ok',
+ mode: 'apply',
+ operation: 'create',
+ match_type: null,
+ item: { uuid_b64: 'new1', name: 'Beans' },
+ });
+
+ const response = await applyItemUpsert({ config: { database: 'db' } }, { item: { name: 'Beans' } });
+
+ expect(response).toEqual({
+ status: 'ok',
+ mode: 'apply',
+ operation: 'create',
+ matchType: null,
+ matchedItem: null,
+ item: { uuid_b64: 'new1', name: 'Beans' },
+ payload: null,
+ });
+ });
+
+ it('useStockItem returns used on 204', async () => {
+ apiRequestMock.mockResolvedValueOnce(null);
+
+ const result = await useStockItem({ config: { database: 'db' } }, 'item-1');
+
+ expect(result).toEqual({ status: 'used' });
+ expect(apiRequestMock).toHaveBeenCalledWith(
+ { config: { database: 'db' } },
+ 'kitchen/items/item-1/use',
+ { method: 'POST' },
+ );
+ });
+
+ it('useStockItem returns already_gone on 409', async () => {
+ apiRequestMock.mockRejectedValueOnce({ status: 409, message: 'Item is out of stock.' });
+
+ const result = await useStockItem({ config: { database: 'db' } }, 'item-1');
+
+ expect(result).toEqual({ status: 'already_gone' });
+ });
+
+ it('useStockItem falls back to delete on 404/405', async () => {
+ apiRequestMock
+ .mockRejectedValueOnce({ status: 404 })
+ .mockResolvedValueOnce(null);
+
+ const result = await useStockItem({ config: { database: 'db' } }, 'item-1');
+
+ expect(result).toEqual({ status: 'fallback_delete' });
+ expect(apiRequestMock).toHaveBeenNthCalledWith(
+ 2,
+ { config: { database: 'db' } },
+ 'kitchen/items/item-1',
+ { method: 'DELETE' },
+ );
+ });
+
+ it('useStockItem does not fallback on unrelated client errors', async () => {
+ apiRequestMock.mockRejectedValueOnce({ status: 422, message: 'validation_error' });
+
+ await expect(useStockItem({ config: { database: 'db' } }, 'item-1')).rejects.toMatchObject({
+ status: 422,
+ message: 'validation_error',
+ });
+ expect(apiRequestMock).toHaveBeenCalledTimes(1);
+ });
+});
diff --git a/tests/features/dashboard/dashboard-page.test.js b/tests/features/dashboard/dashboard-page.test.js
new file mode 100644
index 0000000..18c6c9a
--- /dev/null
+++ b/tests/features/dashboard/dashboard-page.test.js
@@ -0,0 +1,143 @@
+import { describe, expect, it, vi } from 'vitest';
+
+const listKitchenChangesMock = vi.fn();
+const getStockEntryMock = vi.fn();
+const fetchLocationsMock = vi.fn();
+
+vi.mock('../../../src/api/stock.js', () => ({
+ listKitchenChanges: (...args) => listKitchenChangesMock(...args),
+ getStockEntry: (...args) => getStockEntryMock(...args),
+}));
+
+vi.mock('../../../src/api/locations.js', () => ({
+ fetchLocations: (...args) => fetchLocationsMock(...args),
+}));
+
+const { dashboardPageData, renderDashboardPage } = await import('../../../src/features/dashboard/dashboard-page.js');
+
+describe('features/dashboard/dashboard-page', () => {
+ it('renders dashboard with recent changes section', () => {
+ const html = renderDashboardPage();
+ expect(html).toContain('Recent changes');
+ expect(html).toContain('x-data="dashboardPage()"');
+ expect(html).toContain('Saved means the backend created or updated a record.');
+ });
+
+ it('loads recent changes on init and renders item-focused state lines', async () => {
+ listKitchenChangesMock.mockResolvedValueOnce({
+ since: null,
+ nextCursor: null,
+ changes: [{
+ type: 'item',
+ action: 'upsert',
+ timestamp: '2026-04-10T10:00:00Z',
+ item: {
+ uuid_b64: 'u1',
+ name: 'Rice',
+ stock_type: 'measured',
+ quantity: 3,
+ uom_symbol: 'kg',
+ level: 'good',
+ expire_date: '2026-04-21',
+ location_initial_uuid_b64: 'loc1',
+ },
+ }],
+ });
+ fetchLocationsMock.mockResolvedValueOnce({
+ flat: [{ uuid_b64: 'loc1', pathLabel: 'Pantry / Shelf A' }],
+ });
+
+ const store = {
+ isConnected: true,
+ setActiveKitchen: vi.fn(),
+ addAlert: vi.fn(),
+ };
+ const data = dashboardPageData(store);
+
+ await data.init();
+
+ expect(listKitchenChangesMock).toHaveBeenCalledWith(store, { limit: 10 });
+ expect(data.recentChanges).toHaveLength(1);
+ expect(data.changesState.error).toBe('');
+ expect(data.changeHeadline(data.recentChanges[0])).toBe('Item saved: Rice');
+ expect(data.changeStateLine(data.recentChanges[0])).toContain('Quantity: 3 kg');
+ expect(data.changeStateLine(data.recentChanges[0])).toContain('Location: Pantry / Shelf A');
+ expect(getStockEntryMock).not.toHaveBeenCalled();
+ });
+
+ it('resolves stock event item context via item lookup when needed', async () => {
+ listKitchenChangesMock.mockResolvedValueOnce({
+ since: null,
+ nextCursor: null,
+ changes: [{
+ type: 'stock',
+ action: 'upsert',
+ timestamp: '2026-04-10T10:00:00Z',
+ stock: {
+ item_uuid_b64: 'item-uuid-1',
+ quantity: 0.5,
+ uom_symbol: 'kg',
+ level: 'some',
+ location_uuid_b64: 'loc2',
+ },
+ }],
+ });
+ getStockEntryMock.mockResolvedValueOnce({
+ uuid_b64: 'item-uuid-1',
+ name: 'Flour',
+ stock_type: 'measured',
+ expire_date: '2026-05-02',
+ });
+ fetchLocationsMock.mockResolvedValueOnce({
+ flat: [{ uuid_b64: 'loc2', pathLabel: 'Pantry / Bin 2' }],
+ });
+
+ const data = dashboardPageData({
+ isConnected: true,
+ setActiveKitchen: vi.fn(),
+ addAlert: vi.fn(),
+ });
+
+ await data.refreshChanges();
+
+ expect(data.changeHeadline(data.recentChanges[0])).toBe('Stock saved: Flour');
+ expect(data.changeStateLine(data.recentChanges[0])).toContain('Quantity: 0.5 kg');
+ expect(data.changeStateLine(data.recentChanges[0])).toContain('Level: Some');
+ expect(data.changeStateLine(data.recentChanges[0])).toContain('Location: Pantry / Bin 2');
+ expect(getStockEntryMock).toHaveBeenCalledWith(expect.anything(), 'item-uuid-1');
+ });
+
+ it('keeps empty state when API returns no changes', async () => {
+ listKitchenChangesMock.mockResolvedValueOnce({
+ since: null,
+ nextCursor: null,
+ changes: [],
+ });
+ fetchLocationsMock.mockResolvedValueOnce({ flat: [] });
+
+ const data = dashboardPageData({
+ isConnected: true,
+ setActiveKitchen: vi.fn(),
+ addAlert: vi.fn(),
+ });
+
+ await data.refreshChanges();
+
+ expect(data.recentChanges).toEqual([]);
+ expect(data.changesState.error).toBe('');
+ });
+
+ it('captures refresh errors in async state', async () => {
+ listKitchenChangesMock.mockRejectedValueOnce(new Error('Feed unavailable'));
+ const data = dashboardPageData({
+ isConnected: true,
+ setActiveKitchen: vi.fn(),
+ addAlert: vi.fn(),
+ });
+
+ await data.refreshChanges();
+
+ expect(data.changesState.error).toBe('Feed unavailable');
+ expect(data.recentChanges).toEqual([]);
+ });
+});
diff --git a/tests/features/labels/upsert-submit.test.js b/tests/features/labels/upsert-submit.test.js
new file mode 100644
index 0000000..5a42434
--- /dev/null
+++ b/tests/features/labels/upsert-submit.test.js
@@ -0,0 +1,133 @@
+import { describe, expect, it, vi } from 'vitest';
+
+const applyItemUpsertMock = vi.fn();
+const previewItemUpsertMock = vi.fn();
+const printItemLabelMock = vi.fn();
+
+vi.mock('../../../src/api/stock.js', () => ({
+ applyItemUpsert: (...args) => applyItemUpsertMock(...args),
+ previewItemUpsert: (...args) => previewItemUpsertMock(...args),
+ searchItemDefinitions: vi.fn(async () => []),
+}));
+
+vi.mock('../../../src/api/labels.js', () => ({
+ previewLabel: vi.fn(async () => ({ objectUrl: 'blob:preview' })),
+ printItemLabel: (...args) => printItemLabelMock(...args),
+ formatPrintErrorMessage: (error) => error?.message || 'Printing failed.',
+}));
+
+vi.mock('../../../src/api/locations.js', () => ({
+ fetchLocations: vi.fn(async () => ({ flat: [], tree: [] })),
+}));
+
+const { labelCreatePageData } = await import('../../../src/features/labels/label-create-page.js');
+
+describe('label create upsert-first submit', () => {
+ it('defaults print checkbox to enabled', () => {
+ const data = labelCreatePageData({
+ isConnected: false,
+ activeKitchen: { id: 1 },
+ addAlert: vi.fn(),
+ });
+ expect(data.printLabelOnSave).toBe(true);
+ });
+
+ it('builds upsert payload with selected template uuid', () => {
+ const store = {
+ isConnected: false,
+ activeKitchen: { id: 7 },
+ addAlert: vi.fn(),
+ };
+ const data = labelCreatePageData(store);
+ data.form = {
+ ...data.form,
+ itemUuidB64: 'uuid-template-1',
+ name: 'Beans',
+ description: 'Dry beans',
+ stockType: 'measured',
+ quantity: '2',
+ uom: 'kg',
+ level: '',
+ productionDate: '2026-04-10',
+ expirationDate: '2026-08-10',
+ locationId: '',
+ identifierCode: '12345',
+ };
+
+ const payload = data.buildUpsertPayload();
+
+ expect(payload.uuid_b64).toBe('uuid-template-1');
+ expect(payload.identifier_code).toBe('12345');
+ expect(payload.item.name).toBe('Beans');
+ expect(payload.item.quantity_initial).toBe(2);
+ });
+
+ it('create uses applyItemUpsert and sets operation-aware success message', async () => {
+ applyItemUpsertMock.mockResolvedValueOnce({
+ operation: 'update',
+ item: { name: 'Rice', uuid_b64: 'uuid-rice-1' },
+ });
+ printItemLabelMock.mockResolvedValueOnce(null);
+
+ const addAlert = vi.fn();
+ const store = {
+ isConnected: false,
+ activeKitchen: { id: 3 },
+ addAlert,
+ };
+ const data = labelCreatePageData(store);
+ data.validateBeforeSubmit = () => true;
+ data.form = {
+ ...data.form,
+ name: 'Rice',
+ stockType: 'binary',
+ locationId: '',
+ productionDate: '2026-04-10',
+ itemUuidB64: 'uuid-rice-1',
+ };
+
+ await data.create();
+
+ expect(applyItemUpsertMock).toHaveBeenCalledTimes(1);
+ expect(applyItemUpsertMock.mock.calls[0][1].uuid_b64).toBe('uuid-rice-1');
+ expect(printItemLabelMock).toHaveBeenCalledWith(store, 'uuid-rice-1');
+ expect(data.successMessage).toBe('Rice was updated successfully.');
+ expect(addAlert).toHaveBeenCalledWith({
+ type: 'success',
+ message: 'Rice was updated successfully.',
+ });
+ });
+
+ it('create shows parsed print issue warning when printing fails', async () => {
+ applyItemUpsertMock.mockResolvedValueOnce({
+ operation: 'create',
+ item: { name: 'Beans', uuid_b64: 'uuid-beans-1' },
+ });
+ printItemLabelMock.mockRejectedValueOnce(new Error('Printer is unavailable.'));
+
+ const addAlert = vi.fn();
+ const store = {
+ isConnected: false,
+ activeKitchen: { id: 3 },
+ addAlert,
+ };
+ const data = labelCreatePageData(store);
+ data.validateBeforeSubmit = () => true;
+ data.form = {
+ ...data.form,
+ name: 'Beans',
+ stockType: 'binary',
+ locationId: '',
+ productionDate: '2026-04-10',
+ itemUuidB64: '',
+ };
+
+ await data.create();
+
+ expect(data.printIssue).toBe('Printer is unavailable.');
+ expect(addAlert).toHaveBeenCalledWith({
+ type: 'warning',
+ message: 'Beans was created, but printing has an issue: Printer is unavailable.',
+ });
+ });
+});
diff --git a/tests/features/stock/mark-gone.test.js b/tests/features/stock/mark-gone.test.js
new file mode 100644
index 0000000..acd9370
--- /dev/null
+++ b/tests/features/stock/mark-gone.test.js
@@ -0,0 +1,71 @@
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
+
+const useStockItemMock = vi.fn();
+const getStockEntryMock = vi.fn();
+
+vi.mock('../../../src/api/stock.js', () => ({
+ useStockItem: (...args) => useStockItemMock(...args),
+ getStockEntry: (...args) => getStockEntryMock(...args),
+ adjustStockEntry: vi.fn(),
+ listStockEntries: vi.fn(),
+ listGroupedStockEntries: vi.fn(),
+ updateStockItem: vi.fn(),
+}));
+
+vi.mock('../../../src/api/locations.js', () => ({
+ fetchLocations: vi.fn(async () => ({ flat: [], tree: [] })),
+}));
+
+const { stockDetailPageData } = await import('../../../src/features/stock/stock-detail-page.js');
+const { stockListPageData } = await import('../../../src/features/stock/stock-list-page.js');
+
+describe('stock mark-gone behavior', () => {
+ beforeEach(() => {
+ useStockItemMock.mockReset();
+ getStockEntryMock.mockReset();
+ globalThis.window = {
+ __loncApp: {
+ navigate: vi.fn(),
+ },
+ };
+ });
+
+ afterEach(() => {
+ vi.restoreAllMocks();
+ delete globalThis.window;
+ });
+
+ it('stock detail markGone uses /use and shows info for already gone', async () => {
+ useStockItemMock.mockResolvedValueOnce({ status: 'already_gone' });
+ const addAlert = vi.fn();
+ const data = stockDetailPageData({ addAlert });
+ data.entry = { uuid_b64: 'item-1', name: 'Rice' };
+
+ await data.markGone();
+
+ expect(useStockItemMock).toHaveBeenCalledWith({ addAlert }, 'item-1');
+ expect(addAlert).toHaveBeenCalledWith({
+ type: 'info',
+ message: 'Rice was already out of stock.',
+ });
+ expect(globalThis.window.__loncApp.navigate).toHaveBeenCalledWith('/stock');
+ });
+
+ it('stock list markGone removes entry and uses /use path', async () => {
+ useStockItemMock.mockResolvedValueOnce({ status: 'used' });
+ const addAlert = vi.fn();
+ const data = stockListPageData({ addAlert, isConnected: false });
+ data.entries = [{ id: 1, uuid_b64: 'item-1', name: 'Flour' }];
+ data.editForms = { 1: { level: 'plenty', quantity: 1 } };
+ data.editErrors = {};
+
+ await data.markGone(data.entries[0]);
+
+ expect(useStockItemMock).toHaveBeenCalledWith({ addAlert, isConnected: false }, 'item-1');
+ expect(data.entries).toEqual([]);
+ expect(addAlert).toHaveBeenCalledWith({
+ type: 'success',
+ message: 'Flour was marked gone and removed from the list.',
+ });
+ });
+});
diff --git a/tests/features/stock/print-label.test.js b/tests/features/stock/print-label.test.js
new file mode 100644
index 0000000..8caccf7
--- /dev/null
+++ b/tests/features/stock/print-label.test.js
@@ -0,0 +1,74 @@
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
+
+const printItemLabelMock = vi.fn();
+const formatPrintErrorMessageMock = vi.fn();
+
+vi.mock('../../../src/api/labels.js', () => ({
+ printItemLabel: (...args) => printItemLabelMock(...args),
+ formatPrintErrorMessage: (...args) => formatPrintErrorMessageMock(...args),
+}));
+
+vi.mock('../../../src/api/stock.js', () => ({
+ getStockEntry: vi.fn(),
+ adjustStockEntry: vi.fn(),
+ useStockItem: vi.fn(),
+ listStockEntries: vi.fn(async () => []),
+ listGroupedStockEntries: vi.fn(async () => []),
+ updateStockItem: vi.fn(),
+}));
+
+vi.mock('../../../src/api/locations.js', () => ({
+ fetchLocations: vi.fn(async () => ({ flat: [], tree: [] })),
+}));
+
+const { stockDetailPageData } = await import('../../../src/features/stock/stock-detail-page.js');
+
+describe('stock print label actions', () => {
+ beforeEach(() => {
+ printItemLabelMock.mockReset();
+ formatPrintErrorMessageMock.mockReset();
+ globalThis.window = {
+ __loncApp: {
+ navigate: vi.fn(),
+ },
+ };
+ });
+
+ afterEach(() => {
+ vi.restoreAllMocks();
+ delete globalThis.window;
+ });
+
+ it('prints from stock detail and shows success alert', async () => {
+ printItemLabelMock.mockResolvedValueOnce(null);
+ const addAlert = vi.fn();
+ const store = { addAlert };
+ const data = stockDetailPageData(store);
+ data.entry = { uuid_b64: 'uuid-1', name: 'Rice' };
+
+ await data.printLabel();
+
+ expect(printItemLabelMock).toHaveBeenCalledWith(store, 'uuid-1');
+ expect(addAlert).toHaveBeenCalledWith({
+ type: 'success',
+ message: 'Rice label sent to printer.',
+ });
+ });
+
+ it('shows parsed warning when detail printing fails', async () => {
+ printItemLabelMock.mockRejectedValueOnce(new Error('boom'));
+ formatPrintErrorMessageMock.mockReturnValueOnce('Printer unavailable.');
+ const addAlert = vi.fn();
+ const store = { addAlert };
+ const data = stockDetailPageData(store);
+ data.entry = { uuid_b64: 'uuid-1', name: 'Rice' };
+
+ await data.printLabel();
+
+ expect(addAlert).toHaveBeenCalledWith({
+ type: 'warning',
+ message: 'Could not print Rice label: Printer unavailable.',
+ });
+ });
+
+});
diff --git a/vite.config.js b/vite.config.js
index 0fd0f89..19cd7b0 100644
--- a/vite.config.js
+++ b/vite.config.js
@@ -1,6 +1,10 @@
import { defineConfig } from 'vite';
+import packageJson from './package.json';
export default defineConfig({
+ define: {
+ __APP_VERSION__: JSON.stringify(packageJson.version),
+ },
server: {
port: 4173,
},