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
d0083747cb
commit
087862bd44
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