diff options
| -rw-r--r-- | drizzle/0000_known_scarecrow.sql | 6 | ||||
| -rw-r--r-- | drizzle/0000_youthful_mercury.sql | 16 | ||||
| -rw-r--r-- | drizzle/0001_dear_bastion.sql | 1 | ||||
| -rw-r--r-- | drizzle/meta/0000_snapshot.json | 59 | ||||
| -rw-r--r-- | drizzle/meta/0001_snapshot.json | 63 | ||||
| -rw-r--r-- | drizzle/meta/_journal.json | 11 | ||||
| -rw-r--r-- | src/lib/server/db/groceries.test.ts | 20 | ||||
| -rw-r--r-- | src/lib/server/db/schema.ts | 6 | ||||
| -rw-r--r-- | src/routes/+page.server.ts | 27 | ||||
| -rw-r--r-- | src/routes/+page.svelte | 136 | ||||
| -rw-r--r-- | src/routes/categories/+page.server.ts | 38 | ||||
| -rw-r--r-- | src/routes/categories/+page.svelte | 134 |
12 files changed, 381 insertions, 136 deletions
diff --git a/drizzle/0000_known_scarecrow.sql b/drizzle/0000_known_scarecrow.sql deleted file mode 100644 index 7d70133..0000000 --- a/drizzle/0000_known_scarecrow.sql +++ /dev/null @@ -1,6 +0,0 @@ -CREATE TABLE `groceries` ( - `id` integer PRIMARY KEY AUTOINCREMENT NOT NULL, - `name` text NOT NULL, - `checked` integer DEFAULT false NOT NULL, - `created_at` text NOT NULL -); diff --git a/drizzle/0000_youthful_mercury.sql b/drizzle/0000_youthful_mercury.sql new file mode 100644 index 0000000..6dde7ff --- /dev/null +++ b/drizzle/0000_youthful_mercury.sql @@ -0,0 +1,16 @@ +CREATE TABLE `categories` ( + `id` integer PRIMARY KEY AUTOINCREMENT NOT NULL, + `name` text NOT NULL +); +--> statement-breakpoint +CREATE UNIQUE INDEX `categories_name_unique` ON `categories` (`name`);--> statement-breakpoint +CREATE TABLE `groceries` ( + `id` integer PRIMARY KEY AUTOINCREMENT NOT NULL, + `name` text NOT NULL, + `checked` integer DEFAULT false NOT NULL, + `created_at` text NOT NULL, + `category_id` integer, + FOREIGN KEY (`category_id`) REFERENCES `categories`(`id`) ON UPDATE no action ON DELETE no action +); +--> statement-breakpoint +CREATE UNIQUE INDEX `groceries_name_unique` ON `groceries` (`name`);
\ No newline at end of file diff --git a/drizzle/0001_dear_bastion.sql b/drizzle/0001_dear_bastion.sql deleted file mode 100644 index ecc896d..0000000 --- a/drizzle/0001_dear_bastion.sql +++ /dev/null @@ -1 +0,0 @@ -CREATE UNIQUE INDEX `groceries_name_unique` ON `groceries` (`name`);
\ No newline at end of file diff --git a/drizzle/meta/0000_snapshot.json b/drizzle/meta/0000_snapshot.json index 99fa112..6e33354 100644 --- a/drizzle/meta/0000_snapshot.json +++ b/drizzle/meta/0000_snapshot.json @@ -1,9 +1,39 @@ { "version": "6", "dialect": "sqlite", - "id": "265bc06f-5de9-43a2-9427-39ce061f134c", + "id": "54c7ae45-9838-4ffb-ad46-29995def7272", "prevId": "00000000-0000-0000-0000-000000000000", "tables": { + "categories": { + "name": "categories", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "categories_name_unique": { + "name": "categories_name_unique", + "columns": ["name"], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, "groceries": { "name": "groceries", "columns": { @@ -35,10 +65,33 @@ "primaryKey": false, "notNull": true, "autoincrement": false + }, + "category_id": { + "name": "category_id", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "groceries_name_unique": { + "name": "groceries_name_unique", + "columns": ["name"], + "isUnique": true + } + }, + "foreignKeys": { + "groceries_category_id_categories_id_fk": { + "name": "groceries_category_id_categories_id_fk", + "tableFrom": "groceries", + "tableTo": "categories", + "columnsFrom": ["category_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" } }, - "indexes": {}, - "foreignKeys": {}, "compositePrimaryKeys": {}, "uniqueConstraints": {}, "checkConstraints": {} diff --git a/drizzle/meta/0001_snapshot.json b/drizzle/meta/0001_snapshot.json deleted file mode 100644 index 92fe548..0000000 --- a/drizzle/meta/0001_snapshot.json +++ /dev/null @@ -1,63 +0,0 @@ -{ - "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 7e6fb0d..2923632 100644 --- a/drizzle/meta/_journal.json +++ b/drizzle/meta/_journal.json @@ -5,15 +5,8 @@ { "idx": 0, "version": "6", - "when": 1785163537728, - "tag": "0000_known_scarecrow", - "breakpoints": true - }, - { - "idx": 1, - "version": "6", - "when": 1785165400399, - "tag": "0001_dear_bastion", + "when": 1785169427572, + "tag": "0000_youthful_mercury", "breakpoints": true } ] diff --git a/src/lib/server/db/groceries.test.ts b/src/lib/server/db/groceries.test.ts index b4f5050..bd14040 100644 --- a/src/lib/server/db/groceries.test.ts +++ b/src/lib/server/db/groceries.test.ts @@ -1,24 +1,30 @@ 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 { groceries, categories } from '$lib/server/db/schema'; import { eq } from 'drizzle-orm'; describe('groceries add', () => { const client = createClient({ url: ':memory:' }); - const db = drizzle(client, { schema: { groceries } }); + const db = drizzle(client, { schema: { groceries, categories } }); beforeEach(async () => { await db.run(`DROP TABLE IF EXISTS groceries`); + await db.run(`DROP TABLE IF EXISTS categories`); + await db.run(`CREATE TABLE categories ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT NOT NULL + )`); 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 + created_at TEXT NOT NULL, + category_id INTEGER REFERENCES categories(id) )`); }); - async function addItem(name: string) { + async function addItem(name: string, categoryId?: number | null) { const existing = await db .select() .from(groceries) @@ -28,11 +34,15 @@ describe('groceries add', () => { if (existing.length > 0) { await db .update(groceries) - .set({ checked: false }) + .set({ + checked: false, + categoryId: categoryId ?? existing[0].categoryId, + }) .where(eq(groceries.id, existing[0].id)); } else { await db.insert(groceries).values({ name, + categoryId, createdAt: new Date().toISOString(), }); } diff --git a/src/lib/server/db/schema.ts b/src/lib/server/db/schema.ts index 403ef51..1d5fd9e 100644 --- a/src/lib/server/db/schema.ts +++ b/src/lib/server/db/schema.ts @@ -1,8 +1,14 @@ import { sqliteTable, text, integer } from 'drizzle-orm/sqlite-core'; +export const categories = sqliteTable('categories', { + id: integer('id').primaryKey({ autoIncrement: true }), + name: text('name').notNull().unique(), +}); + export const groceries = sqliteTable('groceries', { id: integer('id').primaryKey({ autoIncrement: true }), name: text('name').notNull().unique(), checked: integer('checked', { mode: 'boolean' }).notNull().default(false), createdAt: text('created_at').notNull(), + categoryId: integer('category_id').references(() => categories.id), }); diff --git a/src/routes/+page.server.ts b/src/routes/+page.server.ts index c57578f..e77b27f 100644 --- a/src/routes/+page.server.ts +++ b/src/routes/+page.server.ts @@ -1,22 +1,31 @@ import { fail } from '@sveltejs/kit'; import { db } from '$lib/server/db'; -import { groceries } from '$lib/server/db/schema'; +import { groceries, categories } from '$lib/server/db/schema'; import { eq, desc } from 'drizzle-orm'; import type { Actions, PageServerLoad } from './$types'; export const load: PageServerLoad = async () => { - const items = await db - .select() - .from(groceries) - .orderBy(desc(groceries.createdAt)); + const [items, cats] = await Promise.all([ + db.select().from(groceries).orderBy(desc(groceries.createdAt)), + db.select().from(categories).orderBy(desc(categories.name)), + ]); - return { items }; + const catMap = new Map(cats.map((c) => [c.id, c.name])); + + return { + items: items.map((i) => ({ + ...i, + categoryName: i.categoryId ? (catMap.get(i.categoryId) ?? null) : null, + })), + categories: cats, + }; }; export const actions: Actions = { add: async ({ request }) => { const data = await request.formData(); const name = data.get('name')?.toString().trim(); + const categoryId = data.get('category_id')?.toString() || null; if (!name) { return fail(400, { error: 'Name is required' }); @@ -31,11 +40,15 @@ export const actions: Actions = { if (existing) { await db .update(groceries) - .set({ checked: false }) + .set({ + checked: false, + categoryId: categoryId ? Number(categoryId) : existing.categoryId, + }) .where(eq(groceries.id, existing.id)); } else { await db.insert(groceries).values({ name, + categoryId: categoryId ? Number(categoryId) : null, createdAt: new Date().toISOString(), }); } diff --git a/src/routes/+page.svelte b/src/routes/+page.svelte index a3e6d23..9490a51 100644 --- a/src/routes/+page.svelte +++ b/src/routes/+page.svelte @@ -7,18 +7,39 @@ let listItems = $derived(data?.items?.filter((i) => !i.checked) ?? []); let catalogItems = $derived(data?.items?.filter((i) => i.checked) ?? []); + + function groupByCategory(items: typeof listItems) { + const groups: Record<string, typeof listItems> = {}; + for (const item of items) { + const key = item.categoryName ?? 'Uncategorized'; + (groups[key] ??= []).push(item); + } + return Object.entries(groups); + } </script> <h1>Groceries</h1> <form class="add-form" method="POST" action="?/add" use:enhance> - <input - type="text" - name="name" - bind:value={name} - placeholder="Add an item..." - autocomplete="off" - /> + <label> + <span class="label">Item</span> + <input + type="text" + name="name" + bind:value={name} + placeholder="Add an item..." + autocomplete="off" + /> + </label> + <label> + <span class="label">Category</span> + <select name="category_id"> + <option value="">—</option> + {#each data?.categories ?? [] as cat (cat.id)} + <option value={cat.id}>{cat.name}</option> + {/each} + </select> + </label> <button type="submit">Add</button> </form> @@ -28,64 +49,86 @@ <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> +{:else} + <form method="POST" action="?/toggle" use:enhance> + {#each groupByCategory(listItems) as [cat, items] (cat)} + <h3 class="category-name">{cat}</h3> + <ul class="item-list"> + {#each items as item (item.id)} + <li> + <button name="id" value={item.id} type="submit"> + {item.name} + </button> + </li> + {/each} + </ul> + {/each} + </form> {/if} <hr /> <h2>Catalog</h2> -<form method="POST" action="?/toggle" use:enhance> - <ul class="item-list"> - {#each catalogItems as item (item.id)} - <li class="catalog-item"> - <button name="id" value={item.id} type="submit"> - {item.name} - </button> - <button - class="delete-btn" - name="id" - value={item.id} - type="submit" - formaction="?/delete" - > - × - </button> - </li> - {/each} - </ul> -</form> - {#if catalogItems.length === 0} <p class="empty">No items yet</p> +{:else} + <form method="POST" action="?/toggle" use:enhance> + {#each groupByCategory(catalogItems) as [cat, items] (cat)} + <h3 class="category-name">{cat}</h3> + <ul class="item-list"> + {#each items as item (item.id)} + <li class="catalog-item"> + <button name="id" value={item.id} type="submit"> + {item.name} + </button> + <button + class="delete-btn" + name="id" + value={item.id} + type="submit" + formaction="?/delete" + > + × + </button> + </li> + {/each} + </ul> + {/each} + </form> {/if} <style> .add-form { display: flex; + flex-direction: column; gap: 0.5rem; margin-bottom: 1rem; } - input { - flex: 1; + .add-form label { + display: flex; + flex-direction: column; + gap: 0.25rem; + } + + .label { + font-size: 0.75rem; + font-weight: 600; + text-transform: uppercase; + color: #999; + } + + input, + select { padding: 0.75rem; border: 1px solid #ccc; border-radius: 0.5rem; font-size: 1rem; + font-family: inherit; + background: #fff; } .add-form button[type='submit'] { @@ -95,6 +138,7 @@ background: #333; color: #fff; font-size: 1rem; + align-self: flex-start; } .error { @@ -168,4 +212,12 @@ font-size: 0.875rem; margin-top: 0.5rem; } + + .category-name { + font-size: 0.75rem; + font-weight: 600; + text-transform: uppercase; + color: #999; + margin: 1rem 0 0.25rem; + } </style> diff --git a/src/routes/categories/+page.server.ts b/src/routes/categories/+page.server.ts new file mode 100644 index 0000000..241370e --- /dev/null +++ b/src/routes/categories/+page.server.ts @@ -0,0 +1,38 @@ +import { fail } from '@sveltejs/kit'; +import { db } from '$lib/server/db'; +import { categories } from '$lib/server/db/schema'; +import { eq, asc } from 'drizzle-orm'; +import type { Actions, PageServerLoad } from './$types'; + +export const load: PageServerLoad = async () => { + const items = await db + .select() + .from(categories) + .orderBy(asc(categories.name)); + + return { categories: items }; +}; + +export const actions: Actions = { + add: async ({ request }) => { + const data = await request.formData(); + const name = data.get('name')?.toString().trim(); + + if (!name) { + return fail(400, { error: 'Name is required' }); + } + + await db.insert(categories).values({ name }); + + return { success: true }; + }, + + delete: async ({ request }) => { + const data = await request.formData(); + const id = Number(data.get('id')); + + await db.delete(categories).where(eq(categories.id, id)); + + return { success: true }; + }, +}; diff --git a/src/routes/categories/+page.svelte b/src/routes/categories/+page.svelte new file mode 100644 index 0000000..0dfc96c --- /dev/null +++ b/src/routes/categories/+page.svelte @@ -0,0 +1,134 @@ +<script lang="ts"> + import '../../app.css'; + import { enhance } from '$app/forms'; + + let { data, form } = $props(); + let name = $state(''); +</script> + +<h1>Categories</h1> + +<form class="add-form" method="POST" action="?/add" use:enhance> + <label> + <span class="label">New category</span> + <input + type="text" + name="name" + bind:value={name} + placeholder="e.g. Boissons" + autocomplete="off" + /> + </label> + <button type="submit">Add</button> +</form> + +{#if form?.error} + <p class="error">{form.error}</p> +{/if} + +<form method="POST" action="?/delete" use:enhance> + <ul class="item-list"> + {#each data?.categories ?? [] as cat (cat.id)} + <li class="catalog-item"> + <span class="cat-name">{cat.name}</span> + <button class="delete-btn" name="id" value={cat.id} type="submit"> + × + </button> + </li> + {/each} + </ul> +</form> + +{#if (data?.categories?.length ?? 0) === 0} + <p class="empty">No categories yet</p> +{/if} + +<style> + .add-form { + display: flex; + flex-direction: column; + gap: 0.5rem; + margin-bottom: 1rem; + } + + .add-form label { + display: flex; + flex-direction: column; + gap: 0.25rem; + } + + .label { + font-size: 0.75rem; + font-weight: 600; + text-transform: uppercase; + color: #999; + } + + input { + padding: 0.75rem; + border: 1px solid #ccc; + border-radius: 0.5rem; + font-size: 1rem; + } + + .add-form button[type='submit'] { + padding: 0.75rem 1.25rem; + border: none; + border-radius: 0.5rem; + background: #333; + color: #fff; + font-size: 1rem; + align-self: flex-start; + } + + .error { + color: #d00; + margin-top: -0.5rem; + margin-bottom: 1rem; + } + + .item-list { + list-style: none; + display: flex; + flex-direction: column; + gap: 0.25rem; + } + + .catalog-item { + display: flex; + gap: 0.25rem; + height: 2rem; + align-items: center; + } + + .cat-name { + flex-grow: 1; + padding: 0 0.75rem; + } + + .delete-btn { + border: 1px solid #e0e0e0; + border-radius: 0.5rem; + background: #fff; + color: #d00; + transition: background 0.15s; + width: 2rem; + aspect-ratio: 1; + padding: 0; + display: flex; + align-items: center; + justify-content: center; + font-size: 1.25rem; + cursor: pointer; + } + + .delete-btn:hover { + background: #fee; + } + + .empty { + color: #999; + font-size: 0.875rem; + margin-top: 0.5rem; + } +</style> |
