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 <noreply@anthropic.com>
This commit is contained in:
Christopher Mühl 2026-02-26 15:34:05 +01:00
parent d56eb2874b
commit 9f4c743eb8
2 changed files with 387 additions and 0 deletions

View file

@ -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> = {}): 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> = {}): 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);
});
});
});

View file

@ -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<Item[]>([]);
locations = $state<Location[]>([]);
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<Item, 'shortId' | 'createdAt' | 'updatedAt' | 'barcodeUri' | 'barcodeFormat'>): Promise<Item> {
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<Item>): Promise<Item | undefined> {
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<void> {
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<void> {
const updates = buildCheckOutUpdate(reason, fromLocationId, toLocationId, note);
await this.updateItem(itemId, updates);
}
async checkIn(itemId: string, returnToLocationId: string): Promise<void> {
const updates = buildCheckInUpdate(returnToLocationId);
await this.updateItem(itemId, updates);
}
async recordSighting(
itemId: string,
locationId: string,
type: 'scan' | 'manual' | 'camera-detect' | 'audit-verify' = 'scan'
): Promise<void> {
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<string | null, Location[]> {
const tree = new Map<string | null, Location[]>();
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();