+
Loading stock review...
@@ -466,7 +528,19 @@ export function renderStockListPage() {
-
View item
+
+ View item
+
+
+ Refreshing...
+
@@ -487,7 +561,7 @@ export function renderStockListPage() {
-
+
@@ -498,14 +572,14 @@ export function renderStockListPage() {
-
+
-
+
@@ -530,7 +604,19 @@ export function renderStockListPage() {
- View item
+
+ View item
+
+
+ Refreshing...
+
@@ -557,7 +643,7 @@ export function renderStockListPage() {
-
+
@@ -569,7 +655,7 @@ export function renderStockListPage() {
-
+
@@ -578,7 +664,7 @@ export function renderStockListPage() {
-
+
@@ -598,7 +684,7 @@ export function renderStockListPage() {
No grouped stock items to show
- Try clearing the filters or switch back to the stock items view.
+ Try clearing the filters or create a new stock label.
@@ -606,117 +692,166 @@ export function renderStockListPage() {
-
+
-
-
+
+
- Latest item in group
-
-
-
- item(s)
- Latest added:
- First expires:
+
+
+
+ item(s)
+ Latest location:
+ Latest quantity:
-
-
-
+
+
+
First expires:
- Show items
+ Items
-
-
-
-
-
-
-
-
-
-
-
-
- →
-
-
-
-
-
+
+
+
+ More dates and metadata
+
-
+
-
+
+
+
+
+
+
+ Loading grouped items...
+
+
+ No items currently available in this group.
+
+
+
+
+
+
+
+
+
+
+
+
`;
}
export function stockListPageData(store) {
return {
- state: createAsyncState(),
- viewMode: 'items',
+ state: {
+ ...createAsyncState(),
+ isRefreshing: false,
+ },
+ viewMode: 'grouped',
entries: [],
+ entriesVersion: 0,
+ itemsLoaded: false,
groupedEntries: [],
+ groupedVersion: 0,
groupedLoaded: false,
+ groupedHydrated: false,
+ groupedHydrating: false,
+ groupedPageSize: GROUPED_PAGE_SIZE,
+ groupedVisibleLimit: GROUPED_PAGE_SIZE,
+ openGroupedCards: {},
+ refreshActivityCount: 0,
locations: [],
+ locationsVersion: 0,
locationMap: {},
locationDescendants: {},
+ locationLineage: {},
editForms: {},
editErrors: {},
levelOptions: LEVEL_OPTIONS,
@@ -730,57 +865,588 @@ export function stockListPageData(store) {
expiration: [],
location: [],
},
+ memo: {
+ filteredEntriesSig: '',
+ filteredEntries: [],
+ filteredGroupedEntriesSig: '',
+ filteredGroupedEntries: [],
+ expirationCountsSig: '',
+ expirationCounts: {},
+ locationCountsSig: '',
+ locationCounts: {},
+ },
+ changeCursor: null,
+ changePollTimer: null,
+ routeChangeHandler: null,
+ isPollingChanges: false,
+ pendingRestoredScrollY: null,
+ itemRefreshCounts: {},
async init() {
if (!store.isConnected) {
return;
}
- await Promise.all([this.loadLocations(), this.loadEntries()]);
+
+ const restoredContext = this.restoreStockListContext();
+ this.registerRouteCleanup();
+
+ const restoredFromRuntime = restoredContext
+ ? this.restoreFromRuntimeCache(restoredContext)
+ : false;
+
+ if (!restoredFromRuntime) {
+ const initTasks = [this.loadLocations()];
+ if (this.viewMode === 'items') {
+ initTasks.push(this.loadEntries());
+ } else {
+ initTasks.push(
+ this.loadGroupedEntries({
+ expanded: 0,
+ resetVisible: !restoredContext,
+ }),
+ );
+ }
+ await Promise.all(initTasks);
+ }
+
+ await this.$nextTick();
+ this.restoreScrollPosition();
+ if (!restoredFromRuntime && this.viewMode === 'grouped') {
+ this.hydrateGroupedEntriesInBackground().catch(() => {});
+ }
+ if (restoredFromRuntime && restoredContext?.focusedItemUuid) {
+ this.refreshFocusedItemInBackground(restoredContext.focusedItemUuid).catch(() => {});
+ }
+ await this.primeChangeCursor();
+ this.startChangePolling();
+ },
+ restoreStockListContext() {
+ const context = loadStoredValue(STORAGE_KEYS.stockListContext, null);
+ clearStoredValue(STORAGE_KEYS.stockListContext);
+
+ if (!context || typeof context !== 'object') {
+ return null;
+ }
+
+ const savedAt = Number(context.savedAt || 0);
+ if (!savedAt || Date.now() - savedAt > STOCK_LIST_CONTEXT_TTL_MS) {
+ return null;
+ }
+
+ if (
+ Number.isFinite(context.kitchenId)
+ && Number.isFinite(store.activeKitchen?.id)
+ && Number(context.kitchenId) !== Number(store.activeKitchen.id)
+ ) {
+ return null;
+ }
+
+ const mode = context.viewMode === 'items' ? 'items' : 'grouped';
+ this.viewMode = mode;
+
+ this.filters = {
+ search: String(context.filters?.search || ''),
+ expiration: Array.isArray(context.filters?.expiration) ? context.filters.expiration : [],
+ location: Array.isArray(context.filters?.location) ? context.filters.location : [],
+ };
+
+ if (mode === 'grouped') {
+ const restoredLimit = Number(context.groupedVisibleLimit);
+ if (Number.isFinite(restoredLimit) && restoredLimit > 0) {
+ this.groupedVisibleLimit = Math.max(this.groupedPageSize, Math.round(restoredLimit));
+ }
+ this.openGroupedCards = context.openGroupedCards && typeof context.openGroupedCards === 'object'
+ ? context.openGroupedCards
+ : {};
+ }
+
+ const restoredScrollY = Number(context.scrollY);
+ if (Number.isFinite(restoredScrollY) && restoredScrollY >= 0) {
+ this.pendingRestoredScrollY = restoredScrollY;
+ }
+
+ return {
+ viewMode: mode,
+ focusedItemUuid:
+ typeof context.focusedItemUuid === 'string' && context.focusedItemUuid.trim()
+ ? context.focusedItemUuid.trim()
+ : null,
+ };
+ },
+ restoreScrollPosition() {
+ if (!Number.isFinite(this.pendingRestoredScrollY)) {
+ return;
+ }
+
+ const top = this.pendingRestoredScrollY;
+ this.pendingRestoredScrollY = null;
+ requestAnimationFrame(() => {
+ window.scrollTo({
+ top,
+ behavior: 'auto',
+ });
+ });
+ },
+ rememberStockListContext(focusedItemUuid = null) {
+ this.persistRuntimeCache();
+ saveStoredValue(STORAGE_KEYS.stockListContext, {
+ savedAt: Date.now(),
+ kitchenId: Number(store.activeKitchen?.id) || null,
+ viewMode: this.viewMode,
+ filters: this.filters,
+ groupedVisibleLimit: this.groupedVisibleLimit,
+ openGroupedCards: this.openGroupedCards,
+ focusedItemUuid:
+ typeof focusedItemUuid === 'string' && focusedItemUuid.trim()
+ ? focusedItemUuid.trim()
+ : null,
+ scrollY:
+ window.scrollY
+ || window.pageYOffset
+ || document.documentElement?.scrollTop
+ || 0,
+ });
+ },
+ persistRuntimeCache() {
+ stockListRuntimeCache = {
+ savedAt: Date.now(),
+ kitchenId: Number(store.activeKitchen?.id) || null,
+ payload: cloneRuntimeSnapshot({
+ entries: this.entries,
+ entriesVersion: this.entriesVersion,
+ itemsLoaded: this.itemsLoaded,
+ groupedEntries: this.groupedEntries,
+ groupedVersion: this.groupedVersion,
+ groupedLoaded: this.groupedLoaded,
+ groupedHydrated: this.groupedHydrated,
+ locations: this.locations,
+ locationsVersion: this.locationsVersion,
+ locationMap: this.locationMap,
+ locationDescendants: this.locationDescendants,
+ locationLineage: this.locationLineage,
+ changeCursor: this.changeCursor,
+ }),
+ };
+ },
+ restoreFromRuntimeCache(restoredContext) {
+ if (!restoredContext) {
+ return false;
+ }
+
+ const cached = stockListRuntimeCache;
+ if (!cached || !cached.payload) {
+ return false;
+ }
+
+ if (!cached.savedAt || Date.now() - cached.savedAt > STOCK_LIST_CONTEXT_TTL_MS) {
+ stockListRuntimeCache = null;
+ return false;
+ }
+
+ if (
+ Number.isFinite(cached.kitchenId)
+ && Number.isFinite(store.activeKitchen?.id)
+ && Number(cached.kitchenId) !== Number(store.activeKitchen.id)
+ ) {
+ return false;
+ }
+
+ const payload = cloneRuntimeSnapshot(cached.payload);
+ if (!payload) {
+ return false;
+ }
+
+ this.entries = Array.isArray(payload.entries) ? payload.entries : [];
+ this.entriesVersion = Number.isFinite(payload.entriesVersion)
+ ? payload.entriesVersion
+ : (this.entries.length ? 1 : 0);
+ this.itemsLoaded = Boolean(payload.itemsLoaded);
+
+ this.groupedEntries = Array.isArray(payload.groupedEntries) ? payload.groupedEntries : [];
+ this.groupedVersion = Number.isFinite(payload.groupedVersion)
+ ? payload.groupedVersion
+ : (this.groupedEntries.length ? 1 : 0);
+ this.groupedLoaded = Boolean(payload.groupedLoaded);
+ this.groupedHydrated = Boolean(payload.groupedHydrated);
+
+ this.locations = Array.isArray(payload.locations) ? payload.locations : [];
+ this.locationsVersion = Number.isFinite(payload.locationsVersion)
+ ? payload.locationsVersion
+ : (this.locations.length ? 1 : 0);
+ this.locationMap = payload.locationMap && typeof payload.locationMap === 'object'
+ ? payload.locationMap
+ : {};
+ this.locationDescendants =
+ payload.locationDescendants && typeof payload.locationDescendants === 'object'
+ ? payload.locationDescendants
+ : {};
+ this.locationLineage = payload.locationLineage && typeof payload.locationLineage === 'object'
+ ? payload.locationLineage
+ : {};
+ this.changeCursor = payload.changeCursor || this.changeCursor;
+
+ if (this.itemsLoaded) {
+ this.syncEditFormsFromEntries();
+ } else {
+ this.editForms = {};
+ this.editErrors = {};
+ }
+ this.pruneOpenGroupedCards();
+ this.invalidateMemo();
+
+ return this.viewMode === 'grouped' ? this.groupedLoaded : this.itemsLoaded;
+ },
+ async refreshFocusedItemInBackground(uuidB64) {
+ if (!uuidB64 || !store.isConnected) {
+ return;
+ }
+
+ this.startItemRefresh(uuidB64);
+ this.beginBackgroundRefresh();
+ try {
+ const updatedEntry = await getStockEntry(store, uuidB64);
+ this.applyUpdatedItemToLists(updatedEntry);
+ } catch (error) {
+ const status = error?.status || error?.cause?.status;
+ if (status === 404) {
+ const removedFromItems = this.removeEntryByUuid(uuidB64);
+ const removedFromGroups = this.removeGroupedItemByUuid(uuidB64);
+ if (removedFromItems || removedFromGroups) {
+ this.persistRuntimeCache();
+ }
+ }
+ } finally {
+ this.endItemRefresh(uuidB64);
+ this.endBackgroundRefresh();
+ }
+ },
+ registerRouteCleanup() {
+ if (this.routeChangeHandler) {
+ return;
+ }
+
+ this.routeChangeHandler = () => {
+ if (!this.isStockListRoute()) {
+ this.stopChangePolling();
+ window.removeEventListener('hashchange', this.routeChangeHandler);
+ this.routeChangeHandler = null;
+ }
+ };
+
+ window.addEventListener('hashchange', this.routeChangeHandler);
+ },
+ isStockListRoute() {
+ const route = (window.location.hash || '#/').replace(/^#/, '') || '/';
+ return route === '/stock';
+ },
+ async primeChangeCursor() {
+ try {
+ const payload = await listKitchenChanges(store, { limit: 1 });
+ this.changeCursor = payload.nextCursor || payload.since || this.changeCursor;
+ } catch {
+ this.changeCursor = this.changeCursor || null;
+ }
+ },
+ startChangePolling() {
+ if (this.changePollTimer) {
+ return;
+ }
+
+ this.changePollTimer = window.setInterval(() => {
+ this.pollKitchenChanges().catch(() => {});
+ }, CHANGE_POLL_INTERVAL_MS);
+ },
+ stopChangePolling() {
+ if (this.changePollTimer) {
+ window.clearInterval(this.changePollTimer);
+ this.changePollTimer = null;
+ }
+ },
+ async pollKitchenChanges() {
+ if (this.isPollingChanges || !store.isConnected || !this.isStockListRoute()) {
+ return;
+ }
+
+ this.isPollingChanges = true;
+
+ try {
+ const payload = await listKitchenChanges(store, {
+ since: this.changeCursor || undefined,
+ limit: 25,
+ });
+ this.changeCursor = payload.nextCursor || payload.since || this.changeCursor;
+
+ if (!payload.changes.length) {
+ return;
+ }
+
+ await this.refreshCurrentView({ background: true });
+ } finally {
+ this.isPollingChanges = false;
+ }
+ },
+ showInitialLoader() {
+ if (!this.state.isLoading) {
+ return false;
+ }
+
+ return this.viewMode === 'grouped' ? !this.groupedLoaded : !this.itemsLoaded;
+ },
+ beginBackgroundRefresh() {
+ this.refreshActivityCount += 1;
+ this.state.isRefreshing = true;
+ },
+ endBackgroundRefresh() {
+ this.refreshActivityCount = Math.max(0, this.refreshActivityCount - 1);
+ this.state.isRefreshing = this.refreshActivityCount > 0;
+ },
+ startItemRefresh(uuidB64) {
+ if (!uuidB64) {
+ return;
+ }
+
+ const nextCount = (this.itemRefreshCounts[uuidB64] || 0) + 1;
+ this.itemRefreshCounts = {
+ ...this.itemRefreshCounts,
+ [uuidB64]: nextCount,
+ };
+ },
+ endItemRefresh(uuidB64) {
+ if (!uuidB64 || !this.itemRefreshCounts[uuidB64]) {
+ return;
+ }
+
+ const nextCount = Math.max(0, (this.itemRefreshCounts[uuidB64] || 0) - 1);
+ if (!nextCount) {
+ const { [uuidB64]: _, ...rest } = this.itemRefreshCounts;
+ this.itemRefreshCounts = rest;
+ return;
+ }
+
+ this.itemRefreshCounts = {
+ ...this.itemRefreshCounts,
+ [uuidB64]: nextCount,
+ };
+ },
+ isItemRefreshing(entryOrUuid) {
+ const uuidB64 = typeof entryOrUuid === 'string' ? entryOrUuid : entryOrUuid?.uuid_b64;
+ if (!uuidB64) {
+ return false;
+ }
+
+ return Number(this.itemRefreshCounts[uuidB64] || 0) > 0;
+ },
+ invalidateMemo() {
+ this.memo.filteredEntriesSig = '';
+ this.memo.filteredGroupedEntriesSig = '';
+ this.memo.expirationCountsSig = '';
+ this.memo.locationCountsSig = '';
+ },
+ indexEntry(entry) {
+ const indexed = { ...entry };
+ indexed._searchBlob = searchBlob(indexed, this.locationMap);
+ return indexed;
+ },
+ indexGroup(group) {
+ const indexedItems = Array.isArray(group.items)
+ ? group.items.map((item) => this.indexEntry(item))
+ : [];
+ const indexed = {
+ ...group,
+ items: indexedItems,
+ };
+ indexed._searchBlob = groupSearchBlob(indexed, this.locationMap);
+ return indexed;
+ },
+ reindexSearchData() {
+ if (this.entries.length) {
+ this.entries = this.entries.map((entry) => this.indexEntry(entry));
+ this.entriesVersion += 1;
+ }
+ if (this.groupedEntries.length) {
+ this.groupedEntries = this.groupedEntries.map((group) => this.indexGroup(group));
+ this.groupedVersion += 1;
+ }
+ this.invalidateMemo();
+ },
+ syncEditFormsFromEntries() {
+ this.editForms = Object.fromEntries(
+ this.entries.map((entry) => [
+ entry.id,
+ {
+ level: entry.level || 'plenty',
+ quantity: entry.quantity ?? '',
+ },
+ ]),
+ );
+ this.editErrors = {};
},
async switchView(mode) {
this.viewMode = mode;
- if (mode === 'grouped' && !this.groupedLoaded) {
- await this.loadGroupedEntries();
+ if (mode === 'grouped') {
+ if (!this.groupedLoaded) {
+ await this.loadGroupedEntries({ expanded: 0, resetVisible: true });
+ }
+ this.hydrateGroupedEntriesInBackground().catch(() => {});
+ return;
+ }
+
+ if (!this.itemsLoaded) {
+ await this.loadEntries();
}
},
- async refreshCurrentView() {
+ async refreshCurrentView({ background = false } = {}) {
+ const useBackground = background || (this.viewMode === 'grouped'
+ ? this.groupedLoaded
+ : this.itemsLoaded);
+
if (this.viewMode === 'grouped') {
- await this.loadGroupedEntries();
+ await this.loadGroupedEntries({ expanded: 0, background: useBackground });
+ this.hydrateGroupedEntriesInBackground({ force: true }).catch(() => {});
return;
}
- await this.loadEntries();
+ await this.loadEntries({ background: useBackground });
},
- async loadEntries() {
+ async loadEntries({ background = false } = {}) {
if (!store.isConnected) {
return;
}
- await runAsyncState(this.state, async () => {
+ const shouldBlock = !background && !this.itemsLoaded;
+ if (background) {
+ this.beginBackgroundRefresh();
+ } else if (shouldBlock) {
+ this.state.isLoading = true;
+ this.state.error = '';
+ }
+
+ try {
const loadedEntries = await listStockEntries(store);
- this.entries = sortEntries(loadedEntries);
- this.editForms = Object.fromEntries(
- this.entries.map((entry) => [
- entry.id,
- {
- level: entry.level || 'plenty',
- quantity: entry.quantity ?? '',
- },
- ]),
- );
- this.editErrors = {};
- }).catch(() => {});
+ this.entries = sortEntries(loadedEntries.map((entry) => this.indexEntry(entry)));
+ this.itemsLoaded = true;
+ this.entriesVersion += 1;
+ this.syncEditFormsFromEntries();
+ this.invalidateMemo();
+ this.persistRuntimeCache();
+ } catch (error) {
+ if (!background) {
+ this.state.error = error.message || 'Could not load stock items.';
+ }
+ } finally {
+ if (background) {
+ this.endBackgroundRefresh();
+ } else if (shouldBlock) {
+ this.state.isLoading = false;
+ }
+ }
},
- async loadGroupedEntries() {
+ applyGroupedSummary(loadedGroups, { resetVisible = false } = {}) {
+ const existingById = new Map(this.groupedEntries.map((group) => [group.id, group]));
+ const nextGroups = loadedGroups.map((group) => {
+ const existing = existingById.get(group.id);
+ const preservedItems = Array.isArray(group.items) ? group.items : existing?.items || [];
+ return this.indexGroup({
+ ...existing,
+ ...group,
+ items: preservedItems,
+ });
+ });
+
+ this.groupedEntries = sortGroupedEntries(nextGroups);
+ this.groupedLoaded = true;
+ this.groupedHydrated = false;
+ this.groupedVersion += 1;
+ this.pruneOpenGroupedCards();
+ this.invalidateMemo();
+ this.persistRuntimeCache();
+
+ if (resetVisible) {
+ this.resetGroupedVisibleLimit();
+ }
+ },
+ applyGroupedHydration(loadedGroups) {
+ const existingById = new Map(this.groupedEntries.map((group) => [group.id, group]));
+ const nextGroups = loadedGroups.map((group) => {
+ const existing = existingById.get(group.id);
+ const mergedItems = Array.isArray(group.items) ? group.items : existing?.items || [];
+ return this.indexGroup({
+ ...existing,
+ ...group,
+ items: mergedItems,
+ });
+ });
+
+ this.groupedEntries = sortGroupedEntries(nextGroups);
+ this.groupedLoaded = true;
+ this.groupedHydrated = true;
+ this.groupedVersion += 1;
+ this.pruneOpenGroupedCards();
+ this.invalidateMemo();
+ this.persistRuntimeCache();
+ },
+ async loadGroupedEntries({ expanded = 1, background = false, resetVisible = false } = {}) {
if (!store.isConnected) {
return;
}
- await runAsyncState(this.state, async () => {
- const loadedGroups = await listGroupedStockEntries(store);
- this.groupedEntries = sortGroupedEntries(loadedGroups);
- this.groupedLoaded = true;
- }).catch(() => {});
+ const shouldBlock = !background && !this.groupedLoaded;
+ if (background) {
+ this.beginBackgroundRefresh();
+ } else if (shouldBlock) {
+ this.state.isLoading = true;
+ this.state.error = '';
+ }
+
+ try {
+ const loadedGroups = await listGroupedStockEntries(store, { expanded });
+ if (expanded === 0) {
+ this.applyGroupedSummary(loadedGroups, { resetVisible });
+ return;
+ }
+
+ this.applyGroupedHydration(loadedGroups);
+ } catch (error) {
+ if (!background) {
+ this.state.error = error.message || 'Could not load grouped stock.';
+ }
+ } finally {
+ if (background) {
+ this.endBackgroundRefresh();
+ } else if (shouldBlock) {
+ this.state.isLoading = false;
+ }
+ }
+ },
+ async hydrateGroupedEntriesInBackground({ force = false } = {}) {
+ if (!this.groupedLoaded || this.groupedHydrating) {
+ return;
+ }
+
+ if (this.groupedHydrated && !force) {
+ return;
+ }
+
+ this.groupedHydrating = true;
+ try {
+ await this.loadGroupedEntries({ expanded: 1, background: true });
+ } finally {
+ this.groupedHydrating = false;
+ }
+ },
+ async refreshLoadedViewsInBackground() {
+ const tasks = [];
+ if (this.itemsLoaded) {
+ tasks.push(this.loadEntries({ background: true }));
+ }
+ if (this.groupedLoaded) {
+ tasks.push(
+ this.loadGroupedEntries({ expanded: 0, background: true }).then(() =>
+ this.hydrateGroupedEntriesInBackground({ force: true }),
+ ),
+ );
+ }
+ await Promise.allSettled(tasks);
},
async loadLocations() {
if (!store.isConnected) {
@@ -793,6 +1459,9 @@ export function stockListPageData(store) {
this.locationMap = Object.fromEntries(
flat.map((location) => [location.uuid_b64, location.pathLabel]),
);
+ this.locationLineage = Object.fromEntries(
+ flat.map((location) => [location.uuid_b64, location.lineage_uuid_b64 || []]),
+ );
this.locationDescendants = Object.fromEntries(
flat.map((location) => [
location.uuid_b64,
@@ -804,15 +1473,47 @@ export function stockListPageData(store) {
} catch {
this.locations = [];
this.locationMap = {};
+ this.locationLineage = {};
this.locationDescendants = {};
+ } finally {
+ this.locationsVersion += 1;
+ this.reindexSearchData();
+ this.persistRuntimeCache();
}
},
+ resetGroupedVisibleLimit() {
+ this.groupedVisibleLimit = this.groupedPageSize;
+ },
+ showMoreGroups() {
+ this.groupedVisibleLimit += this.groupedPageSize;
+ },
+ groupItemCount(group) {
+ if (Number.isFinite(group.items_count)) {
+ return group.items_count;
+ }
+ if (Number.isFinite(group.count)) {
+ return group.count;
+ }
+ return Array.isArray(group.items) ? group.items.length : 0;
+ },
+ isGroupedCardOpen(groupId) {
+ return Boolean(this.openGroupedCards[String(groupId)]);
+ },
+ pruneOpenGroupedCards() {
+ const activeIds = new Set(this.groupedEntries.map((group) => String(group.id)));
+ this.openGroupedCards = Object.fromEntries(
+ Object.entries(this.openGroupedCards).filter(
+ ([groupId, isOpen]) => isOpen && activeIds.has(groupId),
+ ),
+ );
+ },
clearFilters() {
this.filters = {
search: '',
expiration: [],
location: [],
};
+ this.resetGroupedVisibleLimit();
},
setOverviewOpen(key, open) {
this.overviewOpen[key] = open;
@@ -852,30 +1553,60 @@ export function stockListPageData(store) {
return key === 'expiration' ? 'col-xl-5' : 'col-xl-7';
},
- expirationFilterSummary() {
- if (this.isAllExpirationSelected()) {
- return 'Show all';
- }
-
- if (!this.filters.expiration.length) {
- return 'No expiration states selected';
- }
-
- if (this.filters.expiration.length === 1) {
- return this.expirationLegend.find((state) => state.key === this.filters.expiration[0])?.label || '1 expiration state';
- }
-
- return `${this.filters.expiration.length} expiration states selected`;
- },
isAllExpirationSelected() {
- return this.filters.expiration.length === 0 || this.filters.expiration.length === EXPIRATION_KEYS.length;
+ return (
+ this.filters.expiration.length === 0
+ || this.filters.expiration.length === EXPIRATION_KEYS.length
+ );
+ },
+ effectiveExpirationFilters() {
+ if (this.isAllExpirationSelected()) {
+ return [];
+ }
+ return [...new Set(this.filters.expiration)];
+ },
+ effectiveLocationFilters() {
+ const unique = [...new Set(this.filters.location)];
+ if (unique.length === 0 || (this.locations.length > 0 && unique.length === this.locations.length)) {
+ return [];
+ }
+ return unique;
+ },
+ expirationFilterSummary() {
+ const active = this.effectiveExpirationFilters();
+ if (!active.length) {
+ return 'All states';
+ }
+
+ if (active.length === 1) {
+ return this.expirationLegend.find((state) => state.key === active[0])?.label || '1 state';
+ }
+
+ return `${active.length} states selected`;
+ },
+ locationFilterSummary() {
+ const active = this.effectiveLocationFilters();
+ if (!active.length) {
+ return 'All locations';
+ }
+
+ if (active.length === 1) {
+ return this.locationMap[active[0]] || '1 location selected';
+ }
+
+ return `${active.length} locations selected`;
+ },
+ normalizedSearchTerm() {
+ return String(this.filters.search || '').trim().toLowerCase();
},
toggleAllExpirationFilters() {
this.filters.expiration = [];
+ this.resetGroupedVisibleLimit();
},
toggleExpirationOverviewFilter(key) {
if (this.isAllExpirationSelected()) {
this.filters.expiration = [key];
+ this.resetGroupedVisibleLimit();
return;
}
@@ -884,48 +1615,46 @@ export function stockListPageData(store) {
toggleExpirationFilter(key) {
if (this.filters.expiration.includes(key)) {
this.filters.expiration = this.filters.expiration.filter((value) => value !== key);
- return;
+ } else {
+ this.filters.expiration = [...this.filters.expiration, key];
}
-
- this.filters.expiration = [...this.filters.expiration, key];
+ this.resetGroupedVisibleLimit();
},
- locationFilterSummary() {
- if (this.isAllLocationsSelected()) {
- return 'All locations';
- }
+ isAllLocationsSelected() {
+ return (
+ this.filters.location.length === 0
+ || (this.locations.length > 0 && this.filters.location.length === this.locations.length)
+ );
+ },
+ toggleAllLocations() {
+ this.filters.location = [];
+ this.resetGroupedVisibleLimit();
+ },
+ toggleLocationFilter(uuid) {
+ const subtree = this.locationSubtree(uuid);
- if (!this.filters.location.length) {
- return 'No locations selected';
+ if (this.filters.location.includes(uuid)) {
+ this.filters.location = this.filters.location.filter((value) => !subtree.includes(value));
+ } else {
+ this.filters.location = [...new Set([...this.filters.location, ...subtree])];
}
-
- if (this.filters.location.length === 1) {
- return this.locationMap[this.filters.location[0]] || '1 location selected';
- }
-
- return `${this.filters.location.length} locations selected`;
+ this.resetGroupedVisibleLimit();
},
selectedLocationSummary() {
if (this.isAllLocationsSelected()) {
return '';
}
-
- if (!this.filters.location.length) {
- return 'No locations selected';
- }
-
return `${this.filters.location.length} selected`;
},
locationOverviewGroups() {
- const byParent = this.locations
+ return this.locations
.filter((location) => location.depth === 0)
.map((parent) => ({
parent,
- items: this.locations.filter((location) =>
- location.lineage_uuid_b64[0] === parent.uuid_b64,
+ items: this.locations.filter(
+ (location) => location.lineage_uuid_b64[0] === parent.uuid_b64,
),
}));
-
- return byParent;
},
balancedLocationOverviewColumns() {
const columns = [[], []];
@@ -940,77 +1669,93 @@ export function stockListPageData(store) {
return columns;
},
- isAllLocationsSelected() {
- return this.filters.location.length === 0 || (this.locations.length > 0 && this.filters.location.length === this.locations.length);
- },
- toggleAllLocations() {
- this.filters.location = [];
- },
- toggleLocationFilter(uuid) {
- const subtree = this.locationSubtree(uuid);
+ get filteredEntries() {
+ const searchTerm = this.normalizedSearchTerm();
+ const expirationFilters = this.effectiveExpirationFilters().slice().sort().join(',');
+ const locationFilters = this.effectiveLocationFilters().slice().sort().join(',');
+ const signature = [
+ this.entriesVersion,
+ this.locationsVersion,
+ searchTerm,
+ expirationFilters,
+ locationFilters,
+ ].join('|');
- if (this.filters.location.includes(uuid)) {
- this.filters.location = this.filters.location.filter((value) => !subtree.includes(value));
- return;
+ if (this.memo.filteredEntriesSig === signature) {
+ return this.memo.filteredEntries;
}
- this.filters.location = [...new Set([...this.filters.location, ...subtree])];
- },
- get filteredEntries() {
- return this.entries.filter((entry) => {
+ const activeExpirationFilters = this.effectiveExpirationFilters();
+ const activeLocationFilters = this.effectiveLocationFilters();
+ const filtered = this.entries.filter((entry) => {
+ if (searchTerm && !(entry._searchBlob || '').includes(searchTerm)) {
+ return false;
+ }
if (
- this.filters.search &&
- !searchBlob(entry, this.locationMap).includes(this.filters.search.toLowerCase())
+ activeExpirationFilters.length
+ && !activeExpirationFilters.includes(expirationInfo(entry).key)
) {
return false;
}
-
if (
- this.filters.expiration.length &&
- this.filters.expiration.length !== EXPIRATION_KEYS.length &&
- !this.filters.expiration.includes(expirationInfo(entry).key)
+ activeLocationFilters.length
+ && !this.locationMatchesAnyFilter(entry.location_initial_uuid_b64, activeLocationFilters)
) {
return false;
}
-
- if (
- this.filters.location.length &&
- this.filters.location.length !== this.locations.length &&
- !this.locationMatchesAnyFilter(entry.location_initial_uuid_b64, this.filters.location)
- ) {
- return false;
- }
-
return true;
});
+
+ this.memo.filteredEntriesSig = signature;
+ this.memo.filteredEntries = filtered;
+ return filtered;
},
get filteredGroupedEntries() {
- return this.groupedEntries.filter((group) => {
+ const searchTerm = this.normalizedSearchTerm();
+ const expirationFilters = this.effectiveExpirationFilters().slice().sort().join(',');
+ const locationFilters = this.effectiveLocationFilters().slice().sort().join(',');
+ const signature = [
+ this.groupedVersion,
+ this.locationsVersion,
+ searchTerm,
+ expirationFilters,
+ locationFilters,
+ ].join('|');
+
+ if (this.memo.filteredGroupedEntriesSig === signature) {
+ return this.memo.filteredGroupedEntries;
+ }
+
+ const activeExpirationFilters = this.effectiveExpirationFilters();
+ const activeLocationFilters = this.effectiveLocationFilters();
+ const filtered = this.groupedEntries.filter((group) => {
+ if (searchTerm && !(group._searchBlob || '').includes(searchTerm)) {
+ return false;
+ }
if (
- this.filters.search &&
- !groupSearchBlob(group, this.locationMap).includes(this.filters.search.toLowerCase())
+ activeExpirationFilters.length
+ && !activeExpirationFilters.includes(expirationInfo(group).key)
) {
return false;
}
-
if (
- this.filters.expiration.length &&
- this.filters.expiration.length !== EXPIRATION_KEYS.length &&
- !this.filters.expiration.includes(expirationInfo(group).key)
+ activeLocationFilters.length
+ && !this.groupMatchesLocationFilters(group, activeLocationFilters)
) {
return false;
}
-
- if (
- this.filters.location.length &&
- this.filters.location.length !== this.locations.length &&
- !this.groupMatchesLocationFilters(group, this.filters.location)
- ) {
- return false;
- }
-
return true;
});
+
+ this.memo.filteredGroupedEntriesSig = signature;
+ this.memo.filteredGroupedEntries = filtered;
+ return filtered;
+ },
+ get visibleGroupedEntries() {
+ return this.filteredGroupedEntries.slice(0, this.groupedVisibleLimit);
+ },
+ get hasMoreGroupedEntries() {
+ return this.filteredGroupedEntries.length > this.visibleGroupedEntries.length;
},
get visibleResultCount() {
return this.viewMode === 'grouped'
@@ -1033,7 +1778,7 @@ export function stockListPageData(store) {
return `expiration-soft-${expirationInfo(entry).key}`;
},
legendClass(key) {
- const hasActiveFilters = this.filters.expiration.length > 0 && !this.isAllExpirationSelected();
+ const hasActiveFilters = !this.isAllExpirationSelected();
return [
`legend-${key}`,
hasActiveFilters && this.isExpirationFilterActive(key) ? 'legend-card-active' : '',
@@ -1042,31 +1787,48 @@ export function stockListPageData(store) {
.filter(Boolean)
.join(' ');
},
- expirationCount(key) {
+ getExpirationCounts() {
+ const searchTerm = this.normalizedSearchTerm();
+ const locationFilters = this.effectiveLocationFilters().slice().sort().join(',');
+ const sourceVersion = this.viewMode === 'grouped' ? this.groupedVersion : this.entriesVersion;
+ const signature = [
+ this.viewMode,
+ sourceVersion,
+ this.locationsVersion,
+ searchTerm,
+ locationFilters,
+ ].join('|');
+
+ if (this.memo.expirationCountsSig === signature) {
+ return this.memo.expirationCounts;
+ }
+
+ const counts = Object.fromEntries(EXPIRATION_KEYS.map((key) => [key, 0]));
+ const activeLocationFilters = this.effectiveLocationFilters();
const source = this.viewMode === 'grouped' ? this.groupedEntries : this.entries;
- return source.filter((entry) => {
- if (
- this.filters.search &&
- !(this.viewMode === 'grouped'
- ? groupSearchBlob(entry, this.locationMap)
- : searchBlob(entry, this.locationMap)
- ).includes(this.filters.search.toLowerCase())
- ) {
- return false;
+ source.forEach((entry) => {
+ const blob = entry._searchBlob || '';
+ if (searchTerm && !blob.includes(searchTerm)) {
+ return;
}
- if (
- this.filters.location.length &&
- this.filters.location.length !== this.locations.length &&
- !(this.viewMode === 'grouped'
- ? this.groupMatchesLocationFilters(entry, this.filters.location)
- : this.locationMatchesAnyFilter(entry.location_initial_uuid_b64, this.filters.location))
- ) {
- return false;
+ const matchesLocation = this.viewMode === 'grouped'
+ ? this.groupMatchesLocationFilters(entry, activeLocationFilters)
+ : this.locationMatchesAnyFilter(entry.location_initial_uuid_b64, activeLocationFilters);
+ if (!matchesLocation) {
+ return;
}
- return expirationInfo(entry).key === key;
- }).length;
+ const key = expirationInfo(entry).key;
+ counts[key] += 1;
+ });
+
+ this.memo.expirationCountsSig = signature;
+ this.memo.expirationCounts = counts;
+ return counts;
+ },
+ expirationCount(key) {
+ return this.getExpirationCounts()[key] || 0;
},
shortId(entry) {
return entry.uuid_b64 ? entry.uuid_b64.slice(0, 10) : 'No id';
@@ -1080,13 +1842,14 @@ export function stockListPageData(store) {
toggleLocationOverviewFilter(uuid) {
if (this.isAllLocationsSelected()) {
this.filters.location = [...this.locationSubtree(uuid)];
+ this.resetGroupedVisibleLimit();
return;
}
this.toggleLocationFilter(uuid);
},
locationOverviewClass(location) {
- const hasActiveFilters = this.filters.location.length > 0 && !this.isAllLocationsSelected();
+ const hasActiveFilters = !this.isAllLocationsSelected();
return [
'location-overview',
`location-type-${location.type || 'unknown'}`,
@@ -1101,31 +1864,78 @@ export function stockListPageData(store) {
const offset = location.depth;
return `margin-left: ${offset}rem; width: calc(100% - ${offset}rem);`;
},
- locationCount(locationUuid) {
+ locationAncestors(locationUuid) {
+ if (!locationUuid) {
+ return [];
+ }
+
+ return this.locationLineage[locationUuid] || [locationUuid];
+ },
+ groupLocationAncestors(group) {
+ const allLocations = new Set();
+ if (group.location_initial_uuid_b64) {
+ allLocations.add(group.location_initial_uuid_b64);
+ }
+ (group.items || []).forEach((item) => {
+ if (item.location_initial_uuid_b64) {
+ allLocations.add(item.location_initial_uuid_b64);
+ }
+ });
+
+ const ancestors = new Set();
+ allLocations.forEach((locationUuid) => {
+ this.locationAncestors(locationUuid).forEach((ancestor) => ancestors.add(ancestor));
+ });
+ return [...ancestors];
+ },
+ getLocationCounts() {
+ const searchTerm = this.normalizedSearchTerm();
+ const expirationFilters = this.effectiveExpirationFilters().slice().sort().join(',');
+ const sourceVersion = this.viewMode === 'grouped' ? this.groupedVersion : this.entriesVersion;
+ const signature = [
+ this.viewMode,
+ sourceVersion,
+ this.locationsVersion,
+ searchTerm,
+ expirationFilters,
+ ].join('|');
+
+ if (this.memo.locationCountsSig === signature) {
+ return this.memo.locationCounts;
+ }
+
+ const counts = Object.fromEntries(this.locations.map((location) => [location.uuid_b64, 0]));
+ const activeExpirationFilters = this.effectiveExpirationFilters();
const source = this.viewMode === 'grouped' ? this.groupedEntries : this.entries;
- return source.filter((entry) => {
+
+ source.forEach((entry) => {
+ const blob = entry._searchBlob || '';
+ if (searchTerm && !blob.includes(searchTerm)) {
+ return;
+ }
if (
- this.filters.search &&
- !(this.viewMode === 'grouped'
- ? groupSearchBlob(entry, this.locationMap)
- : searchBlob(entry, this.locationMap)
- ).includes(this.filters.search.toLowerCase())
+ activeExpirationFilters.length
+ && !activeExpirationFilters.includes(expirationInfo(entry).key)
) {
- return false;
+ return;
}
- if (
- this.filters.expiration.length &&
- this.filters.expiration.length !== EXPIRATION_KEYS.length &&
- !this.filters.expiration.includes(expirationInfo(entry).key)
- ) {
- return false;
- }
+ const ancestors = this.viewMode === 'grouped'
+ ? this.groupLocationAncestors(entry)
+ : this.locationAncestors(entry.location_initial_uuid_b64);
+ ancestors.forEach((ancestorUuid) => {
+ if (counts[ancestorUuid] !== undefined) {
+ counts[ancestorUuid] += 1;
+ }
+ });
+ });
- return this.viewMode === 'grouped'
- ? this.groupMatchesLocationFilter(entry, locationUuid)
- : this.locationMatchesFilter(entry.location_initial_uuid_b64, locationUuid);
- }).length;
+ this.memo.locationCountsSig = signature;
+ this.memo.locationCounts = counts;
+ return counts;
+ },
+ locationCount(locationUuid) {
+ return this.getLocationCounts()[locationUuid] || 0;
},
locationMatchesFilter(entryLocationUuid, selectedLocationUuid) {
if (!selectedLocationUuid) {
@@ -1148,6 +1958,10 @@ export function stockListPageData(store) {
);
},
groupMatchesLocationFilter(group, selectedLocationUuid) {
+ if (!selectedLocationUuid) {
+ return true;
+ }
+
if (this.locationMatchesFilter(group.location_initial_uuid_b64, selectedLocationUuid)) {
return true;
}
@@ -1165,22 +1979,81 @@ export function stockListPageData(store) {
this.groupMatchesLocationFilter(group, selectedLocationUuid),
);
},
+ onItemDetailNavigate(entry, event) {
+ if (this.isItemRefreshing(entry)) {
+ event.preventDefault();
+ return;
+ }
+
+ this.rememberStockListContext(entry?.uuid_b64);
+ },
detailHref(entry) {
return `#/stock/${entry.uuid_b64}`;
},
+ refreshGroupFromItems(group) {
+ if (!group || !Array.isArray(group.items) || !group.items.length) {
+ return group;
+ }
+
+ const sortedByDateDesc = [...group.items].sort((left, right) =>
+ (right.date || '').localeCompare(left.date || ''),
+ );
+ const latestItem = sortedByDateDesc[0] || group;
+ const datedItems = group.items.filter((item) => item.date);
+ const productionDates = datedItems.map((item) => item.date).sort((left, right) =>
+ left.localeCompare(right),
+ );
+ const expirationDates = group.items
+ .filter((item) => item.expire_date)
+ .map((item) => item.expire_date)
+ .sort((left, right) => left.localeCompare(right));
+ const firstExpireDate = expirationDates[0] || null;
+ const firstProductionDate = productionDates[0] || null;
+
+ return {
+ ...group,
+ items_count: Number.isFinite(group.items_count) ? group.items_count : group.items.length,
+ date: latestItem.date || group.date,
+ stock_type: latestItem.stock_type || group.stock_type,
+ level: latestItem.level || group.level,
+ quantity:
+ latestItem.quantity !== undefined && latestItem.quantity !== null
+ ? latestItem.quantity
+ : group.quantity,
+ uom_symbol: latestItem.uom_symbol || group.uom_symbol,
+ location_initial_uuid_b64: latestItem.location_initial_uuid_b64 || null,
+ first_production_date: firstProductionDate,
+ first_expire_date: firstExpireDate,
+ first_expire_in: firstExpireDate ? daysUntilDate(firstExpireDate) : null,
+ expire_date: firstExpireDate,
+ };
+ },
closeGroupedCard(details) {
if (!details) {
return;
}
details.open = false;
+ if (details.dataset?.groupId) {
+ this.openGroupedCards = {
+ ...this.openGroupedCards,
+ [String(details.dataset.groupId)]: false,
+ };
+ }
},
handleGroupedToggle(event) {
const details = event.target;
- if (!(details instanceof HTMLDetailsElement) || details.open) {
+ if (!(details instanceof HTMLDetailsElement)) {
return;
}
+ if (details.dataset?.groupId) {
+ this.openGroupedCards = {
+ ...this.openGroupedCards,
+ [String(details.dataset.groupId)]: details.open,
+ };
+ }
+
const summary = details.querySelector('.grouped-stock-summary');
if (!summary) {
return;
@@ -1208,7 +2081,88 @@ export function stockListPageData(store) {
return entry.uom_symbol ? `Measured in ${entry.uom_symbol}` : 'Measured stock';
},
formatDate,
- async updateBinary(entry, level) {
+ applyUpdatedItemToLists(updatedEntry) {
+ if (!updatedEntry || typeof updatedEntry !== 'object' || !updatedEntry.uuid_b64) {
+ return;
+ }
+
+ let changedEntries = false;
+ if (this.entries.length) {
+ this.entries = sortEntries(
+ this.entries.map((entry) => {
+ if (entry.uuid_b64 !== updatedEntry.uuid_b64) {
+ return entry;
+ }
+
+ changedEntries = true;
+ return this.indexEntry({
+ ...entry,
+ ...updatedEntry,
+ });
+ }),
+ );
+ }
+
+ if (changedEntries) {
+ this.entriesVersion += 1;
+ }
+
+ let changedGrouped = false;
+ if (this.groupedEntries.length) {
+ this.groupedEntries = sortGroupedEntries(
+ this.groupedEntries.map((group) => {
+ if (!Array.isArray(group.items) || !group.items.length) {
+ return group;
+ }
+
+ let groupChanged = false;
+ const nextItems = group.items.map((item) => {
+ if (item.uuid_b64 !== updatedEntry.uuid_b64) {
+ return item;
+ }
+
+ groupChanged = true;
+ return this.indexEntry({
+ ...item,
+ ...updatedEntry,
+ });
+ });
+
+ if (!groupChanged) {
+ return group;
+ }
+
+ changedGrouped = true;
+ return this.indexGroup(this.refreshGroupFromItems({
+ ...group,
+ items: nextItems,
+ }));
+ }),
+ );
+ }
+
+ if (changedGrouped) {
+ this.groupedVersion += 1;
+ }
+
+ if (!changedEntries && !changedGrouped) {
+ return;
+ }
+
+ this.invalidateMemo();
+ if (updatedEntry.id !== undefined && updatedEntry.id !== null) {
+ this.editForms[updatedEntry.id] = {
+ level: updatedEntry.level || 'plenty',
+ quantity: updatedEntry.quantity ?? '',
+ };
+ }
+ this.persistRuntimeCache();
+ },
+ async updateBinary(entry) {
+ if (this.isItemRefreshing(entry)) {
+ return;
+ }
+
await this.useEntry(entry);
},
async saveLevel(entry) {
@@ -1218,9 +2172,13 @@ export function stockListPageData(store) {
return;
}
- await this.saveEntryUpdate(entry, {
- level,
- }, { level });
+ await this.saveEntryUpdate(
+ entry,
+ {
+ level,
+ },
+ { level },
+ );
},
async saveQuantity(entry) {
const quantity = Number(this.editForms[entry.id]?.quantity);
@@ -1229,21 +2187,33 @@ export function stockListPageData(store) {
return;
}
- await this.saveEntryUpdate(entry, {
- quantity,
- }, { quantity });
+ await this.saveEntryUpdate(
+ entry,
+ {
+ quantity,
+ },
+ { quantity },
+ );
},
async markGone(entry) {
+ if (this.isItemRefreshing(entry)) {
+ return;
+ }
+
await this.useEntry(entry);
},
async markGoneFromGroup(item, group) {
+ if (this.isItemRefreshing(item)) {
+ return;
+ }
+
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);
+ this.removeEntryLocally(item.id);
delete this.editForms[item.id];
delete this.editErrors[item.id];
store.addAlert({
@@ -1252,6 +2222,7 @@ export function stockListPageData(store) {
? `${item.name} was already out of stock and removed from the group.`
: `${item.name} was marked gone and removed from the group.`,
});
+ this.refreshLoadedViewsInBackground().catch(() => {});
} catch (error) {
this.editErrors[item.id] = error.message || 'Mark gone failed.';
}
@@ -1266,56 +2237,149 @@ export function stockListPageData(store) {
type: 'success',
message: `${entry.name} updated successfully.`,
});
+ this.refreshLoadedViewsInBackground().catch(() => {});
} catch (error) {
this.editErrors[entry.id] = error.message || 'Update failed.';
}
},
async useEntry(entry) {
+ if (this.isItemRefreshing(entry)) {
+ return;
+ }
+
this.editErrors[entry.id] = '';
try {
const result = await useStockItem(store, entry.uuid_b64);
- this.entries = this.entries.filter((candidate) => candidate.id !== entry.id);
+ const alreadyGone = result.status === 'already_gone';
+ this.removeEntryLocally(entry.id);
delete this.editForms[entry.id];
delete this.editErrors[entry.id];
- const alreadyGone = result.status === 'already_gone';
store.addAlert({
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.`,
});
+ this.refreshLoadedViewsInBackground().catch(() => {});
} catch (error) {
this.editErrors[entry.id] = error.message || 'Mark gone failed.';
}
},
+ removeEntryLocally(entryId) {
+ if (!this.entries.length) {
+ return;
+ }
+
+ this.entries = this.entries.filter((entry) => entry.id !== entryId);
+ this.entriesVersion += 1;
+ this.invalidateMemo();
+ this.persistRuntimeCache();
+ },
+ removeEntryByUuid(uuidB64) {
+ if (!uuidB64 || !this.entries.length) {
+ return false;
+ }
+
+ const removed = this.entries.filter((entry) => entry.uuid_b64 === uuidB64);
+ if (!removed.length) {
+ return false;
+ }
+
+ this.entries = this.entries.filter((entry) => entry.uuid_b64 !== uuidB64);
+ removed.forEach((entry) => {
+ delete this.editForms[entry.id];
+ delete this.editErrors[entry.id];
+ });
+ this.entriesVersion += 1;
+ this.invalidateMemo();
+ return true;
+ },
replaceEntry(entryId, nextEntry) {
this.entries = sortEntries(
- this.entries.map((entry) => (entry.id === entryId ? nextEntry : entry)),
+ this.entries.map((entry) =>
+ entry.id === entryId ? this.indexEntry(nextEntry) : entry,
+ ),
);
+ this.entriesVersion += 1;
+ this.invalidateMemo();
this.editForms[entryId] = {
level: nextEntry.level || 'plenty',
quantity: nextEntry.quantity ?? '',
};
+ this.persistRuntimeCache();
},
removeGroupedItem(groupId, itemId) {
- this.groupedEntries = this.groupedEntries
+ this.groupedEntries = sortGroupedEntries(
+ 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 this.indexGroup(this.refreshGroupFromItems({
+ ...group,
+ items_count: Number.isFinite(group.items_count)
+ ? Math.max(0, group.items_count - 1)
+ : nextItems.length,
+ items: nextItems,
+ }));
+ })
+ .filter(Boolean),
+ );
+ this.groupedVersion += 1;
+ this.pruneOpenGroupedCards();
+ this.invalidateMemo();
+ this.persistRuntimeCache();
+ },
+ removeGroupedItemByUuid(uuidB64) {
+ if (!uuidB64 || !this.groupedEntries.length) {
+ return false;
+ }
+
+ let changed = false;
+ const nextGroups = this.groupedEntries
.map((group) => {
- if (group.id !== groupId) {
+ if (!Array.isArray(group.items) || !group.items.length) {
return group;
}
- const nextItems = (group.items || []).filter((candidate) => candidate.id !== itemId);
- if (!nextItems.length) {
+ const removedCount = group.items.filter((item) => item.uuid_b64 === uuidB64).length;
+ if (!removedCount) {
+ return group;
+ }
+
+ changed = true;
+ const nextItems = group.items.filter((item) => item.uuid_b64 !== uuidB64);
+ const hasKnownCount = Number.isFinite(group.items_count);
+ const nextCount = hasKnownCount ? Math.max(0, group.items_count - removedCount) : null;
+
+ if (!nextItems.length && hasKnownCount && nextCount <= 0) {
return null;
}
- return {
+ return this.indexGroup(this.refreshGroupFromItems({
...group,
items: nextItems,
- };
+ ...(hasKnownCount ? { items_count: nextCount } : {}),
+ }));
})
.filter(Boolean);
+
+ if (!changed) {
+ return false;
+ }
+
+ this.groupedEntries = sortGroupedEntries(nextGroups);
+ this.groupedVersion += 1;
+ this.pruneOpenGroupedCards();
+ this.invalidateMemo();
+ return true;
},
};
}
diff --git a/src/styles/app.css b/src/styles/app.css
index c4048e1..82c939d 100644
--- a/src/styles/app.css
+++ b/src/styles/app.css
@@ -314,10 +314,112 @@ body {
color: var(--lonc-primary);
}
+.stock-filter-hub {
+ background:
+ linear-gradient(160deg, rgba(255, 255, 255, 0.94), rgba(245, 250, 255, 0.88)),
+ linear-gradient(135deg, rgba(93, 169, 255, 0.1), rgba(31, 75, 153, 0.06));
+}
+
+.stock-workspace {
+ align-items: flex-start;
+}
+
+.stock-results-pane {
+ min-width: 0;
+}
+
+.stock-filter-summary {
+ padding: 0.85rem 0.95rem;
+ border-radius: 0.9rem;
+ border: 1px dashed rgba(31, 75, 153, 0.24);
+ background: rgba(255, 255, 255, 0.7);
+ min-height: 100%;
+}
+
+.stock-filter-rail .overview-list-locations {
+ max-height: 18rem;
+}
+
+.stock-filter-rail .location-overview-columns {
+ grid-template-columns: 1fr;
+}
+
+@media (min-width: 768px) and (max-width: 1199.98px) {
+ .stock-filter-panels {
+ grid-template-columns: repeat(2, minmax(0, 1fr));
+ align-items: start;
+ }
+
+ .stock-filter-panels.stock-filter-panels-single-open {
+ grid-template-columns: 1fr;
+ }
+}
+
+@media (min-width: 1200px) {
+ .stock-filter-rail .stock-filter-hub {
+ position: sticky;
+ top: 1rem;
+ }
+}
+
.stock-view-switch {
- display: inline-flex;
- flex-wrap: wrap;
- gap: 0.5rem;
+ display: grid;
+ grid-template-columns: repeat(2, minmax(0, 1fr));
+ gap: 0.65rem;
+}
+
+.stock-view-switch-wrap {
+ min-width: min(100%, 38rem);
+}
+
+.stock-view-tab {
+ display: grid;
+ align-content: start;
+ gap: 0.2rem;
+ text-align: left;
+ border: 1px solid var(--lonc-border);
+ border-radius: 0.9rem;
+ background: rgba(255, 255, 255, 0.66);
+ color: inherit;
+ padding: 0.65rem 0.85rem;
+ transition:
+ border-color 160ms ease,
+ box-shadow 160ms ease,
+ background-color 160ms ease,
+ transform 160ms ease;
+}
+
+.stock-view-tab:hover {
+ transform: translateY(-1px);
+ border-color: rgba(31, 75, 153, 0.24);
+ box-shadow: 0 8px 16px rgba(24, 42, 79, 0.08);
+}
+
+.stock-view-tab:focus-visible {
+ outline: 2px solid rgba(31, 75, 153, 0.4);
+ outline-offset: 2px;
+}
+
+.stock-view-tab-active {
+ border-color: rgba(31, 75, 153, 0.42);
+ background: rgba(31, 75, 153, 0.1);
+ box-shadow: inset 0 0 0 1px rgba(31, 75, 153, 0.18);
+}
+
+.stock-view-tab-title {
+ font-weight: 700;
+ font-size: 0.95rem;
+}
+
+.stock-view-tab-subtitle {
+ font-size: 0.78rem;
+ color: var(--lonc-muted);
+}
+
+@media (max-width: 767.98px) {
+ .stock-view-switch {
+ grid-template-columns: 1fr;
+ }
}
.overview-row-single-open > [class*='col-'] {
@@ -329,6 +431,16 @@ body {
height: auto;
}
+.stock-filter-panel-card {
+ border: 1px solid var(--lonc-border);
+ border-radius: 1rem;
+ background: rgba(255, 255, 255, 0.84);
+}
+
+.stock-filter-panel-card[open] {
+ box-shadow: 0 10px 24px rgba(24, 42, 79, 0.08);
+}
+
.overview-summary {
display: block;
cursor: pointer;
@@ -592,21 +704,69 @@ button.legend-card:focus-visible {
list-style: none;
}
+.grouped-stock-summary-row {
+ min-height: 0;
+}
+
+.grouped-stock-summary-title {
+ font-size: 1.1rem;
+ line-height: 1.2;
+}
+
+.grouped-stock-summary-description {
+ margin: 0.12rem 0 0.35rem;
+}
+
.grouped-stock-summary::-webkit-details-marker {
display: none;
}
.grouped-stock-summary-meta {
align-items: center;
+ align-content: flex-start;
+ column-gap: 0.95rem;
+ row-gap: 0.08rem;
+ line-height: 1.25;
+}
+
+.grouped-stock-summary-status {
+ display: flex;
+ flex-wrap: wrap;
+ align-items: center;
+ justify-content: flex-start;
+ gap: 0.4rem 0.75rem;
+}
+
+.grouped-stock-secondary-details {
+ border-top: 1px dashed rgba(31, 39, 64, 0.14);
+ padding-top: 0.75rem;
+}
+
+.grouped-stock-secondary-toggle {
+ border-radius: 0.85rem;
+ border: 1px solid var(--lonc-border);
+ padding: 0.5rem 0.75rem;
+ background: rgba(255, 255, 255, 0.66);
+}
+
+.grouped-stock-secondary-toggle > summary {
+ list-style: none;
+ cursor: pointer;
+}
+
+.grouped-stock-secondary-toggle > summary::-webkit-details-marker {
+ display: none;
}
.grouped-stock-toggle-label {
color: var(--lonc-primary);
+ font-size: 0.8rem;
+ line-height: 1.1;
}
.grouped-stock-toggle-label::after {
content: 'Expand';
- margin-left: 0.35rem;
+ margin-left: 0.25rem;
}
.grouped-stock-card[open] .grouped-stock-toggle-label::after {
@@ -633,14 +793,20 @@ button.legend-card:focus-visible {
border-left-color: #6c757d;
}
+@media (min-width: 1200px) {
+ .grouped-stock-summary-status {
+ justify-content: flex-end;
+ }
+}
+
.grouped-stock-items {
display: grid;
- gap: 0.75rem;
+ gap: 0.55rem;
}
.grouped-stock-item {
display: block;
- padding: 0.9rem 1rem;
+ padding: 0.65rem 0.8rem;
border-radius: 0.95rem;
border: 1px solid var(--lonc-border);
background: rgba(255, 255, 255, 0.72);
@@ -677,12 +843,71 @@ button.legend-card:focus-visible {
background: rgba(108, 117, 125, 0.08);
}
-.grouped-stock-item-meta {
- justify-content: flex-start;
+.grouped-stock-item-row {
+ display: grid;
+ grid-template-columns: minmax(0, 1fr) auto auto;
+ align-items: center;
+ gap: 0.75rem;
+}
+
+.grouped-stock-item-main {
+ min-width: 0;
+}
+
+.grouped-stock-item-title-line {
+ display: flex;
+ align-items: baseline;
+ justify-content: space-between;
+ gap: 0.6rem;
+}
+
+.grouped-stock-item-name {
+ font-size: 1rem;
+ line-height: 1.2;
+}
+
+.grouped-stock-item-id {
+ color: var(--lonc-muted);
+ font-size: 0.8rem;
+}
+
+.grouped-stock-item-aux {
+ display: grid;
+ gap: 0.2rem;
+ text-align: right;
+ justify-items: end;
+ white-space: nowrap;
+}
+
+.grouped-stock-item-actions {
+ display: flex;
+ align-items: center;
+ gap: 0.5rem;
+ white-space: nowrap;
+}
+
+.grouped-stock-item-link {
+ font-size: 0.8rem;
+}
+
+.stock-item-link-disabled {
+ pointer-events: none;
+ opacity: 0.55;
+}
+
+.stock-item-refresh-indicator {
+ color: rgba(31, 39, 64, 0.78) !important;
+ font-weight: 600;
+ margin-top: 0.15rem;
+}
+
+.stock-item-refreshing {
+ opacity: 0.94;
}
.grouped-stock-mark-gone {
- align-self: center;
+ padding: 0.2rem 0.55rem;
+ line-height: 1.2;
white-space: nowrap;
}
@@ -708,9 +933,62 @@ button.legend-card:focus-visible {
font-weight: 700;
}
-@media (min-width: 1200px) {
- .grouped-stock-item-meta {
- justify-content: flex-end;
+.grouped-stock-close-row {
+ display: flex;
+ justify-content: flex-end;
+ margin-top: 0.2rem;
+ padding-top: 0.2rem;
+ border-top: 1px dashed rgba(31, 39, 64, 0.12);
+}
+
+.grouped-stock-close {
+ padding: 0.1rem 0.2rem;
+ font-size: 0.78rem;
+ color: rgba(31, 39, 64, 0.82);
+ line-height: 1.1;
+}
+
+.grouped-stock-close:hover {
+ color: var(--lonc-primary-dark);
+}
+
+@media (max-width: 991.98px) {
+ .grouped-stock-item-row {
+ grid-template-columns: minmax(0, 1fr) auto;
+ align-items: start;
+ }
+
+ .grouped-stock-item-actions {
+ grid-column: 1 / -1;
+ justify-content: space-between;
+ padding-top: 0.15rem;
+ }
+
+ .grouped-stock-item-aux {
+ text-align: left;
+ justify-items: start;
+ }
+}
+
+@media (max-width: 575.98px) {
+ .grouped-stock-item {
+ padding: 0.6rem 0.7rem;
+ }
+
+ .grouped-stock-item-row {
+ grid-template-columns: 1fr;
+ gap: 0.4rem;
+ }
+
+ .grouped-stock-item-aux {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 0.5rem;
+ text-align: left;
+ }
+
+ .grouped-stock-item-title-line {
+ gap: 0.45rem;
}
}
diff --git a/tests/api/stock.test.js b/tests/api/stock.test.js
index 3ffa1bd..2cd477d 100644
--- a/tests/api/stock.test.js
+++ b/tests/api/stock.test.js
@@ -15,6 +15,8 @@ vi.mock('../../src/api/client.js', () => ({
const {
applyItemUpsert,
+ listGroupedStockEntries,
+ listStockEntries,
lookupItemByIdentifier,
lookupItemDetails,
listKitchenChanges,
@@ -28,6 +30,51 @@ describe('api/stock', () => {
apiRequestMock.mockReset();
});
+ it('listStockEntries forwards optional query filters', async () => {
+ apiRequestMock.mockResolvedValueOnce([]);
+
+ await listStockEntries(
+ { config: { database: 'db' } },
+ { searchName: 'Milk', limit: 20, offset: 40, cursor: 'cursor-1' },
+ );
+
+ expect(apiRequestMock).toHaveBeenCalledWith(
+ { config: { database: 'db' } },
+ 'kitchen/items',
+ {
+ query: {
+ search_name: 'Milk',
+ limit: 20,
+ offset: 40,
+ cursor: 'cursor-1',
+ },
+ },
+ );
+ });
+
+ it('listGroupedStockEntries defaults to expanded=1 and forwards options', async () => {
+ apiRequestMock.mockResolvedValueOnce([]);
+
+ await listGroupedStockEntries(
+ { config: { database: 'db' } },
+ { expanded: 0, searchName: 'Rice', limit: 10, offset: 0, cursor: 'cursor-2' },
+ );
+
+ expect(apiRequestMock).toHaveBeenCalledWith(
+ { config: { database: 'db' } },
+ 'kitchen/items/grouped',
+ {
+ query: {
+ expanded: 0,
+ search_name: 'Rice',
+ limit: 10,
+ offset: 0,
+ cursor: 'cursor-2',
+ },
+ },
+ );
+ });
+
it('listKitchenChanges returns normalized changes payload', async () => {
apiRequestMock.mockResolvedValueOnce({
since: 'cursor-1',
diff --git a/tests/features/stock/mark-gone.test.js b/tests/features/stock/mark-gone.test.js
index 2412e24..531bce2 100644
--- a/tests/features/stock/mark-gone.test.js
+++ b/tests/features/stock/mark-gone.test.js
@@ -70,4 +70,59 @@ describe('stock mark-gone behavior', () => {
message: 'Flour was marked gone and removed from the list.',
});
});
+
+ it('stock list grouped markGone removes item from grouped and flat collections', async () => {
+ useStockItemMock.mockResolvedValueOnce({ status: 'used' });
+ const addAlert = vi.fn();
+ const data = stockListPageData({ addAlert, isConnected: false });
+
+ data.groupedEntries = [
+ {
+ id: 10,
+ uuid_b64: 'group-10',
+ name: 'Beans',
+ stock_type: 'measured',
+ location_initial_uuid_b64: null,
+ date: '2026-04-12',
+ expire_date: '2026-04-20',
+ items: [
+ {
+ id: 11,
+ uuid_b64: 'item-11',
+ name: 'Beans',
+ stock_type: 'measured',
+ quantity: 1,
+ location_initial_uuid_b64: null,
+ date: '2026-04-12',
+ expire_date: '2026-04-20',
+ },
+ ],
+ },
+ ].map((group) => data.indexGroup(group));
+ data.entries = [
+ data.indexEntry({
+ id: 11,
+ uuid_b64: 'item-11',
+ name: 'Beans',
+ stock_type: 'measured',
+ quantity: 1,
+ location_initial_uuid_b64: null,
+ date: '2026-04-12',
+ expire_date: '2026-04-20',
+ }),
+ ];
+ data.entriesVersion = 1;
+ data.groupedVersion = 1;
+ data.editForms = { 11: { level: 'plenty', quantity: 1 } };
+ data.editErrors = {};
+
+ await data.markGoneFromGroup(data.groupedEntries[0].items[0], data.groupedEntries[0]);
+
+ expect(data.entries).toEqual([]);
+ expect(data.groupedEntries).toEqual([]);
+ expect(addAlert).toHaveBeenCalledWith({
+ type: 'success',
+ message: 'Beans was marked gone and removed from the group.',
+ });
+ });
});
diff --git a/tests/features/stock/stock-list-page-grouped.test.js b/tests/features/stock/stock-list-page-grouped.test.js
new file mode 100644
index 0000000..0a2f710
--- /dev/null
+++ b/tests/features/stock/stock-list-page-grouped.test.js
@@ -0,0 +1,418 @@
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
+
+const listStockEntriesMock = vi.fn();
+const listGroupedStockEntriesMock = vi.fn();
+const listKitchenChangesMock = vi.fn();
+const getStockEntryMock = vi.fn();
+const updateStockItemMock = vi.fn();
+const useStockItemMock = vi.fn();
+const fetchLocationsMock = vi.fn();
+
+vi.mock('../../../src/api/stock.js', () => ({
+ listStockEntries: (...args) => listStockEntriesMock(...args),
+ listGroupedStockEntries: (...args) => listGroupedStockEntriesMock(...args),
+ listKitchenChanges: (...args) => listKitchenChangesMock(...args),
+ getStockEntry: (...args) => getStockEntryMock(...args),
+ updateStockItem: (...args) => updateStockItemMock(...args),
+ useStockItem: (...args) => useStockItemMock(...args),
+}));
+
+vi.mock('../../../src/api/locations.js', () => ({
+ fetchLocations: (...args) => fetchLocationsMock(...args),
+}));
+
+const { stockListPageData } = await import('../../../src/features/stock/stock-list-page.js');
+
+function createGroupedSummary() {
+ return [
+ {
+ id: 10,
+ uuid_b64: 'group-10',
+ name: 'Rice',
+ description: 'Basmati',
+ stock_type: 'measured',
+ level: 'good',
+ quantity: 1,
+ uom_symbol: 'kg',
+ location_initial_uuid_b64: 'loc-root',
+ date: '2026-04-10',
+ expire_date: '2026-04-25',
+ first_expire_date: '2026-04-25',
+ first_production_date: '2026-04-10',
+ items_count: 1,
+ items: [],
+ },
+ ];
+}
+
+function createGroupedExpanded() {
+ return [
+ {
+ ...createGroupedSummary()[0],
+ items: [
+ {
+ id: 100,
+ uuid_b64: 'item-100',
+ name: 'Rice',
+ description: 'Open bag',
+ stock_type: 'measured',
+ level: 'good',
+ quantity: 1,
+ uom_symbol: 'kg',
+ location_initial_uuid_b64: 'loc-root',
+ date: '2026-04-10',
+ expire_date: '2026-04-25',
+ expire_in: 13,
+ },
+ ],
+ },
+ ];
+}
+
+function createWindowMock() {
+ const intervals = new Map();
+ let nextId = 1;
+ const storage = new Map();
+
+ return {
+ location: { hash: '#/stock' },
+ scrollY: 1,
+ setInterval: vi.fn((fn) => {
+ const id = nextId;
+ nextId += 1;
+ intervals.set(id, fn);
+ return id;
+ }),
+ clearInterval: vi.fn((id) => {
+ intervals.delete(id);
+ }),
+ addEventListener: vi.fn(),
+ removeEventListener: vi.fn(),
+ matchMedia: vi.fn(() => ({ matches: false })),
+ scrollTo: vi.fn(),
+ localStorage: {
+ getItem: vi.fn((key) => storage.get(key) ?? null),
+ setItem: vi.fn((key, value) => {
+ storage.set(key, value);
+ }),
+ removeItem: vi.fn((key) => {
+ storage.delete(key);
+ }),
+ },
+ __intervals: intervals,
+ };
+}
+
+describe('stock list grouped-first behavior', () => {
+ beforeEach(() => {
+ listStockEntriesMock.mockReset();
+ listGroupedStockEntriesMock.mockReset();
+ listKitchenChangesMock.mockReset();
+ getStockEntryMock.mockReset();
+ updateStockItemMock.mockReset();
+ useStockItemMock.mockReset();
+ fetchLocationsMock.mockReset();
+
+ globalThis.window = createWindowMock();
+ globalThis.requestAnimationFrame = (callback) => callback();
+ globalThis.HTMLDetailsElement = class MockDetailsElement {};
+ });
+
+ afterEach(() => {
+ vi.restoreAllMocks();
+ delete globalThis.window;
+ delete globalThis.requestAnimationFrame;
+ delete globalThis.HTMLDetailsElement;
+ delete globalThis.structuredClone;
+ });
+
+ it('defaults to grouped mode and loads grouped summary before lazy item list', async () => {
+ listGroupedStockEntriesMock
+ .mockResolvedValueOnce(createGroupedSummary())
+ .mockResolvedValueOnce(createGroupedExpanded());
+ listStockEntriesMock.mockResolvedValueOnce([]);
+ listKitchenChangesMock.mockResolvedValue({ since: null, nextCursor: null, changes: [] });
+ fetchLocationsMock.mockResolvedValue({ flat: [], tree: [] });
+
+ const store = { isConnected: true, addAlert: vi.fn() };
+ const data = stockListPageData(store);
+ data.$nextTick = vi.fn(async () => {});
+
+ await data.init();
+
+ expect(data.viewMode).toBe('grouped');
+ expect(listGroupedStockEntriesMock).toHaveBeenNthCalledWith(1, store, { expanded: 0 });
+ expect(listStockEntriesMock).not.toHaveBeenCalled();
+
+ await Promise.resolve();
+
+ expect(listGroupedStockEntriesMock).toHaveBeenNthCalledWith(2, store, { expanded: 1 });
+
+ await data.switchView('items');
+ expect(listStockEntriesMock).toHaveBeenCalledTimes(1);
+
+ await data.switchView('grouped');
+ await data.switchView('items');
+ expect(listStockEntriesMock).toHaveBeenCalledTimes(1);
+ });
+
+ it('hydrates grouped children in background and merges into existing groups', async () => {
+ listGroupedStockEntriesMock
+ .mockResolvedValueOnce(createGroupedSummary())
+ .mockResolvedValueOnce(createGroupedExpanded());
+
+ const data = stockListPageData({ isConnected: true, addAlert: vi.fn() });
+ await data.loadGroupedEntries({ expanded: 0, resetVisible: true });
+
+ expect(data.groupedEntries[0].items).toEqual([]);
+
+ await data.hydrateGroupedEntriesInBackground();
+
+ expect(data.groupedHydrated).toBe(true);
+ expect(data.groupedEntries[0].items).toHaveLength(1);
+ });
+
+ it('memoizes filtered results and invalidates when filters change', () => {
+ const data = stockListPageData({ isConnected: true, addAlert: vi.fn() });
+ data.entries = [
+ data.indexEntry({
+ id: 1,
+ uuid_b64: 'item-1',
+ name: 'Milk',
+ description: 'Fresh',
+ location_initial_uuid_b64: null,
+ stock_type: 'binary',
+ level: 'good',
+ }),
+ ];
+ data.entriesVersion = 1;
+
+ const first = data.filteredEntries;
+ const second = data.filteredEntries;
+
+ expect(second).toBe(first);
+
+ data.filters.search = 'milk';
+
+ const third = data.filteredEntries;
+ expect(third).not.toBe(first);
+ expect(third).toHaveLength(1);
+ });
+
+ it('keeps grouped data visible while background summary refresh is in progress', async () => {
+ let resolveRefresh;
+ const refreshPromise = new Promise((resolve) => {
+ resolveRefresh = resolve;
+ });
+
+ listGroupedStockEntriesMock.mockImplementationOnce(() => refreshPromise);
+
+ const data = stockListPageData({ isConnected: true, addAlert: vi.fn() });
+ data.groupedLoaded = true;
+ data.groupedEntries = createGroupedSummary().map((group) => data.indexGroup(group));
+
+ const pending = data.loadGroupedEntries({ expanded: 0, background: true });
+
+ expect(data.state.isRefreshing).toBe(true);
+ expect(data.groupedEntries).toHaveLength(1);
+
+ resolveRefresh(createGroupedSummary());
+ await pending;
+
+ expect(data.state.isRefreshing).toBe(false);
+ expect(data.groupedEntries).toHaveLength(1);
+ });
+
+ it('poll refreshes view only when new changes exist', async () => {
+ listKitchenChangesMock
+ .mockResolvedValueOnce({ since: 'a', nextCursor: 'b', changes: [] })
+ .mockResolvedValueOnce({
+ since: 'b',
+ nextCursor: 'c',
+ changes: [{ type: 'stock', action: 'use', timestamp: '2026-04-12T08:00:00Z' }],
+ });
+
+ const data = stockListPageData({ isConnected: true, addAlert: vi.fn() });
+ const refreshSpy = vi.fn(async () => {});
+ data.refreshCurrentView = refreshSpy;
+
+ await data.pollKitchenChanges();
+ expect(refreshSpy).not.toHaveBeenCalled();
+
+ await data.pollKitchenChanges();
+ expect(refreshSpy).toHaveBeenCalledTimes(1);
+ expect(refreshSpy).toHaveBeenCalledWith({ background: true });
+ });
+
+ it('tracks grouped card open state from details toggle events', () => {
+ const data = stockListPageData({ isConnected: true, addAlert: vi.fn() });
+
+ class MockDetails extends HTMLDetailsElement {
+ constructor() {
+ super();
+ this.open = true;
+ this.dataset = { groupId: '10' };
+ }
+
+ querySelector() {
+ return {
+ scrollIntoView: vi.fn(),
+ };
+ }
+ }
+
+ const details = new MockDetails();
+
+ data.handleGroupedToggle({ target: details });
+ expect(data.isGroupedCardOpen(10)).toBe(true);
+
+ details.open = false;
+ data.handleGroupedToggle({ target: details });
+ expect(data.isGroupedCardOpen(10)).toBe(false);
+ });
+
+ it('restores from runtime cache on back navigation and refreshes only focused item', async () => {
+ listKitchenChangesMock.mockResolvedValue({ since: null, nextCursor: null, changes: [] });
+ getStockEntryMock.mockResolvedValueOnce({
+ id: 100,
+ uuid_b64: 'item-100',
+ name: 'Rice',
+ description: 'Open bag',
+ stock_type: 'measured',
+ level: 'good',
+ quantity: 2,
+ uom_symbol: 'kg',
+ location_initial_uuid_b64: 'loc-root',
+ date: '2026-04-12',
+ expire_date: '2026-04-20',
+ expire_in: 8,
+ });
+
+ const store = {
+ isConnected: true,
+ addAlert: vi.fn(),
+ activeKitchen: { id: 1 },
+ };
+ const firstVisit = stockListPageData(store);
+ firstVisit.entries = [
+ firstVisit.indexEntry({
+ id: 100,
+ uuid_b64: 'item-100',
+ name: 'Rice',
+ description: 'Open bag',
+ stock_type: 'measured',
+ level: 'good',
+ quantity: 1,
+ uom_symbol: 'kg',
+ location_initial_uuid_b64: 'loc-root',
+ date: '2026-04-10',
+ expire_date: '2026-04-25',
+ expire_in: 13,
+ }),
+ ];
+ firstVisit.entriesVersion = 1;
+ firstVisit.itemsLoaded = true;
+ firstVisit.groupedEntries = createGroupedExpanded().map((group) => firstVisit.indexGroup(group));
+ firstVisit.groupedVersion = 1;
+ firstVisit.groupedLoaded = true;
+ firstVisit.groupedHydrated = true;
+ firstVisit.locations = [
+ {
+ id: 1,
+ uuid_b64: 'loc-root',
+ name: 'Pantry',
+ pathLabel: 'Pantry',
+ depth: 0,
+ type: 'storage',
+ lineage_uuid_b64: ['loc-root'],
+ },
+ ];
+ firstVisit.locationsVersion = 1;
+ firstVisit.locationMap = { 'loc-root': 'Pantry' };
+ firstVisit.locationDescendants = { 'loc-root': ['loc-root'] };
+ firstVisit.locationLineage = { 'loc-root': ['loc-root'] };
+ firstVisit.viewMode = 'grouped';
+ firstVisit.filters.search = 'rice';
+ firstVisit.rememberStockListContext('item-100');
+
+ const returnVisit = stockListPageData(store);
+ returnVisit.$nextTick = vi.fn(async () => {});
+
+ await returnVisit.init();
+ await Promise.resolve();
+ await Promise.resolve();
+
+ expect(listGroupedStockEntriesMock).not.toHaveBeenCalled();
+ expect(listStockEntriesMock).not.toHaveBeenCalled();
+ expect(fetchLocationsMock).not.toHaveBeenCalled();
+ expect(getStockEntryMock).toHaveBeenCalledWith(store, 'item-100');
+ expect(returnVisit.entries[0].quantity).toBe(2);
+ expect(returnVisit.groupedEntries[0].items[0].quantity).toBe(2);
+ expect(returnVisit.groupedEntries[0].quantity).toBe(2);
+ expect(returnVisit.groupedEntries[0].first_expire_date).toBe('2026-04-20');
+ expect(returnVisit.groupedEntries[0].date).toBe('2026-04-12');
+ });
+
+ it('tracks item-level refresh state while focused item refresh is in progress', async () => {
+ let resolveEntry;
+ getStockEntryMock.mockImplementationOnce(
+ () =>
+ new Promise((resolve) => {
+ resolveEntry = resolve;
+ }),
+ );
+
+ const data = stockListPageData({ isConnected: true, addAlert: vi.fn() });
+ data.entries = [
+ data.indexEntry({
+ id: 100,
+ uuid_b64: 'item-100',
+ name: 'Rice',
+ description: 'Open bag',
+ stock_type: 'measured',
+ level: 'good',
+ quantity: 1,
+ uom_symbol: 'kg',
+ location_initial_uuid_b64: 'loc-root',
+ date: '2026-04-10',
+ expire_date: '2026-04-25',
+ }),
+ ];
+ data.entriesVersion = 1;
+ data.itemsLoaded = true;
+
+ const pending = data.refreshFocusedItemInBackground('item-100');
+ expect(data.isItemRefreshing('item-100')).toBe(true);
+
+ resolveEntry({
+ ...data.entries[0],
+ quantity: 3,
+ });
+ await pending;
+
+ expect(data.isItemRefreshing('item-100')).toBe(false);
+ expect(data.entries[0].quantity).toBe(3);
+ });
+
+ it('falls back when structuredClone throws during runtime cache snapshot', async () => {
+ globalThis.structuredClone = vi.fn(() => {
+ throw new Error('The object can not be cloned.');
+ });
+
+ listGroupedStockEntriesMock
+ .mockResolvedValueOnce(createGroupedSummary())
+ .mockResolvedValueOnce(createGroupedExpanded());
+ listKitchenChangesMock.mockResolvedValue({ since: null, nextCursor: null, changes: [] });
+ fetchLocationsMock.mockResolvedValue({ flat: [], tree: [] });
+
+ const store = { isConnected: true, addAlert: vi.fn() };
+ const data = stockListPageData(store);
+ data.$nextTick = vi.fn(async () => {});
+
+ await data.init();
+ await Promise.resolve();
+
+ expect(data.state.error).toBe('');
+ expect(data.groupedEntries.length).toBeGreaterThan(0);
+ });
+});
|