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:
parent
d56eb2874b
commit
9f4c743eb8
2 changed files with 387 additions and 0 deletions
259
src/lib/stores/inventory.svelte.test.ts
Normal file
259
src/lib/stores/inventory.svelte.test.ts
Normal 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
128
src/lib/stores/inventory.svelte.ts
Normal file
128
src/lib/stores/inventory.svelte.ts
Normal 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();
|
||||||
Loading…
Add table
Reference in a new issue