diff --git a/src/lib/data/db.test.ts b/src/lib/data/db.test.ts new file mode 100644 index 0000000..0b12a08 --- /dev/null +++ b/src/lib/data/db.test.ts @@ -0,0 +1,110 @@ +import { describe, it, expect, beforeEach } from 'vitest'; +import { deleteDB } from 'idb'; +import { getDB, resetDBPromise } from '$lib/data/db'; + +beforeEach(async () => { + await resetDBPromise(); + await deleteDB('solidhaus'); +}); + +describe('getDB', () => { + it('returns a valid database instance', async () => { + const db = await getDB(); + expect(db).toBeDefined(); + expect(db.name).toBe('solidhaus'); + expect(db.version).toBe(1); + }); + + it('returns the same DB instance when called multiple times (singleton)', async () => { + const db1 = await getDB(); + const db2 = await getDB(); + expect(db1).toBe(db2); + }); + + describe('object stores', () => { + it('has the items store', async () => { + const db = await getDB(); + expect(db.objectStoreNames.contains('items')).toBe(true); + }); + + it('has the locations store', async () => { + const db = await getDB(); + expect(db.objectStoreNames.contains('locations')).toBe(true); + }); + + it('has the sightings store', async () => { + const db = await getDB(); + expect(db.objectStoreNames.contains('sightings')).toBe(true); + }); + + it('has the photos store', async () => { + const db = await getDB(); + expect(db.objectStoreNames.contains('photos')).toBe(true); + }); + + it('has the preGeneratedIds store', async () => { + const db = await getDB(); + expect(db.objectStoreNames.contains('preGeneratedIds')).toBe(true); + }); + + it('has the settings store', async () => { + const db = await getDB(); + expect(db.objectStoreNames.contains('settings')).toBe(true); + }); + + it('has exactly 6 object stores', async () => { + const db = await getDB(); + expect(db.objectStoreNames.length).toBe(6); + }); + }); + + describe('indexes', () => { + it('has all indexes on the items store', async () => { + const db = await getDB(); + const tx = db.transaction('items', 'readonly'); + const store = tx.objectStore('items'); + expect(store.indexNames.contains('by-name')).toBe(true); + expect(store.indexNames.contains('by-category')).toBe(true); + expect(store.indexNames.contains('by-location')).toBe(true); + expect(store.indexNames.contains('by-type')).toBe(true); + expect(store.indexNames.contains('by-custody')).toBe(true); + await tx.done; + }); + + it('has all indexes on the locations store', async () => { + const db = await getDB(); + const tx = db.transaction('locations', 'readonly'); + const store = tx.objectStore('locations'); + expect(store.indexNames.contains('by-parent')).toBe(true); + expect(store.indexNames.contains('by-name')).toBe(true); + await tx.done; + }); + + it('has all indexes on the sightings store', async () => { + const db = await getDB(); + const tx = db.transaction('sightings', 'readonly'); + const store = tx.objectStore('sightings'); + expect(store.indexNames.contains('by-item')).toBe(true); + expect(store.indexNames.contains('by-location')).toBe(true); + expect(store.indexNames.contains('by-timestamp')).toBe(true); + await tx.done; + }); + + it('has the by-item index on the photos store', async () => { + const db = await getDB(); + const tx = db.transaction('photos', 'readonly'); + const store = tx.objectStore('photos'); + expect(store.indexNames.contains('by-item')).toBe(true); + await tx.done; + }); + + it('has all indexes on the preGeneratedIds store', async () => { + const db = await getDB(); + const tx = db.transaction('preGeneratedIds', 'readonly'); + const store = tx.objectStore('preGeneratedIds'); + expect(store.indexNames.contains('by-batch')).toBe(true); + expect(store.indexNames.contains('by-assigned')).toBe(true); + await tx.done; + }); + }); +}); diff --git a/src/lib/data/db.ts b/src/lib/data/db.ts new file mode 100644 index 0000000..0f498d3 --- /dev/null +++ b/src/lib/data/db.ts @@ -0,0 +1,109 @@ +import { openDB, type IDBPDatabase, type DBSchema } from 'idb'; + +interface SolidHausDB extends DBSchema { + items: { + key: string; + value: import('$lib/types').Item; + indexes: { + 'by-name': string; + 'by-category': string; + 'by-location': string; + 'by-type': string; + 'by-custody': string; + }; + }; + locations: { + key: string; + value: import('$lib/types').Location; + indexes: { + 'by-parent': string; + 'by-name': string; + }; + }; + sightings: { + key: string; + value: import('$lib/types').Sighting; + indexes: { + 'by-item': string; + 'by-location': string; + 'by-timestamp': string; + }; + }; + photos: { + key: string; + value: import('$lib/types').Photo; + indexes: { + 'by-item': string; + }; + }; + preGeneratedIds: { + key: string; + value: import('$lib/types').PreGeneratedId; + indexes: { + 'by-batch': string; + 'by-assigned': string; + }; + }; + settings: { + key: string; + value: { + key: string; + value: unknown; + }; + }; +} + +export type { SolidHausDB }; + +const DB_NAME = 'solidhaus'; +const DB_VERSION = 1; + +let dbPromise: Promise> | null = null; + +export function getDB(): Promise> { + if (!dbPromise) { + dbPromise = openDB(DB_NAME, DB_VERSION, { + upgrade(db) { + // Items + const itemStore = db.createObjectStore('items', { keyPath: 'shortId' }); + itemStore.createIndex('by-name', 'name'); + itemStore.createIndex('by-category', 'category'); + itemStore.createIndex('by-location', 'lastSeenAt'); + itemStore.createIndex('by-type', 'itemType'); + itemStore.createIndex('by-custody', 'custodyState'); + + // Locations + const locStore = db.createObjectStore('locations', { keyPath: 'id' }); + locStore.createIndex('by-parent', 'parentId'); + locStore.createIndex('by-name', 'name'); + + // Sightings + const sightStore = db.createObjectStore('sightings', { keyPath: 'id' }); + sightStore.createIndex('by-item', 'itemId'); + sightStore.createIndex('by-location', 'locationId'); + sightStore.createIndex('by-timestamp', 'timestamp'); + + // Photos + const photoStore = db.createObjectStore('photos', { keyPath: 'id' }); + photoStore.createIndex('by-item', 'itemId'); + + // Pre-generated IDs + const pregenStore = db.createObjectStore('preGeneratedIds', { keyPath: 'id' }); + pregenStore.createIndex('by-batch', 'batchId'); + pregenStore.createIndex('by-assigned', 'assignedTo'); + + // Settings + db.createObjectStore('settings', { keyPath: 'key' }); + }, + }); + } + return dbPromise; +} + +export async function resetDBPromise(): Promise { + if (dbPromise) { + const db = await dbPromise; + db.close(); + dbPromise = null; + } +} diff --git a/src/lib/data/items.test.ts b/src/lib/data/items.test.ts new file mode 100644 index 0000000..4027fb3 --- /dev/null +++ b/src/lib/data/items.test.ts @@ -0,0 +1,223 @@ +import { describe, it, expect, beforeEach } from 'vitest'; +import { deleteDB } from 'idb'; +import { resetDBPromise } from '$lib/data/db'; +import { + createItem, + getAllItems, + getItemById, + updateItem, + removeItem, + getItemsByCategory, + getItemsByType, + getItemsByCustodyState, + getItemsByLocation, +} from '$lib/data/items'; +import type { Item } from '$lib/types'; + +function makeItem(overrides: Partial = {}): Item { + const now = new Date().toISOString(); + return { + shortId: 'abc1234', + name: 'Test Hammer', + description: 'A sturdy claw hammer', + category: 'tools', + brand: 'Stanley', + serialNumber: 'SN-00123', + color: 'yellow', + purchaseDate: '2024-06-15', + itemType: 'durable', + currentQuantity: null, + originalQuantity: null, + quantityUnit: null, + lowThreshold: null, + expiryDate: null, + barcodeFormat: 'qr', + barcodeUri: 'https://haus.toph.so/abc1234', + photoIds: [], + lastSeenAt: 'werkstatt', + lastSeenTimestamp: now, + lastUsedAt: null, + supposedToBeAt: 'werkstatt', + locationConfidence: 'confirmed', + custodyState: 'checked-in', + checkedOutSince: null, + checkedOutReason: null, + checkedOutFrom: null, + checkedOutTo: null, + checkedOutNote: null, + storageTier: 'hot', + storageContainerId: null, + storageContainerLabel: null, + labelPrinted: false, + labelPrintedAt: null, + labelBatchId: null, + createdAt: now, + updatedAt: now, + tags: ['hand-tool'], + createdBy: null, + ...overrides, + }; +} + +beforeEach(async () => { + await resetDBPromise(); + await deleteDB('solidhaus'); +}); + +describe('createItem', () => { + it('creates an item and returns it', async () => { + const item = makeItem(); + const result = await createItem(item); + expect(result).toEqual(item); + }); + + it('persists the item in the database', async () => { + const item = makeItem(); + await createItem(item); + const fetched = await getItemById('abc1234'); + expect(fetched).toEqual(item); + }); + + it('creates multiple items with different IDs', async () => { + const item1 = makeItem({ shortId: 'item001' }); + const item2 = makeItem({ shortId: 'item002', name: 'Screwdriver' }); + await createItem(item1); + await createItem(item2); + const all = await getAllItems(); + expect(all).toHaveLength(2); + }); +}); + +describe('getAllItems', () => { + it('returns an empty array when no items exist', async () => { + const items = await getAllItems(); + expect(items).toEqual([]); + }); + + it('returns all created items', async () => { + await createItem(makeItem({ shortId: 'aaa1111' })); + await createItem(makeItem({ shortId: 'bbb2222' })); + await createItem(makeItem({ shortId: 'ccc3333' })); + const items = await getAllItems(); + expect(items).toHaveLength(3); + const ids = items.map((i) => i.shortId); + expect(ids).toContain('aaa1111'); + expect(ids).toContain('bbb2222'); + expect(ids).toContain('ccc3333'); + }); +}); + +describe('getItemById', () => { + it('returns the correct item', async () => { + const item = makeItem({ shortId: 'xyz7890', name: 'Drill' }); + await createItem(item); + const result = await getItemById('xyz7890'); + expect(result).toBeDefined(); + expect(result!.name).toBe('Drill'); + }); + + it('returns undefined for a non-existent ID', async () => { + const result = await getItemById('notreal'); + expect(result).toBeUndefined(); + }); +}); + +describe('updateItem', () => { + it('updates specified fields on an existing item', async () => { + await createItem(makeItem({ shortId: 'upd0001', name: 'Old Name' })); + const result = await updateItem('upd0001', { name: 'New Name' }); + expect(result).toBeDefined(); + expect(result!.name).toBe('New Name'); + }); + + it('sets updatedAt to a new timestamp', async () => { + const originalDate = '2024-01-01T00:00:00.000Z'; + await createItem(makeItem({ shortId: 'upd0002', updatedAt: originalDate })); + const result = await updateItem('upd0002', { name: 'Changed' }); + expect(result).toBeDefined(); + expect(result!.updatedAt).not.toBe(originalDate); + // The new updatedAt should be a valid ISO string and more recent + expect(new Date(result!.updatedAt).getTime()).toBeGreaterThan( + new Date(originalDate).getTime() + ); + }); + + it('persists the update in the database', async () => { + await createItem(makeItem({ shortId: 'upd0003', category: 'tools' })); + await updateItem('upd0003', { category: 'electronics' }); + const fetched = await getItemById('upd0003'); + expect(fetched!.category).toBe('electronics'); + }); + + it('returns undefined when updating a non-existent item', async () => { + const result = await updateItem('noexist', { name: 'Nope' }); + expect(result).toBeUndefined(); + }); + + it('does not modify fields that are not in the updates', async () => { + await createItem(makeItem({ shortId: 'upd0004', name: 'Hammer', brand: 'Stanley' })); + await updateItem('upd0004', { name: 'Mallet' }); + const fetched = await getItemById('upd0004'); + expect(fetched!.name).toBe('Mallet'); + expect(fetched!.brand).toBe('Stanley'); + }); +}); + +describe('removeItem', () => { + it('deletes an item from the database', async () => { + await createItem(makeItem({ shortId: 'del0001' })); + await removeItem('del0001'); + const result = await getItemById('del0001'); + expect(result).toBeUndefined(); + }); + + it('does not throw when removing a non-existent item', async () => { + await expect(removeItem('nonexistent')).resolves.toBeUndefined(); + }); + + it('only removes the specified item', async () => { + await createItem(makeItem({ shortId: 'keep001' })); + await createItem(makeItem({ shortId: 'del0002' })); + await removeItem('del0002'); + const all = await getAllItems(); + expect(all).toHaveLength(1); + expect(all[0].shortId).toBe('keep001'); + }); +}); + +describe('index queries', () => { + it('getItemsByCategory returns items matching the category', async () => { + await createItem(makeItem({ shortId: 'cat0001', category: 'tools' })); + await createItem(makeItem({ shortId: 'cat0002', category: 'electronics' })); + await createItem(makeItem({ shortId: 'cat0003', category: 'tools' })); + const tools = await getItemsByCategory('tools'); + expect(tools).toHaveLength(2); + expect(tools.every((i) => i.category === 'tools')).toBe(true); + }); + + it('getItemsByType returns items matching the item type', async () => { + await createItem(makeItem({ shortId: 'typ0001', itemType: 'consumable' })); + await createItem(makeItem({ shortId: 'typ0002', itemType: 'durable' })); + const consumables = await getItemsByType('consumable'); + expect(consumables).toHaveLength(1); + expect(consumables[0].shortId).toBe('typ0001'); + }); + + it('getItemsByCustodyState returns items matching the custody state', async () => { + await createItem(makeItem({ shortId: 'cus0001', custodyState: 'checked-in' })); + await createItem(makeItem({ shortId: 'cus0002', custodyState: 'checked-out' })); + await createItem(makeItem({ shortId: 'cus0003', custodyState: 'checked-out' })); + const checkedOut = await getItemsByCustodyState('checked-out'); + expect(checkedOut).toHaveLength(2); + expect(checkedOut.every((i) => i.custodyState === 'checked-out')).toBe(true); + }); + + it('getItemsByLocation returns items at a given location', async () => { + await createItem(makeItem({ shortId: 'loc0001', lastSeenAt: 'kueche' })); + await createItem(makeItem({ shortId: 'loc0002', lastSeenAt: 'buero' })); + await createItem(makeItem({ shortId: 'loc0003', lastSeenAt: 'kueche' })); + const inKueche = await getItemsByLocation('kueche'); + expect(inKueche).toHaveLength(2); + expect(inKueche.every((i) => i.lastSeenAt === 'kueche')).toBe(true); + }); +}); diff --git a/src/lib/data/items.ts b/src/lib/data/items.ts new file mode 100644 index 0000000..7d8bf5d --- /dev/null +++ b/src/lib/data/items.ts @@ -0,0 +1,52 @@ +import { getDB } from './db'; +import type { Item } from '$lib/types'; + +export async function getAllItems(): Promise { + const db = await getDB(); + return db.getAll('items'); +} + +export async function getItemById(shortId: string): Promise { + const db = await getDB(); + return db.get('items', shortId); +} + +export async function createItem(item: Item): Promise { + const db = await getDB(); + await db.put('items', item); + return item; +} + +export async function updateItem(shortId: string, updates: Partial): Promise { + const db = await getDB(); + const existing = await db.get('items', shortId); + if (!existing) return undefined; + const updated = { ...existing, ...updates, updatedAt: new Date().toISOString() }; + await db.put('items', updated); + return updated; +} + +export async function removeItem(shortId: string): Promise { + const db = await getDB(); + await db.delete('items', shortId); +} + +export async function getItemsByCategory(category: string): Promise { + const db = await getDB(); + return db.getAllFromIndex('items', 'by-category', category); +} + +export async function getItemsByType(itemType: string): Promise { + const db = await getDB(); + return db.getAllFromIndex('items', 'by-type', itemType); +} + +export async function getItemsByCustodyState(state: string): Promise { + const db = await getDB(); + return db.getAllFromIndex('items', 'by-custody', state); +} + +export async function getItemsByLocation(locationId: string): Promise { + const db = await getDB(); + return db.getAllFromIndex('items', 'by-location', locationId); +} diff --git a/src/lib/data/labels.test.ts b/src/lib/data/labels.test.ts new file mode 100644 index 0000000..6d58c01 --- /dev/null +++ b/src/lib/data/labels.test.ts @@ -0,0 +1,207 @@ +import { describe, it, expect, beforeEach } from 'vitest'; +import { deleteDB } from 'idb'; +import { resetDBPromise, getDB } from '$lib/data/db'; +import { + createPreGeneratedId, + getUnassignedIds, + assignIdToItem, + getIdsByBatch, + generateBatch, +} from '$lib/data/labels'; +import type { PreGeneratedId } from '$lib/types'; + +function makePreGeneratedId(overrides: Partial = {}): PreGeneratedId { + return { + id: 'tst1234', + generatedAt: new Date().toISOString(), + assignedTo: null, + batchId: 'batch-001', + ...overrides, + }; +} + +beforeEach(async () => { + await resetDBPromise(); + await deleteDB('solidhaus'); +}); + +describe('createPreGeneratedId', () => { + it('creates a pre-generated ID entry and returns it', async () => { + const entry = makePreGeneratedId(); + const result = await createPreGeneratedId(entry); + expect(result).toEqual(entry); + }); + + it('persists the entry in the database', async () => { + const entry = makePreGeneratedId({ id: 'per1234' }); + await createPreGeneratedId(entry); + const db = await getDB(); + const stored = await db.get('preGeneratedIds', 'per1234'); + expect(stored).toBeDefined(); + expect(stored!.id).toBe('per1234'); + }); + + it('stores all fields correctly', async () => { + const entry = makePreGeneratedId({ + id: 'fld1234', + batchId: 'batch-xyz', + assignedTo: null, + }); + await createPreGeneratedId(entry); + const db = await getDB(); + const stored = await db.get('preGeneratedIds', 'fld1234'); + expect(stored!.batchId).toBe('batch-xyz'); + expect(stored!.assignedTo).toBeNull(); + expect(stored!.generatedAt).toBeDefined(); + }); +}); + +describe('getUnassignedIds', () => { + it('returns entries that have no assignedTo value', async () => { + await createPreGeneratedId(makePreGeneratedId({ id: 'free001', assignedTo: null })); + await createPreGeneratedId(makePreGeneratedId({ id: 'free002', assignedTo: null })); + await createPreGeneratedId(makePreGeneratedId({ id: 'used001', assignedTo: 'item-abc' })); + + const unassigned = await getUnassignedIds(); + expect(unassigned).toHaveLength(2); + const ids = unassigned.map((e) => e.id); + expect(ids).toContain('free001'); + expect(ids).toContain('free002'); + }); + + it('returns an empty array when all IDs are assigned', async () => { + await createPreGeneratedId(makePreGeneratedId({ id: 'u001', assignedTo: 'item-1' })); + await createPreGeneratedId(makePreGeneratedId({ id: 'u002', assignedTo: 'item-2' })); + const unassigned = await getUnassignedIds(); + expect(unassigned).toEqual([]); + }); + + it('returns an empty array when no entries exist', async () => { + const unassigned = await getUnassignedIds(); + expect(unassigned).toEqual([]); + }); + + it('returns all entries when none are assigned', async () => { + await createPreGeneratedId(makePreGeneratedId({ id: 'all001' })); + await createPreGeneratedId(makePreGeneratedId({ id: 'all002' })); + await createPreGeneratedId(makePreGeneratedId({ id: 'all003' })); + const unassigned = await getUnassignedIds(); + expect(unassigned).toHaveLength(3); + }); +}); + +describe('assignIdToItem', () => { + it('assigns a pre-generated ID to an item', async () => { + await createPreGeneratedId(makePreGeneratedId({ id: 'asn001', assignedTo: null })); + const result = await assignIdToItem('asn001', 'myitem1'); + expect(result).toBeDefined(); + expect(result!.assignedTo).toBe('myitem1'); + }); + + it('persists the assignment in the database', async () => { + await createPreGeneratedId(makePreGeneratedId({ id: 'asn002', assignedTo: null })); + await assignIdToItem('asn002', 'myitem2'); + const db = await getDB(); + const stored = await db.get('preGeneratedIds', 'asn002'); + expect(stored!.assignedTo).toBe('myitem2'); + }); + + it('returns undefined when the ID does not exist', async () => { + const result = await assignIdToItem('nonexist', 'myitem3'); + expect(result).toBeUndefined(); + }); + + it('makes the ID no longer appear in unassigned list', async () => { + await createPreGeneratedId(makePreGeneratedId({ id: 'asn003', assignedTo: null })); + await createPreGeneratedId(makePreGeneratedId({ id: 'asn004', assignedTo: null })); + await assignIdToItem('asn003', 'target-item'); + + const unassigned = await getUnassignedIds(); + const ids = unassigned.map((e) => e.id); + expect(ids).not.toContain('asn003'); + expect(ids).toContain('asn004'); + }); +}); + +describe('getIdsByBatch', () => { + it('returns all IDs belonging to a specific batch', async () => { + await createPreGeneratedId(makePreGeneratedId({ id: 'bat001', batchId: 'batch-A' })); + await createPreGeneratedId(makePreGeneratedId({ id: 'bat002', batchId: 'batch-A' })); + await createPreGeneratedId(makePreGeneratedId({ id: 'bat003', batchId: 'batch-B' })); + + const batchA = await getIdsByBatch('batch-A'); + expect(batchA).toHaveLength(2); + expect(batchA.every((e) => e.batchId === 'batch-A')).toBe(true); + }); + + it('returns an empty array when no IDs match the batch', async () => { + await createPreGeneratedId(makePreGeneratedId({ id: 'bat004', batchId: 'batch-C' })); + const result = await getIdsByBatch('batch-nonexistent'); + expect(result).toEqual([]); + }); + + it('returns an empty array when the store is empty', async () => { + const result = await getIdsByBatch('any-batch'); + expect(result).toEqual([]); + }); +}); + +describe('generateBatch', () => { + it('creates the specified number of IDs', async () => { + const entries = await generateBatch(5, 'gen-batch-1'); + expect(entries).toHaveLength(5); + }); + + it('returns entries with the correct batchId', async () => { + const entries = await generateBatch(3, 'gen-batch-2'); + expect(entries.every((e) => e.batchId === 'gen-batch-2')).toBe(true); + }); + + it('returns entries with null assignedTo', async () => { + const entries = await generateBatch(2, 'gen-batch-3'); + expect(entries.every((e) => e.assignedTo === null)).toBe(true); + }); + + it('generates unique IDs for each entry', async () => { + const entries = await generateBatch(10, 'gen-batch-4'); + const ids = entries.map((e) => e.id); + const uniqueIds = new Set(ids); + expect(uniqueIds.size).toBe(10); + }); + + it('persists all generated entries in the database', async () => { + await generateBatch(4, 'gen-batch-5'); + const stored = await getIdsByBatch('gen-batch-5'); + expect(stored).toHaveLength(4); + }); + + it('sets generatedAt on all entries', async () => { + const entries = await generateBatch(2, 'gen-batch-6'); + for (const entry of entries) { + expect(entry.generatedAt).toBeDefined(); + expect(() => new Date(entry.generatedAt)).not.toThrow(); + } + }); + + it('generates IDs with the expected format (7-char nanoid alphabet)', async () => { + const entries = await generateBatch(5, 'gen-batch-7'); + const validChars = /^[23456789a-hjkmnp-z]{7}$/; + for (const entry of entries) { + expect(entry.id).toMatch(validChars); + } + }); + + it('handles generating zero IDs', async () => { + const entries = await generateBatch(0, 'gen-batch-empty'); + expect(entries).toHaveLength(0); + }); + + it('can generate multiple batches independently', async () => { + await generateBatch(3, 'batch-x'); + await generateBatch(4, 'batch-y'); + const batchX = await getIdsByBatch('batch-x'); + const batchY = await getIdsByBatch('batch-y'); + expect(batchX).toHaveLength(3); + expect(batchY).toHaveLength(4); + }); +}); diff --git a/src/lib/data/labels.ts b/src/lib/data/labels.ts new file mode 100644 index 0000000..e4cc5eb --- /dev/null +++ b/src/lib/data/labels.ts @@ -0,0 +1,52 @@ +import { getDB } from './db'; +import type { PreGeneratedId } from '$lib/types'; +import { generateItemId } from '$lib/utils/id'; + +export async function createPreGeneratedId(entry: PreGeneratedId): Promise { + const db = await getDB(); + await db.put('preGeneratedIds', entry); + return entry; +} + +export async function getUnassignedIds(): Promise { + const db = await getDB(); + const all = await db.getAllFromIndex('preGeneratedIds', 'by-assigned', ''); + // Also get nulls — by-assigned index may not store null correctly, so filter + const allIds = await db.getAll('preGeneratedIds'); + return allIds.filter((entry) => !entry.assignedTo); +} + +export async function assignIdToItem(id: string, itemShortId: string): Promise { + const db = await getDB(); + const entry = await db.get('preGeneratedIds', id); + if (!entry) return undefined; + const updated = { ...entry, assignedTo: itemShortId }; + await db.put('preGeneratedIds', updated); + return updated; +} + +export async function getIdsByBatch(batchId: string): Promise { + const db = await getDB(); + return db.getAllFromIndex('preGeneratedIds', 'by-batch', batchId); +} + +export async function generateBatch(count: number, batchId: string): Promise { + const db = await getDB(); + const now = new Date().toISOString(); + const entries: PreGeneratedId[] = []; + + const tx = db.transaction('preGeneratedIds', 'readwrite'); + for (let i = 0; i < count; i++) { + const entry: PreGeneratedId = { + id: generateItemId(), + generatedAt: now, + assignedTo: null, + batchId, + }; + await tx.store.put(entry); + entries.push(entry); + } + await tx.done; + + return entries; +} diff --git a/src/lib/data/locations.test.ts b/src/lib/data/locations.test.ts new file mode 100644 index 0000000..5f44bff --- /dev/null +++ b/src/lib/data/locations.test.ts @@ -0,0 +1,211 @@ +import { describe, it, expect, beforeEach } from 'vitest'; +import { deleteDB } from 'idb'; +import { resetDBPromise } from '$lib/data/db'; +import { + createLocation, + getAllLocations, + getLocationById, + updateLocation, + removeLocation, + getLocationsByParent, + seedDefaultLocations, +} from '$lib/data/locations'; +import type { Location } from '$lib/types'; + +function makeLocation(overrides: Partial = {}): Location { + const now = new Date().toISOString(); + return { + id: 'test-room', + name: 'Test Room', + description: 'A room for testing', + parentId: null, + locationType: 'room', + defaultStorageTier: null, + sortOrder: 0, + createdAt: now, + updatedAt: now, + ...overrides, + }; +} + +beforeEach(async () => { + await resetDBPromise(); + await deleteDB('solidhaus'); +}); + +describe('createLocation', () => { + it('creates a location and returns it', async () => { + const loc = makeLocation(); + const result = await createLocation(loc); + expect(result).toEqual(loc); + }); + + it('persists the location in the database', async () => { + const loc = makeLocation({ id: 'garage', name: 'Garage' }); + await createLocation(loc); + const fetched = await getLocationById('garage'); + expect(fetched).toEqual(loc); + }); +}); + +describe('getAllLocations', () => { + it('returns an empty array when no locations exist', async () => { + const locations = await getAllLocations(); + expect(locations).toEqual([]); + }); + + it('returns all created locations', async () => { + await createLocation(makeLocation({ id: 'loc-a', name: 'Room A' })); + await createLocation(makeLocation({ id: 'loc-b', name: 'Room B' })); + const locations = await getAllLocations(); + expect(locations).toHaveLength(2); + }); +}); + +describe('getLocationById', () => { + it('returns the correct location', async () => { + const loc = makeLocation({ id: 'kitchen', name: 'Kitchen' }); + await createLocation(loc); + const result = await getLocationById('kitchen'); + expect(result).toBeDefined(); + expect(result!.name).toBe('Kitchen'); + }); + + it('returns undefined for a non-existent ID', async () => { + const result = await getLocationById('does-not-exist'); + expect(result).toBeUndefined(); + }); +}); + +describe('updateLocation', () => { + it('updates specified fields on an existing location', async () => { + await createLocation(makeLocation({ id: 'upd-loc', name: 'Old Name' })); + const result = await updateLocation('upd-loc', { name: 'New Name' }); + expect(result).toBeDefined(); + expect(result!.name).toBe('New Name'); + }); + + it('sets updatedAt to a new timestamp', async () => { + const oldDate = '2023-01-01T00:00:00.000Z'; + await createLocation(makeLocation({ id: 'upd-loc2', updatedAt: oldDate })); + const result = await updateLocation('upd-loc2', { description: 'Updated' }); + expect(result).toBeDefined(); + expect(new Date(result!.updatedAt).getTime()).toBeGreaterThan(new Date(oldDate).getTime()); + }); + + it('persists the update in the database', async () => { + await createLocation(makeLocation({ id: 'upd-loc3', description: 'Before' })); + await updateLocation('upd-loc3', { description: 'After' }); + const fetched = await getLocationById('upd-loc3'); + expect(fetched!.description).toBe('After'); + }); + + it('returns undefined when updating a non-existent location', async () => { + const result = await updateLocation('nonexistent', { name: 'Nope' }); + expect(result).toBeUndefined(); + }); + + it('does not modify fields that are not in the updates', async () => { + await createLocation( + makeLocation({ id: 'upd-loc4', name: 'Room', locationType: 'room' }) + ); + await updateLocation('upd-loc4', { name: 'Updated Room' }); + const fetched = await getLocationById('upd-loc4'); + expect(fetched!.name).toBe('Updated Room'); + expect(fetched!.locationType).toBe('room'); + }); +}); + +describe('removeLocation', () => { + it('deletes a location from the database', async () => { + await createLocation(makeLocation({ id: 'del-loc' })); + await removeLocation('del-loc'); + const result = await getLocationById('del-loc'); + expect(result).toBeUndefined(); + }); + + it('does not throw when removing a non-existent location', async () => { + await expect(removeLocation('nonexistent')).resolves.toBeUndefined(); + }); + + it('only removes the specified location', async () => { + await createLocation(makeLocation({ id: 'keep-loc' })); + await createLocation(makeLocation({ id: 'del-loc2' })); + await removeLocation('del-loc2'); + const all = await getAllLocations(); + expect(all).toHaveLength(1); + expect(all[0].id).toBe('keep-loc'); + }); +}); + +describe('getLocationsByParent', () => { + it('returns children of a given parent', async () => { + await createLocation(makeLocation({ id: 'house', name: 'House', parentId: null })); + await createLocation( + makeLocation({ id: 'floor1', name: 'Floor 1', parentId: 'house' }) + ); + await createLocation( + makeLocation({ id: 'floor2', name: 'Floor 2', parentId: 'house' }) + ); + await createLocation( + makeLocation({ id: 'room1', name: 'Room 1', parentId: 'floor1' }) + ); + + const houseChildren = await getLocationsByParent('house'); + expect(houseChildren).toHaveLength(2); + const childIds = houseChildren.map((l) => l.id); + expect(childIds).toContain('floor1'); + expect(childIds).toContain('floor2'); + }); + + it('returns an empty array when no children exist', async () => { + await createLocation(makeLocation({ id: 'leaf', parentId: null })); + const children = await getLocationsByParent('leaf'); + expect(children).toEqual([]); + }); +}); + +describe('seedDefaultLocations', () => { + it('populates default locations when the store is empty', async () => { + await seedDefaultLocations(); + const all = await getAllLocations(); + expect(all.length).toBe(11); + }); + + it('includes expected default locations', async () => { + await seedDefaultLocations(); + const home = await getLocationById('home'); + expect(home).toBeDefined(); + expect(home!.name).toBe('Home'); + expect(home!.locationType).toBe('house'); + + const kueche = await getLocationById('kueche'); + expect(kueche).toBeDefined(); + expect(kueche!.name).toBe('Küche'); + expect(kueche!.parentId).toBe('eg'); + }); + + it('sets createdAt and updatedAt on seeded locations', async () => { + await seedDefaultLocations(); + const home = await getLocationById('home'); + expect(home!.createdAt).toBeDefined(); + expect(home!.updatedAt).toBeDefined(); + expect(() => new Date(home!.createdAt)).not.toThrow(); + }); + + it('skips seeding when locations already exist', async () => { + await createLocation(makeLocation({ id: 'custom', name: 'Custom Location' })); + await seedDefaultLocations(); + const all = await getAllLocations(); + // Should only have the one custom location, not 11 defaults + 1 custom + expect(all).toHaveLength(1); + expect(all[0].id).toBe('custom'); + }); + + it('is idempotent — calling twice does not duplicate data', async () => { + await seedDefaultLocations(); + await seedDefaultLocations(); + const all = await getAllLocations(); + expect(all).toHaveLength(11); + }); +}); diff --git a/src/lib/data/locations.ts b/src/lib/data/locations.ts new file mode 100644 index 0000000..c3b880f --- /dev/null +++ b/src/lib/data/locations.ts @@ -0,0 +1,64 @@ +import { getDB } from './db'; +import type { Location } from '$lib/types'; + +export async function getAllLocations(): Promise { + const db = await getDB(); + return db.getAll('locations'); +} + +export async function getLocationById(id: string): Promise { + const db = await getDB(); + return db.get('locations', id); +} + +export async function createLocation(location: Location): Promise { + const db = await getDB(); + await db.put('locations', location); + return location; +} + +export async function updateLocation(id: string, updates: Partial): Promise { + const db = await getDB(); + const existing = await db.get('locations', id); + if (!existing) return undefined; + const updated = { ...existing, ...updates, updatedAt: new Date().toISOString() }; + await db.put('locations', updated); + return updated; +} + +export async function removeLocation(id: string): Promise { + const db = await getDB(); + await db.delete('locations', id); +} + +export async function getLocationsByParent(parentId: string | null): Promise { + const db = await getDB(); + return db.getAllFromIndex('locations', 'by-parent', parentId as string); +} + +const DEFAULT_LOCATIONS: Omit[] = [ + { id: 'home', name: 'Home', description: '', parentId: null, locationType: 'house', defaultStorageTier: null, sortOrder: 0 }, + { id: 'eg', name: 'Erdgeschoss', description: '', parentId: 'home', locationType: 'floor', defaultStorageTier: null, sortOrder: 0 }, + { id: 'og', name: 'Obergeschoss', description: '', parentId: 'home', locationType: 'floor', defaultStorageTier: null, sortOrder: 1 }, + { id: 'keller', name: 'Keller', description: '', parentId: 'home', locationType: 'floor', defaultStorageTier: null, sortOrder: 2 }, + { id: 'kueche', name: 'Küche', description: '', parentId: 'eg', locationType: 'room', defaultStorageTier: null, sortOrder: 0 }, + { id: 'wohnzimmer', name: 'Wohnzimmer', description: '', parentId: 'eg', locationType: 'room', defaultStorageTier: null, sortOrder: 1 }, + { id: 'flur', name: 'Flur', description: '', parentId: 'eg', locationType: 'room', defaultStorageTier: null, sortOrder: 2 }, + { id: 'schlafzimmer', name: 'Schlafzimmer', description: '', parentId: 'og', locationType: 'room', defaultStorageTier: null, sortOrder: 0 }, + { id: 'buero', name: 'Büro', description: '', parentId: 'og', locationType: 'room', defaultStorageTier: null, sortOrder: 1 }, + { id: 'bad', name: 'Bad', description: '', parentId: 'og', locationType: 'room', defaultStorageTier: null, sortOrder: 2 }, + { id: 'werkstatt', name: 'Werkstatt', description: '', parentId: 'keller', locationType: 'room', defaultStorageTier: null, sortOrder: 0 }, +]; + +export async function seedDefaultLocations(): Promise { + const db = await getDB(); + const existing = await db.count('locations'); + if (existing > 0) return; + + const now = new Date().toISOString(); + const tx = db.transaction('locations', 'readwrite'); + for (const loc of DEFAULT_LOCATIONS) { + await tx.store.put({ ...loc, createdAt: now, updatedAt: now }); + } + await tx.done; +} diff --git a/src/lib/data/sightings.test.ts b/src/lib/data/sightings.test.ts new file mode 100644 index 0000000..73549b8 --- /dev/null +++ b/src/lib/data/sightings.test.ts @@ -0,0 +1,151 @@ +import { describe, it, expect, beforeEach } from 'vitest'; +import { deleteDB } from 'idb'; +import { resetDBPromise } from '$lib/data/db'; +import { + createSighting, + getSightingsByItem, + getSightingsByLocation, + getAllSightings, + removeSighting, +} from '$lib/data/sightings'; +import type { Sighting } from '$lib/types'; + +function makeSighting(overrides: Partial = {}): Sighting { + return { + id: 's_test001', + itemId: 'abc1234', + locationId: 'kueche', + timestamp: new Date().toISOString(), + sightingType: 'scan', + confidence: 'confirmed', + notes: '', + createdBy: null, + ...overrides, + }; +} + +beforeEach(async () => { + await resetDBPromise(); + await deleteDB('solidhaus'); +}); + +describe('createSighting', () => { + it('creates a sighting and returns it', async () => { + const sighting = makeSighting(); + const result = await createSighting(sighting); + expect(result).toEqual(sighting); + }); + + it('persists the sighting in the database', async () => { + const sighting = makeSighting({ id: 's_persist' }); + await createSighting(sighting); + const all = await getAllSightings(); + expect(all).toHaveLength(1); + expect(all[0].id).toBe('s_persist'); + }); + + it('creates multiple sightings', async () => { + await createSighting(makeSighting({ id: 's_one' })); + await createSighting(makeSighting({ id: 's_two' })); + await createSighting(makeSighting({ id: 's_three' })); + const all = await getAllSightings(); + expect(all).toHaveLength(3); + }); + + it('stores all sighting fields correctly', async () => { + const sighting = makeSighting({ + id: 's_full', + itemId: 'item777', + locationId: 'buero', + sightingType: 'manual', + confidence: 'inferred', + notes: 'Spotted on desk', + createdBy: 'user-42', + }); + await createSighting(sighting); + const all = await getAllSightings(); + const stored = all.find((s) => s.id === 's_full'); + expect(stored).toBeDefined(); + expect(stored!.itemId).toBe('item777'); + expect(stored!.locationId).toBe('buero'); + expect(stored!.sightingType).toBe('manual'); + expect(stored!.confidence).toBe('inferred'); + expect(stored!.notes).toBe('Spotted on desk'); + expect(stored!.createdBy).toBe('user-42'); + }); +}); + +describe('getSightingsByItem', () => { + it('returns sightings for a specific item', async () => { + await createSighting(makeSighting({ id: 's_a1', itemId: 'item-a' })); + await createSighting(makeSighting({ id: 's_a2', itemId: 'item-a' })); + await createSighting(makeSighting({ id: 's_b1', itemId: 'item-b' })); + + const sightings = await getSightingsByItem('item-a'); + expect(sightings).toHaveLength(2); + expect(sightings.every((s) => s.itemId === 'item-a')).toBe(true); + }); + + it('returns an empty array when no sightings exist for the item', async () => { + await createSighting(makeSighting({ id: 's_other', itemId: 'item-x' })); + const sightings = await getSightingsByItem('item-y'); + expect(sightings).toEqual([]); + }); + + it('returns an empty array when the database is empty', async () => { + const sightings = await getSightingsByItem('anything'); + expect(sightings).toEqual([]); + }); +}); + +describe('getSightingsByLocation', () => { + it('returns sightings for a specific location', async () => { + await createSighting(makeSighting({ id: 's_k1', locationId: 'kueche' })); + await createSighting(makeSighting({ id: 's_k2', locationId: 'kueche' })); + await createSighting(makeSighting({ id: 's_b1', locationId: 'buero' })); + + const sightings = await getSightingsByLocation('kueche'); + expect(sightings).toHaveLength(2); + expect(sightings.every((s) => s.locationId === 'kueche')).toBe(true); + }); + + it('returns an empty array when no sightings exist for the location', async () => { + await createSighting(makeSighting({ id: 's_elsewhere', locationId: 'werkstatt' })); + const sightings = await getSightingsByLocation('schlafzimmer'); + expect(sightings).toEqual([]); + }); + + it('returns an empty array when the database is empty', async () => { + const sightings = await getSightingsByLocation('anywhere'); + expect(sightings).toEqual([]); + }); +}); + +describe('removeSighting', () => { + it('deletes a sighting from the database', async () => { + await createSighting(makeSighting({ id: 's_del' })); + await removeSighting('s_del'); + const all = await getAllSightings(); + expect(all).toHaveLength(0); + }); + + it('does not throw when removing a non-existent sighting', async () => { + await expect(removeSighting('s_nonexistent')).resolves.toBeUndefined(); + }); + + it('only removes the specified sighting', async () => { + await createSighting(makeSighting({ id: 's_keep' })); + await createSighting(makeSighting({ id: 's_remove' })); + await removeSighting('s_remove'); + const all = await getAllSightings(); + expect(all).toHaveLength(1); + expect(all[0].id).toBe('s_keep'); + }); +}); + +describe('getAllSightings', () => { + it('returns an empty array when no sightings exist', async () => { + const all = await getAllSightings(); + expect(all).toEqual([]); + }); +}); diff --git a/src/lib/data/sightings.ts b/src/lib/data/sightings.ts new file mode 100644 index 0000000..d533529 --- /dev/null +++ b/src/lib/data/sightings.ts @@ -0,0 +1,28 @@ +import { getDB } from './db'; +import type { Sighting } from '$lib/types'; + +export async function createSighting(sighting: Sighting): Promise { + const db = await getDB(); + await db.put('sightings', sighting); + return sighting; +} + +export async function getSightingsByItem(itemId: string): Promise { + const db = await getDB(); + return db.getAllFromIndex('sightings', 'by-item', itemId); +} + +export async function getSightingsByLocation(locationId: string): Promise { + const db = await getDB(); + return db.getAllFromIndex('sightings', 'by-location', locationId); +} + +export async function getAllSightings(): Promise { + const db = await getDB(); + return db.getAll('sightings'); +} + +export async function removeSighting(id: string): Promise { + const db = await getDB(); + await db.delete('sightings', id); +} diff --git a/src/lib/scanning/parser.test.ts b/src/lib/scanning/parser.test.ts new file mode 100644 index 0000000..706cef8 --- /dev/null +++ b/src/lib/scanning/parser.test.ts @@ -0,0 +1,150 @@ +import { describe, it, expect } from 'vitest'; +import { parseItemId } from '$lib/scanning/parser'; +import { generateItemId, generateSightingId } from '$lib/utils/id'; + +describe('parseItemId', () => { + describe('HTTPS URI format', () => { + it('parses a valid HTTPS URI', () => { + expect(parseItemId('https://haus.toph.so/abc2345')).toBe('abc2345'); + }); + + it('parses a valid HTTPS URI with different valid ID', () => { + expect(parseItemId('https://haus.toph.so/mn3pqr7')).toBe('mn3pqr7'); + }); + + it('returns null for HTTPS URI with invalid characters in ID', () => { + expect(parseItemId('https://haus.toph.so/abc1lO0')).toBeNull(); + }); + + it('returns null for HTTPS URI with wrong length ID', () => { + expect(parseItemId('https://haus.toph.so/abc23')).toBeNull(); + }); + + it('returns null for wrong domain', () => { + expect(parseItemId('https://example.com/abc2345')).toBeNull(); + }); + }); + + describe('custom scheme format', () => { + it('parses a valid haus:// URI', () => { + expect(parseItemId('haus://abc2345')).toBe('abc2345'); + }); + + it('returns null for haus:// URI with invalid characters', () => { + expect(parseItemId('haus://abc1lO0')).toBeNull(); + }); + + it('returns null for haus:// URI with wrong length', () => { + expect(parseItemId('haus://abc23456789')).toBeNull(); + }); + }); + + describe('raw ID format', () => { + it('parses a valid raw 7-char ID', () => { + expect(parseItemId('abc2345')).toBe('abc2345'); + }); + + it('accepts all valid alphabet characters', () => { + // Alphabet: 23456789abcdefghjkmnpqrstuvwxyz + expect(parseItemId('2345678')).toBe('2345678'); + expect(parseItemId('abcdefg')).toBe('abcdefg'); + expect(parseItemId('hjkmnpq')).toBe('hjkmnpq'); + expect(parseItemId('rstuvwx')).toBe('rstuvwx'); + }); + + it('rejects excluded characters (0, 1, i, l, o)', () => { + expect(parseItemId('abc0def')).toBeNull(); + expect(parseItemId('abc1def')).toBeNull(); + expect(parseItemId('abcidef')).toBeNull(); + expect(parseItemId('abcldef')).toBeNull(); + expect(parseItemId('abcodef')).toBeNull(); + }); + + it('rejects uppercase characters', () => { + expect(parseItemId('ABCdefg')).toBeNull(); + }); + }); + + describe('invalid inputs', () => { + it('returns null for empty string', () => { + expect(parseItemId('')).toBeNull(); + }); + + it('returns null for wrong length (too short)', () => { + expect(parseItemId('abc23')).toBeNull(); + }); + + it('returns null for wrong length (too long)', () => { + expect(parseItemId('abc23456')).toBeNull(); + }); + + it('returns null for string with spaces', () => { + expect(parseItemId('abc 234')).toBeNull(); + }); + + it('returns null for string with special characters', () => { + expect(parseItemId('abc-234')).toBeNull(); + }); + + it('returns null for completely invalid input', () => { + expect(parseItemId('not a barcode at all')).toBeNull(); + }); + }); +}); + +describe('generateItemId', () => { + const VALID_CHARS = /^[23456789a-hjkmnp-z]+$/; + + it('generates a 7-character string', () => { + const id = generateItemId(); + expect(id).toHaveLength(7); + }); + + it('uses only valid alphabet characters', () => { + for (let i = 0; i < 50; i++) { + const id = generateItemId(); + expect(id).toMatch(VALID_CHARS); + } + }); + + it('does not contain ambiguous characters (0, 1, i, l, o)', () => { + for (let i = 0; i < 50; i++) { + const id = generateItemId(); + expect(id).not.toMatch(/[01ilo]/); + } + }); + + it('generates unique IDs across multiple calls', () => { + const ids = new Set(); + for (let i = 0; i < 1000; i++) { + ids.add(generateItemId()); + } + expect(ids.size).toBe(1000); + }); +}); + +describe('generateSightingId', () => { + it('starts with "s_" prefix', () => { + const id = generateSightingId(); + expect(id.startsWith('s_')).toBe(true); + }); + + it('has total length of 9 (2 prefix + 7 ID)', () => { + const id = generateSightingId(); + expect(id).toHaveLength(9); + }); + + it('has a valid item ID after the prefix', () => { + const id = generateSightingId(); + const suffix = id.slice(2); + expect(suffix).toMatch(/^[23456789a-hjkmnp-z]{7}$/); + }); + + it('generates unique IDs across multiple calls', () => { + const ids = new Set(); + for (let i = 0; i < 100; i++) { + ids.add(generateSightingId()); + } + expect(ids.size).toBe(100); + }); +}); diff --git a/src/lib/scanning/parser.ts b/src/lib/scanning/parser.ts new file mode 100644 index 0000000..dc8b9cf --- /dev/null +++ b/src/lib/scanning/parser.ts @@ -0,0 +1,18 @@ +const VALID_CHARS = /^[23456789a-hjkmnp-z]{7}$/; + +export function parseItemId(rawValue: string): string | null { + if (!rawValue) return null; + + // HTTPS URI + const httpsMatch = rawValue.match(/haus\.toph\.so\/([23456789a-hjkmnp-z]{7})\/?$/); + if (httpsMatch) return httpsMatch[1]; + + // Custom scheme + const schemeMatch = rawValue.match(/^haus:\/\/([23456789a-hjkmnp-z]{7})$/); + if (schemeMatch) return schemeMatch[1]; + + // Raw ID + if (VALID_CHARS.test(rawValue)) return rawValue; + + return null; +} diff --git a/src/lib/utils/confidence.test.ts b/src/lib/utils/confidence.test.ts new file mode 100644 index 0000000..56adb06 --- /dev/null +++ b/src/lib/utils/confidence.test.ts @@ -0,0 +1,50 @@ +import { describe, it, expect } from 'vitest'; +import { getConfidence } from '$lib/utils/confidence'; + +function daysAgo(days: number): string { + const date = new Date(); + date.setDate(date.getDate() - days); + return date.toISOString(); +} + +describe('getConfidence', () => { + it('returns "unknown" when lastSeenTimestamp is null', () => { + expect(getConfidence(null)).toBe('unknown'); + }); + + it('returns "confirmed" for a timestamp from today', () => { + expect(getConfidence(new Date().toISOString())).toBe('confirmed'); + }); + + it('returns "confirmed" at the 30-day boundary', () => { + expect(getConfidence(daysAgo(30))).toBe('confirmed'); + }); + + it('returns "likely" at 31 days ago', () => { + expect(getConfidence(daysAgo(31))).toBe('likely'); + }); + + it('returns "likely" at the 90-day boundary', () => { + expect(getConfidence(daysAgo(90))).toBe('likely'); + }); + + it('returns "assumed" at 91 days ago', () => { + expect(getConfidence(daysAgo(91))).toBe('assumed'); + }); + + it('returns "assumed" at the 180-day boundary', () => { + expect(getConfidence(daysAgo(180))).toBe('assumed'); + }); + + it('returns "unknown" at 181 days ago', () => { + expect(getConfidence(daysAgo(181))).toBe('unknown'); + }); + + it('returns "unknown" for a very old timestamp', () => { + expect(getConfidence('2020-01-01T00:00:00.000Z')).toBe('unknown'); + }); + + it('returns "confirmed" for a timestamp 1 day ago', () => { + expect(getConfidence(daysAgo(1))).toBe('confirmed'); + }); +}); diff --git a/src/lib/utils/confidence.ts b/src/lib/utils/confidence.ts new file mode 100644 index 0000000..3a672d2 --- /dev/null +++ b/src/lib/utils/confidence.ts @@ -0,0 +1,15 @@ +import { differenceInDays } from 'date-fns'; +import type { LocationConfidence } from '$lib/types'; + +export function getConfidence(lastSeenTimestamp: string | null): LocationConfidence { + if (!lastSeenTimestamp) return 'unknown'; + + const date = new Date(lastSeenTimestamp); + if (isNaN(date.getTime())) return 'unknown'; + + const daysSince = differenceInDays(new Date(), date); + if (daysSince <= 30) return 'confirmed'; + if (daysSince <= 90) return 'likely'; + if (daysSince <= 180) return 'assumed'; + return 'unknown'; +} diff --git a/src/lib/utils/custody.test.ts b/src/lib/utils/custody.test.ts new file mode 100644 index 0000000..5316dfa --- /dev/null +++ b/src/lib/utils/custody.test.ts @@ -0,0 +1,138 @@ +import { describe, it, expect } from 'vitest'; +import { buildCheckOutUpdate, buildCheckInUpdate } from '$lib/utils/custody'; + +describe('buildCheckOutUpdate', () => { + it('returns custodyState "checked-out"', () => { + const result = buildCheckOutUpdate('in-use', 'loc-from', 'loc-to'); + expect(result.custodyState).toBe('checked-out'); + }); + + it('sets checkedOutSince to a valid ISO timestamp', () => { + const before = new Date().toISOString(); + const result = buildCheckOutUpdate('in-use', 'loc-from', 'loc-to'); + const after = new Date().toISOString(); + + expect(result.checkedOutSince).toBeDefined(); + expect(result.checkedOutSince! >= before).toBe(true); + expect(result.checkedOutSince! <= after).toBe(true); + }); + + it('sets the checkout reason', () => { + const result = buildCheckOutUpdate('lent', 'loc-from', 'loc-to'); + expect(result.checkedOutReason).toBe('lent'); + }); + + it('sets checkedOutFrom to the from location', () => { + const result = buildCheckOutUpdate('in-use', 'kitchen', 'garage'); + expect(result.checkedOutFrom).toBe('kitchen'); + }); + + it('sets checkedOutTo to the to location', () => { + const result = buildCheckOutUpdate('in-use', 'kitchen', 'garage'); + expect(result.checkedOutTo).toBe('garage'); + }); + + it('sets checkedOutNote when provided', () => { + const result = buildCheckOutUpdate('lent', 'loc-a', 'loc-b', 'Lent to neighbor'); + expect(result.checkedOutNote).toBe('Lent to neighbor'); + }); + + it('sets checkedOutNote to empty string or null when not provided', () => { + const result = buildCheckOutUpdate('in-use', 'loc-a', 'loc-b'); + // Accept either null, undefined, or empty string as "no note" + expect(result.checkedOutNote === null || result.checkedOutNote === undefined || result.checkedOutNote === '').toBe(true); + }); + + it('sets lastSeenAt to toLocationId when provided', () => { + const result = buildCheckOutUpdate('in-transit', 'loc-a', 'loc-b'); + expect(result.lastSeenAt).toBe('loc-b'); + }); + + it('sets lastSeenAt to fromLocationId when toLocationId is null', () => { + const result = buildCheckOutUpdate('in-use', 'loc-a', null); + expect(result.lastSeenAt).toBe('loc-a'); + }); + + it('sets lastSeenAt to fromLocationId when toLocationId is empty string', () => { + const result = buildCheckOutUpdate('in-use', 'loc-a', ''); + expect(result.lastSeenAt).toBe('loc-a'); + }); + + it('sets locationConfidence to "confirmed"', () => { + const result = buildCheckOutUpdate('in-use', 'loc-a', 'loc-b'); + expect(result.locationConfidence).toBe('confirmed'); + }); + + it('sets lastSeenTimestamp', () => { + const result = buildCheckOutUpdate('in-use', 'loc-a', 'loc-b'); + expect(result.lastSeenTimestamp).toBeDefined(); + // Should be a valid ISO string + expect(new Date(result.lastSeenTimestamp!).toISOString()).toBe(result.lastSeenTimestamp); + }); + + it('handles all checkout reason types', () => { + const reasons = ['in-use', 'in-transit', 'lent', 'in-repair', 'temporary', 'consumed'] as const; + for (const reason of reasons) { + const result = buildCheckOutUpdate(reason, 'loc-a', 'loc-b'); + expect(result.checkedOutReason).toBe(reason); + expect(result.custodyState).toBe('checked-out'); + } + }); +}); + +describe('buildCheckInUpdate', () => { + it('returns custodyState "checked-in"', () => { + const result = buildCheckInUpdate('living-room'); + expect(result.custodyState).toBe('checked-in'); + }); + + it('nulls checkedOutSince', () => { + const result = buildCheckInUpdate('living-room'); + expect(result.checkedOutSince).toBeNull(); + }); + + it('nulls checkedOutReason', () => { + const result = buildCheckInUpdate('living-room'); + expect(result.checkedOutReason).toBeNull(); + }); + + it('nulls checkedOutFrom', () => { + const result = buildCheckInUpdate('living-room'); + expect(result.checkedOutFrom).toBeNull(); + }); + + it('nulls checkedOutTo', () => { + const result = buildCheckInUpdate('living-room'); + expect(result.checkedOutTo).toBeNull(); + }); + + it('nulls checkedOutNote', () => { + const result = buildCheckInUpdate('living-room'); + expect(result.checkedOutNote).toBeNull(); + }); + + it('sets lastSeenAt to the return location', () => { + const result = buildCheckInUpdate('kitchen'); + expect(result.lastSeenAt).toBe('kitchen'); + }); + + it('sets supposedToBeAt to the return location', () => { + const result = buildCheckInUpdate('kitchen'); + expect(result.supposedToBeAt).toBe('kitchen'); + }); + + it('sets locationConfidence to "confirmed"', () => { + const result = buildCheckInUpdate('kitchen'); + expect(result.locationConfidence).toBe('confirmed'); + }); + + it('sets lastSeenTimestamp to a valid ISO timestamp', () => { + const before = new Date().toISOString(); + const result = buildCheckInUpdate('kitchen'); + const after = new Date().toISOString(); + + expect(result.lastSeenTimestamp).toBeDefined(); + expect(result.lastSeenTimestamp! >= before).toBe(true); + expect(result.lastSeenTimestamp! <= after).toBe(true); + }); +}); diff --git a/src/lib/utils/custody.ts b/src/lib/utils/custody.ts new file mode 100644 index 0000000..85a8dd2 --- /dev/null +++ b/src/lib/utils/custody.ts @@ -0,0 +1,37 @@ +import type { Item, CheckOutReason } from '$lib/types'; + +export function buildCheckOutUpdate( + reason: CheckOutReason, + fromLocationId: string, + toLocationId: string | null, + note: string = '', +): Partial { + const now = new Date().toISOString(); + return { + custodyState: 'checked-out', + checkedOutSince: now, + checkedOutReason: reason, + checkedOutFrom: fromLocationId, + checkedOutTo: toLocationId, + checkedOutNote: note, + lastSeenAt: toLocationId || fromLocationId, + lastSeenTimestamp: now, + locationConfidence: 'confirmed', + }; +} + +export function buildCheckInUpdate(returnToLocationId: string): Partial { + const now = new Date().toISOString(); + return { + custodyState: 'checked-in', + checkedOutSince: null, + checkedOutReason: null, + checkedOutFrom: null, + checkedOutTo: null, + checkedOutNote: null, + lastSeenAt: returnToLocationId, + lastSeenTimestamp: now, + locationConfidence: 'confirmed', + supposedToBeAt: returnToLocationId, + }; +}