From 9f4c743eb8ee226ca2a4312a2d3250445b2eb68e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Christopher=20M=C3=BChl?= Date: Thu, 26 Feb 2026 15:34:05 +0100 Subject: [PATCH] feat: inventory store with Svelte 5 runes and full test suite Reactive store with $state/$derived for items, locations, checked-out items, overdue items, and low-stock items. Supports CRUD, check-in/out, sighting recording, and location tree queries. 12 tests passing. Co-Authored-By: Claude Opus 4.6 --- src/lib/stores/inventory.svelte.test.ts | 259 ++++++++++++++++++++++++ src/lib/stores/inventory.svelte.ts | 128 ++++++++++++ 2 files changed, 387 insertions(+) create mode 100644 src/lib/stores/inventory.svelte.test.ts create mode 100644 src/lib/stores/inventory.svelte.ts diff --git a/src/lib/stores/inventory.svelte.test.ts b/src/lib/stores/inventory.svelte.test.ts new file mode 100644 index 0000000..917c49c --- /dev/null +++ b/src/lib/stores/inventory.svelte.test.ts @@ -0,0 +1,259 @@ +import { describe, it, expect, beforeEach } from 'vitest'; +import { deleteDB } from 'idb'; +import { resetDBPromise } from '$lib/data/db'; +import { inventory } from '$lib/stores/inventory.svelte'; +import { createItem } from '$lib/data/items'; +import { createLocation } from '$lib/data/locations'; +import type { Item, Location } from '$lib/types'; + +function makeItem(overrides: Partial = {}): Item { + return { + shortId: 'test123', + name: 'Test Item', + description: '', + category: 'electronics', + brand: '', + serialNumber: '', + color: '', + purchaseDate: null, + itemType: 'durable', + currentQuantity: null, + originalQuantity: null, + quantityUnit: null, + lowThreshold: null, + expiryDate: null, + barcodeFormat: 'qr', + barcodeUri: 'https://haus.toph.so/test123', + photoIds: [], + lastSeenAt: null, + lastSeenTimestamp: null, + lastUsedAt: null, + supposedToBeAt: null, + locationConfidence: 'unknown', + custodyState: 'checked-in', + checkedOutSince: null, + checkedOutReason: null, + checkedOutFrom: null, + checkedOutTo: null, + checkedOutNote: null, + storageTier: 'warm', + storageContainerId: null, + storageContainerLabel: null, + labelPrinted: false, + labelPrintedAt: null, + labelBatchId: null, + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + tags: [], + createdBy: null, + ...overrides, + }; +} + +function makeLocation(overrides: Partial = {}): Location { + return { + id: 'test-loc', + name: 'Test Location', + description: '', + parentId: null, + locationType: 'room', + defaultStorageTier: null, + sortOrder: 0, + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + ...overrides, + }; +} + +beforeEach(async () => { + await resetDBPromise(); + await deleteDB('solidhaus'); + // Reset store state + inventory.items = []; + inventory.locations = []; + inventory.loading = true; +}); + +describe('inventory store', () => { + describe('loadAll', () => { + it('loads items and locations from the database', async () => { + await createItem(makeItem({ shortId: 'load001' })); + await createLocation(makeLocation({ id: 'loc1' })); + + // Reset to get a fresh DB connection + await resetDBPromise(); + + await inventory.loadAll(); + expect(inventory.loading).toBe(false); + // At least the seeded defaults + our loc1 + expect(inventory.locations.length).toBeGreaterThanOrEqual(1); + }); + + it('seeds default locations on first load', async () => { + await inventory.loadAll(); + expect(inventory.locations.length).toBe(11); // 11 default locations + }); + }); + + describe('createItem', () => { + it('creates an item with auto-generated ID', async () => { + await inventory.loadAll(); + const item = await inventory.createItem({ + name: 'New Item', + description: '', + category: 'tools', + brand: '', + serialNumber: '', + color: '', + purchaseDate: null, + itemType: 'durable', + currentQuantity: null, + originalQuantity: null, + quantityUnit: null, + lowThreshold: null, + expiryDate: null, + photoIds: [], + lastSeenAt: null, + lastSeenTimestamp: null, + lastUsedAt: null, + supposedToBeAt: null, + locationConfidence: 'unknown', + custodyState: 'checked-in', + checkedOutSince: null, + checkedOutReason: null, + checkedOutFrom: null, + checkedOutTo: null, + checkedOutNote: null, + storageTier: 'warm', + storageContainerId: null, + storageContainerLabel: null, + labelPrinted: false, + labelPrintedAt: null, + labelBatchId: null, + tags: [], + createdBy: null, + }); + + expect(item.shortId).toHaveLength(7); + expect(item.barcodeUri).toContain(item.shortId); + expect(inventory.items).toHaveLength(1); + }); + }); + + describe('updateItem', () => { + it('updates an item and reflects in store', async () => { + await inventory.loadAll(); + await createItem(makeItem({ shortId: 'upd001' })); + inventory.items = [makeItem({ shortId: 'upd001' })]; + + const updated = await inventory.updateItem('upd001', { name: 'Updated Name' }); + expect(updated).toBeDefined(); + expect(updated!.name).toBe('Updated Name'); + expect(inventory.items[0].name).toBe('Updated Name'); + }); + }); + + describe('removeItem', () => { + it('removes an item from store and DB', async () => { + await inventory.loadAll(); + await createItem(makeItem({ shortId: 'del001' })); + inventory.items = [makeItem({ shortId: 'del001' })]; + + await inventory.removeItem('del001'); + expect(inventory.items).toHaveLength(0); + }); + }); + + describe('checkOut', () => { + it('checks out an item', async () => { + await inventory.loadAll(); + await createItem(makeItem({ shortId: 'co001' })); + inventory.items = [makeItem({ shortId: 'co001' })]; + + await inventory.checkOut('co001', 'lent', 'kueche', 'buero', 'Lending to friend'); + + const item = inventory.items.find((i) => i.shortId === 'co001'); + expect(item?.custodyState).toBe('checked-out'); + expect(item?.checkedOutReason).toBe('lent'); + expect(item?.checkedOutFrom).toBe('kueche'); + expect(item?.checkedOutTo).toBe('buero'); + }); + }); + + describe('checkIn', () => { + it('checks in an item', async () => { + await inventory.loadAll(); + await createItem(makeItem({ shortId: 'ci001', custodyState: 'checked-out' })); + inventory.items = [makeItem({ shortId: 'ci001', custodyState: 'checked-out' })]; + + await inventory.checkIn('ci001', 'kueche'); + + const item = inventory.items.find((i) => i.shortId === 'ci001'); + expect(item?.custodyState).toBe('checked-in'); + expect(item?.lastSeenAt).toBe('kueche'); + expect(item?.supposedToBeAt).toBe('kueche'); + }); + }); + + describe('recordSighting', () => { + it('records a sighting and updates item location', async () => { + await inventory.loadAll(); + await createItem(makeItem({ shortId: 'sight01' })); + inventory.items = [makeItem({ shortId: 'sight01' })]; + + await inventory.recordSighting('sight01', 'buero', 'scan'); + + const item = inventory.items.find((i) => i.shortId === 'sight01'); + expect(item?.lastSeenAt).toBe('buero'); + expect(item?.locationConfidence).toBe('confirmed'); + }); + }); + + describe('derived state', () => { + it('checkedOutItems filters correctly', async () => { + inventory.items = [ + makeItem({ shortId: 'a', custodyState: 'checked-out' }), + makeItem({ shortId: 'b', custodyState: 'checked-in' }), + makeItem({ shortId: 'c', custodyState: 'checked-out' }), + ]; + expect(inventory.checkedOutItems).toHaveLength(2); + }); + + it('lowStockItems filters correctly', async () => { + inventory.items = [ + makeItem({ shortId: 'a', currentQuantity: 2, lowThreshold: 5 }), + makeItem({ shortId: 'b', currentQuantity: 10, lowThreshold: 5 }), + makeItem({ shortId: 'c', currentQuantity: null, lowThreshold: null }), + ]; + expect(inventory.lowStockItems).toHaveLength(1); + expect(inventory.lowStockItems[0].shortId).toBe('a'); + }); + }); + + describe('getItemsByLocation', () => { + it('returns items at a given location', () => { + inventory.items = [ + makeItem({ shortId: 'a', lastSeenAt: 'kueche' }), + makeItem({ shortId: 'b', lastSeenAt: 'buero' }), + makeItem({ shortId: 'c', lastSeenAt: 'kueche' }), + ]; + const result = inventory.getItemsByLocation('kueche'); + expect(result).toHaveLength(2); + }); + }); + + describe('getLocationTree', () => { + it('groups locations by parentId', () => { + inventory.locations = [ + makeLocation({ id: 'home', parentId: null }), + makeLocation({ id: 'eg', parentId: 'home' }), + makeLocation({ id: 'og', parentId: 'home' }), + makeLocation({ id: 'kueche', parentId: 'eg' }), + ]; + const tree = inventory.getLocationTree(); + expect(tree.get(null)).toHaveLength(1); + expect(tree.get('home')).toHaveLength(2); + expect(tree.get('eg')).toHaveLength(1); + }); + }); +}); diff --git a/src/lib/stores/inventory.svelte.ts b/src/lib/stores/inventory.svelte.ts new file mode 100644 index 0000000..7272b29 --- /dev/null +++ b/src/lib/stores/inventory.svelte.ts @@ -0,0 +1,128 @@ +import { getDB } from '$lib/data/db'; +import { getAllItems, createItem as dbCreateItem, updateItem as dbUpdateItem, removeItem as dbRemoveItem } from '$lib/data/items'; +import { getAllLocations, seedDefaultLocations } from '$lib/data/locations'; +import { createSighting } from '$lib/data/sightings'; +import { generateItemId, generateSightingId } from '$lib/utils/id'; +import { getConfidence } from '$lib/utils/confidence'; +import { buildCheckOutUpdate, buildCheckInUpdate } from '$lib/utils/custody'; +import type { Item, Location, Sighting, CheckOutReason } from '$lib/types'; + +class InventoryStore { + items = $state([]); + locations = $state([]); + loading = $state(true); + + checkedOutItems = $derived( + this.items.filter((i) => i.custodyState === 'checked-out') + ); + + overdueItems = $derived( + this.items.filter( + (i) => + i.custodyState === 'checked-out' && + i.checkedOutSince && + Date.now() - new Date(i.checkedOutSince).getTime() > 7 * 86400000 + ) + ); + + lowStockItems = $derived( + this.items.filter( + (i) => + i.currentQuantity != null && + i.lowThreshold != null && + i.currentQuantity <= i.lowThreshold + ) + ); + + async loadAll() { + await seedDefaultLocations(); + this.items = await getAllItems(); + this.locations = await getAllLocations(); + this.loading = false; + } + + async createItem(data: Omit): Promise { + const now = new Date().toISOString(); + const shortId = generateItemId(); + const item: Item = { + ...data, + shortId, + barcodeFormat: 'qr', + barcodeUri: `https://haus.toph.so/${shortId}`, + createdAt: now, + updatedAt: now, + }; + await dbCreateItem(item); + this.items = [...this.items, item]; + return item; + } + + async updateItem(shortId: string, updates: Partial): Promise { + const updated = await dbUpdateItem(shortId, updates); + if (updated) { + this.items = this.items.map((i) => (i.shortId === shortId ? updated : i)); + } + return updated; + } + + async removeItem(shortId: string): Promise { + await dbRemoveItem(shortId); + this.items = this.items.filter((i) => i.shortId !== shortId); + } + + async checkOut( + itemId: string, + reason: CheckOutReason, + fromLocationId: string, + toLocationId: string | null = null, + note: string = '' + ): Promise { + const updates = buildCheckOutUpdate(reason, fromLocationId, toLocationId, note); + await this.updateItem(itemId, updates); + } + + async checkIn(itemId: string, returnToLocationId: string): Promise { + const updates = buildCheckInUpdate(returnToLocationId); + await this.updateItem(itemId, updates); + } + + async recordSighting( + itemId: string, + locationId: string, + type: 'scan' | 'manual' | 'camera-detect' | 'audit-verify' = 'scan' + ): Promise { + const now = new Date().toISOString(); + const sighting: Sighting = { + id: generateSightingId(), + itemId, + locationId, + timestamp: now, + sightingType: type, + confidence: 'confirmed', + notes: '', + createdBy: null, + }; + await createSighting(sighting); + await this.updateItem(itemId, { + lastSeenAt: locationId, + lastSeenTimestamp: now, + locationConfidence: 'confirmed', + }); + } + + getItemsByLocation(locationId: string): Item[] { + return this.items.filter((i) => i.lastSeenAt === locationId); + } + + getLocationTree(): Map { + const tree = new Map(); + for (const loc of this.locations) { + const children = tree.get(loc.parentId) ?? []; + children.push(loc); + tree.set(loc.parentId, children); + } + return tree; + } +} + +export const inventory = new InventoryStore();