`;
}
@@ -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..4b08c7d 100644
--- a/src/features/labels/label-create-page.js
+++ b/src/features/labels/label-create-page.js
@@ -1,4 +1,8 @@
-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 { STORAGE_KEYS } from '../../app/config.js';
@@ -400,6 +404,14 @@ export function renderLabelCreatePage() {
@@ -478,6 +490,10 @@ function diffDays(fromIsoDate, toIsoDate) {
function createDefaultForm() {
return {
itemId: '',
+ itemUuidB64: '',
+ identifierCode: '',
+ externalSource: '',
+ externalId: '',
search: '',
name: '',
description: '',
@@ -505,6 +521,10 @@ function loadLabelDraft() {
? ''
: draft.quantity,
itemId: '',
+ itemUuidB64: '',
+ identifierCode: '',
+ externalSource: '',
+ externalId: '',
search: '',
};
}
@@ -513,6 +533,10 @@ function buildDraftPayload(form) {
return {
...form,
itemId: '',
+ itemUuidB64: '',
+ identifierCode: '',
+ externalSource: '',
+ externalId: '',
search: '',
};
}
@@ -536,6 +560,7 @@ export function labelCreatePageData(store) {
successMessage: '',
submitError: '',
fieldErrors: {},
+ upsertPreview: null,
form: {
...loadLabelDraft(),
},
@@ -590,6 +615,14 @@ export function labelCreatePageData(store) {
}
},
onSearchInput() {
+ this.upsertPreview = null;
+ if (this.form.itemUuidB64 || this.form.itemId) {
+ this.form.itemId = '';
+ this.form.itemUuidB64 = '';
+ this.form.identifierCode = '';
+ this.form.externalSource = '';
+ this.form.externalId = '';
+ }
this.persistDraft();
this.searchDebounced();
},
@@ -603,6 +636,10 @@ export function labelCreatePageData(store) {
: null;
this.form.itemId = item.id;
+ this.form.itemUuidB64 = item.uuid_b64 || '';
+ this.form.identifierCode = item.identifier_code || '';
+ this.form.externalSource = item.external_source || '';
+ this.form.externalId = item.external_id || '';
this.form.search = item.name;
this.form.name = item.name;
this.form.description = item.description || this.form.description;
@@ -623,7 +660,12 @@ export function labelCreatePageData(store) {
},
clearItemSearch() {
this.form.itemId = '';
+ this.form.itemUuidB64 = '';
+ this.form.identifierCode = '';
+ this.form.externalSource = '';
+ this.form.externalId = '';
this.form.search = '';
+ this.upsertPreview = null;
this.suggestions = [];
this.persistDraft();
},
@@ -908,6 +950,8 @@ export function labelCreatePageData(store) {
: null
: Number(this.form.quantity);
+ const selectedLocationUuidB64 = this.selectedLocation?.uuid_b64 || null;
+
return {
item_id: this.form.itemId || null,
name: this.form.name.trim(),
@@ -920,13 +964,51 @@ export function labelCreatePageData(store) {
level: this.form.stockType === 'measured' ? null : this.form.level || null,
date: this.form.productionDate || null,
expire_date: this.form.expirationDate || null,
- location_initial: this.form.locationId || null,
+ location_initial: selectedLocationUuidB64,
kitchen_id: store.activeKitchen?.id || null,
};
},
+ buildUpsertPayload() {
+ const basePayload = this.buildPayload();
+ const itemPayload = {
+ name: basePayload.name,
+ description: basePayload.description,
+ quantity_initial: basePayload.quantity_initial,
+ uom_symbol: basePayload.uom_symbol,
+ calories: basePayload.calories,
+ calories_unit: basePayload.calories_unit,
+ stock_type: basePayload.stock_type,
+ level: basePayload.level,
+ date: basePayload.date,
+ expire_date: basePayload.expire_date,
+ location_initial: basePayload.location_initial,
+ };
+
+ return {
+ uuid_b64: this.form.itemUuidB64 || null,
+ identifier_code: this.form.identifierCode || null,
+ external_source: this.form.externalSource || null,
+ external_id: this.form.externalId || null,
+ item: itemPayload,
+ };
+ },
+ upsertPreviewSummary() {
+ if (!this.upsertPreview || this.upsertPreview.error) {
+ return '';
+ }
+
+ if (this.upsertPreview.operation === 'update') {
+ const name = this.upsertPreview.matchedItem?.name || this.form.name;
+ const matchType = this.upsertPreview.matchType ? ` (matched by ${this.upsertPreview.matchType})` : '';
+ return `Submit will update: ${name}${matchType}.`;
+ }
+
+ return 'Submit will create a new stock item.';
+ },
async preview() {
this.submitError = '';
this.fieldErrors = {};
+ this.upsertPreview = null;
if (!this.validateBeforeSubmit()) {
this.previewState.error = 'Please fill out the required fields before previewing the label.';
@@ -940,6 +1022,13 @@ export function labelCreatePageData(store) {
URL.revokeObjectURL(this.previewUrl);
}
this.previewUrl = result.objectUrl;
+ try {
+ this.upsertPreview = await previewItemUpsert(store, this.buildUpsertPayload());
+ } catch (error) {
+ this.upsertPreview = {
+ error: error.message || 'Upsert preview failed.',
+ };
+ }
this.persistDraft();
});
},
@@ -948,22 +1037,25 @@ export function labelCreatePageData(store) {
this.fieldErrors = {};
if (!this.validateBeforeSubmit()) {
- this.submitError = 'Please fill out the required fields before creating the stock entry.';
+ this.submitError = 'Please fill out the required fields before saving the stock entry.';
return;
}
await runAsyncState(this.createState, async () => {
try {
- const entry = await createStockEntry(store, this.buildPayload());
+ const entry = await applyItemUpsert(store, this.buildUpsertPayload());
if (this.previewUrl && this.previewUrl.startsWith('blob:')) {
URL.revokeObjectURL(this.previewUrl);
}
this.previewUrl = '';
- this.successMessage = `${entry.name || this.form.name} was created successfully.`;
+ const entryName = entry.item?.name || this.form.name;
+ const operationVerb = entry.operation === 'update' ? 'updated' : 'created';
+ this.successMessage = `${entryName} was ${operationVerb} successfully.`;
store.addAlert({
type: 'success',
- message: `${entry.name || this.form.name} was created successfully.`,
+ message: `${entryName} was ${operationVerb} successfully.`,
});
+ this.upsertPreview = entry;
saveStoredValue(STORAGE_KEYS.labelDraft, buildDraftPayload(this.form));
} catch (error) {
this.fieldErrors = normalizeValidationError(error);
@@ -981,6 +1073,7 @@ export function labelCreatePageData(store) {
this.successMessage = '';
this.submitError = '';
this.fieldErrors = {};
+ this.upsertPreview = null;
saveStoredValue(STORAGE_KEYS.labelDraft, this.form);
if (revokePreview && this.previewUrl.startsWith('blob:')) {
URL.revokeObjectURL(this.previewUrl);
diff --git a/src/features/stock/stock-detail-page.js b/src/features/stock/stock-detail-page.js
index 457de0e..453329b 100644
--- a/src/features/stock/stock-detail-page.js
+++ b/src/features/stock/stock-detail-page.js
@@ -1,7 +1,7 @@
import {
adjustStockEntry,
- deleteStockItem,
getStockEntry,
+ useStockItem,
} from '../../api/stock.js';
import { getRouteContext } from '../../app/router.js';
import { createAsyncState, runAsyncState } from '../shared/ui-state.js';
@@ -207,7 +207,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,8 +225,14 @@ 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(() => {});
},
diff --git a/src/features/stock/stock-list-page.js b/src/features/stock/stock-list-page.js
index df5cd5c..bb85855 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';
@@ -1204,12 +1204,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 +1229,7 @@ export function stockListPageData(store) {
}, { quantity });
},
async markGone(entry) {
- await this.deleteEntry(entry);
+ await this.useEntry(entry);
},
async saveEntryUpdate(entry, payload, localPatch) {
this.editErrors[entry.id] = '';
@@ -1245,20 +1245,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) {
diff --git a/src/styles/app.css b/src/styles/app.css
index 9179731..f5665fc 100644
--- a/src/styles/app.css
+++ b/src/styles/app.css
@@ -26,6 +26,11 @@ body {
position: relative;
}
+.app-footer {
+ background: rgba(255, 255, 255, 0.8);
+ backdrop-filter: blur(4px);
+}
+
.brand-mark {
display: inline-grid;
place-items: center;
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/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..ed6acea
--- /dev/null
+++ b/tests/features/labels/upsert-submit.test.js
@@ -0,0 +1,86 @@
+import { describe, expect, it, vi } from 'vitest';
+
+const applyItemUpsertMock = vi.fn();
+const previewItemUpsertMock = 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' })),
+}));
+
+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('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' },
+ });
+
+ 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(data.successMessage).toBe('Rice was updated successfully.');
+ expect(addAlert).toHaveBeenCalledWith({
+ type: 'success',
+ message: 'Rice was updated successfully.',
+ });
+ });
+});
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/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,
},