11 Commits

Author SHA1 Message Date
bblaz 977c62818c Upgrade OFF lookup UX and stock detail identifier editing
ci/woodpecker/push/woodpecker Pipeline was successful
ci/woodpecker/pr/woodpecker Pipeline was successful
2026-04-11 10:14:49 +02:00
bblaz ea8a95b95d Merge pull request 'Add app version management, update checks, and periodic SW updates' (#4) from codex/add-pwa-update-controls into main
ci/woodpecker/push/woodpecker Pipeline was successful
Reviewed-on: #4
2026-04-11 01:21:19 +00:00
bblaz 0a8464f63c Add app version management, update checks, and periodic SW updates
ci/woodpecker/push/woodpecker Pipeline was successful
ci/woodpecker/pr/woodpecker Pipeline was successful
- Introduced version.json and appVersionAssetPlugin for build-time version tracking.
- Enhanced settings page with update check/status UI.
- Refactored bootstrap to handle SW updates and initiate periodic checks.
2026-04-11 03:19:53 +02:00
bblaz 9e6ad2dc08 Merge pull request 'Add barcode scanner integration, identifier lookup, and enhanced field mapping for label creation' (#3) from codex/ean-label-scanner into main
ci/woodpecker/push/woodpecker Pipeline was successful
Reviewed-on: #3
2026-04-11 00:38:29 +00:00
bblaz ca9517508d Remove redundant stockType field mapping, refactor location handling in identifier mapper, and update related tests.
ci/woodpecker/push/woodpecker Pipeline was successful
ci/woodpecker/pr/woodpecker Pipeline was successful
2026-04-11 02:33:47 +02:00
bblaz 9e3245a427 Add barcode scanner integration, identifier lookup, and enhanced field mapping for label creation
ci/woodpecker/push/woodpecker Pipeline was successful
ci/woodpecker/pr/woodpecker Pipeline is pending
2026-04-11 02:24:28 +02:00
bblaz 9f84251af0 Merge pull request 'codex/update-app-for-new-api' (#2) from codex/update-app-for-new-api into main
ci/woodpecker/push/woodpecker Pipeline was successful
Reviewed-on: #2
2026-04-10 20:52:14 +00:00
bblaz e1383c4d56 Add label printing functionality and error handling in stock and label flows
ci/woodpecker/push/woodpecker Pipeline was successful
ci/woodpecker/pr/woodpecker Pipeline was successful
2026-04-10 22:08:01 +02:00
bblaz 1dc1bb4912 Implement upsert label flow and use-based mark gone handling
ci/woodpecker/push/woodpecker Pipeline was successful
2026-04-10 15:43:39 +02:00
bblaz caa6ca6ce1 Merge pull request 'Add Vitest coverage reporting to Woodpecker CI' (#1) from codex/add-woodpecker-test-suite into main
ci/woodpecker/push/woodpecker Pipeline was successful
Reviewed-on: #1
2026-04-10 13:01:38 +00:00
bblaz 6f617fe449 Add Vitest coverage reporting to Woodpecker CI
ci/woodpecker/push/woodpecker Pipeline was successful
ci/woodpecker/pr/woodpecker Pipeline was successful
2026-04-10 15:00:13 +02:00
36 changed files with 4445 additions and 122 deletions
+1
View File
@@ -1,3 +1,4 @@
node_modules/
dist/
coverage/
.DS_Store
+12
View File
@@ -0,0 +1,12 @@
steps:
verify:
image: node:20
commands:
- npm ci
- npm run test:coverage
- npm run build
when:
event:
- push
- pull_request
+230
View File
@@ -0,0 +1,230 @@
# Lonc Agent Guidelines
This document is the working guide for future coding agents on the Lonc frontend.
## Project purpose
Lonc is a lightweight PWA frontend for household kitchen stock and labeling workflows.
The frontend:
- talks to a separate Tryton backend
- uses Tryton user application keys for auth
- is optimized for phone and desktop use
- prefers practical, high-frequency operational UX over generic CRUD
## Core stack
- Vite
- Alpine.js
- Bootstrap 5
- plain `fetch()` API modules
- static PWA assets in `public/`
## General operating procedure
When changing this project, follow this order:
1. Find the existing feature module instead of adding parallel logic.
2. Prefer updating shared API helpers before patching individual pages.
3. Preserve the existing UX language and interaction patterns.
4. Verify with `npm run build`.
For larger behavior changes:
1. Check the relevant API module in `src/api/`
2. Check the page module in `src/features/`
3. Check supporting shared state in `src/app/`
4. Check matching CSS in `src/styles/app.css`
## Key architecture rules
### API usage
- All HTTP requests should go through `src/api/client.js`
- Do not build ad hoc URLs in feature components
- Use `getPath()` and shared API modules in `src/api/`
- Prefer same-origin requests when `baseUrl` is empty
- Keep custom headers minimal
### Auth model
Lonc does not use Tryton JSON-RPC session login.
It uses Tryton user application keys for the `kitchen` application:
1. `POST /{database}/user/application/`
2. store returned key locally
3. user validates key in Tryton preferences
4. frontend verifies with authenticated kitchen request
Relevant session states:
- `not_connected`
- `pending_validation`
- `connected`
- `invalid_key`
If authenticated requests start failing after a key had previously worked, the app should move toward invalid-key handling instead of repeatedly hitting the backend.
### Service worker
- In development, service workers should not stay registered
- In production, avoid sticky caching for JS/CSS
- Be careful when debugging frontend/API mismatches because stale cached bundles have already caused confusion in this project
## Current backend conventions
These are current project assumptions and should not be casually changed.
### Main endpoints
- `/{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
- 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
The label form does not search `items` directly anymore.
It now searches grouped items:
- `GET /{database}/kitchen/items/grouped?search_name=...&expanded=0`
Reason:
- grouped result is a better template for “new item from existing definition”
- not expanding children keeps the query lighter
### Grouped stock view
Grouped stock view uses:
- `GET /{database}/kitchen/items/grouped?expanded=1`
Important:
- group-level fields are meaningful and should be used
- group expiration status should follow the backend-provided “first item expires” semantics
- do not assume grouped child records are always returned unless `expanded=1`
## UX rules that should be preserved
### Label creation
- Required fields must be clearly marked with a red asterisk
- Required-field validation should happen before both preview and create
- `Quantity` is always visible
- `Quantity` is required only for `measured`
- `Production date` is required
- changing production date should keep expiration calculations in sync
- item-definition selection should act like copying defaults for a new item, not cloning old dates blindly
- location picker and expiration day picker were tuned for Safari/iPhone behavior, so be cautious when changing event handling
### Stock review
This is an operational page, not a generic data table.
Priorities:
- expiration visibility
- stock state clarity
- location clarity
- quick actions
- phone usability
Do not degrade it into a dense admin CRUD screen.
### Filters and overviews
Stock page filtering is intentionally overview-driven:
- main text search
- `Expiration overview`
- `Location overview`
The overviews:
- are collapsible
- are collapsed by default
- act as filter controls
- should remain visually understandable on both small and large screens
### Normal and grouped stock views
These two views should feel related.
- same filter concepts
- similar visual language
- direct “view item” access should exist in both
- grouped view should stay compact by default
## Known project-specific decisions
- `Quantity` prefill on label creation comes from `quantity_initial`, not `quantity`
- grouped label search should use grouped entities as source defaults
- grouped expiration logic should rely on backend-provided group semantics
- location filtering includes descendants when parent is selected
- removing a parent location from filters removes its selected children too
- grouped child item cards use softer expiration colors than the parent group card
## File map
### App shell and state
- `src/app/bootstrap.js`
- `src/app/router.js`
- `src/app/store.js`
- `src/app/config.js`
### API layer
- `src/api/client.js`
- `src/api/auth.js`
- `src/api/stock.js`
- `src/api/locations.js`
- `src/api/labels.js`
### Main feature pages
- `src/features/auth/login-page.js`
- `src/features/auth/settings-page.js`
- `src/features/labels/label-create-page.js`
- `src/features/stock/stock-list-page.js`
- `src/features/stock/stock-detail-page.js`
### Styling
- `src/styles/app.css`
## Editing guidance for future agents
- Keep behavior centralized where possible
- Reuse existing helpers instead of inventing near-duplicates
- Be careful when modifying auth invalidation, service worker behavior, or custom pickers
- If changing endpoint behavior, update both API modules and README/AGENTS notes when appropriate
- When changing interactive UI, test the desktop and small-screen behavior mentally at minimum
## Verification expectation
After meaningful frontend changes, run:
```bash
npm run build
```
If behavior depends on caching, auth, or service workers, mention that explicitly in the handoff.
+29 -5
View File
@@ -14,6 +14,8 @@ Lonc is a responsive PWA frontend for household kitchen stock and labeling workf
- `npm install`
- `npm run dev`
- `npm test`
- `npm run test:coverage`
- `npm run build`
- `npm run preview`
@@ -91,6 +93,7 @@ For installability and service worker support:
- serve `manifest.webmanifest` with an appropriate web manifest content type
- make sure `service-worker.js` is reachable from the deployed site root
- make sure `version.json` is reachable from the deployed site root for app update checks
- avoid aggressive caching on `index.html` during upgrades so new builds are picked up reliably
### Smoke test after deployment
@@ -111,6 +114,7 @@ public/
manifest.webmanifest
offline.html
service-worker.js
version.json
src/
api/
app/
@@ -122,6 +126,10 @@ index.html
package.json
```
## Working guide
Project-specific operating conventions for future contributors and coding agents are documented in [AGENTS.md](/Users/blaz/PycharmProjects/lonc/AGENTS.md).
## Current MVP features
- Login/configuration screen for Tryton server URL and database
@@ -141,8 +149,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/`
@@ -159,14 +167,28 @@ 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/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`
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.
- `PATCH /{database}/kitchen/items/{uuid_b64}`
Used for item-level edits from stock detail (for example identifier code updates).
- `GET /{database}/kitchen/locations`
Returns a nested location tree.
@@ -175,4 +197,6 @@ 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).
- Stock detail supports inline identifier editing and OpenFoodFacts refresh/apply actions with rate-limit and cache-freshness hints.
+674 -3
View File
@@ -1,18 +1,81 @@
{
"name": "lonc-web",
"version": "0.1.0",
"version": "0.2.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "lonc-web",
"version": "0.1.0",
"version": "0.2.0",
"dependencies": {
"@zxing/browser": "^0.1.5",
"alpinejs": "^3.14.9",
"bootstrap": "^5.3.3"
},
"devDependencies": {
"vite": "^7.0.0"
"@vitest/coverage-v8": "^4.1.4",
"vite": "^7.0.0",
"vitest": "^4.1.4"
}
},
"node_modules/@babel/helper-string-parser": {
"version": "7.27.1",
"resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz",
"integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=6.9.0"
}
},
"node_modules/@babel/helper-validator-identifier": {
"version": "7.28.5",
"resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz",
"integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=6.9.0"
}
},
"node_modules/@babel/parser": {
"version": "7.29.2",
"resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.2.tgz",
"integrity": "sha512-4GgRzy/+fsBa72/RZVJmGKPmZu9Byn8o4MoLpmNe1m8ZfYnz5emHLQz3U4gLud6Zwl0RZIcgiLD7Uq7ySFuDLA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@babel/types": "^7.29.0"
},
"bin": {
"parser": "bin/babel-parser.js"
},
"engines": {
"node": ">=6.0.0"
}
},
"node_modules/@babel/types": {
"version": "7.29.0",
"resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.0.tgz",
"integrity": "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==",
"dev": true,
"license": "MIT",
"dependencies": {
"@babel/helper-string-parser": "^7.27.1",
"@babel/helper-validator-identifier": "^7.28.5"
},
"engines": {
"node": ">=6.9.0"
}
},
"node_modules/@bcoe/v8-coverage": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/@bcoe/v8-coverage/-/v8-coverage-1.0.2.tgz",
"integrity": "sha512-6zABk/ECA/QYSCQ1NGiVwwbQerUCZ+TQbp64Q3AgmfNvurHH0j8TtXa1qbShXA6qqkpAj4V5W8pP6mLe1mcMqA==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/aix-ppc64": {
@@ -457,6 +520,34 @@
"node": ">=18"
}
},
"node_modules/@jridgewell/resolve-uri": {
"version": "3.1.2",
"resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz",
"integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=6.0.0"
}
},
"node_modules/@jridgewell/sourcemap-codec": {
"version": "1.5.5",
"resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz",
"integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==",
"dev": true,
"license": "MIT"
},
"node_modules/@jridgewell/trace-mapping": {
"version": "0.3.31",
"resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz",
"integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@jridgewell/resolve-uri": "^3.1.0",
"@jridgewell/sourcemap-codec": "^1.4.14"
}
},
"node_modules/@popperjs/core": {
"version": "2.11.8",
"resolved": "https://registry.npmjs.org/@popperjs/core/-/core-2.11.8.tgz",
@@ -818,6 +909,31 @@
"win32"
]
},
"node_modules/@standard-schema/spec": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz",
"integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==",
"dev": true,
"license": "MIT"
},
"node_modules/@types/chai": {
"version": "5.2.3",
"resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz",
"integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/deep-eql": "*",
"assertion-error": "^2.0.1"
}
},
"node_modules/@types/deep-eql": {
"version": "4.0.2",
"resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz",
"integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==",
"dev": true,
"license": "MIT"
},
"node_modules/@types/estree": {
"version": "1.0.8",
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz",
@@ -825,6 +941,150 @@
"dev": true,
"license": "MIT"
},
"node_modules/@vitest/coverage-v8": {
"version": "4.1.4",
"resolved": "https://registry.npmjs.org/@vitest/coverage-v8/-/coverage-v8-4.1.4.tgz",
"integrity": "sha512-x7FptB5oDruxNPDNY2+S8tCh0pcq7ymCe1gTHcsp733jYjrJl8V1gMUlVysuCD9Kz46Xz9t1akkv08dPcYDs1w==",
"dev": true,
"license": "MIT",
"dependencies": {
"@bcoe/v8-coverage": "^1.0.2",
"@vitest/utils": "4.1.4",
"ast-v8-to-istanbul": "^1.0.0",
"istanbul-lib-coverage": "^3.2.2",
"istanbul-lib-report": "^3.0.1",
"istanbul-reports": "^3.2.0",
"magicast": "^0.5.2",
"obug": "^2.1.1",
"std-env": "^4.0.0-rc.1",
"tinyrainbow": "^3.1.0"
},
"funding": {
"url": "https://opencollective.com/vitest"
},
"peerDependencies": {
"@vitest/browser": "4.1.4",
"vitest": "4.1.4"
},
"peerDependenciesMeta": {
"@vitest/browser": {
"optional": true
}
}
},
"node_modules/@vitest/expect": {
"version": "4.1.4",
"resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.4.tgz",
"integrity": "sha512-iPBpra+VDuXmBFI3FMKHSFXp3Gx5HfmSCE8X67Dn+bwephCnQCaB7qWK2ldHa+8ncN8hJU8VTMcxjPpyMkUjww==",
"dev": true,
"license": "MIT",
"dependencies": {
"@standard-schema/spec": "^1.1.0",
"@types/chai": "^5.2.2",
"@vitest/spy": "4.1.4",
"@vitest/utils": "4.1.4",
"chai": "^6.2.2",
"tinyrainbow": "^3.1.0"
},
"funding": {
"url": "https://opencollective.com/vitest"
}
},
"node_modules/@vitest/mocker": {
"version": "4.1.4",
"resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.4.tgz",
"integrity": "sha512-R9HTZBhW6yCSGbGQnDnH3QHfJxokKN4KB+Yvk9Q1le7eQNYwiCyKxmLmurSpFy6BzJanSLuEUDrD+j97Q+ZLPg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@vitest/spy": "4.1.4",
"estree-walker": "^3.0.3",
"magic-string": "^0.30.21"
},
"funding": {
"url": "https://opencollective.com/vitest"
},
"peerDependencies": {
"msw": "^2.4.9",
"vite": "^6.0.0 || ^7.0.0 || ^8.0.0"
},
"peerDependenciesMeta": {
"msw": {
"optional": true
},
"vite": {
"optional": true
}
}
},
"node_modules/@vitest/pretty-format": {
"version": "4.1.4",
"resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.4.tgz",
"integrity": "sha512-ddmDHU0gjEUyEVLxtZa7xamrpIefdEETu3nZjWtHeZX4QxqJ7tRxSteHVXJOcr8jhiLoGAhkK4WJ3WqBpjx42A==",
"dev": true,
"license": "MIT",
"dependencies": {
"tinyrainbow": "^3.1.0"
},
"funding": {
"url": "https://opencollective.com/vitest"
}
},
"node_modules/@vitest/runner": {
"version": "4.1.4",
"resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.4.tgz",
"integrity": "sha512-xTp7VZ5aXP5ZJrn15UtJUWlx6qXLnGtF6jNxHepdPHpMfz/aVPx+htHtgcAL2mDXJgKhpoo2e9/hVJsIeFbytQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@vitest/utils": "4.1.4",
"pathe": "^2.0.3"
},
"funding": {
"url": "https://opencollective.com/vitest"
}
},
"node_modules/@vitest/snapshot": {
"version": "4.1.4",
"resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.4.tgz",
"integrity": "sha512-MCjCFgaS8aZz+m5nTcEcgk/xhWv0rEH4Yl53PPlMXOZ1/Ka2VcZU6CJ+MgYCZbcJvzGhQRjVrGQNZqkGPttIKw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@vitest/pretty-format": "4.1.4",
"@vitest/utils": "4.1.4",
"magic-string": "^0.30.21",
"pathe": "^2.0.3"
},
"funding": {
"url": "https://opencollective.com/vitest"
}
},
"node_modules/@vitest/spy": {
"version": "4.1.4",
"resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.4.tgz",
"integrity": "sha512-XxNdAsKW7C+FLydqFJLb5KhJtl3PGCMmYwFRfhvIgxJvLSXhhVI1zM8f1qD3Zg7RCjTSzDVyct6sghs9UEgBEQ==",
"dev": true,
"license": "MIT",
"funding": {
"url": "https://opencollective.com/vitest"
}
},
"node_modules/@vitest/utils": {
"version": "4.1.4",
"resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.4.tgz",
"integrity": "sha512-13QMT+eysM5uVGa1rG4kegGYNp6cnQcsTc67ELFbhNLQO+vgsygtYJx2khvdt4gVQqSSpC/KT5FZZxUpP3Oatw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@vitest/pretty-format": "4.1.4",
"convert-source-map": "^2.0.0",
"tinyrainbow": "^3.1.0"
},
"funding": {
"url": "https://opencollective.com/vitest"
}
},
"node_modules/@vue/reactivity": {
"version": "3.1.5",
"resolved": "https://registry.npmjs.org/@vue/reactivity/-/reactivity-3.1.5.tgz",
@@ -840,6 +1100,41 @@
"integrity": "sha512-oJ4F3TnvpXaQwZJNF3ZK+kLPHKarDmJjJ6jyzVNDKH9md1dptjC7lWR//jrGuLdek/U6iltWxqAnYOu8gCiOvA==",
"license": "MIT"
},
"node_modules/@zxing/browser": {
"version": "0.1.5",
"resolved": "https://registry.npmjs.org/@zxing/browser/-/browser-0.1.5.tgz",
"integrity": "sha512-4Lmrn/il4+UNb87Gk8h1iWnhj39TASEHpd91CwwSJtY5u+wa0iH9qS0wNLAWbNVYXR66WmT5uiMhZ7oVTrKfxw==",
"license": "MIT",
"optionalDependencies": {
"@zxing/text-encoding": "^0.9.0"
},
"peerDependencies": {
"@zxing/library": "^0.21.0"
}
},
"node_modules/@zxing/library": {
"version": "0.21.3",
"resolved": "https://registry.npmjs.org/@zxing/library/-/library-0.21.3.tgz",
"integrity": "sha512-hZHqFe2JyH/ZxviJZosZjV+2s6EDSY0O24R+FQmlWZBZXP9IqMo7S3nb3+2LBWxodJQkSurdQGnqE7KXqrYgow==",
"license": "MIT",
"peer": true,
"dependencies": {
"ts-custom-error": "^3.2.1"
},
"engines": {
"node": ">= 10.4.0"
},
"optionalDependencies": {
"@zxing/text-encoding": "~0.9.0"
}
},
"node_modules/@zxing/text-encoding": {
"version": "0.9.0",
"resolved": "https://registry.npmjs.org/@zxing/text-encoding/-/text-encoding-0.9.0.tgz",
"integrity": "sha512-U/4aVJ2mxI0aDNI8Uq0wEhMgY+u4CNtEb0om3+y3+niDAsoTCOB33UF0sxpzqzdqXLqmvc+vZyAt4O8pPdfkwA==",
"license": "(Unlicense OR Apache-2.0)",
"optional": true
},
"node_modules/alpinejs": {
"version": "3.15.11",
"resolved": "https://registry.npmjs.org/alpinejs/-/alpinejs-3.15.11.tgz",
@@ -849,6 +1144,28 @@
"@vue/reactivity": "~3.1.1"
}
},
"node_modules/assertion-error": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz",
"integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=12"
}
},
"node_modules/ast-v8-to-istanbul": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/ast-v8-to-istanbul/-/ast-v8-to-istanbul-1.0.0.tgz",
"integrity": "sha512-1fSfIwuDICFA4LKkCzRPO7F0hzFf0B7+Xqrl27ynQaa+Rh0e1Es0v6kWHPott3lU10AyAr7oKHa65OppjLn3Rg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@jridgewell/trace-mapping": "^0.3.31",
"estree-walker": "^3.0.3",
"js-tokens": "^10.0.0"
}
},
"node_modules/bootstrap": {
"version": "5.3.8",
"resolved": "https://registry.npmjs.org/bootstrap/-/bootstrap-5.3.8.tgz",
@@ -868,6 +1185,30 @@
"@popperjs/core": "^2.11.8"
}
},
"node_modules/chai": {
"version": "6.2.2",
"resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz",
"integrity": "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=18"
}
},
"node_modules/convert-source-map": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz",
"integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==",
"dev": true,
"license": "MIT"
},
"node_modules/es-module-lexer": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.0.0.tgz",
"integrity": "sha512-5POEcUuZybH7IdmGsD8wlf0AI55wMecM9rVBTI/qEAy2c1kTOm3DjFYjrBdI2K3BaJjJYfYFeRtM0t9ssnRuxw==",
"dev": true,
"license": "MIT"
},
"node_modules/esbuild": {
"version": "0.27.7",
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.7.tgz",
@@ -910,6 +1251,26 @@
"@esbuild/win32-x64": "0.27.7"
}
},
"node_modules/estree-walker": {
"version": "3.0.3",
"resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz",
"integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/estree": "^1.0.0"
}
},
"node_modules/expect-type": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.3.0.tgz",
"integrity": "sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==",
"dev": true,
"license": "Apache-2.0",
"engines": {
"node": ">=12.0.0"
}
},
"node_modules/fdir": {
"version": "6.5.0",
"resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz",
@@ -943,6 +1304,107 @@
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
}
},
"node_modules/has-flag": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz",
"integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=8"
}
},
"node_modules/html-escaper": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-2.0.2.tgz",
"integrity": "sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==",
"dev": true,
"license": "MIT"
},
"node_modules/istanbul-lib-coverage": {
"version": "3.2.2",
"resolved": "https://registry.npmjs.org/istanbul-lib-coverage/-/istanbul-lib-coverage-3.2.2.tgz",
"integrity": "sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg==",
"dev": true,
"license": "BSD-3-Clause",
"engines": {
"node": ">=8"
}
},
"node_modules/istanbul-lib-report": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/istanbul-lib-report/-/istanbul-lib-report-3.0.1.tgz",
"integrity": "sha512-GCfE1mtsHGOELCU8e/Z7YWzpmybrx/+dSTfLrvY8qRmaY6zXTKWn6WQIjaAFw069icm6GVMNkgu0NzI4iPZUNw==",
"dev": true,
"license": "BSD-3-Clause",
"dependencies": {
"istanbul-lib-coverage": "^3.0.0",
"make-dir": "^4.0.0",
"supports-color": "^7.1.0"
},
"engines": {
"node": ">=10"
}
},
"node_modules/istanbul-reports": {
"version": "3.2.0",
"resolved": "https://registry.npmjs.org/istanbul-reports/-/istanbul-reports-3.2.0.tgz",
"integrity": "sha512-HGYWWS/ehqTV3xN10i23tkPkpH46MLCIMFNCaaKNavAXTF1RkqxawEPtnjnGZ6XKSInBKkiOA5BKS+aZiY3AvA==",
"dev": true,
"license": "BSD-3-Clause",
"dependencies": {
"html-escaper": "^2.0.0",
"istanbul-lib-report": "^3.0.0"
},
"engines": {
"node": ">=8"
}
},
"node_modules/js-tokens": {
"version": "10.0.0",
"resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-10.0.0.tgz",
"integrity": "sha512-lM/UBzQmfJRo9ABXbPWemivdCW8V2G8FHaHdypQaIy523snUjog0W71ayWXTjiR+ixeMyVHN2XcpnTd/liPg/Q==",
"dev": true,
"license": "MIT"
},
"node_modules/magic-string": {
"version": "0.30.21",
"resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz",
"integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@jridgewell/sourcemap-codec": "^1.5.5"
}
},
"node_modules/magicast": {
"version": "0.5.2",
"resolved": "https://registry.npmjs.org/magicast/-/magicast-0.5.2.tgz",
"integrity": "sha512-E3ZJh4J3S9KfwdjZhe2afj6R9lGIN5Pher1pF39UGrXRqq/VDaGVIGN13BjHd2u8B61hArAGOnso7nBOouW3TQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@babel/parser": "^7.29.0",
"@babel/types": "^7.29.0",
"source-map-js": "^1.2.1"
}
},
"node_modules/make-dir": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/make-dir/-/make-dir-4.0.0.tgz",
"integrity": "sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw==",
"dev": true,
"license": "MIT",
"dependencies": {
"semver": "^7.5.3"
},
"engines": {
"node": ">=10"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/nanoid": {
"version": "3.3.11",
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz",
@@ -962,6 +1424,24 @@
"node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1"
}
},
"node_modules/obug": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/obug/-/obug-2.1.1.tgz",
"integrity": "sha512-uTqF9MuPraAQ+IsnPf366RG4cP9RtUi7MLO1N3KEc+wb0a6yKpeL0lmk2IB1jY5KHPAlTc6T/JRdC/YqxHNwkQ==",
"dev": true,
"funding": [
"https://github.com/sponsors/sxzz",
"https://opencollective.com/debug"
],
"license": "MIT"
},
"node_modules/pathe": {
"version": "2.0.3",
"resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz",
"integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==",
"dev": true,
"license": "MIT"
},
"node_modules/picocolors": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
@@ -1056,6 +1536,26 @@
"fsevents": "~2.3.2"
}
},
"node_modules/semver": {
"version": "7.7.4",
"resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz",
"integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==",
"dev": true,
"license": "ISC",
"bin": {
"semver": "bin/semver.js"
},
"engines": {
"node": ">=10"
}
},
"node_modules/siginfo": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz",
"integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==",
"dev": true,
"license": "ISC"
},
"node_modules/source-map-js": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
@@ -1066,6 +1566,50 @@
"node": ">=0.10.0"
}
},
"node_modules/stackback": {
"version": "0.0.2",
"resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz",
"integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==",
"dev": true,
"license": "MIT"
},
"node_modules/std-env": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/std-env/-/std-env-4.0.0.tgz",
"integrity": "sha512-zUMPtQ/HBY3/50VbpkupYHbRroTRZJPRLvreamgErJVys0ceuzMkD44J/QjqhHjOzK42GQ3QZIeFG1OYfOtKqQ==",
"dev": true,
"license": "MIT"
},
"node_modules/supports-color": {
"version": "7.2.0",
"resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz",
"integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==",
"dev": true,
"license": "MIT",
"dependencies": {
"has-flag": "^4.0.0"
},
"engines": {
"node": ">=8"
}
},
"node_modules/tinybench": {
"version": "2.9.0",
"resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz",
"integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==",
"dev": true,
"license": "MIT"
},
"node_modules/tinyexec": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.1.1.tgz",
"integrity": "sha512-VKS/ZaQhhkKFMANmAOhhXVoIfBXblQxGX1myCQ2faQrfmobMftXeJPcZGp0gS07ocvGJWDLZGyOZDadDBqYIJg==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=18"
}
},
"node_modules/tinyglobby": {
"version": "0.2.15",
"resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz",
@@ -1083,6 +1627,26 @@
"url": "https://github.com/sponsors/SuperchupuDev"
}
},
"node_modules/tinyrainbow": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.1.0.tgz",
"integrity": "sha512-Bf+ILmBgretUrdJxzXM0SgXLZ3XfiaUuOj/IKQHuTXip+05Xn+uyEYdVg0kYDipTBcLrCVyUzAPz7QmArb0mmw==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=14.0.0"
}
},
"node_modules/ts-custom-error": {
"version": "3.3.1",
"resolved": "https://registry.npmjs.org/ts-custom-error/-/ts-custom-error-3.3.1.tgz",
"integrity": "sha512-5OX1tzOjxWEgsr/YEUWSuPrQ00deKLh6D7OTWcvNHm12/7QPyRh8SYpyWvA4IZv8H/+GQWQEh/kwo95Q9OVW1A==",
"license": "MIT",
"peer": true,
"engines": {
"node": ">=14.0.0"
}
},
"node_modules/vite": {
"version": "7.3.1",
"resolved": "https://registry.npmjs.org/vite/-/vite-7.3.1.tgz",
@@ -1157,6 +1721,113 @@
"optional": true
}
}
},
"node_modules/vitest": {
"version": "4.1.4",
"resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.4.tgz",
"integrity": "sha512-tFuJqTxKb8AvfyqMfnavXdzfy3h3sWZRWwfluGbkeR7n0HUev+FmNgZ8SDrRBTVrVCjgH5cA21qGbCffMNtWvg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@vitest/expect": "4.1.4",
"@vitest/mocker": "4.1.4",
"@vitest/pretty-format": "4.1.4",
"@vitest/runner": "4.1.4",
"@vitest/snapshot": "4.1.4",
"@vitest/spy": "4.1.4",
"@vitest/utils": "4.1.4",
"es-module-lexer": "^2.0.0",
"expect-type": "^1.3.0",
"magic-string": "^0.30.21",
"obug": "^2.1.1",
"pathe": "^2.0.3",
"picomatch": "^4.0.3",
"std-env": "^4.0.0-rc.1",
"tinybench": "^2.9.0",
"tinyexec": "^1.0.2",
"tinyglobby": "^0.2.15",
"tinyrainbow": "^3.1.0",
"vite": "^6.0.0 || ^7.0.0 || ^8.0.0",
"why-is-node-running": "^2.3.0"
},
"bin": {
"vitest": "vitest.mjs"
},
"engines": {
"node": "^20.0.0 || ^22.0.0 || >=24.0.0"
},
"funding": {
"url": "https://opencollective.com/vitest"
},
"peerDependencies": {
"@edge-runtime/vm": "*",
"@opentelemetry/api": "^1.9.0",
"@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0",
"@vitest/browser-playwright": "4.1.4",
"@vitest/browser-preview": "4.1.4",
"@vitest/browser-webdriverio": "4.1.4",
"@vitest/coverage-istanbul": "4.1.4",
"@vitest/coverage-v8": "4.1.4",
"@vitest/ui": "4.1.4",
"happy-dom": "*",
"jsdom": "*",
"vite": "^6.0.0 || ^7.0.0 || ^8.0.0"
},
"peerDependenciesMeta": {
"@edge-runtime/vm": {
"optional": true
},
"@opentelemetry/api": {
"optional": true
},
"@types/node": {
"optional": true
},
"@vitest/browser-playwright": {
"optional": true
},
"@vitest/browser-preview": {
"optional": true
},
"@vitest/browser-webdriverio": {
"optional": true
},
"@vitest/coverage-istanbul": {
"optional": true
},
"@vitest/coverage-v8": {
"optional": true
},
"@vitest/ui": {
"optional": true
},
"happy-dom": {
"optional": true
},
"jsdom": {
"optional": true
},
"vite": {
"optional": false
}
}
},
"node_modules/why-is-node-running": {
"version": "2.3.0",
"resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz",
"integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==",
"dev": true,
"license": "MIT",
"dependencies": {
"siginfo": "^2.0.0",
"stackback": "0.0.2"
},
"bin": {
"why-is-node-running": "cli.js"
},
"engines": {
"node": ">=8"
}
}
}
}
+9 -3
View File
@@ -1,18 +1,24 @@
{
"name": "lonc-web",
"version": "0.1.0",
"version": "0.2.0",
"private": true,
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview"
"preview": "vite preview",
"test": "vitest run",
"test:watch": "vitest",
"test:coverage": "vitest run --coverage"
},
"dependencies": {
"@zxing/browser": "^0.1.5",
"alpinejs": "^3.14.9",
"bootstrap": "^5.3.3"
},
"devDependencies": {
"vite": "^7.0.0"
"@vitest/coverage-v8": "^4.1.4",
"vite": "^7.0.0",
"vitest": "^4.1.4"
}
}
+11 -1
View File
@@ -3,7 +3,6 @@ const APP_SHELL = ['/', '/index.html', '/manifest.webmanifest', '/offline.html',
self.addEventListener('install', (event) => {
event.waitUntil(caches.open(CACHE_NAME).then((cache) => cache.addAll(APP_SHELL)));
self.skipWaiting();
});
self.addEventListener('activate', (event) => {
@@ -19,6 +18,12 @@ self.addEventListener('activate', (event) => {
self.clients.claim();
});
self.addEventListener('message', (event) => {
if (event?.data?.type === 'SKIP_WAITING') {
self.skipWaiting();
}
});
self.addEventListener('fetch', (event) => {
if (event.request.method !== 'GET') {
return;
@@ -40,6 +45,11 @@ self.addEventListener('fetch', (event) => {
return;
}
if (requestUrl.pathname === '/version.json') {
event.respondWith(fetch(event.request, { cache: 'no-store' }));
return;
}
const destination = event.request.destination;
if (
destination === 'script' ||
-2
View File
@@ -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,
});
}
+9 -14
View File
@@ -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,
});
}
+1 -3
View File
@@ -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;
}
+54 -1
View File
@@ -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;
}
+1 -3
View File
@@ -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 || [];
+130 -12
View File
@@ -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,107 @@ 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,
};
}
function normalizeIdentifierLookupResponse(payload) {
return {
status: payload?.status || null,
source: payload?.source || null,
cacheHit: Boolean(payload?.cache_hit),
identifierCode: payload?.identifier_code || null,
identifierType: payload?.identifier_type || null,
item: payload?.item || null,
payloadFetchedAt: payload?.payload_fetched_at || null,
retryAfterSeconds:
Number.isInteger(payload?.retry_after_seconds) ? payload.retry_after_seconds : null,
staleCache: Boolean(payload?.stale_cache),
};
}
function normalizeItemLookupResponse(payload) {
return {
status: payload?.status || null,
found: Boolean(payload?.found),
update: Boolean(payload?.update),
identifierCode: payload?.identifier_code || null,
identifierType: payload?.identifier_type || null,
preview: payload?.preview || null,
updatedFields: Array.isArray(payload?.updated_fields) ? payload.updated_fields : [],
offPayloadFetchedAt: payload?.off_payload_fetched_at || null,
retryAfterSeconds:
Number.isInteger(payload?.retry_after_seconds) ? payload.retry_after_seconds : null,
staleCache: Boolean(payload?.stale_cache),
item: payload?.item || 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 lookupItemByIdentifier(store, identifierCode) {
const payload = await apiRequest(store, `${getPath('items')}/lookup`, {
method: 'POST',
body: {
identifier_code: String(identifierCode || '').trim(),
},
});
return normalizeIdentifierLookupResponse(payload);
}
export async function lookupItemDetails(store, uuidB64, { update = false } = {}) {
const payload = await apiRequest(store, `${getPath('items')}/${uuidB64}/lookup`, {
method: 'POST',
query: { update: update ? 1 : 0 },
});
return normalizeItemLookupResponse(payload);
}
export async function patchStockItem(store, uuidB64, body) {
const payload = await apiRequest(store, `${getPath('items')}/${uuidB64}`, {
method: 'PATCH',
body,
});
return unwrapEntryPayload(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 +159,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 : [],
};
}
+205 -15
View File
@@ -2,35 +2,216 @@ 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';
import { navBar } from '../components/nav-bar.js';
import { registerFeatureData } from '../features/register.js';
async function installServiceWorker() {
if (!('serviceWorker' in navigator)) {
return;
const APP_UPDATE_CHECK_INTERVAL_MS = 5 * 60 * 1000;
function createAppUpdateManager() {
let registration = null;
let waitingWorker = null;
async function fetchServerVersion() {
try {
const response = await fetch(`/version.json?ts=${Date.now()}`, {
cache: 'no-store',
headers: {
'cache-control': 'no-cache',
pragma: 'no-cache',
},
});
if (!response.ok) {
throw new Error(`Version endpoint failed with HTTP ${response.status}`);
}
const payload = await response.json();
const serverVersion = String(payload?.version || '').trim();
const serverBuildTime = String(payload?.buildTime || '').trim();
return {
serverVersion: serverVersion || null,
serverBuildTime: serverBuildTime || null,
};
} catch (error) {
return {
serverVersion: null,
serverBuildTime: null,
error: error instanceof Error ? error.message : 'Unable to reach version endpoint.',
};
}
}
if (import.meta.env.DEV) {
const registrations = await navigator.serviceWorker.getRegistrations();
await Promise.all(registrations.map((registration) => registration.unregister()));
return;
function syncWaitingWorker() {
waitingWorker = registration?.waiting || null;
}
await navigator.serviceWorker.register('/service-worker.js');
function setupRegistrationHooks() {
if (!registration) {
return;
}
syncWaitingWorker();
registration.addEventListener('updatefound', () => {
const installing = registration.installing;
if (!installing) {
return;
}
installing.addEventListener('statechange', () => {
if (installing.state === 'installed' && navigator.serviceWorker.controller) {
waitingWorker = registration.waiting || installing;
}
});
});
}
async function installServiceWorker() {
if (!('serviceWorker' in navigator)) {
return { supported: false };
}
if (import.meta.env.DEV) {
const registrations = await navigator.serviceWorker.getRegistrations();
await Promise.all(registrations.map((existingRegistration) => existingRegistration.unregister()));
return { supported: false, development: true };
}
registration = await navigator.serviceWorker.register('/service-worker.js');
setupRegistrationHooks();
return {
supported: true,
registered: true,
};
}
async function checkForAppUpdate() {
if (registration) {
await registration.update().catch(() => {});
syncWaitingWorker();
}
const server = await fetchServerVersion();
const hasVersionMismatch = Boolean(server.serverVersion && server.serverVersion !== APP_VERSION);
return {
supported: 'serviceWorker' in navigator,
currentVersion: APP_VERSION,
serverVersion: server.serverVersion,
serverBuildTime: server.serverBuildTime,
waitingWorker: Boolean(waitingWorker),
updateAvailable: Boolean(waitingWorker) || hasVersionMismatch,
hasVersionMismatch,
serverError: server.error || null,
};
}
async function waitForControllerChange(previousController, timeoutMs = 4000) {
if (!('serviceWorker' in navigator)) {
return;
}
if (navigator.serviceWorker.controller && navigator.serviceWorker.controller !== previousController) {
return;
}
await new Promise((resolve) => {
let isDone = false;
const finish = () => {
if (isDone) {
return;
}
isDone = true;
navigator.serviceWorker.removeEventListener('controllerchange', onControllerChange);
clearTimeout(timeout);
resolve();
};
const onControllerChange = () => {
finish();
};
const timeout = setTimeout(finish, timeoutMs);
navigator.serviceWorker.addEventListener('controllerchange', onControllerChange);
});
}
async function clearAllServiceWorkerCaches() {
if (!('caches' in window)) {
return;
}
const keys = await caches.keys();
await Promise.all(keys.map((key) => caches.delete(key)));
}
async function applyAppUpdate() {
const previousController = navigator.serviceWorker?.controller || null;
if (registration) {
await registration.update().catch(() => {});
syncWaitingWorker();
}
if (waitingWorker) {
waitingWorker.postMessage({ type: 'SKIP_WAITING' });
await waitForControllerChange(previousController);
}
await clearAllServiceWorkerCaches().catch(() => {});
const nextUrl = new URL(window.location.href);
nextUrl.searchParams.set('app_update', String(Date.now()));
window.location.replace(nextUrl.toString());
}
function startPeriodicChecks() {
if (!registration) {
return;
}
window.setInterval(() => {
checkForAppUpdate().catch(() => {});
}, APP_UPDATE_CHECK_INTERVAL_MS);
document.addEventListener('visibilitychange', () => {
if (document.visibilityState === 'visible') {
checkForAppUpdate().catch(() => {});
}
});
}
return {
installServiceWorker,
checkForAppUpdate,
applyAppUpdate,
startPeriodicChecks,
};
}
export function bootstrapApp() {
const store = createAppStore();
Alpine.store('app', store);
const appUpdateManager = createAppUpdateManager();
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');
@@ -100,6 +281,12 @@ export function bootstrapApp() {
renderNav();
return result;
},
async checkForAppUpdate() {
return appUpdateManager.checkForAppUpdate();
},
async applyAppUpdate() {
return appUpdateManager.applyAppUpdate();
},
handleAuthFailure(error) {
if (!store.session?.applicationKey || !store.session?.hasValidated || authFailureHandled) {
return;
@@ -148,10 +335,13 @@ export function bootstrapApp() {
renderNav();
installServiceWorker().catch(() => {
store.addAlert({
type: 'warning',
message: 'PWA installation support could not be initialized.',
appUpdateManager
.installServiceWorker()
.then(() => appUpdateManager.startPeriodicChecks())
.catch(() => {
store.addAlert({
type: 'warning',
message: 'PWA installation support could not be initialized.',
});
});
});
}
+2 -1
View File
@@ -1,4 +1,5 @@
export const APP_NAME = 'Lonc';
export const APP_VERSION = typeof __APP_VERSION__ !== 'undefined' ? __APP_VERSION__ : '0.2.0';
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 = {
+11 -1
View File
@@ -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 `
<div class="app-shell d-flex flex-column min-vh-100">
<div id="app-nav">
${navBar(appName)}
</div>
<main id="route-view" class="flex-grow-1"></main>
<footer class="app-footer border-top mt-auto py-3">
<div class="container-xxl d-flex flex-column flex-md-row justify-content-between align-items-md-center gap-1 small">
<div>&copy; ${currentYear} AKLARO</div>
<div class="text-body-secondary">
${appName} v${appVersion}${runtimeMode} mode • PWA frontend for Tryton kitchen
</div>
</div>
</footer>
<div class="toast-stack" x-data="alertsData()">
<template x-for="alert in alerts" :key="alert.id">
<div class="toast show align-items-center border-0 mb-2 text-bg-dark" role="status">
+129 -1
View File
@@ -1,10 +1,12 @@
import { APP_VERSION } from '../../app/config.js';
export function renderSettingsPage() {
return `
<section class="container-xxl py-4 py-lg-5">
<div class="row g-4">
<div class="col-12 col-lg-7">
<div class="card border-0 shadow-sm">
<div class="card-body p-4" x-data="settingsPage()">
<div class="card-body p-4" x-data="settingsPage()" x-init="initUpdatePanel()">
<div class="d-flex justify-content-between align-items-start mb-4">
<div>
<p class="eyebrow mb-2">Client Settings</p>
@@ -40,6 +42,45 @@ export function renderSettingsPage() {
</div>
<button class="btn btn-primary align-self-start" type="submit">Save settings</button>
</form>
<hr class="my-4" />
<div class="vstack gap-3">
<div>
<h2 class="h5 mb-1">App update</h2>
<p class="text-body-secondary mb-0">
Check for the latest deployed build and force-refresh this installed app when needed.
</p>
</div>
<div class="row g-3 small">
<div class="col-12 col-md-6">
<div class="border rounded-3 p-3 h-100 bg-body-tertiary">
<div class="text-uppercase text-body-secondary fw-semibold mb-1">Current version</div>
<div class="fw-semibold" x-text="update.currentVersion"></div>
</div>
</div>
<div class="col-12 col-md-6">
<div class="border rounded-3 p-3 h-100 bg-body-tertiary">
<div class="text-uppercase text-body-secondary fw-semibold mb-1">Server version</div>
<div class="fw-semibold" x-text="update.serverVersion || 'Unavailable'"></div>
<template x-if="update.serverBuildTime">
<div class="text-body-secondary mt-1" x-text="'Built: ' + formatBuildTime(update.serverBuildTime)"></div>
</template>
</div>
</div>
</div>
<div class="d-flex flex-wrap align-items-center gap-2">
<button class="btn btn-outline-secondary" type="button" @click="checkForUpdates()" :disabled="update.isChecking || update.isApplying">
<span x-show="!update.isChecking">Check for updates</span>
<span x-show="update.isChecking">Checking...</span>
</button>
<button class="btn btn-primary" type="button" @click="applyUpdate()" :disabled="update.isApplying">
<span x-show="!update.isApplying">Update app</span>
<span x-show="update.isApplying">Updating...</span>
</button>
</div>
<div class="small" :class="updateStatusClass()" x-text="update.statusText"></div>
</div>
</div>
</div>
</div>
@@ -68,6 +109,15 @@ export function settingsPageData(store) {
baseUrl: store.config.baseUrl || '',
database: store.config.database || '',
},
update: {
currentVersion: APP_VERSION,
serverVersion: null,
serverBuildTime: null,
statusText: 'Ready to check for updates.',
statusType: 'secondary',
isChecking: false,
isApplying: false,
},
get userLogin() {
return store.session?.userLogin || '';
},
@@ -83,5 +133,83 @@ export function settingsPageData(store) {
store.addAlert({ type: 'success', message: 'Settings saved locally.' });
},
formatBuildTime(value) {
const date = new Date(value);
if (Number.isNaN(date.getTime())) {
return value;
}
return date.toLocaleString();
},
updateStatusClass() {
switch (this.update.statusType) {
case 'success':
return 'text-success';
case 'warning':
return 'text-warning';
case 'danger':
return 'text-danger';
default:
return 'text-body-secondary';
}
},
async initUpdatePanel() {
await this.checkForUpdates();
},
async checkForUpdates() {
if (!window.__loncApp?.checkForAppUpdate) {
this.update.statusText = 'Service worker updates are not available in this browser.';
this.update.statusType = 'warning';
return;
}
this.update.isChecking = true;
this.update.statusText = 'Checking for updates...';
this.update.statusType = 'secondary';
try {
const result = await window.__loncApp.checkForAppUpdate();
this.update.currentVersion = result.currentVersion || APP_VERSION;
this.update.serverVersion = result.serverVersion || null;
this.update.serverBuildTime = result.serverBuildTime || null;
if (result.updateAvailable) {
this.update.statusText = 'Update available. Use "Update app" to refresh this installed build.';
this.update.statusType = 'warning';
} else if (result.serverError) {
this.update.statusText = `No update signal from server (${result.serverError}).`;
this.update.statusType = 'secondary';
} else {
this.update.statusText = 'This app is up to date.';
this.update.statusType = 'success';
}
} catch (error) {
const message = error instanceof Error ? error.message : 'Unknown update check error.';
this.update.statusText = `Update check failed: ${message}`;
this.update.statusType = 'danger';
} finally {
this.update.isChecking = false;
}
},
async applyUpdate() {
if (!window.__loncApp?.applyAppUpdate) {
this.update.statusText = 'Update action is not available in this browser.';
this.update.statusType = 'warning';
return;
}
this.update.isApplying = true;
this.update.statusText = 'Applying update and refreshing app...';
this.update.statusType = 'warning';
try {
await window.__loncApp.applyAppUpdate();
} catch (error) {
const message = error instanceof Error ? error.message : 'Unknown update error.';
this.update.statusText = `Update failed: ${message}`;
this.update.statusType = 'danger';
this.update.isApplying = false;
}
},
};
}
+232 -1
View File
@@ -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 `
<section class="container-xxl py-4 py-lg-5" x-data="dashboardPage()">
<section class="container-xxl py-4 py-lg-5" x-data="dashboardPage()" x-init="init()">
<div class="hero-card p-4 p-lg-5 mb-4">
<div class="row align-items-center g-4">
<div class="col-12 col-lg-7">
@@ -80,6 +84,52 @@ export function renderDashboardPage() {
</a>
</div>
</div>
<div class="row g-4 mt-1">
<div class="col-12">
<div class="card border-0 shadow-sm">
<div class="card-body p-4">
<div class="d-flex flex-wrap justify-content-between align-items-center gap-2 mb-3">
<div>
<h2 class="h5 mb-1">Recent changes</h2>
<p class="text-body-secondary mb-0 small">Latest item and stock updates from the kitchen change feed.</p>
<p class="text-body-secondary mb-0 small">Saved means the backend created or updated a record.</p>
</div>
<button class="btn btn-outline-secondary btn-sm" @click="refreshChanges()" :disabled="changesState.isLoading">
<span x-show="!changesState.isLoading">Refresh</span>
<span x-show="changesState.isLoading">Refreshing...</span>
</button>
</div>
<template x-if="changesState.error">
<div class="alert alert-warning mb-0" x-text="changesState.error"></div>
</template>
<template x-if="!changesState.error && changesState.isLoading && !recentChanges.length">
<div class="text-body-secondary">Loading changes...</div>
</template>
<template x-if="!changesState.error && !changesState.isLoading && !recentChanges.length">
<div class="text-body-secondary">No recent changes yet.</div>
</template>
<template x-if="recentChanges.length">
<div class="list-group list-group-flush">
<template x-for="(change, index) in recentChanges" :key="change.timestamp || index">
<div class="list-group-item px-0">
<div class="d-flex flex-wrap justify-content-between align-items-center gap-2">
<div class="fw-semibold" x-text="changeHeadline(change)"></div>
<div class="small text-body-secondary" x-text="formatChangeTimestamp(change.timestamp)"></div>
</div>
<div class="small text-body-secondary mt-1" x-text="changeStateLine(change)"></div>
</div>
</template>
</div>
</template>
</div>
</div>
</div>
</div>
</section>
`;
}
@@ -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();
},
};
}
@@ -0,0 +1,156 @@
const ISO_DATE_PATTERN = /^\d{4}-\d{2}-\d{2}$/;
function normalizedText(value) {
return typeof value === 'string' ? value.trim() : '';
}
function nonEmptyText(value) {
const text = normalizedText(value);
return text ? text : null;
}
function normalizedNumberText(value) {
if (typeof value === 'number' && Number.isFinite(value)) {
return String(value);
}
if (typeof value === 'string') {
const text = value.trim();
if (!text) {
return null;
}
const parsed = Number(text);
if (Number.isFinite(parsed)) {
return String(parsed);
}
}
return '';
}
function parseIsoDate(isoDate) {
if (!ISO_DATE_PATTERN.test(String(isoDate || ''))) {
return null;
}
const [year, month, day] = String(isoDate).split('-').map(Number);
const parsed = new Date(year, month - 1, day);
if (
parsed.getFullYear() !== year
|| parsed.getMonth() !== month - 1
|| parsed.getDate() !== day
) {
return null;
}
return parsed;
}
function addDaysToIsoDate(isoDate, days) {
const parsed = parseIsoDate(isoDate);
if (!parsed) {
return '';
}
parsed.setDate(parsed.getDate() + days);
const year = parsed.getFullYear();
const month = String(parsed.getMonth() + 1).padStart(2, '0');
const day = String(parsed.getDate()).padStart(2, '0');
return `${year}-${month}-${day}`;
}
function diffIsoDays(fromIsoDate, toIsoDate) {
const fromDate = parseIsoDate(fromIsoDate);
const toDate = parseIsoDate(toIsoDate);
if (!fromDate || !toDate) {
return null;
}
const millisecondsPerDay = 24 * 60 * 60 * 1000;
const diff = Math.round((toDate - fromDate) / millisecondsPerDay);
return diff >= 0 ? diff : null;
}
function nonNegativeDays(value) {
if (value == null) {
return null;
}
const parsed = Number(value);
if (!Number.isFinite(parsed) || parsed < 0) {
return null;
}
return Math.round(parsed);
}
export function deriveLookupExpirationDays(lookupItem) {
const explicitDays = nonNegativeDays(lookupItem?.expiration_days);
if (explicitDays !== null) {
return explicitDays;
}
return diffIsoDays(lookupItem?.date, lookupItem?.expire_date);
}
export function mapLookupItemToForm({
form,
lookupItem,
locations = [],
}) {
const nextForm = { ...form };
let nextLocationSearch = null;
let didUpdate = false;
const setField = (targetField, value) => {
if (nextForm[targetField] === value) {
return;
}
nextForm[targetField] = value;
didUpdate = true;
};
const textValue = (sourceField) => normalizedText(lookupItem?.[sourceField]);
const numberValue = (sourceField) => normalizedNumberText(lookupItem?.[sourceField]);
setField('identifierCode', textValue('identifier_code'));
setField('name', textValue('name'));
setField('description', textValue('description'));
setField('level', textValue('level'));
setField('quantity', numberValue('quantity_initial'));
setField('uom', textValue('uom_symbol'));
setField('energy', numberValue('calories'));
setField('energyUnit', textValue('calories_unit'));
setField('externalSource', textValue('external_source'));
setField('externalId', textValue('external_id'));
setField('search', nextForm.name);
const expirationDays = deriveLookupExpirationDays(lookupItem);
if (expirationDays !== null) {
setField('expireDays', String(expirationDays));
setField('expirationDate', addDaysToIsoDate(nextForm.productionDate, expirationDays));
} else {
setField('expireDays', '');
setField('expirationDate', '');
}
const locationUuid = nonEmptyText(lookupItem?.location_initial_uuid_b64);
if (locationUuid) {
const matchingLocation = locations.find((entry) => entry.uuid_b64 === locationUuid);
if (matchingLocation) {
setField('locationId', String(matchingLocation.id));
if (nextLocationSearch !== matchingLocation.name) {
nextLocationSearch = matchingLocation.name;
}
}
}
return {
form: nextForm,
locationSearch: nextLocationSearch,
didUpdate,
};
}
+556 -39
View File
@@ -1,6 +1,17 @@
import { createStockEntry, searchItemDefinitions } from '../../api/stock.js';
import {
applyItemUpsert,
lookupItemByIdentifier,
previewItemUpsert,
searchItemDefinitions,
} from '../../api/stock.js';
import { BrowserMultiFormatReader } from '@zxing/browser';
import { mapLookupItemToForm } from './identifier-lookup-mapper.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';
@@ -46,28 +57,75 @@ export function renderLabelCreatePage() {
<div class="card border-0 shadow-sm">
<div class="card-body p-4">
<form class="vstack gap-3" @submit.prevent="create()" autocomplete="off" x-ref="labelForm">
<div class="position-relative search-field-with-clear">
<label class="form-label">Search item definitions</label>
<input class="form-control pe-5" type="text" x-model="form.search" @input="onSearchInput()" placeholder="Search by item name" autocomplete="off" />
<button
type="button"
class="btn btn-sm btn-link text-body-secondary clear-field-button search-clear-button"
x-show="form.search || form.itemId"
@click="clearItemSearch()"
aria-label="Clear item search"
>
&times;
</button>
<template x-if="suggestions.length">
<div class="list-group shadow-sm position-absolute start-0 end-0 z-3 mt-1 search-suggestions-picker">
<template x-for="item in suggestions" :key="item.id">
<button class="list-group-item list-group-item-action" type="button" @click="pickSuggestion(item)">
<div class="fw-semibold" x-text="item.name"></div>
<div class="small text-body-secondary" x-text="item.description || 'Existing item definition'"></div>
</button>
</template>
<div class="row g-3 align-items-start">
<div class="col-12 col-md-6 position-relative search-field-with-clear">
<label class="form-label">Search item definitions</label>
<input class="form-control pe-5" type="text" x-model="form.search" @input="onSearchInput()" placeholder="Search by item name" autocomplete="off" />
<button
type="button"
class="btn btn-sm btn-link text-body-secondary clear-field-button search-clear-button"
x-show="form.search || form.itemId"
@click="clearItemSearch()"
aria-label="Clear item search"
>
&times;
</button>
<template x-if="suggestions.length">
<div class="list-group shadow-sm position-absolute start-0 end-0 z-3 mt-1 search-suggestions-picker">
<template x-for="item in suggestions" :key="item.id">
<button class="list-group-item list-group-item-action" type="button" @click="pickSuggestion(item)">
<div class="fw-semibold" x-text="item.name"></div>
<div class="small text-body-secondary" x-text="item.description || 'Existing item definition'"></div>
</button>
</template>
</div>
</template>
</div>
<div class="col-12 col-md-6">
<label class="form-label">Identifier code</label>
<div class="input-group">
<input
class="form-control"
type="text"
x-model="form.identifierCode"
@input="lookupState.error = ''"
inputmode="numeric"
autocomplete="off"
placeholder="EAN / UPC / GTIN"
/>
<button
class="btn btn-outline-primary"
type="button"
@click="lookupIdentifierDetails()"
:disabled="lookupState.isLoading || !hasLookupIdentifierCode()"
>
<span x-show="!lookupState.isLoading">Lookup</span>
<span x-show="lookupState.isLoading">Looking up...</span>
</button>
<button
class="btn btn-outline-secondary"
type="button"
@click="openScanner()"
x-show="scannerState.hasCamera"
>
Camera
</button>
<button
class="btn btn-outline-secondary"
type="button"
x-show="form.identifierCode"
@click="form.identifierCode = ''"
>
Clear
</button>
</div>
</template>
<div class="form-text">
Optional. Scan with camera or enter manually.
</div>
<template x-if="lookupState.error">
<div class="small text-danger mt-1" x-text="lookupState.error"></div>
</template>
</div>
</div>
<div class="row g-3">
@@ -400,18 +458,36 @@ export function renderLabelCreatePage() {
<div class="alert alert-success mb-0" x-text="successMessage"></div>
</template>
<div class="d-flex flex-wrap justify-content-between align-items-center gap-2">
<div class="d-flex flex-wrap gap-2">
<button class="btn btn-outline-primary" type="button" @click="preview()" :disabled="previewState.isLoading">
<template x-if="upsertPreview?.mode === 'preview' && !upsertPreview.error">
<div class="alert alert-info mb-0 py-2" x-text="upsertPreviewSummary()"></div>
</template>
<template x-if="upsertPreview?.error">
<div class="alert alert-warning mb-0 py-2" x-text="upsertPreview.error"></div>
</template>
<template x-if="printIssue">
<div class="alert alert-warning mb-0 py-2" x-text="printIssue"></div>
</template>
<div class="d-flex flex-wrap justify-content-between align-items-center gap-2 label-actions-row">
<div class="d-flex flex-wrap gap-2 label-actions-primary">
<button class="btn btn-outline-primary label-action-btn" type="button" @click="preview()" :disabled="previewState.isLoading">
<span x-show="!previewState.isLoading">Preview label</span>
<span x-show="previewState.isLoading">Rendering preview...</span>
</button>
<button class="btn btn-primary" type="submit" :disabled="createState.isLoading">
<span x-show="!createState.isLoading">Create stock entry</span>
<span x-show="createState.isLoading">Saving...</span>
</button>
<div class="input-group input-group-label-submit">
<span class="input-group-text">
<input class="form-check-input mt-0 me-2" type="checkbox" x-model="printLabelOnSave" aria-label="Print label on save" />
Print
</span>
<button class="btn btn-primary label-action-btn" type="submit" :disabled="createState.isLoading">
<span x-show="!createState.isLoading">Save stock entry</span>
<span x-show="createState.isLoading">Saving...</span>
</button>
</div>
</div>
<button class="btn btn-outline-secondary" type="button" @click="reset()">Clear form</button>
<button class="btn btn-outline-secondary label-action-btn" type="button" @click="reset()">Clear form</button>
</div>
<div class="small text-body-secondary">
<span class="text-danger">*</span> Required field
@@ -444,6 +520,39 @@ export function renderLabelCreatePage() {
</div>
</div>
</div>
<div
class="scanner-modal-backdrop"
x-show="scannerState.isOpen"
@click.self="closeScanner()"
@keydown.escape.window="closeScanner()"
>
<div class="scanner-modal card border-0 shadow-lg">
<div class="card-body p-3 p-md-4">
<div class="d-flex align-items-center justify-content-between gap-3 mb-3">
<div>
<h2 class="h5 mb-1">Scan barcode</h2>
<p class="text-body-secondary small mb-0">Point your camera at the barcode to fill the identifier field.</p>
</div>
<button class="btn btn-sm btn-outline-secondary" type="button" @click="closeScanner()">Close</button>
</div>
<div class="scanner-video-shell mb-3">
<video class="scanner-video" x-ref="scannerVideo" autoplay muted playsinline></video>
</div>
<div class="d-flex flex-wrap align-items-center gap-2">
<div class="small text-body-secondary" x-show="scannerState.isLoading">Starting camera...</div>
<div class="small text-success" x-show="scannerState.lastDetectedCode" x-text="'Detected: ' + scannerState.lastDetectedCode"></div>
<button class="btn btn-outline-secondary btn-sm" type="button" @click="startScanner()" :disabled="scannerState.isLoading">Retry</button>
</div>
<template x-if="scannerState.error">
<div class="alert alert-warning py-2 mt-3 mb-0" x-text="scannerState.error"></div>
</template>
</div>
</div>
</div>
</section>
`;
}
@@ -478,6 +587,10 @@ function diffDays(fromIsoDate, toIsoDate) {
function createDefaultForm() {
return {
itemId: '',
itemUuidB64: '',
identifierCode: '',
externalSource: '',
externalId: '',
search: '',
name: '',
description: '',
@@ -505,6 +618,10 @@ function loadLabelDraft() {
? ''
: draft.quantity,
itemId: '',
itemUuidB64: '',
identifierCode: '',
externalSource: '',
externalId: '',
search: '',
};
}
@@ -513,6 +630,10 @@ function buildDraftPayload(form) {
return {
...form,
itemId: '',
itemUuidB64: '',
identifierCode: '',
externalSource: '',
externalId: '',
search: '',
};
}
@@ -521,6 +642,7 @@ export function labelCreatePageData(store) {
return {
previewState: createAsyncState(),
createState: createAsyncState(),
lookupState: createAsyncState(),
stockTypeOptions: STOCK_TYPE_OPTIONS,
stockLevelOptions: STOCK_LEVEL_OPTIONS,
quantityUnitOptions: QUANTITY_UNIT_OPTIONS,
@@ -536,10 +658,23 @@ export function labelCreatePageData(store) {
successMessage: '',
submitError: '',
fieldErrors: {},
upsertPreview: null,
printLabelOnSave: true,
printIssue: '',
scannerReader: null,
scannerControls: null,
scannerState: {
isOpen: false,
isLoading: false,
hasCamera: false,
error: '',
lastDetectedCode: '',
},
form: {
...loadLabelDraft(),
},
async init() {
this.scannerState.hasCamera = this.canUseCameraScanner();
if (!store.isConnected) {
return;
}
@@ -573,6 +708,300 @@ export function labelCreatePageData(store) {
this.suggestions = await searchItemDefinitions(store, this.form.search.trim());
}, 250);
},
destroy() {
this.stopScanner();
},
canUseCameraScanner() {
return Boolean(
typeof navigator !== 'undefined'
&& navigator.mediaDevices
&& typeof navigator.mediaDevices.getUserMedia === 'function',
);
},
normalizeIdentifierCode(value) {
return String(value || '').replace(/\s+/g, '').trim();
},
hasLookupIdentifierCode() {
return Boolean(this.normalizeIdentifierCode(this.form.identifierCode));
},
lookupStatusMessage(status, identifierCode) {
const normalizedCode = this.normalizeIdentifierCode(identifierCode);
if (!normalizedCode || status === 'missing_identifier') {
return 'Provide an identifier code before lookup.';
}
if (status === 'not_found') {
return `No lookup result found for code ${normalizedCode}.`;
}
if (status === 'lookup_failed') {
return 'Lookup failed on the server. You can still fill the form manually.';
}
if (status === 'rate_limited') {
return 'Lookup is temporarily rate-limited. Try again shortly.';
}
return 'Lookup response could not be applied to this form.';
},
lookupStatusMessageWithDetails(response, identifierCode) {
const base = this.lookupStatusMessage(response?.status, identifierCode);
if (response?.status !== 'rate_limited') {
return base;
}
if (!Number.isInteger(response?.retryAfterSeconds) || response.retryAfterSeconds <= 0) {
return base;
}
return `${base} Retry in ${response.retryAfterSeconds}s.`;
},
lookupSourceLabel(source) {
if (!source) {
return '';
}
const labels = {
item: 'existing item',
cache: 'cache',
openfoodfacts: 'OpenFoodFacts',
};
return labels[source] || source;
},
lookupSuccessMessage(response) {
const parts = ['Lookup applied product details'];
const metadata = [];
if (response?.source) {
metadata.push(`source: ${this.lookupSourceLabel(response.source)}`);
}
if (response?.cacheHit) {
metadata.push('cache hit');
}
if (response?.staleCache) {
metadata.push('stale cache');
}
if (response?.payloadFetchedAt) {
const fetchedAt = new Date(response.payloadFetchedAt);
metadata.push(
`fetched: ${
Number.isNaN(fetchedAt.getTime())
? response.payloadFetchedAt
: fetchedAt.toLocaleString()
}`,
);
}
if (metadata.length) {
parts.push(`(${metadata.join(', ')})`);
}
return `${parts.join(' ')}.`;
},
normalizeScannerError(error) {
const message = String(error?.message || '');
const normalized = message.toLowerCase();
if (error?.name === 'NotAllowedError' || normalized.includes('permission')) {
return 'Camera access was denied. Allow access to scan, or enter the code manually.';
}
if (error?.name === 'NotFoundError' || normalized.includes('requested device not found')) {
return 'No camera was found on this device. Enter the identifier code manually.';
}
if (error?.name === 'NotReadableError' || normalized.includes('could not start video source')) {
return 'Camera is busy in another app. Close it there and try scanning again.';
}
return 'Could not start barcode scanning. Enter the identifier code manually.';
},
async openScanner() {
this.scannerState.error = '';
this.scannerState.lastDetectedCode = '';
this.scannerState.isOpen = true;
await this.$nextTick();
await this.startScanner();
},
async startScanner() {
this.scannerState.error = '';
this.scannerState.lastDetectedCode = '';
if (!this.canUseCameraScanner()) {
this.scannerState.hasCamera = false;
this.scannerState.error = 'Camera scanning is not supported in this browser. Enter the identifier code manually.';
return;
}
const videoElement = this.$refs.scannerVideo;
if (!videoElement) {
this.scannerState.error = 'Scanner video element is unavailable. Close and reopen scanner.';
return;
}
this.stopScanner();
this.scannerState.isLoading = true;
try {
if (!this.scannerReader) {
this.scannerReader = new BrowserMultiFormatReader();
}
this.scannerControls = await this.scannerReader.decodeFromConstraints(
{
audio: false,
video: {
facingMode: { ideal: 'environment' },
},
},
videoElement,
(result, error) => {
if (result) {
this.onBarcodeDetected(result.getText?.() || '');
return;
}
if (!error || error?.name === 'NotFoundException') {
return;
}
if (!this.scannerState.error) {
this.scannerState.error = this.normalizeScannerError(error);
}
},
);
} catch (error) {
this.scannerState.error = this.normalizeScannerError(error);
} finally {
this.scannerState.isLoading = false;
}
},
stopScanner() {
try {
this.scannerControls?.stop?.();
} catch {
// Ignore cleanup errors when scanner is already stopped.
}
this.scannerControls = null;
try {
this.scannerReader?.reset?.();
} catch {
// Ignore cleanup errors from stale reader state.
}
const videoElement = this.$refs.scannerVideo;
const stream = videoElement?.srcObject;
if (stream && typeof stream.getTracks === 'function') {
stream.getTracks().forEach((track) => track.stop());
}
if (videoElement) {
videoElement.srcObject = null;
}
},
closeScanner() {
this.stopScanner();
this.scannerState.isOpen = false;
this.scannerState.isLoading = false;
this.scannerState.error = '';
},
onBarcodeDetected(rawCode) {
const code = this.normalizeIdentifierCode(rawCode);
if (!code || !this.scannerState.isOpen) {
return;
}
this.form.identifierCode = code;
this.scannerState.lastDetectedCode = code;
this.closeScanner();
store.addAlert({
type: 'success',
message: `Scanned identifier code: ${code}`,
});
},
async lookupIdentifierDetails() {
const identifierCode = this.normalizeIdentifierCode(this.form.identifierCode);
this.form.identifierCode = identifierCode;
this.lookupState.error = '';
if (!identifierCode) {
this.lookupState.error = 'Provide an identifier code before lookup.';
return;
}
await runAsyncState(this.lookupState, async () => {
const response = await lookupItemByIdentifier(store, identifierCode);
if (response.status !== 'ok') {
const message = this.lookupStatusMessageWithDetails(response, identifierCode);
this.lookupState.error = message;
store.addAlert({
type: response.status === 'not_found' ? 'info' : 'warning',
message,
});
return;
}
if (!response.item || typeof response.item !== 'object') {
const message = 'Lookup returned no item payload to apply.';
this.lookupState.error = message;
store.addAlert({
type: 'warning',
message,
});
return;
}
const mapped = mapLookupItemToForm({
form: this.form,
lookupItem: response.item,
locations: this.locations,
});
if (!mapped.didUpdate) {
const message = 'Lookup finished, but no compatible fields were returned.';
this.lookupState.error = message;
store.addAlert({
type: 'info',
message,
});
return;
}
this.form = {
...mapped.form,
itemId: '',
itemUuidB64: '',
};
if (mapped.locationSearch !== null) {
this.locationSearch = mapped.locationSearch;
}
this.syncStockTypeState(this.form.stockType);
this.syncStockTypeSelect();
this.syncStockLevelSelect();
this.syncLocationValidity();
this.upsertPreview = null;
this.previewState.error = '';
this.submitError = '';
if (this.previewUrl.startsWith('blob:')) {
URL.revokeObjectURL(this.previewUrl);
}
this.previewUrl = '';
this.suggestions = [];
this.persistDraft();
store.addAlert({
type: 'success',
message: this.lookupSuccessMessage(response),
});
}).catch((error) => {
store.addAlert({
type: 'warning',
message: `Could not complete lookup: ${error.message || 'Unknown lookup error.'}`,
});
});
},
async loadLocations() {
if (!store.isConnected) {
return;
@@ -590,6 +1019,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 +1040,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 +1064,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 +1354,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 +1368,56 @@ 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.mode !== 'preview') {
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;
this.printIssue = '';
if (!this.validateBeforeSubmit()) {
this.previewState.error = 'Please fill out the required fields before previewing the label.';
@@ -940,30 +1431,52 @@ 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();
});
},
async create() {
this.submitError = '';
this.fieldErrors = {};
this.printIssue = '';
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());
if (this.previewUrl && this.previewUrl.startsWith('blob:')) {
URL.revokeObjectURL(this.previewUrl);
const entry = await applyItemUpsert(store, this.buildUpsertPayload());
const entryName = entry.item?.name || this.form.name;
const operationVerb = entry.operation === 'update' ? 'updated' : 'created';
const createdUuidB64 = entry.item?.uuid_b64 || null;
if (this.printLabelOnSave && createdUuidB64) {
try {
await printItemLabel(store, createdUuidB64);
} catch (printError) {
const parsedPrintMessage = formatPrintErrorMessage(printError);
this.printIssue = parsedPrintMessage;
store.addAlert({
type: 'warning',
message: `${entryName} was ${operationVerb}, but printing has an issue: ${parsedPrintMessage}`,
});
}
}
this.previewUrl = '';
this.successMessage = `${entry.name || this.form.name} was created successfully.`;
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);
@@ -973,6 +1486,7 @@ export function labelCreatePageData(store) {
}).catch(() => {});
},
reset(revokePreview = true) {
this.closeScanner();
this.form = createDefaultForm();
this.syncStockTypeState(this.form.stockType);
this.suggestions = [];
@@ -980,7 +1494,10 @@ export function labelCreatePageData(store) {
this.locationPickerOpen = false;
this.successMessage = '';
this.submitError = '';
this.lookupState.error = '';
this.fieldErrors = {};
this.upsertPreview = null;
this.printIssue = '';
saveStoredValue(STORAGE_KEYS.labelDraft, this.form);
if (revokePreview && this.previewUrl.startsWith('blob:')) {
URL.revokeObjectURL(this.previewUrl);
+520 -6
View File
@@ -1,12 +1,94 @@
import {
adjustStockEntry,
deleteStockItem,
getStockEntry,
lookupItemDetails,
patchStockItem,
useStockItem,
} from '../../api/stock.js';
import { formatPrintErrorMessage, printItemLabel } from '../../api/labels.js';
import { fetchLocations } from '../../api/locations.js';
import { getRouteContext } from '../../app/router.js';
import { createAsyncState, runAsyncState } from '../shared/ui-state.js';
import { formatDate } from '../shared/date-utils.js';
function todayAtMidnight() {
const now = new Date();
return new Date(now.getFullYear(), now.getMonth(), now.getDate());
}
function parseDateValue(value) {
if (!value) {
return null;
}
const [year, month, day] = String(value).split('-').map(Number);
if (!year || !month || !day) {
return null;
}
return new Date(year, month - 1, day);
}
function normalizeIdentifierCode(value) {
return String(value || '').replace(/\s+/g, '').trim();
}
function expirationInfo(entry) {
if (!entry?.expire_date) {
return {
key: 'none',
label: 'No expiration date',
detail: 'No expiration date',
};
}
const expireDate = parseDateValue(entry.expire_date);
const expireIn =
typeof entry.expire_in === 'number'
? entry.expire_in
: expireDate
? Math.round((expireDate - todayAtMidnight()) / (24 * 60 * 60 * 1000))
: null;
if (expireIn === null) {
return {
key: 'none',
label: 'No expiration date',
detail: 'No expiration date',
};
}
if (expireIn < 0) {
return {
key: 'expired',
label: 'Expired',
detail: `Expired ${Math.abs(expireIn)} day${Math.abs(expireIn) === 1 ? '' : 's'} ago`,
};
}
if (expireIn <= 2) {
return {
key: 'use-first',
label: expireIn === 0 ? 'Use today' : 'Use first',
detail: expireIn === 0 ? 'Expires today' : `Expires in ${expireIn} day${expireIn === 1 ? '' : 's'}`,
};
}
if (expireIn <= 7) {
return {
key: 'upcoming',
label: 'Upcoming expiration',
detail: `Expires in ${expireIn} days`,
};
}
return {
key: 'within-date',
label: 'Within date',
detail: `Expires in ${expireIn} days`,
};
}
export function renderStockDetailPage() {
return `
<section class="container-xxl py-4 py-lg-5" x-data="stockDetailPage()" x-init="init()">
@@ -42,14 +124,106 @@ export function renderStockDetailPage() {
<dt class="col-5">Quantity</dt>
<dd class="col-7" x-text="formatQuantity(entry)"></dd>
<dt class="col-5">Location</dt>
<dd class="col-7" x-text="entry.location_initial_uuid_b64 || 'Unassigned'"></dd>
<dd class="col-7" x-text="locationLabel(entry)"></dd>
<dt class="col-5">Production date</dt>
<dd class="col-7" x-text="formatDate(entry.date)"></dd>
<dt class="col-5">Expiration date</dt>
<dd class="col-7" x-text="formatDate(entry.expire_date)"></dd>
<dt class="col-5">Expiration status</dt>
<dd class="col-7">
<div class="d-flex align-items-center gap-2 mb-1">
<span class="badge rounded-pill" :class="expirationBadgeClass(entry)" x-text="expirationFor(entry).label"></span>
</div>
<div class="small text-body-secondary" x-text="expirationFor(entry).detail"></div>
</dd>
<dt class="col-5">Stock type</dt>
<dd class="col-7" x-text="entry.stock_type || 'Stored'"></dd>
</dl>
<div class="mt-4">
<h3 class="h6 mb-3">Identifier</h3>
<div class="input-group">
<input
class="form-control"
type="text"
x-model="identifierDraft"
inputmode="numeric"
autocomplete="off"
placeholder="EAN / UPC / GTIN"
/>
<button
class="btn btn-outline-primary"
type="button"
@click="saveIdentifierCode()"
:disabled="identifierState.isLoading"
>
<span x-show="!identifierState.isLoading">Save identifier</span>
<span x-show="identifierState.isLoading">Saving...</span>
</button>
</div>
<div class="form-text">Used for OpenFoodFacts lookups and product metadata refresh.</div>
<template x-if="identifierState.error">
<div class="small text-danger mt-1" x-text="identifierState.error"></div>
</template>
</div>
<div class="mt-4">
<h3 class="h6 mb-2">OpenFoodFacts</h3>
<div class="d-flex flex-wrap gap-2">
<button
class="btn btn-outline-secondary"
type="button"
@click="runItemLookup(false)"
:disabled="lookupDetailsState.isLoading || !hasIdentifierCode()"
>
<span x-show="!lookupDetailsState.isLoading">Refresh details</span>
<span x-show="lookupDetailsState.isLoading">Refreshing...</span>
</button>
<button
class="btn btn-outline-primary"
type="button"
@click="runItemLookup(true)"
:disabled="lookupDetailsState.isLoading || !hasIdentifierCode()"
>
<span x-show="!lookupDetailsState.isLoading">Apply missing fields</span>
<span x-show="lookupDetailsState.isLoading">Applying...</span>
</button>
</div>
<template x-if="!hasIdentifierCode()">
<div class="small text-body-secondary mt-2">Save an identifier code first to enable lookup refresh.</div>
</template>
<template x-if="offLookupFeedback.message">
<div
class="alert mt-3 mb-0"
:class="offLookupFeedback.type === 'success' ? 'alert-success' : 'alert-warning'"
x-text="offLookupFeedback.message"
></div>
</template>
</div>
<div class="mt-4">
<h3 class="h6 mb-3">Nutrition</h3>
<dl class="row mb-0 detail-grid">
<dt class="col-5">Nutri-Score</dt>
<dd class="col-7" x-text="nutriScoreLabel(entry)"></dd>
<dt class="col-5">Nutriments</dt>
<dd class="col-7">
<template x-if="nutritionFactsRows(entry).length">
<ul class="list-unstyled mb-0 small d-grid gap-1">
<template x-for="fact in nutritionFactsRows(entry)" :key="fact.key">
<li>
<span class="text-body-secondary" x-text="fact.label + ':'"></span>
<span class="fw-semibold" x-text="fact.value"></span>
</li>
</template>
</ul>
</template>
<template x-if="!nutritionFactsRows(entry).length">
<span class="text-body-secondary">Not available</span>
</template>
</dd>
</dl>
</div>
</div>
</div>
</div>
@@ -83,12 +257,23 @@ export function renderStockDetailPage() {
<template x-if="adjustmentState.error">
<div class="alert alert-danger mb-0" x-text="adjustmentState.error"></div>
</template>
<template x-if="printFeedback.message">
<div
class="alert mb-0"
:class="printFeedback.type === 'success' ? 'alert-success' : 'alert-warning'"
x-text="printFeedback.message"
></div>
</template>
<div class="d-flex flex-wrap gap-2">
<button class="btn btn-primary" type="submit" :disabled="adjustmentState.isLoading">
<span x-show="!adjustmentState.isLoading">Save quantity</span>
<span x-show="adjustmentState.isLoading">Saving...</span>
</button>
<button class="btn btn-outline-secondary" type="button" @click="printLabel()" :disabled="printState.isLoading">
<span x-show="!printState.isLoading">Print label</span>
<span x-show="printState.isLoading">Printing...</span>
</button>
<button class="btn btn-outline-danger" type="button" @click="markGone()" :disabled="adjustmentState.isLoading">
<span x-show="!adjustmentState.isLoading">Mark gone</span>
<span x-show="adjustmentState.isLoading">Removing...</span>
@@ -114,12 +299,23 @@ export function renderStockDetailPage() {
<template x-if="adjustmentState.error">
<div class="alert alert-danger mb-0" x-text="adjustmentState.error"></div>
</template>
<template x-if="printFeedback.message">
<div
class="alert mb-0"
:class="printFeedback.type === 'success' ? 'alert-success' : 'alert-warning'"
x-text="printFeedback.message"
></div>
</template>
<div class="d-flex flex-wrap gap-2">
<button class="btn btn-primary" type="submit" :disabled="adjustmentState.isLoading">
<span x-show="!adjustmentState.isLoading">Save stock level</span>
<span x-show="adjustmentState.isLoading">Saving...</span>
</button>
<button class="btn btn-outline-secondary" type="button" @click="printLabel()" :disabled="printState.isLoading">
<span x-show="!printState.isLoading">Print label</span>
<span x-show="printState.isLoading">Printing...</span>
</button>
<button class="btn btn-outline-danger" type="button" @click="markGone()" :disabled="adjustmentState.isLoading">
<span x-show="!adjustmentState.isLoading">Mark gone</span>
<span x-show="adjustmentState.isLoading">Removing...</span>
@@ -137,11 +333,22 @@ export function renderStockDetailPage() {
<template x-if="adjustmentState.error">
<div class="alert alert-danger mb-0" x-text="adjustmentState.error"></div>
</template>
<template x-if="printFeedback.message">
<div
class="alert mb-0"
:class="printFeedback.type === 'success' ? 'alert-success' : 'alert-warning'"
x-text="printFeedback.message"
></div>
</template>
<button class="btn btn-outline-danger align-self-start" type="button" @click="markGone()" :disabled="adjustmentState.isLoading">
<span x-show="!adjustmentState.isLoading">Mark gone</span>
<span x-show="adjustmentState.isLoading">Removing...</span>
</button>
<button class="btn btn-outline-secondary align-self-start" type="button" @click="printLabel()" :disabled="printState.isLoading">
<span x-show="!printState.isLoading">Print label</span>
<span x-show="printState.isLoading">Printing...</span>
</button>
</div>
</template>
</div>
@@ -157,7 +364,20 @@ export function stockDetailPageData(store) {
return {
state: createAsyncState(),
adjustmentState: createAsyncState(),
printState: createAsyncState(),
identifierState: createAsyncState(),
lookupDetailsState: createAsyncState(),
printFeedback: {
type: '',
message: '',
},
offLookupFeedback: {
type: '',
message: '',
},
entry: null,
locationPathByUuid: {},
identifierDraft: '',
adjustment: {
mode: 'increment',
quantity: '1',
@@ -170,10 +390,163 @@ 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.identifierDraft = normalizeIdentifierCode(entry?.identifier_code);
this.locationPathByUuid = Object.fromEntries(
(locations.flat || [])
.filter((location) => location.uuid_b64)
.map((location) => [location.uuid_b64, location.pathLabel || location.name]),
);
this.adjustment.level = this.entry?.level || 'plenty';
}).catch(() => {});
},
normalizedIdentifierDraft() {
return normalizeIdentifierCode(this.identifierDraft);
},
hasIdentifierCode() {
return Boolean(this.normalizedIdentifierDraft());
},
async reloadEntry(uuidB64) {
const refreshed = await getStockEntry(store, uuidB64);
this.entry = refreshed;
this.identifierDraft = normalizeIdentifierCode(refreshed?.identifier_code);
this.adjustment.level = this.entry?.level || 'plenty';
},
itemLookupStatusMessage(response) {
const retryAfter = Number.isInteger(response?.retryAfterSeconds) && response.retryAfterSeconds > 0
? ` Retry in ${response.retryAfterSeconds}s.`
: '';
if (response?.status === 'missing_identifier') {
return 'Save an identifier code before running lookup.';
}
if (response?.status === 'not_found') {
return `No OpenFoodFacts result found for code ${this.normalizedIdentifierDraft() || 'unknown'}.`;
}
if (response?.status === 'rate_limited') {
return `OpenFoodFacts lookup is temporarily rate-limited.${retryAfter}`;
}
if (response?.status === 'lookup_failed') {
return 'OpenFoodFacts lookup failed. Try again shortly or continue manually.';
}
return 'Lookup response could not be applied.';
},
itemLookupSuccessMessage(response) {
const parts = [
response?.update
? 'Applied missing fields from OpenFoodFacts.'
: 'Fetched OpenFoodFacts details preview.',
];
const source = response?.item?.external_source || this.entry?.external_source;
if (source) {
parts.push(`Source: ${source}.`);
}
if (Array.isArray(response?.updatedFields) && response.updatedFields.length) {
parts.push(`Updated: ${response.updatedFields.join(', ')}.`);
}
if (response?.staleCache) {
parts.push('Using stale cache data.');
} else {
parts.push('Cache freshness: current.');
}
if (response?.offPayloadFetchedAt) {
const fetchedAt = new Date(response.offPayloadFetchedAt);
parts.push(
`Fetched at: ${
Number.isNaN(fetchedAt.getTime())
? response.offPayloadFetchedAt
: fetchedAt.toLocaleString()
}.`,
);
}
return parts.join(' ');
},
async saveIdentifierCode() {
if (!this.entry?.uuid_b64) {
return;
}
this.identifierState.error = '';
await runAsyncState(this.identifierState, async () => {
const identifierCode = this.normalizedIdentifierDraft();
const updated = await patchStockItem(store, this.entry.uuid_b64, {
identifier_code: identifierCode || null,
});
this.entry = updated;
this.identifierDraft = normalizeIdentifierCode(updated?.identifier_code || identifierCode);
this.offLookupFeedback = {
type: '',
message: '',
};
store.addAlert({
type: 'success',
message: identifierCode
? `Identifier code saved for ${this.entry.name}.`
: `Identifier code cleared for ${this.entry.name}.`,
});
}).catch(() => {});
},
async runItemLookup(update) {
if (!this.entry?.uuid_b64) {
return;
}
const identifierCode = this.normalizedIdentifierDraft();
if (!identifierCode) {
this.offLookupFeedback = {
type: 'warning',
message: 'Save an identifier code before running lookup refresh.',
};
return;
}
this.lookupDetailsState.error = '';
await runAsyncState(this.lookupDetailsState, async () => {
const response = await lookupItemDetails(store, this.entry.uuid_b64, { update });
if (response.status !== 'ok') {
const message = this.itemLookupStatusMessage(response);
this.offLookupFeedback = {
type: 'warning',
message,
};
store.addAlert({ type: 'warning', message });
return;
}
if (update) {
await this.reloadEntry(this.entry.uuid_b64);
} else if (response.item) {
this.entry = response.item;
this.identifierDraft = normalizeIdentifierCode(response.item.identifier_code || identifierCode);
}
const message = this.itemLookupSuccessMessage(response);
this.offLookupFeedback = {
type: 'success',
message,
};
store.addAlert({
type: 'success',
message,
});
}).catch((error) => {
this.offLookupFeedback = {
type: 'warning',
message: error?.message || 'OpenFoodFacts lookup failed.',
};
});
},
async submitMeasuredAdjustment() {
if (!this.entry) {
return;
@@ -196,6 +569,7 @@ export function stockDetailPageData(store) {
this.entry = await adjustStockEntry(store, this.entry.uuid_b64, {
quantity: exactQuantity,
});
this.identifierDraft = normalizeIdentifierCode(this.entry?.identifier_code);
store.addAlert({ type: 'success', message: 'Stock quantity updated.' });
}).catch(() => {});
},
@@ -207,7 +581,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;
@@ -216,6 +590,7 @@ export function stockDetailPageData(store) {
this.entry = await adjustStockEntry(store, this.entry.uuid_b64, {
level: this.adjustment.level,
});
this.identifierDraft = normalizeIdentifierCode(this.entry?.identifier_code);
store.addAlert({ type: 'success', message: 'Stock level updated.' });
}).catch(() => {});
},
@@ -225,16 +600,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();
},
+58 -11
View File
@@ -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() {
<div class="grouped-stock-items">
<template x-for="item in group.items" :key="item.id">
<a class="grouped-stock-item text-decoration-none" :class="groupedItemClass(item)" :href="detailHref(item)">
<div class="grouped-stock-item" :class="groupedItemClass(item)">
<div class="d-flex flex-column flex-lg-row justify-content-between gap-2">
<div>
<div class="fw-semibold" x-text="item.name"></div>
@@ -669,6 +669,7 @@ export function renderStockListPage() {
<span class="grouped-stock-subline-separator" aria-hidden="true">•</span>
<span x-text="shortDescription(item.description)"></span>
</div>
<a class="small text-decoration-none fw-semibold" :href="detailHref(item)">View item</a>
</div>
<div class="d-flex flex-wrap gap-3 small text-body-secondary grouped-stock-item-meta">
<span x-text="quantityLabel(item)"></span>
@@ -678,9 +679,13 @@ export function renderStockListPage() {
<span x-text="formatDate(item.expire_date)"></span>
</span>
<span class="font-monospace" x-text="shortId(item)"></span>
<button class="btn btn-sm btn-outline-danger grouped-stock-mark-gone" type="button" @click="markGoneFromGroup(item, group)">Mark gone</button>
</div>
</div>
</a>
<template x-if="editErrors[item.id]">
<div class="small text-danger mt-2" x-text="editErrors[item.id]"></div>
</template>
</div>
</template>
</div>
@@ -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);
},
};
}
+85
View File
@@ -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;
@@ -747,6 +791,36 @@ button.legend-card:focus-visible {
cursor: pointer;
}
.scanner-modal-backdrop {
position: fixed;
inset: 0;
z-index: 1100;
display: grid;
place-items: center;
padding: 1rem;
background: rgba(16, 24, 40, 0.62);
backdrop-filter: blur(2px);
}
.scanner-modal {
width: min(40rem, 100%);
border-radius: 1rem;
}
.scanner-video-shell {
border-radius: 0.85rem;
overflow: hidden;
border: 1px solid rgba(31, 75, 153, 0.2);
background: #0f172a;
}
.scanner-video {
display: block;
width: 100%;
aspect-ratio: 16 / 10;
object-fit: cover;
}
@media (max-width: 991.98px) {
.navbar {
backdrop-filter: blur(10px);
@@ -760,4 +834,15 @@ button.legend-card:focus-visible {
.empty-preview {
border-radius: 1.25rem;
}
.scanner-modal-backdrop {
align-items: end;
}
.scanner-modal {
width: 100%;
margin-bottom: 0.75rem;
border-bottom-left-radius: 0;
border-bottom-right-radius: 0;
}
}
+166
View File
@@ -0,0 +1,166 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { apiRequest, buildKitchenApiUrl, getPath } from '../../src/api/client.js';
function createStore(overrides = {}) {
return {
config: {
baseUrl: '',
database: 'kitchen-db',
...(overrides.config || {}),
},
session: {
applicationKey: 'app-key',
hasValidated: false,
state: 'pending_validation',
...(overrides.session || {}),
},
activeKitchen: {
id: 'kitchen-1',
...(overrides.activeKitchen || {}),
},
...overrides,
};
}
describe('api/client', () => {
let authFailureSpy;
beforeEach(() => {
authFailureSpy = vi.fn();
globalThis.window = {
location: {
origin: 'https://app.local',
},
__loncApp: {
handleAuthFailure: authFailureSpy,
},
};
vi.spyOn(console, 'error').mockImplementation(() => {});
});
afterEach(() => {
vi.restoreAllMocks();
vi.unstubAllGlobals();
delete globalThis.window;
});
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 database-scoped kitchen urls with encoded query values', () => {
const store = createStore({
config: {
baseUrl: 'https://api.example.com',
database: 'my db',
},
});
const url = buildKitchenApiUrl(store, 'kitchen/items/grouped', {
search_name: 'Milk + eggs',
expanded: 1,
ignored: '',
});
expect(url).toBe(
'https://api.example.com/my%20db/kitchen/items/grouped?search_name=Milk+%2B+eggs&expanded=1',
);
});
it('sends json request data and auth header through fetch', async () => {
const store = createStore();
const fetchSpy = vi.fn(async () =>
new Response(JSON.stringify({ ok: true }), {
status: 200,
headers: {
'content-type': 'application/json',
},
}),
);
vi.stubGlobal('fetch', fetchSpy);
const payload = await apiRequest(store, getPath('items'), {
method: 'POST',
body: {
name: 'Rice',
},
query: {
label: 1,
},
});
expect(payload).toEqual({ ok: true });
const [url, request] = fetchSpy.mock.calls[0];
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');
expect(request.headers.get('Content-Type')).toBe('application/json');
expect(request.headers.get('Authorization')).toBe('Bearer app-key');
});
it('normalizes api error payload into ApiRequestError details', async () => {
const store = createStore();
vi.stubGlobal(
'fetch',
vi.fn(async () =>
new Response(
JSON.stringify({
errors: {
name: ['Required'],
quantity: 'Must be positive',
},
}),
{
status: 400,
headers: {
'content-type': 'application/json',
},
},
),
),
);
await expect(apiRequest(store, getPath('items'))).rejects.toMatchObject({
name: 'ApiRequestError',
status: 400,
details: {
name: 'Required',
quantity: 'Must be positive',
},
});
});
it('triggers auth failure handler after validated session receives auth errors', async () => {
const store = createStore({
session: {
applicationKey: 'app-key',
hasValidated: true,
state: 'connected',
},
});
vi.stubGlobal(
'fetch',
vi.fn(async () =>
new Response(JSON.stringify({ detail: 'Unauthorized' }), {
status: 401,
headers: {
'content-type': 'application/json',
},
}),
),
);
await expect(apiRequest(store, getPath('items'))).rejects.toMatchObject({
name: 'ApiRequestError',
status: 401,
});
expect(authFailureSpy).toHaveBeenCalledTimes(1);
});
});
+23
View File
@@ -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');
});
});
+259
View File
@@ -0,0 +1,259 @@
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,
lookupItemByIdentifier,
lookupItemDetails,
listKitchenChanges,
patchStockItem,
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('lookupItemByIdentifier normalizes lookup metadata fields', async () => {
apiRequestMock.mockResolvedValueOnce({
status: 'rate_limited',
source: 'openfoodfacts',
cache_hit: true,
identifier_code: '1234',
identifier_type: 'ean_13',
retry_after_seconds: 42,
payload_fetched_at: '2026-04-11T08:00:00Z',
stale_cache: true,
item: null,
});
const response = await lookupItemByIdentifier(
{ config: { database: 'db' } },
'1234',
);
expect(response).toEqual({
status: 'rate_limited',
source: 'openfoodfacts',
cacheHit: true,
identifierCode: '1234',
identifierType: 'ean_13',
retryAfterSeconds: 42,
payloadFetchedAt: '2026-04-11T08:00:00Z',
staleCache: true,
item: null,
});
});
it('lookupItemDetails maps item lookup response and query flag', async () => {
apiRequestMock.mockResolvedValueOnce({
status: 'ok',
found: true,
update: true,
identifier_code: '555',
identifier_type: 'ean_13',
preview: { name: 'Milk' },
updated_fields: ['name'],
off_payload_fetched_at: '2026-04-11T09:00:00Z',
retry_after_seconds: null,
stale_cache: false,
item: { uuid_b64: 'item-1', name: 'Milk' },
});
const response = await lookupItemDetails(
{ config: { database: 'db' } },
'item-1',
{ update: true },
);
expect(apiRequestMock).toHaveBeenCalledWith(
{ config: { database: 'db' } },
'kitchen/items/item-1/lookup',
{ method: 'POST', query: { update: 1 } },
);
expect(response).toEqual({
status: 'ok',
found: true,
update: true,
identifierCode: '555',
identifierType: 'ean_13',
preview: { name: 'Milk' },
updatedFields: ['name'],
offPayloadFetchedAt: '2026-04-11T09:00:00Z',
retryAfterSeconds: null,
staleCache: false,
item: { uuid_b64: 'item-1', name: 'Milk' },
});
});
it('patchStockItem sends PATCH to item endpoint', async () => {
apiRequestMock.mockResolvedValueOnce({
uuid_b64: 'item-1',
identifier_code: '3830012345678',
});
const response = await patchStockItem(
{ config: { database: 'db' } },
'item-1',
{ identifier_code: '3830012345678' },
);
expect(apiRequestMock).toHaveBeenCalledWith(
{ config: { database: 'db' } },
'kitchen/items/item-1',
{ method: 'PATCH', body: { identifier_code: '3830012345678' } },
);
expect(response).toEqual({
uuid_b64: 'item-1',
identifier_code: '3830012345678',
});
});
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);
});
});
@@ -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([]);
});
});
@@ -0,0 +1,87 @@
import { describe, expect, it, vi } from 'vitest';
const lookupItemByIdentifierMock = vi.fn();
vi.mock('../../../src/api/stock.js', () => ({
applyItemUpsert: vi.fn(),
previewItemUpsert: vi.fn(),
searchItemDefinitions: vi.fn(async () => []),
lookupItemByIdentifier: (...args) => lookupItemByIdentifierMock(...args),
}));
vi.mock('../../../src/api/labels.js', () => ({
previewLabel: vi.fn(async () => ({ objectUrl: 'blob:preview' })),
printItemLabel: vi.fn(async () => null),
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 identifier lookup feedback', () => {
it('shows retry hint for rate-limited lookup responses', () => {
const data = labelCreatePageData({
isConnected: false,
activeKitchen: { id: 1 },
addAlert: vi.fn(),
});
const message = data.lookupStatusMessageWithDetails(
{ status: 'rate_limited', retryAfterSeconds: 30 },
'3830012345678',
);
expect(message).toContain('rate-limited');
expect(message).toContain('Retry in 30s');
});
it('builds metadata-aware success message with source/cache/freshness context', () => {
const data = labelCreatePageData({
isConnected: false,
activeKitchen: { id: 1 },
addAlert: vi.fn(),
});
const message = data.lookupSuccessMessage({
source: 'openfoodfacts',
cacheHit: true,
staleCache: true,
payloadFetchedAt: '2026-04-11T09:00:00Z',
});
expect(message).toContain('OpenFoodFacts');
expect(message).toContain('cache hit');
expect(message).toContain('stale cache');
expect(message).toContain('fetched:');
});
it('applies non-ok lookup status as warning message with details', async () => {
lookupItemByIdentifierMock.mockResolvedValueOnce({
status: 'rate_limited',
retryAfterSeconds: 45,
source: 'openfoodfacts',
cacheHit: false,
staleCache: false,
item: null,
});
const addAlert = vi.fn();
const data = labelCreatePageData({
isConnected: false,
activeKitchen: { id: 1 },
addAlert,
});
data.form.identifierCode = '3830012345678';
await data.lookupIdentifierDetails();
expect(data.lookupState.error).toContain('Retry in 45s');
expect(addAlert).toHaveBeenCalledWith({
type: 'warning',
message: data.lookupState.error,
});
});
});
@@ -0,0 +1,187 @@
import { describe, expect, it } from 'vitest';
import {
deriveLookupExpirationDays,
mapLookupItemToForm,
} from '../../../src/features/labels/identifier-lookup-mapper.js';
function createForm(overrides = {}) {
return {
itemId: '',
itemUuidB64: '',
identifierCode: '',
externalSource: '',
externalId: '',
search: '',
name: '',
description: '',
quantity: '',
uom: 'g',
stockType: 'binary',
level: 'plenty',
energy: '',
energyUnit: 'kcal (100g/ml)',
productionDate: '2026-04-10',
expireDays: '',
expirationDate: '',
locationId: '',
...overrides,
};
}
describe('identifier lookup form mapper', () => {
it('overwrites mapped fields with non-empty values and preserves production date', () => {
const form = createForm({
name: 'Old name',
productionDate: '2026-04-10',
});
const lookupItem = {
identifier_code: ' 3830012345678 ',
name: 'Yogurt',
description: 'Plain yogurt',
stock_type: 'measured',
level: 'low',
quantity_initial: 0,
uom_symbol: 'ml',
calories: 61,
calories_unit: 'kcal',
external_source: 'openfoodfacts',
external_id: 'off-123',
expiration_days: 5,
location_initial_uuid_b64: 'loc-freezer',
};
const locations = [
{ id: 44, uuid_b64: 'loc-freezer', name: 'Freezer' },
];
const result = mapLookupItemToForm({
form,
lookupItem,
locations,
});
expect(result.didUpdate).toBe(true);
expect(result.form.identifierCode).toBe('3830012345678');
expect(result.form.name).toBe('Yogurt');
expect(result.form.search).toBe('Yogurt');
expect(result.form.description).toBe('Plain yogurt');
expect(result.form.stockType).toBe('binary');
expect(result.form.level).toBe('low');
expect(result.form.quantity).toBe('0');
expect(result.form.uom).toBe('ml');
expect(result.form.energy).toBe('61');
expect(result.form.energyUnit).toBe('kcal');
expect(result.form.externalSource).toBe('openfoodfacts');
expect(result.form.externalId).toBe('off-123');
expect(result.form.productionDate).toBe('2026-04-10');
expect(result.form.expireDays).toBe('5');
expect(result.form.expirationDate).toBe('2026-04-15');
expect(result.form.locationId).toBe('44');
expect(result.locationSearch).toBe('Freezer');
});
it('clears mapped fields when lookup values are empty or null', () => {
const form = createForm({
name: 'Keep me',
description: 'Still here',
quantity: '2',
uom: 'pc',
energy: '120',
energyUnit: 'kcal',
stockType: 'descriptive',
level: 'some',
search: 'Keep me',
identifierCode: '12345678',
externalSource: 'cache',
externalId: 'xyz',
expireDays: '3',
expirationDate: '2026-04-13',
});
const lookupItem = {
name: ' ',
description: null,
quantity_initial: null,
uom_symbol: '',
calories: null,
calories_unit: '',
stock_type: '',
level: '',
identifier_code: '',
external_source: null,
external_id: ' ',
date: 'bad-date',
expire_date: '2026-05-20',
};
const result = mapLookupItemToForm({
form,
lookupItem,
locations: [],
});
expect(result.didUpdate).toBe(true);
expect(result.form.identifierCode).toBe('');
expect(result.form.name).toBe('');
expect(result.form.search).toBe('');
expect(result.form.description).toBe('');
expect(result.form.stockType).toBe('descriptive');
expect(result.form.level).toBe('');
expect(result.form.quantity).toBe('');
expect(result.form.uom).toBe('');
expect(result.form.energy).toBe('');
expect(result.form.energyUnit).toBe('');
expect(result.form.externalSource).toBe('');
expect(result.form.externalId).toBe('');
expect(result.form.expireDays).toBe('');
expect(result.form.expirationDate).toBe('');
expect(result.form.productionDate).toBe('2026-04-10');
});
it('derives expiration days from date/expire_date when expiration_days is missing', () => {
const days = deriveLookupExpirationDays({
date: '2026-01-02',
expire_date: '2026-01-12',
});
expect(days).toBe(10);
});
it('updates location when lookup provides one', () => {
const lookupItem = {
location_initial_uuid_b64: 'loc-fridge',
};
const locations = [
{ id: 5, uuid_b64: 'loc-fridge', name: 'Fridge' },
];
const withExistingLocation = mapLookupItemToForm({
form: createForm({ locationId: '8' }),
lookupItem,
locations,
});
const withoutLocation = mapLookupItemToForm({
form: createForm({ locationId: '' }),
lookupItem,
locations,
});
expect(withExistingLocation.form.locationId).toBe('5');
expect(withExistingLocation.locationSearch).toBe('Fridge');
expect(withoutLocation.form.locationId).toBe('5');
expect(withoutLocation.locationSearch).toBe('Fridge');
});
it('keeps location unchanged when lookup location is null', () => {
const form = createForm({ locationId: '9' });
const result = mapLookupItemToForm({
form,
lookupItem: {
location_initial_uuid_b64: null,
},
locations: [{ id: 5, uuid_b64: 'loc-fridge', name: 'Fridge' }],
});
expect(result.form.locationId).toBe('9');
expect(result.locationSearch).toBeNull();
});
});
+133
View File
@@ -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.',
});
});
});
+18
View File
@@ -0,0 +1,18 @@
import { describe, expect, it } from 'vitest';
import { formatDate } from '../../../src/features/shared/date-utils.js';
describe('formatDate', () => {
it('returns fallback when date value is missing', () => {
expect(formatDate('')).toBe('Not set');
expect(formatDate(null)).toBe('Not set');
});
it('returns the raw input for invalid date values', () => {
expect(formatDate('not-a-date')).toBe('not-a-date');
});
it('formats valid date values', () => {
expect(formatDate('2026-01-15')).toContain('2026');
});
});
+73
View File
@@ -0,0 +1,73 @@
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(),
lookupItemDetails: vi.fn(),
patchStockItem: 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.',
});
});
});
@@ -0,0 +1,127 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
const lookupItemDetailsMock = vi.fn();
const patchStockItemMock = vi.fn();
const getStockEntryMock = vi.fn();
vi.mock('../../../src/api/stock.js', () => ({
adjustStockEntry: vi.fn(),
getStockEntry: (...args) => getStockEntryMock(...args),
lookupItemDetails: (...args) => lookupItemDetailsMock(...args),
patchStockItem: (...args) => patchStockItemMock(...args),
useStockItem: vi.fn(),
}));
vi.mock('../../../src/api/labels.js', () => ({
printItemLabel: vi.fn(),
formatPrintErrorMessage: (error) => error?.message || 'Printing failed.',
}));
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 detail identifier and OFF lookup', () => {
beforeEach(() => {
lookupItemDetailsMock.mockReset();
patchStockItemMock.mockReset();
getStockEntryMock.mockReset();
globalThis.window = {
__loncApp: {
navigate: vi.fn(),
},
};
});
afterEach(() => {
vi.restoreAllMocks();
delete globalThis.window;
});
it('saves normalized identifier code via PATCH', async () => {
patchStockItemMock.mockResolvedValueOnce({
uuid_b64: 'item-1',
name: 'Milk',
identifier_code: '3830012345678',
});
const addAlert = vi.fn();
const store = { addAlert, isConnected: false };
const data = stockDetailPageData(store);
data.entry = { uuid_b64: 'item-1', name: 'Milk', identifier_code: '' };
data.identifierDraft = ' 3830 0123 45678 ';
await data.saveIdentifierCode();
expect(patchStockItemMock).toHaveBeenCalledWith(store, 'item-1', {
identifier_code: '3830012345678',
});
expect(data.identifierDraft).toBe('3830012345678');
expect(addAlert).toHaveBeenCalledWith({
type: 'success',
message: 'Identifier code saved for Milk.',
});
});
it('refreshes OFF details and surfaces stale-cache metadata', async () => {
lookupItemDetailsMock.mockResolvedValueOnce({
status: 'ok',
update: false,
updatedFields: ['name', 'nutrition_facts'],
staleCache: true,
offPayloadFetchedAt: '2026-04-11T09:00:00Z',
item: {
uuid_b64: 'item-1',
name: 'Milk',
identifier_code: '3830012345678',
},
});
const addAlert = vi.fn();
const store = { addAlert, isConnected: false };
const data = stockDetailPageData(store);
data.entry = { uuid_b64: 'item-1', name: 'Milk', identifier_code: '3830012345678' };
data.identifierDraft = '3830012345678';
await data.runItemLookup(false);
expect(lookupItemDetailsMock).toHaveBeenCalledWith(store, 'item-1', { update: false });
expect(data.offLookupFeedback.type).toBe('success');
expect(data.offLookupFeedback.message).toContain('Using stale cache data.');
expect(addAlert).toHaveBeenCalledWith({
type: 'success',
message: data.offLookupFeedback.message,
});
});
it('apply missing fields reloads entry after successful lookup', async () => {
lookupItemDetailsMock.mockResolvedValueOnce({
status: 'ok',
update: true,
updatedFields: ['description'],
staleCache: false,
offPayloadFetchedAt: null,
item: null,
});
getStockEntryMock.mockResolvedValueOnce({
uuid_b64: 'item-1',
name: 'Milk',
identifier_code: '3830012345678',
description: 'Whole milk',
});
const addAlert = vi.fn();
const store = { addAlert, isConnected: false };
const data = stockDetailPageData(store);
data.entry = { uuid_b64: 'item-1', name: 'Milk', identifier_code: '3830012345678' };
data.identifierDraft = '3830012345678';
await data.runItemLookup(true);
expect(getStockEntryMock).toHaveBeenCalledWith(store, 'item-1');
expect(data.entry.description).toBe('Whole milk');
expect(data.offLookupFeedback.type).toBe('success');
});
});
+76
View File
@@ -0,0 +1,76 @@
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(),
lookupItemDetails: vi.fn(),
patchStockItem: 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.',
});
});
});
+26
View File
@@ -1,6 +1,32 @@
import { defineConfig } from 'vite';
import packageJson from './package.json';
function appVersionAssetPlugin() {
return {
name: 'app-version-asset',
apply: 'build',
generateBundle() {
this.emitFile({
type: 'asset',
fileName: 'version.json',
source: JSON.stringify(
{
version: packageJson.version,
buildTime: new Date().toISOString(),
},
null,
2,
),
});
},
};
}
export default defineConfig({
define: {
__APP_VERSION__: JSON.stringify(packageJson.version),
},
plugins: [appVersionAssetPlugin()],
server: {
port: 4173,
},
+12
View File
@@ -0,0 +1,12 @@
import { defineConfig } from 'vitest/config';
export default defineConfig({
test: {
coverage: {
provider: 'v8',
reporter: ['text', 'lcov'],
reportsDirectory: 'coverage',
include: ['src/**/*.js'],
},
},
});