From 1ef4661d9a7a0b758ac27c0dc4e8b06343d48d81 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Christopher=20M=C3=BChl?= Date: Thu, 26 Feb 2026 15:36:56 +0100 Subject: [PATCH] =?UTF-8?q?feat:=20UI=20components=20=E2=80=94=20ItemCard,?= =?UTF-8?q?=20ItemForm,=20LocationTree,=20LocationPicker?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Includes ConfidenceBadge and CustodyBadge helper components. Dark slate theme with confidence/custody color coding. ItemCard test suite (6 tests). Fixed vitest browser resolve conditions. Co-Authored-By: Claude Opus 4.6 --- src/lib/components/ConfidenceBadge.svelte | 19 ++ src/lib/components/CustodyBadge.svelte | 24 ++ src/lib/components/ItemCard.svelte | 67 +++++ src/lib/components/ItemCard.test.ts | 97 +++++++ src/lib/components/ItemForm.svelte | 292 ++++++++++++++++++++++ src/lib/components/LocationPicker.svelte | 101 ++++++++ src/lib/components/LocationTree.svelte | 92 +++++++ vitest.config.ts | 1 + 8 files changed, 693 insertions(+) create mode 100644 src/lib/components/ConfidenceBadge.svelte create mode 100644 src/lib/components/CustodyBadge.svelte create mode 100644 src/lib/components/ItemCard.svelte create mode 100644 src/lib/components/ItemCard.test.ts create mode 100644 src/lib/components/ItemForm.svelte create mode 100644 src/lib/components/LocationPicker.svelte create mode 100644 src/lib/components/LocationTree.svelte diff --git a/src/lib/components/ConfidenceBadge.svelte b/src/lib/components/ConfidenceBadge.svelte new file mode 100644 index 0000000..d76186e --- /dev/null +++ b/src/lib/components/ConfidenceBadge.svelte @@ -0,0 +1,19 @@ + + + + {c.icon} + {c.label} + diff --git a/src/lib/components/CustodyBadge.svelte b/src/lib/components/CustodyBadge.svelte new file mode 100644 index 0000000..0e1d38d --- /dev/null +++ b/src/lib/components/CustodyBadge.svelte @@ -0,0 +1,24 @@ + + +{#if state === 'checked-in'} + + Home + +{:else} + + {reason ? reasonLabels[reason] ?? 'Out' : 'Out'} + +{/if} diff --git a/src/lib/components/ItemCard.svelte b/src/lib/components/ItemCard.svelte new file mode 100644 index 0000000..bc86c1f --- /dev/null +++ b/src/lib/components/ItemCard.svelte @@ -0,0 +1,67 @@ + + + diff --git a/src/lib/components/ItemCard.test.ts b/src/lib/components/ItemCard.test.ts new file mode 100644 index 0000000..527289d --- /dev/null +++ b/src/lib/components/ItemCard.test.ts @@ -0,0 +1,97 @@ +import { describe, it, expect } from 'vitest'; +import { render, screen } from '@testing-library/svelte'; +import ItemCard from './ItemCard.svelte'; +import type { Item } from '$lib/types'; + +function makeItem(overrides: Partial = {}): Item { + return { + shortId: 'abc2345', + name: 'Test Item', + description: 'A test item', + category: 'electronics', + brand: 'TestBrand', + serialNumber: 'SN123', + color: 'black', + purchaseDate: null, + itemType: 'durable', + currentQuantity: null, + originalQuantity: null, + quantityUnit: null, + lowThreshold: null, + expiryDate: null, + barcodeFormat: 'qr', + barcodeUri: 'https://haus.toph.so/abc2345', + 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, + }; +} + +describe('ItemCard', () => { + it('renders item name', () => { + render(ItemCard, { props: { item: makeItem({ name: 'My Widget' }) } }); + expect(screen.getByText('My Widget')).toBeInTheDocument(); + }); + + it('renders item category', () => { + render(ItemCard, { props: { item: makeItem({ category: 'tools' }) } }); + expect(screen.getByText('tools')).toBeInTheDocument(); + }); + + it('renders shortId', () => { + render(ItemCard, { props: { item: makeItem({ shortId: 'xyz9876' }) } }); + expect(screen.getByText('xyz9876')).toBeInTheDocument(); + }); + + it('shows Home badge for checked-in items', () => { + render(ItemCard, { props: { item: makeItem({ custodyState: 'checked-in' }) } }); + expect(screen.getByText('Home')).toBeInTheDocument(); + }); + + it('shows checkout reason for checked-out items', () => { + render(ItemCard, { + props: { + item: makeItem({ + custodyState: 'checked-out', + checkedOutReason: 'lent', + }), + }, + }); + expect(screen.getByText('Lent')).toBeInTheDocument(); + }); + + it('shows quantity bar for consumable items', () => { + const { container } = render(ItemCard, { + props: { + item: makeItem({ + itemType: 'consumable', + currentQuantity: 30, + originalQuantity: 100, + quantityUnit: 'ml', + }), + }, + }); + expect(screen.getByText(/30 ml/)).toBeInTheDocument(); + expect(screen.getByText('30%')).toBeInTheDocument(); + }); +}); diff --git a/src/lib/components/ItemForm.svelte b/src/lib/components/ItemForm.svelte new file mode 100644 index 0000000..b346d03 --- /dev/null +++ b/src/lib/components/ItemForm.svelte @@ -0,0 +1,292 @@ + + +
+ +
+ + +
+ + +
+ + +
+ + +
+
+ + +
+
+ + +
+
+ + +
+ + +
+ + + {#if showQuantityFields} +
+

Quantity Tracking

+
+
+ + +
+
+ + +
+
+
+
+ + +
+
+ + +
+
+ {#if itemType === 'perishable'} +
+ + +
+ {/if} +
+ {/if} + + +
+
+ + +
+
+ + +
+
+ + +
+ + +
+ + +
+ +
+ {#each storageTiers as tier} + + {/each} +
+
+ + +
+ + +
+ + +
+ {#if oncancel} + + {/if} + +
+
diff --git a/src/lib/components/LocationPicker.svelte b/src/lib/components/LocationPicker.svelte new file mode 100644 index 0000000..2c6225c --- /dev/null +++ b/src/lib/components/LocationPicker.svelte @@ -0,0 +1,101 @@ + + +
+ + + {#if open} +
+
+ +
+ + {#each sortedLocations as loc} + {@const depth = getDepth(loc)} + + {/each} + + {#if sortedLocations.length === 0} +

No locations found

+ {/if} +
+ {/if} +
diff --git a/src/lib/components/LocationTree.svelte b/src/lib/components/LocationTree.svelte new file mode 100644 index 0000000..8b3fe99 --- /dev/null +++ b/src/lib/components/LocationTree.svelte @@ -0,0 +1,92 @@ + + +{#snippet locationNode(location: Location, depth: number)} + {@const children = getChildren(location.id)} + {@const itemCount = getItemCount(location.id)} + {@const isExpanded = expanded.has(location.id)} + {@const isSelected = selectedId === location.id} + +
+ + {:else} + + {/if} + + {location.name} + + {#if itemCount > 0} + {itemCount} + {/if} + +
+ + {#if isExpanded && children.length > 0} + {#each children as child} + {@render locationNode(child, depth + 1)} + {/each} + {/if} +{/snippet} + +
+ {#each roots as root} + {@render locationNode(root, 0)} + {/each} + + {#if roots.length === 0} +

No locations configured

+ {/if} +
diff --git a/vitest.config.ts b/vitest.config.ts index d7d7ba0..14f3d20 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -9,6 +9,7 @@ export default defineConfig({ $lib: path.resolve('./src/lib'), $app: path.resolve('./src/test/mocks'), }, + conditions: ['browser'], }, test: { environment: 'jsdom',