diff --git a/README.md b/README.md index 1799285..9998788 100644 --- a/README.md +++ b/README.md @@ -163,30 +163,33 @@ Expected shapes today: Returns `{ data: [...] }` or `{ kitchens: [...] }`. - `GET /{database}/kitchen/items?search_name=...` Returns item definitions for autocomplete. + Item payloads now expose category links via `categories` (array of IDs). - `GET /{database}/kitchen/items` Returns the current stock review list. Endpoint is paginated (`limit`/`offset`, backend default `limit=100`); frontend helpers aggregate pages by default unless explicit pagination is passed. -- `GET /{database}/kitchen/items/grouped?expanded=0|1` - Returns grouped stock data; grouped review uses summary-first loading (`expanded=0`) and hydrates item children in background (`expanded=1`). - With `expanded=0`, child `items` can be ID-only stubs (`{ id }`) instead of full item payloads. +- `GET /{database}/kitchen/items/grouped?expanded=true|false` + Returns grouped stock data; grouped review uses summary-first loading (`expanded=false`) and hydrates item children in background (`expanded=true`). + With `expanded=false`, child `items` can be ID-only stubs (`{ id }`) instead of full item payloads. - `GET /{database}/kitchen/items/{uuid_b64}` Returns one item detail payload. + Supports `allow_inactive=true|false` query filtering when needed. - `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/lookup` Identifier lookup response includes source/freshness metadata (`source`, `cache_hit`, `stale_cache`, `payload_fetched_at`, `retry_after_seconds`) used for richer user feedback. -- `POST /{database}/kitchen/items/{uuid_b64}/lookup?update=0|1` - Item-scoped OpenFoodFacts lookup used by stock detail to preview (`update=0`) or apply missing fields (`update=1`). -- `POST /{database}/kitchen/items?label=1` - Used for label image preview rendering. -- `POST /{database}/kitchen/items?label=1&preview=1` +- `POST /{database}/kitchen/items/{uuid_b64}/lookup?update=true|false` + Item-scoped OpenFoodFacts lookup used by stock detail to preview (`update=false`) or apply missing fields (`update=true`). +- `POST /{database}/kitchen/items?label=true&preview=true` Returns an image blob, `{ imageUrl }`, or `{ imageSvg }` for in-browser preview. +- `GET /{database}/kitchen/items/{uuid_b64}/label` + Returns rendered label PNG for an existing item. - `POST /{database}/kitchen/items/{uuid_b64}/stock` - Creates a stock event for measured or descriptive updates using `{ quantity }` or `{ level }`. + Creates a stock event for measured or descriptive updates using `{ quantity }` or `{ level }`, + and for non-consumed gone transitions (for example `{ level: "gone", gone_reason: "spoiled" }`). Response shape is `{ status, stock }`; frontend re-fetches the item detail after successful update. - `POST /{database}/kitchen/items/{uuid_b64}/use` - Marks an item used up (`gone`) via stock-event semantics. + Marks an item consumed/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. - `PATCH /{database}/kitchen/items/{uuid_b64}` diff --git a/package-lock.json b/package-lock.json index d8b10e9..3fa5c33 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "lonc-web", - "version": "0.2.4", + "version": "0.2.6", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "lonc-web", - "version": "0.2.4", + "version": "0.2.6", "dependencies": { "@zxing/browser": "^0.1.5", "alpinejs": "^3.14.9", diff --git a/package.json b/package.json index 1100bd1..ee0424d 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "lonc-web", - "version": "0.2.5", + "version": "0.2.6", "private": true, "type": "module", "scripts": { diff --git a/src/api/labels.js b/src/api/labels.js index 514635f..5ac4f31 100644 --- a/src/api/labels.js +++ b/src/api/labels.js @@ -42,7 +42,7 @@ export async function previewLabel(store, body) { method: 'POST', body, accept: 'image/svg+xml, image/png, application/json', - query: { label: 1, preview: 1 }, + query: { label: true, preview: true }, }); const image = normalizeLabelImagePayload(payload); @@ -53,6 +53,19 @@ export async function previewLabel(store, body) { throw new Error('Label preview response did not include an image.'); } +export async function getItemLabel(store, uuidB64) { + const payload = await apiRequest(store, `${getPath('items')}/${uuidB64}/label`, { + method: 'GET', + accept: 'image/png, application/json', + }); + const image = normalizeLabelImagePayload(payload); + if (image) { + return image; + } + + throw new Error('Item label response did not include an image.'); +} + export async function printItemLabel(store, uuidB64) { return apiRequest(store, `${getPath('items')}/${uuidB64}/print-label`, { method: 'POST', diff --git a/src/api/stock.js b/src/api/stock.js index 7f39dae..8fcc69a 100644 --- a/src/api/stock.js +++ b/src/api/stock.js @@ -2,6 +2,35 @@ import { apiRequest, getPath } from './client.js'; const DEFAULT_LIST_PAGE_LIMIT = 100; +function toBooleanFlag(value, defaultValue = false) { + if (value === undefined || value === null || value === '') { + return defaultValue; + } + + if (typeof value === 'boolean') { + return value; + } + + if (typeof value === 'number') { + return value !== 0; + } + + if (typeof value === 'string') { + const normalized = value.trim().toLowerCase(); + if (!normalized) { + return defaultValue; + } + if (['1', 'true', 'yes', 'y', 'on'].includes(normalized)) { + return true; + } + if (['0', 'false', 'no', 'n', 'off'].includes(normalized)) { + return false; + } + } + + return Boolean(value); +} + function unwrapEntryPayload(payload) { return payload?.data || payload?.entry || payload?.item || payload; } @@ -52,7 +81,7 @@ export async function searchItemDefinitions(store, query) { } const payload = await apiRequest(store, `${getPath('items')}/grouped`, { - query: { search_name: query, expanded: 0 }, + query: { search_name: query, expanded: false }, }); if (Array.isArray(payload)) { @@ -90,7 +119,7 @@ export async function listStockEntries(store, filters = {}) { export async function listGroupedStockEntries(store, options = {}) { const baseQuery = {}; - const expanded = options.expanded ?? 1; + const expanded = toBooleanFlag(options.expanded, true); baseQuery.expanded = expanded; const searchName = options.searchName || options.search_name; if (searchName) { @@ -119,7 +148,7 @@ export async function listGroupedStockEntries(store, options = {}) { export async function getStockEntry(store, stockId, { allowInactive = false } = {}) { const path = `${getPath('items')}/${stockId}`; const payload = allowInactive - ? await apiRequest(store, path, { query: { allow_inactive: 1 } }) + ? await apiRequest(store, path, { query: { allow_inactive: true } }) : await apiRequest(store, path); return unwrapEntryPayload(payload); } @@ -128,7 +157,7 @@ export async function createStockEntry(store, body) { const payload = await apiRequest(store, getPath('items'), { method: 'POST', body, - query: { label: 1, print: 1 }, + query: { label: true, print: true }, }); return unwrapEntryPayload(payload); } @@ -211,7 +240,7 @@ export async function lookupItemByIdentifier(store, identifierCode) { export async function lookupItemDetails(store, uuidB64, { update = false } = {}) { const payload = await apiRequest(store, `${getPath('items')}/${uuidB64}/lookup`, { method: 'POST', - query: { update: update ? 1 : 0 }, + query: { update: toBooleanFlag(update, false) }, }); return normalizeItemLookupResponse(payload); @@ -246,7 +275,7 @@ export async function createStockEvent(store, uuidB64, body) { export async function listStockEvents(store, uuidB64, options = {}) { const query = {}; if (options.allowInactive) { - query.allow_inactive = 1; + query.allow_inactive = true; } if (options.limit !== undefined && options.limit !== null) { query.limit = options.limit; @@ -269,6 +298,14 @@ export async function listStockEvents(store, uuidB64, options = {}) { export async function markStockGone(store, uuidB64, reason = 'consumed') { try { + if (reason === 'consumed') { + const result = await useStockItem(store, uuidB64); + if (result.status === 'already_gone') { + return { status: 'already_gone', reason }; + } + return { status: 'gone', reason }; + } + await createStockEvent(store, uuidB64, { level: 'gone', gone_reason: reason, diff --git a/src/app/config.js b/src/app/config.js index 94008eb..1a1dddf 100644 --- a/src/app/config.js +++ b/src/app/config.js @@ -1,5 +1,5 @@ export const APP_NAME = 'Lonc'; -export const APP_VERSION = typeof __APP_VERSION__ !== 'undefined' ? __APP_VERSION__ : '0.2.5'; +export const APP_VERSION = typeof __APP_VERSION__ !== 'undefined' ? __APP_VERSION__ : '0.2.6'; export const TRYTON_APPLICATION = 'kitchen'; export const CONNECTION_STATES = { diff --git a/src/features/stock/stock-list-page.js b/src/features/stock/stock-list-page.js index 88d2045..c83aac5 100644 --- a/src/features/stock/stock-list-page.js +++ b/src/features/stock/stock-list-page.js @@ -924,7 +924,7 @@ export function stockListPageData(store) { } else { initTasks.push( this.loadGroupedEntries({ - expanded: 0, + expanded: false, resetVisible: !restoredContext, }), ); @@ -1309,7 +1309,7 @@ export function stockListPageData(store) { if (mode === 'grouped') { if (!this.groupedLoaded) { - await this.loadGroupedEntries({ expanded: 0, resetVisible: true }); + await this.loadGroupedEntries({ expanded: false, resetVisible: true }); } this.hydrateGroupedEntriesInBackground().catch(() => {}); return; @@ -1325,7 +1325,7 @@ export function stockListPageData(store) { : this.itemsLoaded); if (this.viewMode === 'grouped') { - await this.loadGroupedEntries({ expanded: 0, background: useBackground }); + await this.loadGroupedEntries({ expanded: false, background: useBackground }); this.hydrateGroupedEntriesInBackground({ force: true }).catch(() => {}); return; } @@ -1417,7 +1417,7 @@ export function stockListPageData(store) { this.invalidateMemo(); this.persistRuntimeCache(); }, - async loadGroupedEntries({ expanded = 1, background = false, resetVisible = false } = {}) { + async loadGroupedEntries({ expanded = true, background = false, resetVisible = false } = {}) { if (!store.isConnected) { return; } @@ -1432,7 +1432,7 @@ export function stockListPageData(store) { try { const loadedGroups = await listGroupedStockEntries(store, { expanded }); - if (expanded === 0) { + if (!expanded) { this.applyGroupedSummary(loadedGroups, { resetVisible }); return; } @@ -1461,7 +1461,7 @@ export function stockListPageData(store) { this.groupedHydrating = true; try { - await this.loadGroupedEntries({ expanded: 1, background: true }); + await this.loadGroupedEntries({ expanded: true, background: true }); } finally { this.groupedHydrating = false; } @@ -1473,7 +1473,7 @@ export function stockListPageData(store) { } if (this.groupedLoaded) { tasks.push( - this.loadGroupedEntries({ expanded: 0, background: true }).then(() => + this.loadGroupedEntries({ expanded: false, background: true }).then(() => this.hydrateGroupedEntriesInBackground({ force: true }), ), ); @@ -2269,7 +2269,7 @@ export function stockListPageData(store) { ? `${item.name} was already out of stock and removed from the group.` : `${item.name} was ${actionLabel} and removed from the group.`, }); - this.loadGroupedEntries({ expanded: 0, background: true }).catch(() => {}); + this.loadGroupedEntries({ expanded: false, background: true }).catch(() => {}); } catch (error) { this.editErrors[item.id] = error.message || 'Removal failed.'; } diff --git a/tests/api/client.test.js b/tests/api/client.test.js index 8e85094..171e0b8 100644 --- a/tests/api/client.test.js +++ b/tests/api/client.test.js @@ -61,12 +61,12 @@ describe('api/client', () => { const url = buildKitchenApiUrl(store, 'kitchen/items/grouped', { search_name: 'Milk + eggs', - expanded: 1, + expanded: true, ignored: '', }); expect(url).toBe( - 'https://api.example.com/my%20db/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=true', ); }); @@ -88,14 +88,14 @@ describe('api/client', () => { name: 'Rice', }, query: { - label: 1, + label: true, }, }); expect(payload).toEqual({ ok: true }); const [url, request] = fetchSpy.mock.calls[0]; - expect(url).toBe('/kitchen-db/kitchen/items?label=1'); + expect(url).toBe('/kitchen-db/kitchen/items?label=true'); 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 index cf1e8b9..5c78646 100644 --- a/tests/api/labels.test.js +++ b/tests/api/labels.test.js @@ -1,6 +1,72 @@ -import { describe, expect, it } from 'vitest'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; -import { formatPrintErrorMessage } from '../../src/api/labels.js'; +const apiRequestMock = vi.fn(); + +vi.mock('../../src/api/client.js', () => ({ + getPath(key) { + const paths = { + items: 'kitchen/items', + }; + return paths[key]; + }, + apiRequest: (...args) => apiRequestMock(...args), +})); + +const { + formatPrintErrorMessage, + getItemLabel, + previewLabel, +} = await import('../../src/api/labels.js'); + +describe('api/labels', () => { + beforeEach(() => { + apiRequestMock.mockReset(); + }); + + it('previewLabel uses boolean label/preview query flags', async () => { + apiRequestMock.mockResolvedValueOnce({ + label: 'YWJj', + }); + + const response = await previewLabel({ config: { database: 'db' } }, { name: 'Rice' }); + + expect(apiRequestMock).toHaveBeenCalledWith( + { config: { database: 'db' } }, + 'kitchen/items', + { + method: 'POST', + body: { name: 'Rice' }, + accept: 'image/svg+xml, image/png, application/json', + query: { label: true, preview: true }, + }, + ); + expect(response).toEqual({ + objectUrl: 'data:image/png;base64,YWJj', + contentType: 'image/png', + }); + }); + + it('getItemLabel fetches PNG from /label endpoint', async () => { + apiRequestMock.mockResolvedValueOnce({ + label: 'YWJj', + }); + + const response = await getItemLabel({ config: { database: 'db' } }, 'item-1'); + + expect(apiRequestMock).toHaveBeenCalledWith( + { config: { database: 'db' } }, + 'kitchen/items/item-1/label', + { + method: 'GET', + accept: 'image/png, application/json', + }, + ); + expect(response).toEqual({ + objectUrl: 'data:image/png;base64,YWJj', + contentType: 'image/png', + }); + }); +}); describe('api/labels formatPrintErrorMessage', () => { it('maps printer_unavailable payload to user-friendly message', () => { diff --git a/tests/api/stock.test.js b/tests/api/stock.test.js index 6ce7d28..9c56e0e 100644 --- a/tests/api/stock.test.js +++ b/tests/api/stock.test.js @@ -20,6 +20,7 @@ const { listGroupedStockEntries, listKitchenChanges, listStockEntries, + markStockGone, lookupItemByIdentifier, lookupItemDetails, patchStockItem, @@ -84,7 +85,7 @@ describe('api/stock', () => { await listGroupedStockEntries( { config: { database: 'db' } }, - { expanded: 0, searchName: 'Rice', limit: 10, offset: 0 }, + { expanded: false, searchName: 'Rice', limit: 10, offset: 0 }, ); expect(apiRequestMock).toHaveBeenCalledWith( @@ -92,7 +93,7 @@ describe('api/stock', () => { 'kitchen/items/grouped', { query: { - expanded: 0, + expanded: false, search_name: 'Rice', limit: 10, offset: 0, @@ -108,7 +109,7 @@ describe('api/stock', () => { const response = await listGroupedStockEntries( { config: { database: 'db' } }, - { expanded: 1, searchName: 'Rice' }, + { expanded: true, searchName: 'Rice' }, ); expect(response).toHaveLength(101); @@ -116,13 +117,13 @@ describe('api/stock', () => { 1, { config: { database: 'db' } }, 'kitchen/items/grouped', - { query: { expanded: 1, search_name: 'Rice', limit: 100, offset: 0 } }, + { query: { expanded: true, search_name: 'Rice', limit: 100, offset: 0 } }, ); expect(apiRequestMock).toHaveBeenNthCalledWith( 2, { config: { database: 'db' } }, 'kitchen/items/grouped', - { query: { expanded: 1, search_name: 'Rice', limit: 100, offset: 100 } }, + { query: { expanded: true, search_name: 'Rice', limit: 100, offset: 100 } }, ); }); @@ -151,7 +152,7 @@ describe('api/stock', () => { expect(apiRequestMock).toHaveBeenCalledWith( { config: { database: 'db' } }, 'kitchen/items/item-2', - { query: { allow_inactive: 1 } }, + { query: { allow_inactive: true } }, ); }); @@ -297,7 +298,7 @@ describe('api/stock', () => { expect(apiRequestMock).toHaveBeenCalledWith( { config: { database: 'db' } }, 'kitchen/items/item-1/lookup', - { method: 'POST', query: { update: 1 } }, + { method: 'POST', query: { update: true } }, ); expect(response).toEqual({ status: 'ok', @@ -426,4 +427,30 @@ describe('api/stock', () => { }); expect(apiRequestMock).toHaveBeenCalledTimes(1); }); + + it('markStockGone uses /use endpoint for consumed reason', async () => { + apiRequestMock.mockResolvedValueOnce(null); + + const result = await markStockGone({ config: { database: 'db' } }, 'item-1', 'consumed'); + + expect(result).toEqual({ status: 'gone', reason: 'consumed' }); + expect(apiRequestMock).toHaveBeenCalledWith( + { config: { database: 'db' } }, + 'kitchen/items/item-1/use', + { method: 'POST' }, + ); + }); + + it('markStockGone uses /stock endpoint for non-consumed reasons', async () => { + apiRequestMock.mockResolvedValueOnce({ status: 'OK', stock: { id: 3 } }); + + const result = await markStockGone({ config: { database: 'db' } }, 'item-1', 'spoiled'); + + expect(result).toEqual({ status: 'gone', reason: 'spoiled' }); + expect(apiRequestMock).toHaveBeenCalledWith( + { config: { database: 'db' } }, + 'kitchen/items/item-1/stock', + { method: 'POST', body: { level: 'gone', gone_reason: 'spoiled' } }, + ); + }); }); diff --git a/tests/features/stock/mark-gone.test.js b/tests/features/stock/mark-gone.test.js index 25cf90c..2c289ed 100644 --- a/tests/features/stock/mark-gone.test.js +++ b/tests/features/stock/mark-gone.test.js @@ -130,6 +130,6 @@ describe('stock mark-gone behavior', () => { message: 'Beans was marked used and removed from the group.', }); expect(listGroupedStockEntriesMock).toHaveBeenCalledTimes(1); - expect(listGroupedStockEntriesMock).toHaveBeenCalledWith(store, { expanded: 0 }); + expect(listGroupedStockEntriesMock).toHaveBeenCalledWith(store, { expanded: false }); }); }); diff --git a/tests/features/stock/stock-list-page-grouped.test.js b/tests/features/stock/stock-list-page-grouped.test.js index 058c26d..c13a9d4 100644 --- a/tests/features/stock/stock-list-page-grouped.test.js +++ b/tests/features/stock/stock-list-page-grouped.test.js @@ -141,12 +141,12 @@ describe('stock list grouped-first behavior', () => { await data.init(); expect(data.viewMode).toBe('grouped'); - expect(listGroupedStockEntriesMock).toHaveBeenNthCalledWith(1, store, { expanded: 0 }); + expect(listGroupedStockEntriesMock).toHaveBeenNthCalledWith(1, store, { expanded: false }); expect(listStockEntriesMock).not.toHaveBeenCalled(); await Promise.resolve(); - expect(listGroupedStockEntriesMock).toHaveBeenNthCalledWith(2, store, { expanded: 1 }); + expect(listGroupedStockEntriesMock).toHaveBeenNthCalledWith(2, store, { expanded: true }); await data.switchView('items'); expect(listStockEntriesMock).toHaveBeenCalledTimes(1); @@ -162,7 +162,7 @@ describe('stock list grouped-first behavior', () => { .mockResolvedValueOnce(createGroupedExpanded()); const data = stockListPageData({ isConnected: true, addAlert: vi.fn() }); - await data.loadGroupedEntries({ expanded: 0, resetVisible: true }); + await data.loadGroupedEntries({ expanded: false, resetVisible: true }); expect(data.groupDisplayItems(data.groupedEntries[0])).toEqual([]); expect(data.hasGroupedChildStubs(data.groupedEntries[0])).toBe(true); @@ -226,7 +226,7 @@ describe('stock list grouped-first behavior', () => { data.groupedLoaded = true; data.groupedEntries = createGroupedSummary().map((group) => data.indexGroup(group)); - const pending = data.loadGroupedEntries({ expanded: 0, background: true }); + const pending = data.loadGroupedEntries({ expanded: false, background: true }); expect(data.state.isRefreshing).toBe(true); expect(data.groupedEntries).toHaveLength(1);