diff --git a/bun.lockb b/bun.lockb
index 109b49c..f0fde90 100755
Binary files a/bun.lockb and b/bun.lockb differ
diff --git a/package.json b/package.json
index 6db9e0d..37c93aa 100644
--- a/package.json
+++ b/package.json
@@ -39,7 +39,7 @@
"globals": "^15.0.0",
"jsdom": "^24.1.0",
"postcss": "^8.4.38",
- "svelte": "^5.0.0-next.166",
+ "svelte": "^5.0.0-next.182",
"svelte-check": "^3.6.0",
"tailwindcss": "^3.4.4",
"tslib": "^2.4.1",
diff --git a/src/lib/components/StoragePlace.svelte b/src/lib/components/StoragePlace.svelte
index 4ae59bf..d4cd26c 100644
--- a/src/lib/components/StoragePlace.svelte
+++ b/src/lib/components/StoragePlace.svelte
@@ -1,78 +1,147 @@
-
{storagePlaceName}
- {#if items} ({items.list.length}) {/if}
-
- {#if items}
- {@const store = items}
-
- {#each items.list as item, i (item.id)}
-
- {
- store.select(i + amount)
- }}
- onDelete={() => {
- store.delete(item.id)
- }}
- onUpdate={(updatedItem: StoredItem) => {
- store.update(item.id, updatedItem)
- }}
- />
- {/each}
+
+
+
+ {storageName}
+
+ ({itemStore.itemCounts[storageName]})
+
+
+
+ sort:
+
- {#if items.list.length === 0}
-
Nothing in the {storagePlaceName}.
- {/if}
- {:else}
-
Loading...
+
+
+
+ {#each itemStore.items[storageName] as item, i (item.id)}
+ - {
+ itemStore.selectItem(storageName, i + amount)
+ }}
+ onDelete={() => {
+ storageOps.deleteItemById(item.id)
+ if (i === itemStore.items[storageName].length)
+ itemStore.selectItem(storageName, i - 1)
+ }}
+ onToggleExpanded={itemStore.toggleExpanded}
+ onUpdate={(updatedItem: StoredItem) => {
+ storageOps.updateItem(updatedItem)
+ }}
+ />
+ {/each}
+
+ {#if itemStore.itemCounts[storageName] === 0}
+ Nothing in the {storageName}.
{/if}
diff --git a/src/lib/components/StoragePlace.test.ts b/src/lib/components/StoragePlace.test.ts
new file mode 100644
index 0000000..2b43f32
--- /dev/null
+++ b/src/lib/components/StoragePlace.test.ts
@@ -0,0 +1,141 @@
+import { type RenderResult, cleanup, fireEvent, render, within } from '@testing-library/svelte'
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
+import StoragePlace from './StoragePlace.svelte'
+import { itemStore } from '$lib/stores/item.svelte'
+import { localDb } from '$lib/db'
+
+vi.mock('$lib/db', () => ({
+ localDb: {
+ storage: {
+ getSort: vi.fn().mockReturnValue('none'),
+ setSort: vi.fn(),
+ rename: vi.fn(),
+ },
+ },
+}))
+
+const mockStorageOperations = {
+ addItem: vi.fn(),
+ deleteItemById: vi.fn(),
+ deleteItemByIndex: vi.fn(),
+ updateItem: vi.fn(),
+ sortItems: vi.fn(),
+ getItemById: vi.fn(),
+ getItemByIndex: vi.fn(),
+ getItemByName: vi.fn(),
+}
+
+vi.mock('$lib/stores/item.svelte', () => {
+ return {
+ itemStore: {
+ items: {},
+ itemCounts: {},
+ selected: {},
+ selectItem: vi.fn(),
+ clearSelected: vi.fn(),
+ updateStorage: vi.fn(),
+ storage: vi.fn(() => mockStorageOperations),
+ },
+ }
+})
+
+describe('storagePlace Component', () => {
+ const mockStorageName = 'TestStorage'
+ let component: RenderResult
+
+ beforeEach(() => {
+ vi.resetAllMocks()
+
+ itemStore.items[mockStorageName] = []
+ itemStore.itemCounts[mockStorageName] = 0
+
+ vi.mocked(itemStore.storage).mockReturnValue({
+ ...mockStorageOperations,
+ })
+
+ component = render(StoragePlace, { props: { storageName: mockStorageName } })
+ })
+
+ afterEach(() => {
+ cleanup()
+ })
+
+ it('renders the storage correctly', () => {
+ const { getByRole, getByText, getByPlaceholderText } = component
+
+ expect(getByText(mockStorageName)).toBeTruthy()
+
+ const sortDropdown = getByRole('listbox')
+ expect(sortDropdown).toBeTruthy()
+
+ const input = getByPlaceholderText('Add a new item...')
+ expect(input).toBeTruthy()
+ })
+
+ it('displays the correct item count and message when empty', () => {
+ const { getByText } = component
+ expect(getByText('(0)')).toBeTruthy()
+ expect(getByText(`Nothing in the ${mockStorageName}.`)).toBeTruthy()
+ })
+
+ it('displays the correct item count when items exist', () => {
+ itemStore.itemCounts[mockStorageName] = 42
+ component = render(StoragePlace, { props: { storageName: mockStorageName } })
+ const { getByText } = component
+ expect(getByText('(42)')).toBeTruthy()
+ })
+
+ it('adds a new item when pressing Enter in the input field', async () => {
+ const { getByPlaceholderText } = component
+ const input = getByPlaceholderText('Add a new item...')
+ await fireEvent.input(input, { target: { value: 'New Item' } })
+ await fireEvent.keyDown(input, { key: 'Enter', code: 13, charCode: 13 })
+ expect(itemStore.storage(mockStorageName).addItem).toHaveBeenCalledWith({ name: 'New Item', quantity: 1 })
+ })
+
+ it('adds a new item with quantity when input format is "2 New Item"', async () => {
+ const { getByPlaceholderText } = component
+ const input = getByPlaceholderText('Add a new item...')
+ await fireEvent.input(input, { target: { value: '2 New Item' } })
+ await fireEvent.keyDown(input, { key: 'Enter', code: 13, charCode: 13 })
+ expect(itemStore.storage(mockStorageName).addItem).toHaveBeenCalledWith({ name: 'New Item', quantity: 2 })
+ })
+
+ it('clears the input field after adding an item', async () => {
+ const { getByPlaceholderText } = component
+ const input = getByPlaceholderText('Add a new item...') as HTMLInputElement
+ await fireEvent.input(input, { target: { value: 'New Item' } })
+ await fireEvent.keyDown(input, { key: 'Enter', code: 13, charCode: 13 })
+ expect(input.value).toBe('')
+ })
+
+ it('changes the sort option when selecting from the dropdown', async () => {
+ const { getByRole } = component
+ const sortDropdown = getByRole('listbox')
+ await fireEvent.change(sortDropdown, { target: { value: 'newest' } })
+ expect(localDb.storage.setSort).toHaveBeenCalledWith(mockStorageName, 'newest')
+ expect(itemStore.storage(mockStorageName).sortItems).toHaveBeenCalledWith('newest')
+ })
+
+ it('renders items when they exist in the store', async () => {
+ cleanup()
+ itemStore.items[mockStorageName] = [
+ { id: '1', name: 'Item 1', quantity: 1, dateAdded: '2024-01-01', shelfLife: 1, storage: mockStorageName },
+ { id: '2', name: 'Item 2', quantity: 1, dateAdded: '2024-01-02', shelfLife: 1, storage: mockStorageName },
+ ]
+ itemStore.itemCounts[mockStorageName] = 2
+ component = render(StoragePlace, { props: { storageName: mockStorageName } })
+ const { getByText } = component
+ expect(getByText('Item 1')).toBeTruthy()
+ expect(getByText('Item 2')).toBeTruthy()
+ })
+
+ it('allows editing the storage name', async () => {
+ const { getByLabelText } = component
+ const nameElement = getByLabelText('storage name')
+ await fireEvent.input(nameElement, { target: { textContent: 'New Storage Name' } })
+ await fireEvent.keyDown(nameElement, { key: 'Enter', code: 'Enter' })
+ expect(itemStore.updateStorage).toHaveBeenCalledWith(mockStorageName, 'New Storage Name')
+ expect(localDb.storage.rename).toHaveBeenCalledWith(mockStorageName, 'New Storage Name')
+ })
+})
diff --git a/src/lib/components/item/Item.svelte b/src/lib/components/item/Item.svelte
index 17145a5..5e7ae60 100644
--- a/src/lib/components/item/Item.svelte
+++ b/src/lib/components/item/Item.svelte
@@ -8,12 +8,14 @@
export type Props = {
item: StoredItem
isSelected: boolean
+ isExpanded: boolean
}
export type Events = {
onSelected: (index?: number) => void
onDelete: () => void
onUpdate: (item: StoredItem) => void
+ onToggleExpanded: () => void
}
@@ -21,13 +23,14 @@
const {
item = $bindable(),
isSelected,
+ isExpanded,
onSelected,
onDelete,
onUpdate,
+ onToggleExpanded,
}: Props & Events = $props()
// State
- let isExpanded = $state(false)
let isEditingName = $state(false)
let draftName = $state(item.name)
let draftShelfLife = $state(item.shelfLife)
@@ -36,10 +39,8 @@
// DOM nodes
let itemDiv: HTMLDivElement
let itemNameInput: HTMLSpanElement
- let itemQuantityInput: HTMLInputElement
let childrenDiv: HTMLDivElement
let dateAddedInput: HTMLInputElement
- let shelfLifeInput: HTMLInputElement
// Reactive declarations
const daysTilSpoil = $derived(
@@ -47,10 +48,8 @@
)
$effect(() => {
- if (isSelected)
- itemDiv.focus()
- else
- isExpanded = false
+ if (document.activeElement === itemDiv)
+ onSelected()
})
// Dayjs configuration
@@ -145,36 +144,39 @@
}
function handleKeyDownOnItem(event: KeyboardEvent) {
- if (event.target instanceof HTMLInputElement || event.target instanceof HTMLTextAreaElement)
- return
+ // if (event.target instanceof HTMLInputElement || event.target instanceof HTMLTextAreaElement)
+ // return
- if (event.key === 'Delete' || event.key === 'Backspace') {
- if (isExpanded) {
- isExpanded = false
- setTimeout(() => onDelete(), 70)
- }
- else {
- onDelete()
- }
- }
- else if (event.key === 'Enter') {
- isExpanded = !isExpanded
- }
- else if (event.key === 'ArrowUp') {
- onSelected(-1)
- }
- else if (event.key === 'ArrowDown') {
- onSelected(1)
- }
- else if (event.key === 'ArrowRight') {
- changeQuantity(item.quantity + 1)
- }
- else if (event.key === 'ArrowLeft') {
- changeQuantity(item.quantity - 1)
- }
- else {
- console.log(event)
- }
+ // if (event.key === 'Delete' || event.key === 'Backspace') {
+ // if (isExpanded) {
+ // isExpanded = false
+ // setTimeout(() => onDelete(), 70)
+ // }
+ // else {
+ // onDelete()
+ // }
+ // }
+ // else if (event.key === 'Enter') {
+ // isExpanded = !isExpanded
+ // }
+ // else if (event.key === 'ArrowUp') {
+ // onSelected(-1)
+ // }
+ // else if (event.key === 'ArrowDown') {
+ // onSelected(1)
+ // }
+ // else if (event.key === 'ArrowRight') {
+ // changeQuantity(item.quantity + 1)
+ // }
+ // else if (event.key === 'ArrowLeft') {
+ // changeQuantity(item.quantity - 1)
+ // }
+ // else {
+ console.log(event)
+ // }
+
+ // if (['ArrowUp', 'ArrowDown', 'ArrowRight', 'ArrowLeft'].includes(event.key))
+ // event.preventDefault()
}
function handleDateAddedKeydown(event: KeyboardEvent) {
@@ -204,10 +206,10 @@
bind:this={itemDiv}
id={item.id}
data-testid="item-{item.id}"
- class="transition select-none rounded-sm px-2 pt-[0.5px] focus:outline-none
+ class="transition select-none rounded-sm px-2 pt-[0.5px]
{isSelected ? 'bg-yellow-200' : ''}
{isExpanded ? 'pb-2' : ''}"
- tabindex="-1"
+ tabindex="0"
role="treeitem"
aria-selected={isSelected}
aria-expanded={isExpanded}
@@ -218,9 +220,7 @@
if (event.target !== itemNameInput)
itemNameInput.blur()
}}
- ondblclick={() => {
- isExpanded = !isExpanded
- }}
+ ondblclick={() => onToggleExpanded()}
onblur={() => {
draftName = item?.name
isEditingName = false
@@ -229,7 +229,6 @@
{dayjs(item.dateAdded).fromNow()}
@@ -298,12 +298,12 @@
onkeydown={stopPropagation(handleDateAddedKeydown)}
ondblclick={stopPropagation()}
onblur={changeDate}
+ tabindex={isExpanded ? 0 : -1}
/>
Edit shelf life:
days
diff --git a/src/lib/components/item/Item.test.ts b/src/lib/components/item/Item.test.ts
index b105fcb..5f0647a 100644
--- a/src/lib/components/item/Item.test.ts
+++ b/src/lib/components/item/Item.test.ts
@@ -30,6 +30,8 @@ describe('the Item component', () => {
item: mockItem,
onDelete: mockDeleteItem,
isSelected: false,
+ isExpanded: false,
+ onToggleExpanded: vi.fn(),
onSelected: vi.fn(),
onUpdate: vi.fn(),
},
@@ -43,42 +45,10 @@ describe('the Item component', () => {
})
it('renders correctly', () => {
- const { getByText, getByDisplayValue } = component
+ const { getByText, getByDisplayValue, getByTitle } = component
expect(getByText(/Test Item/)).toBeTruthy()
expect(getByDisplayValue('2')).toBeTruthy()
- expect(getByText('delete')).toBeTruthy()
- })
-
- it('increases quantity on right arrow key press', async () => {
- const { getByTestId } = component
-
- const itemElement = getByTestId('item-1')
- await fireEvent.keyDown(itemElement, { key: 'ArrowRight' })
- expect(mockItem.quantity).toBe(3)
- })
-
- it('decreases quantity on left arrow key press', async () => {
- const { getByTestId } = component
-
- const itemElement = getByTestId('item-1')
- await fireEvent.keyDown(itemElement, { key: 'ArrowLeft' })
- expect(mockItem.quantity).toBe(1)
- })
-
- it('calls deleteItem when left arrow key is pressed and quantity becomes 0', async () => {
- const { getByTestId } = component
-
- const itemElement = getByTestId('item-1')
- await fireEvent.keyDown(itemElement, { key: 'ArrowLeft' })
- await fireEvent.keyDown(itemElement, { key: 'ArrowLeft' })
- expect(mockDeleteItem).toHaveBeenCalledOnce()
- })
-
- it('calls deleteItem when delete button is clicked', async () => {
- const { getByText } = component
-
- await fireEvent.click(getByText('delete'))
- expect(mockDeleteItem).toHaveBeenCalledOnce()
+ expect(getByTitle('delete')).toBeTruthy()
})
})
diff --git a/src/lib/db.ts b/src/lib/db.ts
index fd5b386..fb758bc 100644
--- a/src/lib/db.ts
+++ b/src/lib/db.ts
@@ -1,13 +1,5 @@
import Dexie, { type Table } from 'dexie'
-
-export interface StoredItem {
- id: string
- name: string
- quantity: number
- dateAdded: string
- storage: string
- shelfLife: number
-}
+import type { SortBy, StoredItem } from './types'
export interface CommonItem {
id: string
@@ -37,4 +29,57 @@ class FoodInventoryDatabase extends Dexie {
}
}
+type SortStorage = {
+ storage: string
+ sortBy: SortBy
+}[]
+
+class LocalStorageDatabase {
+ private getTable
(tableName: string): T {
+ const tableJson = localStorage.getItem(tableName)
+ return tableJson ? JSON.parse(tableJson) : []
+ }
+
+ private setTable(tableName: string, data: T) {
+ localStorage.setItem(tableName, JSON.stringify(data))
+ }
+
+ constructor() {
+ if (!localStorage.getItem('sortOptions'))
+ this.setTable('sortOptions', [])
+ }
+
+ public storage = {
+ add: (storage: string) => {
+ const sortOptions = this.getTable('sortOptions')
+ sortOptions.push({ storage, sortBy: 'none' })
+ this.setTable('sortOptions', sortOptions)
+ },
+ getSort: (storage: string) => {
+ const sortOptions = this.getTable('sortOptions')
+ const storageSort = sortOptions.find(sort => sort.storage === storage)
+ return storageSort ? storageSort.sortBy : 'none'
+ },
+ setSort: (storage: string, sortBy: SortBy) => {
+ const sortOptions = this.getTable('sortOptions')
+ const storageSort = sortOptions.find(sort => sort.storage === storage)
+ if (storageSort)
+ storageSort.sortBy = sortBy
+ else
+ sortOptions.push({ storage, sortBy })
+
+ this.setTable('sortOptions', sortOptions)
+ },
+ rename: (oldName: string, newName: string) => {
+ const sortOptions = this.getTable('sortOptions')
+ const storageSort = sortOptions.find(sort => sort.storage === oldName)
+ if (storageSort)
+ storageSort.storage = newName
+
+ this.setTable('sortOptions', sortOptions)
+ },
+ }
+}
+
export const db = new FoodInventoryDatabase()
+export const localDb = new LocalStorageDatabase()
diff --git a/src/lib/imageUtils.ts b/src/lib/imageUtils.ts
deleted file mode 100644
index 1ea2647..0000000
--- a/src/lib/imageUtils.ts
+++ /dev/null
@@ -1,14 +0,0 @@
-export async function encodeImage(file: File): Promise {
- return new Promise((resolve, reject) => {
- const reader = new FileReader();
-
- reader.onload = () => {
- const base64String = reader.result?.toString().split(',')[1] || '';
- resolve(base64String);
- };
-
- reader.onerror = (error) => reject(error);
-
- reader.readAsDataURL(file);
- });
-}
diff --git a/src/lib/prompt.ts b/src/lib/prompt.ts
new file mode 100644
index 0000000..8a78328
--- /dev/null
+++ b/src/lib/prompt.ts
@@ -0,0 +1,99 @@
+import dayjs from 'dayjs'
+
+const todaysDateISOFormat = dayjs().format('YYYY-MM-DD')
+
+export function getPromptText(existingInventory: string) {
+ return `# System Message
+You are an advanced AI assistant specializing in optical character recognition, data structuring, and inventory management. Your task is to analyze images of grocery receipts and convert it into structured JSON data. Each object should follow this structure:
+
+{
+ "name": string,
+ "quantity": number,
+ "dateAdded": string (YYYY-MM-DD format),
+ "shelfLife": number (estimated days until expiration),
+ "storage": string (either "fridge", "freezer", or "pantry")
+}
+
+# Detailed Instructions
+1. Image Analysis
+ - Carefully examine the receipt image for the names of food items that would typically be stored in a refrigerator, freezer, or pantry.
+ - If the image quality is poor or text is unclear, make reasonable assumptions.
+ - If the image is not a receipt or no relevant items are found, return an empty "items" list.
+
+2. JSON Object Creation
+ For each identified item, determine the following:
+ a. Name:
+ - Use the commonly-used name of the item in all lowercase.
+ - If an item name is misspelled or abbreviated, make reasonable guesses (e.g., "SUMMR SQUASH" is likely "summer squash").
+ - If the new item roughly matches a name in the existing inventory list for its storage place, use the exact string from the list.
+ Example: If a new item would be "eggs" and it would go in the fridge, and the existing inventory for fridge contains the string "egg", use "egg" as the item name. If the existing inventory contained the string "eggs", use "eggs".
+
+ b. Quantity:
+ - Set based on the information provided, defaulting to 1 if not specified. Use only whole numbers.
+
+ c. Date Added:
+ - Use today's date (${todaysDateISOFormat}) for all new items.
+
+ d. Shelf Life:
+ - Estimate a reasonable shelf life in days based on common knowledge of food preservation.
+
+ e. Storage:
+ - Assign the appropriate storage location ("fridge", "freezer", or "pantry").
+ - If an item doesn't clearly fit into these categories, use your best judgment and explain your reasoning.
+
+3. Output Format
+ - Return a JSON object containing the following:
+ - "items" (required), which is a list of the properly formatted JSON objects for each food item identified in the receipt.
+ - "note" (optional), which is an brief explanatory note of any ambiguities you encountered during the process: if any items on the receipt couldn't be clearly identified, or if the storage location for an item was unclear.
+
+# Example
+
+Today's date: 2024-07-20
+Receipt items: 2 milk, 1 potato, 1 ice cream
+Existing inventory:
+{
+ "fridge": ["milk", "eggs"],
+ "freezer": ["frozen peas"]
+}
+
+Output:
+{
+ "items": [
+ {
+ "name": "milk",
+ "quantity": 2,
+ "dateAdded": "2024-07-20",
+ "shelfLife": 7,
+ "storage": "fridge"
+ },
+ {
+ "name": "potato",
+ "quantity": 1,
+ "dateAdded": "2024-07-20",
+ "shelfLife": 30,
+ "storage": "pantry"
+ },
+ {
+ "name": "ice cream",
+ "quantity": 1,
+ "dateAdded": "2024-07-20",
+ "shelfLife": 90,
+ "storage": "freezer"
+ }
+ ]
+}
+
+# Summary of Steps
+1. Examine the receipt image carefully.
+2. Identify relevant food items.
+3. Create JSON objects for each item, referring to the existing inventory for naming consistency.
+4. Compile all items into the final JSON output format.
+5. Review the output for consistency and completeness.
+
+Please work step by step and process the provided receipt image, using the existing inventory to guide naming:
+
+Existing inventory:
+${existingInventory}
+
+Then output the new items as a JSON object.`
+}
diff --git a/src/lib/promptText.ts b/src/lib/promptText.ts
deleted file mode 100644
index b80e4f4..0000000
--- a/src/lib/promptText.ts
+++ /dev/null
@@ -1,91 +0,0 @@
-import dayjs from 'dayjs'
-
-const todaysDateISOFormat = dayjs().format('YYYY-MM-DD')
-
-export function getPromptText(existingInventory: string) {
- return `You are an advanced AI assistant specializing in optical character recognition, data structuring, and inventory management. Your task is to analyze an image of a grocery receipt, convert the relevant information into a list of JSON objects, and merge this with an existing fridge inventory. Each object should represent a perishable food item and follow this structure:
-
- {
- id: string,
- name: string,
- quantity: number,
- dateAdded: string (YYYY-MM-DD format),
- shelfLife: number (estimated days until expiration)
- }
-
- Instructions:
- 1. Examine the receipt image carefully, focusing on item names and quantities.
- 2. Identify perishable food items that would typically be stored in a refrigerator.
- 3. For each item, create a JSON object with the specified structure.
- 4. From the item name that appears on the receipt, use the commonly-used name of the item in all lowercase. For example, if the receipt reads "ZUCHINNI GREEN", use "zucchini". If the receipt reads "PEAS SNOW", use "snow peas".
- 5. Set the quantity based on the information provided, defaulting to 1 if not specified.
- 6. Estimate a reasonable shelfLife in days for each item based on common knowledge of food preservation.
- 7. Exclude non-perishable items or those not typically refrigerated.
- 8. Use today's date (${todaysDateISOFormat}) for the dateAdded field for new items.
- 9. Merge the new items with the existing inventory using these rules:
- - If an item from the receipt already exists in the inventory:
- 1. Create a JSON object with the existing item's id.
- 2. Use the oldest dateAdded.
- 3. If shelfLife differs, use the shorter one to be conservative.
- - If an item from the receipt does not exist in the inventory, use the string "to be generated" for the id.
- 10. Present the results as a JSON object containing one property "items", which is a list of properly formatted JSON objects representing the updated inventory.
-
- Additional notes:
- - Try to preserve the order of items as they appear on the receipt.
- - If the image quality is poor or text is unclear, make reasonable assumptions.
- - If the item name is misspelled or abbreviated, make reasonable guesses. For example: "SUMMR SQUASH" is likely to be "summer squash".
- - If the image is not a receipt or no relevant items are found, return the original inventory unchanged.
-
- Example input:
- Existing inventory:
- [
- {
- "id" : "550e8400-e29b-41d4-a716-446655440000",
- "name": "milk",
- "quantity": 1,
- "dateAdded": "2024-06-25",
- "shelfLife": 7
- },
- {
- "id": "6ba7b810-9dad-11d1-80b4-00c04fd430c8",
- "name": "zucchini",
- "quantity": 2,
- "dateAdded": "2024-06-27",
- "shelfLife": 7
- }
- ]
-
-
- Example output (assuming receipt adds 2 milk, 1 zucchini and 1 carrot):
- {
- "items": [
- {
- "id" : "550e8400-e29b-41d4-a716-446655440000",
- "name": "milk",
- "quantity": 2,
- "dateAdded": "2024-06-25",
- "shelfLife": 7
- },
- {
- "id": "6ba7b810-9dad-11d1-80b4-00c04fd430c8",
- "name": "zucchini",
- "quantity": 1,
- "dateAdded": "2024-06-27",
- "shelfLife": 7
- },
- {
- "id": "to be generated",
- "name": "carrot",
- "quantity": 1,
- "dateAdded": "${todaysDateISOFormat}",
- "shelfLife": 7
- }
- ]
- }
-
- Please process the provided receipt image, generate the appropriate JSON objects for new items, and merge them with the existing inventory provided below:
-
- ${existingInventory}
-
- Then provide the updated inventory as a JSON object.`
-}
diff --git a/src/lib/stores/item.svelte.ts b/src/lib/stores/item.svelte.ts
index b85274a..45d9b95 100644
--- a/src/lib/stores/item.svelte.ts
+++ b/src/lib/stores/item.svelte.ts
@@ -1,86 +1,247 @@
import { v7 as uuid } from 'uuid'
import dayjs from 'dayjs'
-import { possibleItems } from '$lib/components/item/itemGenerator'
-import type { StoredItem } from '$lib/types'
+import type { SortBy, StoredItem } from '$lib/types'
import { db } from '$lib/db'
+type AddItem = Pick & Partial>
+type UpdateItem = Pick & Partial>
+
export interface ItemStore {
- readonly list: StoredItem[]
- add: (name: string) => void
- delete: (id: string) => void
- readonly selected: number
- select: (i: number) => void
- update: (id: string, item: StoredItem) => void
- importItem: (item: StoredItem) => void
+ items: Record
+ storages: string[]
+ itemCounts: Record
+ storageCount: number
+ selected: { storage: string, index: number }
+ expanded: boolean
+ addStorage: (storage: string) => Promise
+ removeStorage: (storage: string) => Promise
+ updateStorage: (storage: string, newStorage: string) => Promise
+ moveItem: (fromStorage: string, toStorage: string, id: string) => Promise
+ selectItem: (storage: string, index: number) => void
+ clearSelected: () => void
+ deleteSelected: () => void
+ decrementSelectedQuantity: () => void
+ incrementSelectedQuantity: () => void
+ toggleExpanded: () => void
+ storage: (storageName: string) => StorageOperations
}
-export async function createItemStore(storagePlaceName: string) {
- const items = await db.foodItems.where('storage').equals(storagePlaceName).toArray()
+interface StorageOperations {
+ getItemById: (id: string) => StoredItem | undefined
+ getItemByIndex: (i: number) => StoredItem | undefined
+ getItemByName: (name: string) => StoredItem | undefined
+ addItem: (item: AddItem) => Promise
+ deleteItemById: (id: string) => Promise
+ deleteItemByIndex: (index: number) => Promise
+ updateItem: (item: UpdateItem) => Promise
+ sortItems: (sortBy: SortBy) => void
+}
- const list = $state(items)
- let selected = $state(-1)
+export const itemStore = createItemStore()
- return {
- get list() {
- return list
- },
- add(input: string) {
- if (input === '')
- return
+function createItemStore(): ItemStore {
+ const items = $state>({})
+ const storages = $derived(Object.keys(items))
+ const itemCounts = $derived.by(() => {
+ const counts: Record = {}
+ for (const storage of storages)
+ counts[storage] = items[storage]?.length ?? 0
+ return counts
+ })
+ const storageCount = $derived(storages.length)
+ let selected = $state<{ storage: string, index: number }>({ storage: '', index: -1 })
+ let expanded = $state(false)
- let name = input
- let quantity = 1
+ async function loadInitialData() {
+ const storedItems = await db.foodItems.toArray()
- const itemList = input.split(' ')
- if (itemList.length > 1 && itemList[0].match(/^\d+$/)) {
- name = input.slice(itemList[0].length).trim()
- quantity = Math.min(Number(itemList[0]), 99)
- }
+ storedItems.forEach((item) => {
+ if (!items[item.storage])
+ items[item.storage] = []
- const newItem: StoredItem = {
- id: uuid(), // Generate UUID here
- name,
- quantity,
- dateAdded: dayjs().format('YYYY-MM-DD'),
- shelfLife: 5,
- storage: storagePlaceName,
- }
+ items[item.storage].push(item)
+ })
+ }
- db.foodItems.add(newItem)
- list.push(newItem)
- selected = list.length
- },
- importItem(item: StoredItem) {
- if (item.storage !== storagePlaceName)
- throw new Error(`Imported item's storage ${item.storage} did not match destination ${storagePlaceName}`)
+ loadInitialData()
- db.foodItems.add(item)
- list.push(item)
- selected = list.length
+ const store: ItemStore = {
+ get items() { return items },
+ get storages() { return storages },
+ get itemCounts() { return itemCounts },
+ get storageCount() { return storageCount },
+ get selected() { return selected },
+ get expanded() { return expanded },
+ async addStorage(storage: string) {
+ if (!storages.includes(storage)) {
+ storages.push(storage)
+ items[storage] = []
+ }
},
- delete(id: string) {
- db.foodItems.delete(id)
-
- const index = list.findIndex(item => item.id === id)
+ async removeStorage(storage: string) {
+ const index = storages.indexOf(storage)
if (index !== -1) {
- list.splice(index, 1)
- if (selected >= list.length)
- selected = Math.max(0, list.length - 1)
+ storages.splice(index, 1)
+ delete items[storage]
+ await db.foodItems.where('storage').equals(storage).delete()
+ }
+ },
+ async updateStorage(storage: string, newStorage: string) {
+ const storageItems = items[storage]
+ for (const item of storageItems) {
+ item.storage = newStorage
+ await db.foodItems.update(item.id, item)
+ }
+ items[newStorage] = items[storage]
+ delete items[storage]
+ },
+ async moveItem(fromStorage: string, toStorage: string, id: string) {
+ const item = this.storage(fromStorage).getItemById(id)
+ if (item) {
+ await this.storage(fromStorage).deleteItemById(id)
+ item.storage = toStorage
+ await this.storage(toStorage).addItem(item)
+ }
+ },
+ selectItem(storage: string, index: number) {
+ if (selected.storage !== storage || selected.index !== index)
+ expanded = false
+
+ const storageIndex = storages.indexOf(storage)
+ if (index < 0) {
+ const newStorageIndex = storageIndex - 1 < 0 ? storages.length - 1 : storageIndex - 1
+ const newStorage = storages[newStorageIndex]
+ selected = { storage: newStorage, index: items[newStorage].length - 1 }
+ }
+ else if (index >= items[storage].length) {
+ const newStorageIndex = (storageIndex + 1) % storages.length
+ const newStorage = storages[newStorageIndex]
+ selected = { storage: newStorage, index: 0 }
+ }
+ else {
+ selected = { storage, index }
}
},
- update(id: string, item: StoredItem) {
- db.foodItems.update(item.id, item)
+ toggleExpanded() {
+ expanded = !expanded
},
- get selected() {
- return selected
+ clearSelected() {
+ selected = { storage: '', index: -1 }
},
- select(i: number) {
- if (i < 0)
- selected = 0
- else if (i >= list.length)
- selected = list.length - 1
- else
- selected = i
+ deleteSelected() {
+ this.storage(itemStore.selected.storage).deleteItemByIndex(itemStore.selected.index)
+ },
+ decrementSelectedQuantity() {
+ const item = this.storage(itemStore.selected.storage).getItemByIndex(itemStore.selected.index)
+ if (item) {
+ const currentQuantity = item.quantity
+ this.storage(itemStore.selected.storage).updateItem({ ...item, quantity: currentQuantity - 1 })
+ }
+ },
+ incrementSelectedQuantity() {
+ const item = this.storage(itemStore.selected.storage).getItemByIndex(itemStore.selected.index)
+ if (item) {
+ const currentQuantity = item.quantity
+ this.storage(itemStore.selected.storage).updateItem({ ...item, quantity: currentQuantity + 1 })
+ }
+ },
+ storage(storageName: string): StorageOperations {
+ return {
+ getItemById: (id: string) => items[storageName]?.find(item => item.id === id),
+ getItemByIndex: (i: number) => items[storageName][i],
+ getItemByName: (name: string) => items[storageName]?.find(item => item.name === name),
+ async addItem(item: AddItem) {
+ if (!storages.includes(storageName))
+ await store.addStorage(storageName)
+
+ const existing = this.getItemByName(item.name)
+ if (existing) {
+ existing.quantity = Math.min(99, existing.quantity + (item.quantity ?? 0))
+ await this.updateItem(existing)
+ }
+ else {
+ const newItem: StoredItem = {
+ ...item,
+ id: uuid(),
+ quantity: Math.min(99, item.quantity ?? 1),
+ dateAdded: item.dateAdded ?? dayjs().format('YYYY-MM-DD'),
+ shelfLife: item.shelfLife ?? 5,
+ storage: storageName,
+ }
+
+ await db.foodItems.add(newItem)
+ items[storageName].push(newItem)
+ }
+ },
+ async deleteItemById(id: string) {
+ if (items[storageName]) {
+ const index = items[storageName].findIndex(item => item.id === id)
+ if (index === -1)
+ return
+
+ items[storageName].splice(index, 1)
+
+ if (items[storageName].length === 0)
+ items[storageName] = []
+
+ await db.foodItems.delete(id)
+ }
+ },
+ async deleteItemByIndex(index: number) {
+ if (!items[storageName])
+ return
+
+ const deletedItem = items[storageName].splice(index, 1)[0]
+
+ if (items[storageName].length === 0)
+ items[storageName] = []
+
+ await db.foodItems.delete(deletedItem.id)
+ },
+ async updateItem(item: UpdateItem) {
+ if (items[storageName]) {
+ const existing = this.getItemById(item.id)
+ if (existing) {
+ Object.assign(existing, item)
+ await db.foodItems.update(item.id, item)
+ if (item.quantity === 0)
+ setTimeout(() => this.deleteItemById(item.id), 400)
+ }
+ }
+ },
+ sortItems(sortBy: SortBy) {
+ if (items[storageName]) {
+ let selectedId: string | undefined
+
+ if (selected.index !== -1)
+ selectedId = items[storageName][selected.index]?.id
+
+ items[storageName].sort((a, b) => {
+ switch (sortBy) {
+ case 'a to z':
+ return a.name.localeCompare(b.name)
+ case 'z to a':
+ return b.name.localeCompare(a.name)
+ case 'quantity':
+ return b.quantity - a.quantity
+ case 'oldest':
+ return new Date(a.dateAdded).getTime() - new Date(b.dateAdded).getTime()
+ case 'newest':
+ return new Date(b.dateAdded).getTime() - new Date(a.dateAdded).getTime()
+ default:
+ return 0
+ }
+ })
+
+ if (selected.index !== -1) {
+ const selectedIndex = items[storageName].findIndex(item => item.id === selectedId)
+ selected = { storage: storageName, index: selectedIndex }
+ }
+ }
+ },
+ }
},
}
+
+ return store
}
diff --git a/src/lib/types.ts b/src/lib/types.ts
index 091b7ea..e9769b7 100644
--- a/src/lib/types.ts
+++ b/src/lib/types.ts
@@ -6,3 +6,14 @@ export interface StoredItem {
shelfLife: number
storage: string
}
+
+export const sortBy = [
+ 'oldest',
+ 'newest',
+ 'a to z',
+ 'z to a',
+ 'quantity',
+ 'none',
+] as const
+
+export type SortBy = typeof sortBy[number]
diff --git a/src/lib/utils.ts b/src/lib/utils.ts
index 331de95..3995f6a 100644
--- a/src/lib/utils.ts
+++ b/src/lib/utils.ts
@@ -1,8 +1,28 @@
+// #region Misc
export function randomIntFromInterval(min: number, max: number) {
// min and max included
return Math.floor(Math.random() * (max - min + 1) + min)
}
+// #endregion
+// #region Image
+export async function encodeImage(file: File): Promise {
+ return new Promise((resolve, reject) => {
+ const reader = new FileReader()
+
+ reader.onload = () => {
+ const base64String = reader.result?.toString().split(',')[1] || ''
+ resolve(base64String)
+ }
+
+ reader.onerror = error => reject(error)
+
+ reader.readAsDataURL(file)
+ })
+}
+// #endregion
+
+// #region Svelte modifiers
export function once(fn?: (event: T) => void) {
return function (event: T) {
if (fn)
@@ -26,3 +46,4 @@ export function stopPropagation(fn?: (event: T) => void) {
fn(event)
}
}
+// #endregion
diff --git a/src/routes/+page.svelte b/src/routes/+page.svelte
index cacd2ab..f1b5739 100644
--- a/src/routes/+page.svelte
+++ b/src/routes/+page.svelte
@@ -3,39 +3,34 @@
import type { StoredItem } from '$lib/types'
import { getRandomItems, possibleItems } from '$lib/components/item/itemGenerator'
import StoragePlace from '$lib/components/StoragePlace.svelte'
- import { type ItemStore, createItemStore } from '$lib/stores/item.svelte'
- import { encodeImage } from '$lib/imageUtils'
- import { getPromptText } from '$lib/promptText'
+ import { encodeImage } from '$lib/utils'
+ import { getPromptText } from '$lib/prompt'
+ import { itemStore } from '$lib/stores/item.svelte'
+ // State
let showTips = $state(false)
let tips: HTMLDivElement | null = $state(null)
-
- let fridgeItems: ItemStore | null = $state(null)
- let freezerItems: ItemStore | null = $state(null)
-
let numRandomItemsToAdd = $state(3)
- let selectedStoragePlace = $state('fridge')
+ let selectedStoragePlace = $state(itemStore.storages[0])
let openAiKey = $state('')
let isOpenAiKeyVisible = $state(false)
- $effect(() => {
- createItemStore('fridge').then(
- (itemStore) => {
- fridgeItems = itemStore
- },
- )
-
- createItemStore('freezer').then(
- (itemStore) => {
- freezerItems = itemStore
- },
- )
+ // Reactive declarations
+ const randomItemList = $derived(getRandomItems(
+ possibleItems.filter(
+ item => item.storage === selectedStoragePlace,
+ ),
+ numRandomItemsToAdd,
+ numRandomItemsToAdd,
+ ))
- // TODO: fix broken accordion on resize
+ $effect(() => {
+ document.addEventListener('keydown', handleKeyDown)
if (tips)
tips.style.height = showTips ? `${tips.scrollHeight + 1}px` : '0px'
})
+ // GPT API
let selectedFile: File | null = null
let isProcessingReceipt = $state(false)
let errorProcessingReceipt = $state(false)
@@ -49,7 +44,11 @@
'Authorization': `Bearer ${api_key}`,
}
- const existingInventory = JSON.stringify(fridgeItems?.list)
+ const existingInventory: Record = {}
+ for (const storageName of itemStore.storages)
+ existingInventory[storageName] = itemStore.items[storageName].map(item => item.name)
+
+ console.log(existingInventory)
const payload = {
model: 'gpt-4o',
response_format: { type: 'json_object' },
@@ -59,7 +58,7 @@
content: [
{
type: 'text',
- text: getPromptText(existingInventory),
+ text: getPromptText(JSON.stringify(existingInventory)),
},
{
type: 'image_url',
@@ -78,13 +77,17 @@
body: JSON.stringify(payload),
})
- if (!response.ok)
- throw new Error(`OpenAI API request failed: ${response.status} ${response.statusText}`)
+ if (!response.ok) {
+ throw new Error(
+ `OpenAI API request failed: ${response.status} ${response.statusText}`,
+ )
+ }
console.log(response)
return response.json()
}
+ // Handlers
function handleFileSelect(event: Event) {
const target = event.target as HTMLInputElement
const files = target.files
@@ -121,7 +124,7 @@
const json_items = json_content.items
console.log(json_items)
const itemsWithIdsAdded = json_items.map(
- (extractedItem: { name: string, quantity: number, shelfLife: number }) => {
+ (extractedItem: StoredItem) => {
console.log('processing', JSON.stringify(extractedItem))
const processedItem = JSON.parse(JSON.stringify(extractedItem))
if (processedItem.id === 'to be generated') {
@@ -131,13 +134,16 @@
else {
console.log('existing item', processedItem)
}
- processedItem.storage = 'fridge'
+ // processedItem.storage = 'fridge'
+ console.log('processed', processedItem)
+ console.log('processed storage', processedItem.storage)
return processedItem
},
- )
+ ) as StoredItem[]
- console.log(itemsWithIdsAdded)
- itemsWithIdsAdded.map((item: StoredItem) => fridgeItems?.importItem(item))
+ itemsWithIdsAdded.map(async (item: StoredItem) =>
+ await itemStore.storage(item.storage).addItem(item),
+ )
}
catch (e) {
console.log('darn couldn\'t parse', e)
@@ -154,36 +160,77 @@
}
}
+ function handleKeyDown(event: KeyboardEvent) {
+ if (event.code?.startsWith('Digit') && event.altKey) {
+ const digit = Number.parseInt(event.code.slice(-1))
+ console.log(itemStore.storages.length, itemStore.items[itemStore.storages[digit - 1]])
+ if (itemStore.storages.length >= digit && itemStore.items[itemStore.storages[digit - 1]].length > 0)
+ itemStore.selectItem(itemStore.storages[digit - 1], 0)
+ }
+ else if (event.key === 'Enter') {
+ itemStore.toggleExpanded()
+ }
+ else if (event.key === 'ArrowUp') {
+ itemStore.selectItem(itemStore.selected.storage, itemStore.selected.index - 1)
+ }
+ else if (event.key === 'ArrowDown') {
+ itemStore.selectItem(itemStore.selected.storage, itemStore.selected.index + 1)
+ }
+ else if (event.key === 'Backspace') {
+ itemStore.storage(itemStore.selected.storage).deleteItemByIndex(itemStore.selected.index)
+ }
+ else if (event.key === 'ArrowRight') {
+ itemStore.incrementSelectedQuantity()
+ }
+ else if (event.key === 'ArrowLeft') {
+ itemStore.decrementSelectedQuantity()
+ }
+ }
+
+ // Methods
+ function addStorage() {
+ itemStore.addStorage(`storage ${itemStore.storages.length + 1}`)
+ }
-
-
-
-
+
+ {#each itemStore.storages as storageName}
+
+ {/each}
+
@@ -221,7 +285,8 @@
()
@@ -230,7 +295,7 @@
bind:this={tips}
class="text-sm overflow-hidden text-stone-500 transition-all"
>
-
This is a demo. Nothing is saved on a server.
+
This is a demo. Nothing is saved on a server.
Click on an item to select it.
Double click or press
diff --git a/tailwind.config.js b/tailwind.config.js
index 7a50f25..4d8fc39 100644
--- a/tailwind.config.js
+++ b/tailwind.config.js
@@ -23,7 +23,10 @@ export default {
customise: (content, name, prefix) => {
switch (prefix) {
case 'tabler':
- return content.replaceAll('stroke-width="2"', 'stroke-width="3"')
+ if (name === 'x')
+ return content
+ else
+ return content.replaceAll('stroke-width="2"', 'stroke-width="3"')
}
return content
},