aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorMartin Kagamino Lehoux <martin@lehoux.net>2026-07-27 18:31:26 +0200
committerMartin Kagamino Lehoux <martin@lehoux.net>2026-07-27 18:31:26 +0200
commite14717b2e7853705469275d64023bb902b195ebb (patch)
tree2e450f7b4e864e232d06df43ad1aba1b62be4f4c
parent8224c7d6572db6ec76c4224eec1df8a538b47ad4 (diff)
feat: separate categories table + /categories management page
- Categories table (id, name unique) with FK from groceries.category_id - /categories page for add/delete categories - Main page uses native <select> dropdown for category - Categories grouped by name in list/catalog views - Regenerated drizzle migrations from scratch
-rw-r--r--drizzle/0000_known_scarecrow.sql6
-rw-r--r--drizzle/0000_youthful_mercury.sql16
-rw-r--r--drizzle/0001_dear_bastion.sql1
-rw-r--r--drizzle/meta/0000_snapshot.json59
-rw-r--r--drizzle/meta/0001_snapshot.json63
-rw-r--r--drizzle/meta/_journal.json11
-rw-r--r--src/lib/server/db/groceries.test.ts20
-rw-r--r--src/lib/server/db/schema.ts6
-rw-r--r--src/routes/+page.server.ts27
-rw-r--r--src/routes/+page.svelte136
-rw-r--r--src/routes/categories/+page.server.ts38
-rw-r--r--src/routes/categories/+page.svelte134
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"
- >
- &times;
- </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"
+ >
+ &times;
+ </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">
+ &times;
+ </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>