diff options
| -rw-r--r-- | drizzle/0001_dear_bastion.sql | 1 | ||||
| -rw-r--r-- | drizzle/meta/0001_snapshot.json | 63 | ||||
| -rw-r--r-- | drizzle/meta/_journal.json | 7 | ||||
| -rw-r--r-- | src/lib/server/db/groceries.test.ts | 73 | ||||
| -rw-r--r-- | src/lib/server/db/schema.ts | 2 | ||||
| -rw-r--r-- | src/routes/+page.server.ts | 22 | ||||
| -rw-r--r-- | src/routes/+page.svelte | 35 |
7 files changed, 196 insertions, 7 deletions
diff --git a/drizzle/0001_dear_bastion.sql b/drizzle/0001_dear_bastion.sql new file mode 100644 index 0000000..ecc896d --- /dev/null +++ b/drizzle/0001_dear_bastion.sql @@ -0,0 +1 @@ +CREATE UNIQUE INDEX `groceries_name_unique` ON `groceries` (`name`);
\ No newline at end of file diff --git a/drizzle/meta/0001_snapshot.json b/drizzle/meta/0001_snapshot.json new file mode 100644 index 0000000..92fe548 --- /dev/null +++ b/drizzle/meta/0001_snapshot.json @@ -0,0 +1,63 @@ +{ + "version": "6", + "dialect": "sqlite", + "id": "b20afe62-9a50-42b6-bb34-1be35863e817", + "prevId": "265bc06f-5de9-43a2-9427-39ce061f134c", + "tables": { + "groceries": { + "name": "groceries", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "checked": { + "name": "checked", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "groceries_name_unique": { + "name": "groceries_name_unique", + "columns": ["name"], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + } + }, + "views": {}, + "enums": {}, + "_meta": { + "schemas": {}, + "tables": {}, + "columns": {} + }, + "internal": { + "indexes": {} + } +} diff --git a/drizzle/meta/_journal.json b/drizzle/meta/_journal.json index 77a9b2e..7e6fb0d 100644 --- a/drizzle/meta/_journal.json +++ b/drizzle/meta/_journal.json @@ -8,6 +8,13 @@ "when": 1785163537728, "tag": "0000_known_scarecrow", "breakpoints": true + }, + { + "idx": 1, + "version": "6", + "when": 1785165400399, + "tag": "0001_dear_bastion", + "breakpoints": true } ] } diff --git a/src/lib/server/db/groceries.test.ts b/src/lib/server/db/groceries.test.ts new file mode 100644 index 0000000..b4f5050 --- /dev/null +++ b/src/lib/server/db/groceries.test.ts @@ -0,0 +1,73 @@ +import { describe, it, expect, beforeEach } from 'vitest'; +import { drizzle } from 'drizzle-orm/libsql'; +import { createClient } from '@libsql/client'; +import { groceries } from '$lib/server/db/schema'; +import { eq } from 'drizzle-orm'; + +describe('groceries add', () => { + const client = createClient({ url: ':memory:' }); + const db = drizzle(client, { schema: { groceries } }); + + beforeEach(async () => { + await db.run(`DROP TABLE IF EXISTS groceries`); + await db.run(`CREATE TABLE groceries ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT NOT NULL, + checked INTEGER NOT NULL DEFAULT 0, + created_at TEXT NOT NULL + )`); + }); + + async function addItem(name: string) { + const existing = await db + .select() + .from(groceries) + .where(eq(groceries.name, name)) + .limit(1); + + if (existing.length > 0) { + await db + .update(groceries) + .set({ checked: false }) + .where(eq(groceries.id, existing[0].id)); + } else { + await db.insert(groceries).values({ + name, + createdAt: new Date().toISOString(), + }); + } + } + + it('creates a new item when name is new', async () => { + await addItem('milk'); + + const items = await db.select().from(groceries); + expect(items).toHaveLength(1); + expect(items[0].name).toBe('milk'); + expect(items[0].checked).toBe(false); + }); + + it('unchecks an existing checked item instead of creating duplicate', async () => { + const now = new Date().toISOString(); + await db + .insert(groceries) + .values({ name: 'milk', checked: true, createdAt: now }); + await addItem('milk'); + + const items = await db.select().from(groceries); + expect(items).toHaveLength(1); + expect(items[0].checked).toBe(false); + }); + + it('keeps an unchecked item unchanged (already on list)', async () => { + const now = new Date().toISOString(); + await db + .insert(groceries) + .values({ name: 'milk', checked: false, createdAt: now }); + await addItem('milk'); + + const items = await db.select().from(groceries); + expect(items).toHaveLength(1); + expect(items[0].checked).toBe(false); + }); +}); diff --git a/src/lib/server/db/schema.ts b/src/lib/server/db/schema.ts index a8527fd..403ef51 100644 --- a/src/lib/server/db/schema.ts +++ b/src/lib/server/db/schema.ts @@ -2,7 +2,7 @@ import { sqliteTable, text, integer } from 'drizzle-orm/sqlite-core'; export const groceries = sqliteTable('groceries', { id: integer('id').primaryKey({ autoIncrement: true }), - name: text('name').notNull(), + name: text('name').notNull().unique(), checked: integer('checked', { mode: 'boolean' }).notNull().default(false), createdAt: text('created_at').notNull(), }); diff --git a/src/routes/+page.server.ts b/src/routes/+page.server.ts index e6f3b0f..947cc3b 100644 --- a/src/routes/+page.server.ts +++ b/src/routes/+page.server.ts @@ -8,7 +8,6 @@ export const load: PageServerLoad = async () => { const items = await db .select() .from(groceries) - .where(eq(groceries.checked, false)) .orderBy(desc(groceries.createdAt)); return { items }; @@ -23,10 +22,23 @@ export const actions: Actions = { return fail(400, { error: 'Name is required' }); } - await db.insert(groceries).values({ - name, - createdAt: new Date().toISOString(), - }); + const [existing] = await db + .select() + .from(groceries) + .where(eq(groceries.name, name)) + .limit(1); + + if (existing) { + await db + .update(groceries) + .set({ checked: false }) + .where(eq(groceries.id, existing.id)); + } else { + await db.insert(groceries).values({ + name, + createdAt: new Date().toISOString(), + }); + } return { success: true }; }, diff --git a/src/routes/+page.svelte b/src/routes/+page.svelte index 0e9e1ac..325d15a 100644 --- a/src/routes/+page.svelte +++ b/src/routes/+page.svelte @@ -4,6 +4,9 @@ let { data, form } = $props(); let name = $state(''); + + let listItems = $derived(data?.items?.filter((i) => !i.checked) ?? []); + let catalogItems = $derived(data?.items?.filter((i) => i.checked) ?? []); </script> <h1>Groceries</h1> @@ -23,11 +26,31 @@ <p class="error">{form.error}</p> {/if} +<h2>List</h2> + +<form method="POST" action="?/toggle" use:enhance> + <ul class="item-list"> + {#each listItems as item (item.id)} + <li> + <button name="id" value={item.id} type="submit"> + {item.name} + </button> + </li> + {/each} + </ul> +</form> + +{#if listItems.length === 0} + <p class="empty">Nothing to buy</p> +{/if} + <hr /> +<h2>Catalog</h2> + <form method="POST" action="?/toggle" use:enhance> <ul class="item-list"> - {#each data?.items ?? [] as item (item.id)} + {#each catalogItems as item (item.id)} <li> <button name="id" value={item.id} type="submit"> {item.name} @@ -37,6 +60,10 @@ </ul> </form> +{#if catalogItems.length === 0} + <p class="empty">No items yet</p> +{/if} + <style> .add-form { display: flex; @@ -94,4 +121,10 @@ .item-list li button:hover { background: #f5f5f5; } + + .empty { + color: #999; + font-size: 0.875rem; + margin-top: 0.5rem; + } </style> |
