2 Commits

4 changed files with 554 additions and 43 deletions
+332 -25
View File
@@ -1,7 +1,10 @@
import { fetchLocations } from '../../api/locations.js'; import { fetchLocations } from '../../api/locations.js';
import { getStockEntry, listKitchenChanges } from '../../api/stock.js'; import { getStockEntry, listKitchenChanges, listStockEvents } from '../../api/stock.js';
import { createAsyncState, runAsyncState } from '../shared/ui-state.js'; import { createAsyncState, runAsyncState } from '../shared/ui-state.js';
const RECENT_CHANGE_FETCH_LIMIT = 200;
const RECENT_CHANGE_DISPLAY_LIMIT = 50;
export function renderDashboardPage() { export function renderDashboardPage() {
return ` return `
<section class="container-xxl py-4 py-lg-5" x-data="dashboardPage()" x-init="init()"> <section class="container-xxl py-4 py-lg-5" x-data="dashboardPage()" x-init="init()">
@@ -93,8 +96,7 @@ export function renderDashboardPage() {
<div class="d-flex flex-wrap justify-content-between align-items-center gap-2 mb-3"> <div class="d-flex flex-wrap justify-content-between align-items-center gap-2 mb-3">
<div> <div>
<h2 class="h5 mb-1">Recent changes</h2> <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">Latest item and stock updates, including used and inactive stock.</p>
<p class="text-body-secondary mb-0 small">Saved means the backend created or updated a record.</p>
</div> </div>
<button class="btn btn-outline-secondary btn-sm" @click="refreshChanges()" :disabled="changesState.isLoading"> <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">Refresh</span>
@@ -115,15 +117,46 @@ export function renderDashboardPage() {
</template> </template>
<template x-if="recentChanges.length"> <template x-if="recentChanges.length">
<div class="list-group list-group-flush"> <div class="recent-change-list">
<template x-for="(change, index) in recentChanges" :key="change.timestamp || index"> <template x-for="(change, index) in recentChanges" :key="change.timestamp || index">
<div class="list-group-item px-0"> <article class="recent-change-item" :class="changeToneClass(change)">
<div class="d-flex flex-wrap justify-content-between align-items-center gap-2"> <div class="recent-change-rail"></div>
<div class="fw-semibold" x-text="changeHeadline(change)"></div> <div class="recent-change-body">
<div class="small text-body-secondary" x-text="formatChangeTimestamp(change.timestamp)"></div> <div class="d-flex flex-wrap justify-content-between align-items-start gap-2">
<div class="min-w-0">
<div class="d-flex flex-wrap align-items-center gap-2 mb-1">
<span class="recent-change-tag" :class="changeTagClass(change)" x-text="changeKindLabel(change)"></span>
<span class="fw-semibold recent-change-title" x-text="changeHeadline(change)"></span>
</div> </div>
<div class="small text-body-secondary mt-1" x-text="changeStateLine(change)"></div> <div class="small text-body-secondary" x-text="changeSubtitle(change)"></div>
</div> </div>
<div class="small text-body-secondary recent-change-time" x-text="formatChangeTimestamp(change.timestamp)"></div>
</div>
<template x-if="changeStockTransition(change)">
<div class="recent-stock-transition mt-3">
<span class="recent-stock-state" x-text="changeStockTransition(change).previous"></span>
<span class="recent-stock-arrow" aria-hidden="true">&rarr;</span>
<span class="recent-stock-state recent-stock-state-current" x-text="changeStockTransition(change).current"></span>
</div>
</template>
<div class="recent-change-meta mt-3">
<template x-for="detail in changeDetails(change)" :key="detail.label">
<span class="recent-change-chip">
<span class="recent-change-chip-label" x-text="detail.label"></span>
<span x-text="detail.value"></span>
</span>
</template>
</div>
<div class="small text-body-secondary mt-2" x-text="changeDebugLine(change)"></div>
<template x-if="changeItemHref(change)">
<a class="stretched-link recent-change-link" :href="changeItemHref(change)" :aria-label="'Open ' + changeHeadline(change)"></a>
</template>
</div>
</article>
</template> </template>
</div> </div>
</template> </template>
@@ -142,6 +175,7 @@ export function dashboardPageData(store) {
recentChanges: [], recentChanges: [],
locationLabelByUuid: {}, locationLabelByUuid: {},
itemByUuid: {}, itemByUuid: {},
stockEventsByItemUuid: {},
async init() { async init() {
if (!store.isConnected) { if (!store.isConnected) {
return; return;
@@ -151,11 +185,24 @@ export function dashboardPageData(store) {
}, },
async refreshChanges() { async refreshChanges() {
await runAsyncState(this.changesState, async () => { await runAsyncState(this.changesState, async () => {
const payload = await listKitchenChanges(store, { limit: 10 }); const payload = await listKitchenChanges(store, { limit: RECENT_CHANGE_FETCH_LIMIT });
this.recentChanges = payload.changes; this.recentChanges = this.normalizeRecentChanges(payload.changes);
await this.loadContextForChanges(payload.changes); await this.loadContextForChanges(payload.changes);
}).catch(() => {}); }).catch(() => {});
}, },
normalizeRecentChanges(changes) {
return [...changes]
.sort((left, right) => this.changeSortValue(right) - this.changeSortValue(left))
.slice(0, RECENT_CHANGE_DISPLAY_LIMIT);
},
changeSortValue(change) {
const date = new Date(change?.timestamp || '');
if (Number.isNaN(date.getTime())) {
return 0;
}
return date.getTime();
},
async loadContextForChanges(changes) { async loadContextForChanges(changes) {
const stockItemUuids = Array.from(new Set( const stockItemUuids = Array.from(new Set(
changes changes
@@ -189,6 +236,34 @@ export function dashboardPageData(store) {
}); });
} }
const stockChangeItemUuids = Array.from(new Set(
changes
.filter((change) => String(change?.type || '') === 'stock')
.map((change) => change?.stock?.item_uuid_b64)
.filter(Boolean),
));
const missingStockHistoryUuids = stockChangeItemUuids
.filter((uuid) => !this.stockEventsByItemUuid[uuid]);
if (missingStockHistoryUuids.length) {
const results = await Promise.allSettled(
missingStockHistoryUuids.map((uuid) => listStockEvents(store, uuid, {
allowInactive: true,
limit: 50,
orderBy: 'id',
orderDir: 'desc',
})),
);
results.forEach((result, index) => {
const uuid = missingStockHistoryUuids[index];
this.stockEventsByItemUuid[uuid] =
result.status === 'fulfilled' && Array.isArray(result.value)
? result.value
: [];
});
}
if (Object.keys(this.locationLabelByUuid).length) { if (Object.keys(this.locationLabelByUuid).length) {
return; return;
} }
@@ -210,6 +285,7 @@ export function dashboardPageData(store) {
store.addAlert({ type: 'success', message: `Working in ${kitchen.name}.` }); store.addAlert({ type: 'success', message: `Working in ${kitchen.name}.` });
this.locationLabelByUuid = {}; this.locationLabelByUuid = {};
this.itemByUuid = {}; this.itemByUuid = {};
this.stockEventsByItemUuid = {};
this.refreshChanges(); this.refreshChanges();
}, },
resolveItemForChange(change) { resolveItemForChange(change) {
@@ -231,6 +307,19 @@ export function dashboardPageData(store) {
return value.charAt(0).toUpperCase() + value.slice(1); return value.charAt(0).toUpperCase() + value.slice(1);
}, },
goneReasonLabel(reason) {
if (reason === 'consumed') {
return 'Used';
}
if (reason === 'spoiled') {
return 'Spoilt';
}
if (reason === 'other') {
return 'Gone';
}
return null;
},
formatQuantity(quantity, uomSymbol) { formatQuantity(quantity, uomSymbol) {
if (quantity === null || quantity === undefined || quantity === '') { if (quantity === null || quantity === undefined || quantity === '') {
return null; return null;
@@ -243,8 +332,151 @@ export function dashboardPageData(store) {
return null; return null;
} }
if (level === 'gone') {
return 'Gone';
}
return level.charAt(0).toUpperCase() + level.slice(1); return level.charAt(0).toUpperCase() + level.slice(1);
}, },
formatStockState(stock, item = null) {
if (!stock) {
return null;
}
if (stock.level === 'gone') {
const reason = this.goneReasonLabel(stock.gone_reason);
return reason ? `${reason} (gone)` : 'Gone';
}
const quantity = this.formatQuantity(
stock.quantity,
stock.uom_symbol || item?.uom_symbol,
);
const level = this.formatLevel(stock.level);
if (quantity && level) {
return `${quantity} · ${level}`;
}
if (quantity) {
return quantity;
}
if (level) {
return level;
}
return null;
},
stockEventTimestamp(event) {
return event?.write_date || event?.create_date || event?.date || null;
},
findPreviousStockEvent(change) {
const stock = change?.stock;
const itemUuid = stock?.item_uuid_b64;
if (!itemUuid) {
return null;
}
const history = this.stockEventsByItemUuid[itemUuid] || [];
const currentId = stock?.id;
if (currentId !== undefined && currentId !== null) {
const currentIndex = history.findIndex((event) => String(event?.id) === String(currentId));
if (currentIndex >= 0) {
return history[currentIndex + 1] || null;
}
}
const currentTime = this.stockEventTimestamp(stock);
if (!currentTime) {
return null;
}
const currentDate = new Date(currentTime);
if (Number.isNaN(currentDate.getTime())) {
return null;
}
return history.find((event) => {
const eventTime = this.stockEventTimestamp(event);
if (!eventTime) {
return false;
}
const eventDate = new Date(eventTime);
return !Number.isNaN(eventDate.getTime()) && eventDate.getTime() < currentDate.getTime();
}) || null;
},
isInitialStockChange(change) {
return String(change?.type || '') === 'stock'
&& Boolean(change?.stock)
&& !this.findPreviousStockEvent(change);
},
isNewItemChange(change) {
if (String(change?.type || '') !== 'item' || !change?.item) {
return false;
}
const item = change.item;
if (!item.create_date || !change.timestamp) {
return false;
}
const created = new Date(item.create_date);
const changed = new Date(change.timestamp);
if (Number.isNaN(created.getTime()) || Number.isNaN(changed.getTime())) {
return item.active !== false;
}
return Math.abs(created.getTime() - changed.getTime()) < 2000;
},
changeKind(change) {
const type = String(change?.type || '');
const item = this.resolveItemForChange(change);
const stock = change?.stock || null;
if (type === 'stock' && this.isInitialStockChange(change) && stock?.level !== 'gone') {
return 'new';
}
if (type === 'stock' && stock?.level === 'gone') {
if (stock.gone_reason === 'consumed') {
return 'used';
}
if (stock.gone_reason === 'spoiled') {
return 'spoiled';
}
return 'gone';
}
if (type === 'item' && item?.active === false) {
return 'inactive';
}
if (type === 'item' && this.isNewItemChange(change)) {
return 'new';
}
if (type === 'stock') {
return 'stock';
}
return 'updated';
},
changeKindLabel(change) {
const labels = {
new: 'New item',
inactive: 'Item gone',
used: 'Used up',
spoiled: 'Spoilt',
gone: 'Gone',
stock: 'Stock changed',
updated: 'Updated',
};
return labels[this.changeKind(change)] || 'Updated';
},
changeToneClass(change) {
return `recent-change-${this.changeKind(change)}`;
},
changeTagClass(change) {
return `recent-change-tag-${this.changeKind(change)}`;
},
formatShortDate(value) { formatShortDate(value) {
if (!value) { if (!value) {
return null; return null;
@@ -275,53 +507,128 @@ export function dashboardPageData(store) {
const action = String(change?.action || 'updated'); const action = String(change?.action || 'updated');
if (action === 'upsert' && type === 'item') { if (action === 'upsert' && type === 'item') {
return `Item saved: ${itemName}`; return itemName;
} }
if (action === 'upsert' && type === 'stock') { if (action === 'upsert' && type === 'stock') {
return `Stock saved: ${itemName}`; return itemName;
} }
return `${type} ${action}: ${itemName}`; return `${type} ${action}: ${itemName}`;
}, },
changeStateLine(change) { changeSubtitle(change) {
const kind = this.changeKind(change);
const item = this.resolveItemForChange(change);
const stock = change?.stock || null;
if (kind === 'new') {
return String(change?.type || '') === 'stock'
? 'Created with initial stock.'
: 'Created as a new stock item.';
}
if (kind === 'inactive') {
return 'No longer shown in the active stock list.';
}
if (kind === 'used') {
return 'Marked used and moved out of active stock.';
}
if (kind === 'spoiled') {
return 'Marked spoilt and moved out of active stock.';
}
if (kind === 'gone') {
return 'Marked gone and moved out of active stock.';
}
if (String(change?.type || '') === 'stock') {
const current = this.formatStockState(stock, item);
return current ? `Current stock is ${current.toLowerCase()}.` : 'Stock event recorded.';
}
return 'Item details were updated.';
},
changeStockTransition(change) {
if (String(change?.type || '') !== 'stock' || !change?.stock) {
return null;
}
const item = this.resolveItemForChange(change);
const previous = this.findPreviousStockEvent(change);
const previousLabel = previous
? this.formatStockState(previous, item)
: 'Initial stock';
const currentLabel = this.formatStockState(change.stock, item) || 'Updated';
return {
previous: previousLabel,
current: currentLabel,
};
},
changeDetails(change) {
const item = this.resolveItemForChange(change); const item = this.resolveItemForChange(change);
const stock = change?.stock || {}; const stock = change?.stock || {};
const state = []; const details = [];
const stockType = this.humanStockType(item?.stock_type); const stockType = this.humanStockType(item?.stock_type);
if (stockType) { if (stockType) {
state.push(`Type: ${stockType}`); details.push({ label: 'Type', value: stockType });
} }
if (String(change?.type || '') !== 'stock') {
const quantity = this.formatQuantity( const quantity = this.formatQuantity(
stock.quantity ?? item?.quantity, item?.quantity,
stock.uom_symbol || item?.uom_symbol, item?.uom_symbol,
); );
if (quantity) { if (quantity) {
state.push(`Quantity: ${quantity}`); details.push({ label: 'Quantity', value: quantity });
}
} }
const level = this.formatLevel(stock.level || item?.level); const level = this.formatLevel(stock.level || item?.level);
if (level) { if (level) {
state.push(`Level: ${level}`); details.push({ label: 'Level', value: level });
}
if (stock.gone_reason) {
details.push({
label: 'Reason',
value: this.goneReasonLabel(stock.gone_reason) || this.formatLevel(stock.gone_reason),
});
} }
const expiry = this.formatShortDate(item?.expire_date); const expiry = this.formatShortDate(item?.expire_date);
if (expiry) { if (expiry) {
state.push(`Expires: ${expiry}`); details.push({ label: 'Expires', value: expiry });
} }
const location = this.resolveLocationLabel(change, item); const location = this.resolveLocationLabel(change, item);
if (location) { if (location) {
state.push(`Location: ${location}`); details.push({ label: 'Location', value: location });
} }
if (!state.length) { return details;
return 'Saved (created or updated).'; },
changeDebugLine(change) {
const parts = [];
if (change?.id !== undefined && change?.id !== null) {
parts.push(`${String(change?.type || 'change')} #${change.id}`);
} }
return state.join(' • '); const itemUuid = change?.stock?.item_uuid_b64 || change?.item?.uuid_b64 || null;
if (itemUuid) {
parts.push(`item ${String(itemUuid).slice(-8)}`);
}
return parts.join(' · ');
},
changeItemHref(change) {
const uuidB64 = this.resolveItemForChange(change)?.uuid_b64
|| change?.stock?.item_uuid_b64
|| change?.item?.uuid_b64
|| null;
if (!uuidB64) {
return null;
}
return `#/stock/${uuidB64}`;
}, },
formatChangeTimestamp(value) { formatChangeTimestamp(value) {
if (!value) { if (!value) {
+13 -1
View File
@@ -502,7 +502,7 @@ export function stockDetailPageData(store) {
const { params } = getRouteContext(); const { params } = getRouteContext();
await runAsyncState(this.state, async () => { await runAsyncState(this.state, async () => {
const [entry, locations, categories] = await Promise.all([ const [entry, locations, categories] = await Promise.all([
getStockEntry(store, params.id), this.loadStockEntry(params.id),
fetchLocations(store).catch(() => ({ flat: [] })), fetchLocations(store).catch(() => ({ flat: [] })),
listCategories(store, { expanded: true }).catch(() => []), listCategories(store, { expanded: true }).catch(() => []),
]); ]);
@@ -522,6 +522,18 @@ export function stockDetailPageData(store) {
}).catch(() => {}); }).catch(() => {});
this.loadStockHistory().catch(() => {}); this.loadStockHistory().catch(() => {});
}, },
async loadStockEntry(uuidB64) {
try {
return await getStockEntry(store, uuidB64);
} catch (error) {
const status = error?.status || error?.cause?.status;
if (status !== 404) {
throw error;
}
return getStockEntry(store, uuidB64, { allowInactive: true });
}
},
destroy() { destroy() {
this.stopScanner(); this.stopScanner();
}, },
+159
View File
@@ -135,6 +135,165 @@ body {
color: var(--lonc-primary); color: var(--lonc-primary);
} }
.min-w-0 {
min-width: 0;
}
.recent-change-list {
display: grid;
gap: 0.85rem;
}
.recent-change-item {
position: relative;
display: grid;
grid-template-columns: 0.3rem minmax(0, 1fr);
overflow: hidden;
border: 1px solid var(--lonc-border);
border-radius: 1rem;
background: rgba(255, 255, 255, 0.82);
transition:
transform 160ms ease,
box-shadow 160ms ease,
border-color 160ms ease;
}
.recent-change-item:has(.recent-change-link):hover {
transform: translateY(-1px);
border-color: rgba(31, 75, 153, 0.2);
box-shadow: 0 12px 24px rgba(24, 42, 79, 0.08);
}
.recent-change-rail {
background: rgba(31, 75, 153, 0.34);
}
.recent-change-body {
position: relative;
padding: 1rem;
}
.recent-change-title {
overflow-wrap: anywhere;
}
.recent-change-time {
white-space: nowrap;
}
.recent-change-tag,
.recent-change-chip {
display: inline-flex;
align-items: center;
border-radius: 999px;
line-height: 1.2;
}
.recent-change-tag {
padding: 0.25rem 0.55rem;
font-size: 0.72rem;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.08em;
color: #1f2740;
background: rgba(31, 75, 153, 0.1);
}
.recent-change-meta {
display: flex;
flex-wrap: wrap;
gap: 0.45rem;
}
.recent-change-chip {
gap: 0.3rem;
padding: 0.28rem 0.55rem;
font-size: 0.78rem;
background: rgba(31, 39, 64, 0.06);
color: var(--lonc-ink);
}
.recent-change-chip-label {
color: var(--lonc-muted);
font-weight: 700;
}
.recent-stock-transition {
display: inline-flex;
flex-wrap: wrap;
align-items: center;
gap: 0.45rem;
max-width: 100%;
padding: 0.55rem 0.65rem;
border-radius: 0.85rem;
background: rgba(255, 255, 255, 0.62);
border: 1px dashed rgba(31, 75, 153, 0.18);
}
.recent-stock-state {
overflow-wrap: anywhere;
font-weight: 600;
}
.recent-stock-state-current {
color: var(--lonc-primary);
}
.recent-stock-arrow {
color: var(--lonc-muted);
}
.recent-change-link {
z-index: 1;
}
.recent-change-new {
background: rgba(25, 135, 84, 0.08);
}
.recent-change-new .recent-change-rail,
.recent-change-tag-new {
background: rgba(25, 135, 84, 0.42);
}
.recent-change-stock {
background: rgba(31, 75, 153, 0.06);
}
.recent-change-stock .recent-change-rail,
.recent-change-tag-stock {
background: rgba(31, 75, 153, 0.18);
}
.recent-change-used,
.recent-change-inactive,
.recent-change-gone {
background: rgba(108, 117, 125, 0.09);
}
.recent-change-used .recent-change-rail,
.recent-change-inactive .recent-change-rail,
.recent-change-gone .recent-change-rail,
.recent-change-tag-used,
.recent-change-tag-inactive,
.recent-change-tag-gone {
background: rgba(108, 117, 125, 0.32);
}
.recent-change-spoiled {
background: rgba(253, 126, 20, 0.1);
}
.recent-change-spoiled .recent-change-rail,
.recent-change-tag-spoiled {
background: rgba(253, 126, 20, 0.32);
}
.recent-change-updated .recent-change-rail,
.recent-change-tag-updated {
background: rgba(93, 169, 255, 0.22);
}
.preview-frame, .preview-frame,
.empty-preview { .empty-preview {
min-height: 24rem; min-height: 24rem;
+46 -13
View File
@@ -1,11 +1,13 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'; import { beforeEach, describe, expect, it, vi } from 'vitest';
const listKitchenChangesMock = vi.fn(); const listKitchenChangesMock = vi.fn();
const listStockEventsMock = vi.fn();
const getStockEntryMock = vi.fn(); const getStockEntryMock = vi.fn();
const fetchLocationsMock = vi.fn(); const fetchLocationsMock = vi.fn();
vi.mock('../../../src/api/stock.js', () => ({ vi.mock('../../../src/api/stock.js', () => ({
listKitchenChanges: (...args) => listKitchenChangesMock(...args), listKitchenChanges: (...args) => listKitchenChangesMock(...args),
listStockEvents: (...args) => listStockEventsMock(...args),
getStockEntry: (...args) => getStockEntryMock(...args), getStockEntry: (...args) => getStockEntryMock(...args),
})); }));
@@ -18,6 +20,7 @@ const { dashboardPageData, renderDashboardPage } = await import('../../../src/fe
describe('features/dashboard/dashboard-page', () => { describe('features/dashboard/dashboard-page', () => {
beforeEach(() => { beforeEach(() => {
listKitchenChangesMock.mockReset(); listKitchenChangesMock.mockReset();
listStockEventsMock.mockReset();
getStockEntryMock.mockReset(); getStockEntryMock.mockReset();
fetchLocationsMock.mockReset(); fetchLocationsMock.mockReset();
}); });
@@ -26,10 +29,11 @@ describe('features/dashboard/dashboard-page', () => {
const html = renderDashboardPage(); const html = renderDashboardPage();
expect(html).toContain('Recent changes'); expect(html).toContain('Recent changes');
expect(html).toContain('x-data="dashboardPage()"'); expect(html).toContain('x-data="dashboardPage()"');
expect(html).toContain('Saved means the backend created or updated a record.'); expect(html).toContain('Latest item and stock updates, including used and inactive stock.');
expect(html).toContain('recent-change-list');
}); });
it('loads recent changes on init and renders item-focused state lines', async () => { it('loads recent changes on init and renders item-focused details', async () => {
listKitchenChangesMock.mockResolvedValueOnce({ listKitchenChangesMock.mockResolvedValueOnce({
since: null, since: null,
nextCursor: null, nextCursor: null,
@@ -62,16 +66,20 @@ describe('features/dashboard/dashboard-page', () => {
await data.init(); await data.init();
expect(listKitchenChangesMock).toHaveBeenCalledWith(store, { limit: 10 }); expect(listKitchenChangesMock).toHaveBeenCalledWith(store, { limit: 200 });
expect(data.recentChanges).toHaveLength(1); expect(data.recentChanges).toHaveLength(1);
expect(data.changesState.error).toBe(''); expect(data.changesState.error).toBe('');
expect(data.changeHeadline(data.recentChanges[0])).toBe('Item saved: Rice'); expect(data.changeHeadline(data.recentChanges[0])).toBe('Rice');
expect(data.changeStateLine(data.recentChanges[0])).toContain('Quantity: 3 kg'); expect(data.changeKindLabel(data.recentChanges[0])).toBe('Updated');
expect(data.changeStateLine(data.recentChanges[0])).toContain('Location: Pantry / Shelf A'); expect(data.changeSubtitle(data.recentChanges[0])).toBe('Item details were updated.');
expect(data.changeDetails(data.recentChanges[0])).toEqual(expect.arrayContaining([
{ label: 'Quantity', value: '3 kg' },
{ label: 'Location', value: 'Pantry / Shelf A' },
]));
expect(getStockEntryMock).not.toHaveBeenCalled(); expect(getStockEntryMock).not.toHaveBeenCalled();
}); });
it('resolves stock event item context via item lookup when needed', async () => { it('resolves stock event item context and renders stock transitions', async () => {
listKitchenChangesMock.mockResolvedValueOnce({ listKitchenChangesMock.mockResolvedValueOnce({
since: null, since: null,
nextCursor: null, nextCursor: null,
@@ -80,6 +88,7 @@ describe('features/dashboard/dashboard-page', () => {
action: 'upsert', action: 'upsert',
timestamp: '2026-04-10T10:00:00Z', timestamp: '2026-04-10T10:00:00Z',
stock: { stock: {
id: 11,
item_uuid_b64: 'item-uuid-1', item_uuid_b64: 'item-uuid-1',
quantity: 0.5, quantity: 0.5,
uom_symbol: 'kg', uom_symbol: 'kg',
@@ -88,6 +97,10 @@ describe('features/dashboard/dashboard-page', () => {
}, },
}], }],
}); });
listStockEventsMock.mockResolvedValueOnce([
{ id: 11, quantity: 0.5, uom_symbol: 'kg', level: 'some' },
{ id: 10, quantity: 1, uom_symbol: 'kg', level: 'good' },
]);
getStockEntryMock.mockResolvedValueOnce({ getStockEntryMock.mockResolvedValueOnce({
uuid_b64: 'item-uuid-1', uuid_b64: 'item-uuid-1',
name: 'Flour', name: 'Flour',
@@ -106,11 +119,23 @@ describe('features/dashboard/dashboard-page', () => {
await data.refreshChanges(); await data.refreshChanges();
expect(data.changeHeadline(data.recentChanges[0])).toBe('Stock saved: Flour'); expect(data.changeHeadline(data.recentChanges[0])).toBe('Flour');
expect(data.changeStateLine(data.recentChanges[0])).toContain('Quantity: 0.5 kg'); expect(data.changeKindLabel(data.recentChanges[0])).toBe('Stock changed');
expect(data.changeStateLine(data.recentChanges[0])).toContain('Level: Some'); expect(data.changeStockTransition(data.recentChanges[0])).toEqual({
expect(data.changeStateLine(data.recentChanges[0])).toContain('Location: Pantry / Bin 2'); previous: '1 kg · Good',
current: '0.5 kg · Some',
});
expect(data.changeDetails(data.recentChanges[0])).toEqual(expect.arrayContaining([
{ label: 'Level', value: 'Some' },
{ label: 'Location', value: 'Pantry / Bin 2' },
]));
expect(getStockEntryMock).toHaveBeenCalledWith(expect.anything(), 'item-uuid-1'); expect(getStockEntryMock).toHaveBeenCalledWith(expect.anything(), 'item-uuid-1');
expect(listStockEventsMock).toHaveBeenCalledWith(expect.anything(), 'item-uuid-1', {
allowInactive: true,
limit: 50,
orderBy: 'id',
orderDir: 'desc',
});
}); });
it('retries stock event item lookup with allowInactive after 404', async () => { it('retries stock event item lookup with allowInactive after 404', async () => {
@@ -122,12 +147,16 @@ describe('features/dashboard/dashboard-page', () => {
action: 'upsert', action: 'upsert',
timestamp: '2026-04-10T10:00:00Z', timestamp: '2026-04-10T10:00:00Z',
stock: { stock: {
id: 12,
item_uuid_b64: 'item-uuid-2', item_uuid_b64: 'item-uuid-2',
quantity: 1, quantity: 1,
uom_symbol: 'pcs', uom_symbol: 'pcs',
}, },
}], }],
}); });
listStockEventsMock.mockResolvedValueOnce([
{ id: 12, quantity: 1, uom_symbol: 'pcs' },
]);
getStockEntryMock getStockEntryMock
.mockRejectedValueOnce(Object.assign(new Error('Not found'), { status: 404 })) .mockRejectedValueOnce(Object.assign(new Error('Not found'), { status: 404 }))
.mockResolvedValueOnce({ .mockResolvedValueOnce({
@@ -153,8 +182,12 @@ describe('features/dashboard/dashboard-page', () => {
'item-uuid-2', 'item-uuid-2',
{ allowInactive: true }, { allowInactive: true },
); );
expect(data.changeHeadline(data.recentChanges[0])).toBe('Stock saved: Archived pasta'); expect(data.changeHeadline(data.recentChanges[0])).toBe('Archived pasta');
expect(data.changeStateLine(data.recentChanges[0])).toContain('Quantity: 1 pcs'); expect(data.changeKindLabel(data.recentChanges[0])).toBe('New item');
expect(data.changeStockTransition(data.recentChanges[0])).toEqual({
previous: 'Initial stock',
current: '1 pcs',
});
}); });
it('keeps empty state when API returns no changes', async () => { it('keeps empty state when API returns no changes', async () => {