From e50f84889650e1441e2983af97b8e3c53c22a511 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bla=C5=BE=20Bregar?= Date: Sun, 12 Apr 2026 22:29:09 +0200 Subject: [PATCH] Drop stale label drafts after inactivity and bump version to 0.2.3 --- package-lock.json | 4 +- package.json | 2 +- src/app/config.js | 2 +- src/features/labels/label-create-page.js | 46 ++++- tests/features/labels/upsert-submit.test.js | 178 +++++++++++++++++++- 5 files changed, 221 insertions(+), 11 deletions(-) diff --git a/package-lock.json b/package-lock.json index ec6e49e..30a0ba6 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "lonc-web", - "version": "0.2.2", + "version": "0.2.3", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "lonc-web", - "version": "0.2.2", + "version": "0.2.3", "dependencies": { "@zxing/browser": "^0.1.5", "alpinejs": "^3.14.9", diff --git a/package.json b/package.json index cf2ec1b..b21aa2f 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "lonc-web", - "version": "0.2.2", + "version": "0.2.3", "private": true, "type": "module", "scripts": { diff --git a/src/app/config.js b/src/app/config.js index 7f19a04..a2f7279 100644 --- a/src/app/config.js +++ b/src/app/config.js @@ -1,5 +1,5 @@ export const APP_NAME = 'Lonc'; -export const APP_VERSION = typeof __APP_VERSION__ !== 'undefined' ? __APP_VERSION__ : '0.2.2'; +export const APP_VERSION = typeof __APP_VERSION__ !== 'undefined' ? __APP_VERSION__ : '0.2.3'; export const TRYTON_APPLICATION = 'kitchen'; export const CONNECTION_STATES = { diff --git a/src/features/labels/label-create-page.js b/src/features/labels/label-create-page.js index 28ac70e..e7d62ab 100644 --- a/src/features/labels/label-create-page.js +++ b/src/features/labels/label-create-page.js @@ -34,6 +34,7 @@ const STOCK_LEVEL_OPTIONS = [ const QUANTITY_UNIT_OPTIONS = ['g', 'ml', 'pc']; const EXPIRATION_DAY_OPTIONS = ['3', '5', '8', '10', '15', '20', '25', '30', '45', '60', '90', '120', '150', '180']; +const LABEL_DRAFT_STALE_MS = 30 * 60 * 1000; export function renderLabelCreatePage() { return ` @@ -607,9 +608,7 @@ function createDefaultForm() { }; } -function loadLabelDraft() { - const draft = loadStoredValue(STORAGE_KEYS.labelDraft, createDefaultForm()); - +function normalizeLabelDraft(draft) { return { ...createDefaultForm(), ...draft, @@ -638,6 +637,41 @@ function buildDraftPayload(form) { }; } +function buildLabelDraftEnvelope(form) { + return { + form: buildDraftPayload(form), + savedAt: Date.now(), + }; +} + +function saveLabelDraft(form) { + saveStoredValue(STORAGE_KEYS.labelDraft, buildLabelDraftEnvelope(form)); +} + +function loadLabelDraft() { + const storedDraft = loadStoredValue(STORAGE_KEYS.labelDraft, null); + + if (!storedDraft || typeof storedDraft !== 'object' || Array.isArray(storedDraft)) { + return createDefaultForm(); + } + + const hasEnvelope = + storedDraft.form + && typeof storedDraft.form === 'object' + && !Array.isArray(storedDraft.form); + + if (!hasEnvelope) { + return normalizeLabelDraft(storedDraft); + } + + const savedAt = Number(storedDraft.savedAt || 0); + if (!savedAt || Date.now() - savedAt >= LABEL_DRAFT_STALE_MS) { + return createDefaultForm(); + } + + return normalizeLabelDraft(storedDraft.form); +} + export function labelCreatePageData(store) { return { previewState: createAsyncState(), @@ -1084,7 +1118,7 @@ export function labelCreatePageData(store) { this.persistDraft(); }, persistDraft() { - saveStoredValue(STORAGE_KEYS.labelDraft, buildDraftPayload(this.form)); + saveLabelDraft(this.form); }, get filteredLocations() { const query = this.locationSearch.trim().toLowerCase(); @@ -1487,7 +1521,7 @@ export function labelCreatePageData(store) { message: `${entryName} was ${operationVerb} successfully.`, }); this.upsertPreview = entry; - saveStoredValue(STORAGE_KEYS.labelDraft, buildDraftPayload(this.form)); + saveLabelDraft(this.form); } catch (error) { this.fieldErrors = normalizeValidationError(error); this.submitError = error.message; @@ -1508,7 +1542,7 @@ export function labelCreatePageData(store) { this.fieldErrors = {}; this.upsertPreview = null; this.printIssue = ''; - saveStoredValue(STORAGE_KEYS.labelDraft, this.form); + saveLabelDraft(this.form); if (revokePreview && this.previewUrl.startsWith('blob:')) { URL.revokeObjectURL(this.previewUrl); } diff --git a/tests/features/labels/upsert-submit.test.js b/tests/features/labels/upsert-submit.test.js index 5a42434..9a6e141 100644 --- a/tests/features/labels/upsert-submit.test.js +++ b/tests/features/labels/upsert-submit.test.js @@ -1,8 +1,33 @@ -import { describe, expect, it, vi } from 'vitest'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; const applyItemUpsertMock = vi.fn(); const previewItemUpsertMock = vi.fn(); const printItemLabelMock = vi.fn(); +const LABEL_DRAFT_STORAGE_KEY = 'lonc.labels.draft'; + +let localStorageState; +let localStorageMock; + +function createWindowStorageMock(initialState = {}) { + const state = new Map(Object.entries(initialState)); + const localStorage = { + getItem: vi.fn((key) => (state.has(key) ? state.get(key) : null)), + setItem: vi.fn((key, value) => { + state.set(key, String(value)); + }), + removeItem: vi.fn((key) => { + state.delete(key); + }), + }; + + vi.stubGlobal('window', { localStorage }); + return { state, localStorage }; +} + +function readStoredLabelDraft() { + const raw = localStorageState.get(LABEL_DRAFT_STORAGE_KEY); + return raw ? JSON.parse(raw) : null; +} vi.mock('../../../src/api/stock.js', () => ({ applyItemUpsert: (...args) => applyItemUpsertMock(...args), @@ -23,6 +48,23 @@ vi.mock('../../../src/api/locations.js', () => ({ const { labelCreatePageData } = await import('../../../src/features/labels/label-create-page.js'); describe('label create upsert-first submit', () => { + beforeEach(() => { + vi.useFakeTimers(); + vi.setSystemTime(new Date('2026-04-12T12:00:00Z')); + applyItemUpsertMock.mockReset(); + previewItemUpsertMock.mockReset(); + printItemLabelMock.mockReset(); + + const storageMock = createWindowStorageMock(); + localStorageState = storageMock.state; + localStorageMock = storageMock.localStorage; + }); + + afterEach(() => { + vi.useRealTimers(); + vi.unstubAllGlobals(); + }); + it('defaults print checkbox to enabled', () => { const data = labelCreatePageData({ isConnected: false, @@ -32,6 +74,132 @@ describe('label create upsert-first submit', () => { expect(data.printLabelOnSave).toBe(true); }); + it('restores a fresh enveloped draft when inactivity is below 30 minutes', () => { + localStorageState.set( + LABEL_DRAFT_STORAGE_KEY, + JSON.stringify({ + form: { + name: 'Draft yogurt', + productionDate: '2026-04-11', + stockType: 'descriptive', + }, + savedAt: Date.now() - (29 * 60 * 1000), + }), + ); + + const data = labelCreatePageData({ + isConnected: false, + activeKitchen: { id: 1 }, + addAlert: vi.fn(), + }); + + expect(data.form.name).toBe('Draft yogurt'); + expect(data.form.productionDate).toBe('2026-04-11'); + expect(data.form.stockType).toBe('descriptive'); + }); + + it('drops stale enveloped drafts at 30 minutes inactivity and loads a clean form', () => { + localStorageState.set( + LABEL_DRAFT_STORAGE_KEY, + JSON.stringify({ + form: { + name: 'Old draft', + description: 'Should be removed', + productionDate: '2026-04-10', + stockType: 'measured', + quantity: '4', + uom: 'kg', + }, + savedAt: Date.now() - (30 * 60 * 1000), + }), + ); + + const data = labelCreatePageData({ + isConnected: false, + activeKitchen: { id: 1 }, + addAlert: vi.fn(), + }); + + expect(data.form.name).toBe(''); + expect(data.form.description).toBe(''); + expect(data.form.stockType).toBe('binary'); + expect(data.form.quantity).toBe(''); + expect(data.form.uom).toBe('g'); + expect(data.form.productionDate).toBe('2026-04-12'); + }); + + it('keeps draft when day changes but inactivity stays below 30 minutes', () => { + vi.setSystemTime(new Date('2026-04-12T00:10:00Z')); + localStorageState.set( + LABEL_DRAFT_STORAGE_KEY, + JSON.stringify({ + form: { + name: 'Day-changed draft', + productionDate: '2026-04-11', + }, + savedAt: Date.now() - (10 * 60 * 1000), + }), + ); + + const data = labelCreatePageData({ + isConnected: false, + activeKitchen: { id: 1 }, + addAlert: vi.fn(), + }); + + expect(data.form.name).toBe('Day-changed draft'); + expect(data.form.productionDate).toBe('2026-04-11'); + }); + + it('loads legacy plain-object drafts without forcing discard', () => { + localStorageState.set( + LABEL_DRAFT_STORAGE_KEY, + JSON.stringify({ + name: 'Legacy draft', + productionDate: '2026-04-05', + }), + ); + + const data = labelCreatePageData({ + isConnected: false, + activeKitchen: { id: 1 }, + addAlert: vi.fn(), + }); + + expect(data.form.name).toBe('Legacy draft'); + expect(data.form.productionDate).toBe('2026-04-05'); + }); + + it('writes enveloped draft payload from persist and reset save paths', () => { + const data = labelCreatePageData({ + isConnected: false, + activeKitchen: { id: 1 }, + addAlert: vi.fn(), + }); + + data.form = { + ...data.form, + name: 'Persisted entry', + search: 'temp search value', + }; + data.persistDraft(); + + const persistedDraft = readStoredLabelDraft(); + expect(persistedDraft.savedAt).toBe(Date.now()); + expect(persistedDraft.form.name).toBe('Persisted entry'); + expect(persistedDraft.form.search).toBe(''); + + vi.setSystemTime(new Date('2026-04-12T12:05:00Z')); + data.form.name = 'Reset me'; + data.$refs = {}; + data.reset(false); + + const resetDraft = readStoredLabelDraft(); + expect(resetDraft.savedAt).toBe(Date.now()); + expect(resetDraft.form.name).toBe(''); + expect(resetDraft.form.productionDate).toBe('2026-04-12'); + }); + it('builds upsert payload with selected template uuid', () => { const store = { isConnected: false, @@ -96,6 +264,13 @@ describe('label create upsert-first submit', () => { type: 'success', message: 'Rice was updated successfully.', }); + const savedDraft = readStoredLabelDraft(); + expect(savedDraft).toMatchObject({ + form: { + name: 'Rice', + }, + }); + expect(savedDraft.savedAt).toBeTypeOf('number'); }); it('create shows parsed print issue warning when printing fails', async () => { @@ -129,5 +304,6 @@ describe('label create upsert-first submit', () => { type: 'warning', message: 'Beans was created, but printing has an issue: Printer is unavailable.', }); + expect(localStorageMock.setItem).toHaveBeenCalled(); }); });