diff --git a/.planning/config.json b/.planning/config.json
index 006e171..ffed394 100644
--- a/.planning/config.json
+++ b/.planning/config.json
@@ -1,14 +1,14 @@
{
- "mode": "yolo",
- "granularity": "coarse",
- "parallelization": true,
- "commit_docs": true,
- "model_profile": "quality",
- "workflow": {
- "research": false,
- "plan_check": true,
- "verifier": true,
- "nyquist_validation": true,
- "_auto_chain_active": true
- }
-}
\ No newline at end of file
+ "mode": "yolo",
+ "granularity": "coarse",
+ "parallelization": true,
+ "commit_docs": true,
+ "model_profile": "quality",
+ "workflow": {
+ "research": false,
+ "plan_check": true,
+ "verifier": true,
+ "nyquist_validation": true,
+ "_auto_chain_active": true
+ }
+}
diff --git a/biome.json b/biome.json
index 8d47a1e..ddf9733 100644
--- a/biome.json
+++ b/biome.json
@@ -6,7 +6,8 @@
"useIgnoreFile": true
},
"files": {
- "ignoreUnknown": false
+ "ignoreUnknown": false,
+ "includes": ["**", "!src/client/routeTree.gen.ts"]
},
"formatter": {
"enabled": true,
@@ -15,7 +16,22 @@
"linter": {
"enabled": true,
"rules": {
- "recommended": true
+ "recommended": true,
+ "a11y": {
+ "noSvgWithoutTitle": "off",
+ "noStaticElementInteractions": "off",
+ "useKeyWithClickEvents": "off",
+ "useSemanticElements": "off",
+ "noAutofocus": "off",
+ "useAriaPropsSupportedByRole": "off",
+ "noLabelWithoutControl": "off"
+ },
+ "suspicious": {
+ "noExplicitAny": "off"
+ },
+ "style": {
+ "noNonNullAssertion": "off"
+ }
}
},
"javascript": {
diff --git a/drizzle/meta/0000_snapshot.json b/drizzle/meta/0000_snapshot.json
index 0bc483f..0d6a811 100644
--- a/drizzle/meta/0000_snapshot.json
+++ b/drizzle/meta/0000_snapshot.json
@@ -1,467 +1,441 @@
{
- "version": "6",
- "dialect": "sqlite",
- "id": "78e5f5c8-f8f0-43f4-93f8-5ef68154ed17",
- "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
- },
- "emoji": {
- "name": "emoji",
- "type": "text",
- "primaryKey": false,
- "notNull": true,
- "autoincrement": false,
- "default": "'📦'"
- },
- "created_at": {
- "name": "created_at",
- "type": "integer",
- "primaryKey": false,
- "notNull": true,
- "autoincrement": false
- }
- },
- "indexes": {
- "categories_name_unique": {
- "name": "categories_name_unique",
- "columns": [
- "name"
- ],
- "isUnique": true
- }
- },
- "foreignKeys": {},
- "compositePrimaryKeys": {},
- "uniqueConstraints": {},
- "checkConstraints": {}
- },
- "items": {
- "name": "items",
- "columns": {
- "id": {
- "name": "id",
- "type": "integer",
- "primaryKey": true,
- "notNull": true,
- "autoincrement": true
- },
- "name": {
- "name": "name",
- "type": "text",
- "primaryKey": false,
- "notNull": true,
- "autoincrement": false
- },
- "weight_grams": {
- "name": "weight_grams",
- "type": "real",
- "primaryKey": false,
- "notNull": false,
- "autoincrement": false
- },
- "price_cents": {
- "name": "price_cents",
- "type": "integer",
- "primaryKey": false,
- "notNull": false,
- "autoincrement": false
- },
- "category_id": {
- "name": "category_id",
- "type": "integer",
- "primaryKey": false,
- "notNull": true,
- "autoincrement": false
- },
- "notes": {
- "name": "notes",
- "type": "text",
- "primaryKey": false,
- "notNull": false,
- "autoincrement": false
- },
- "product_url": {
- "name": "product_url",
- "type": "text",
- "primaryKey": false,
- "notNull": false,
- "autoincrement": false
- },
- "image_filename": {
- "name": "image_filename",
- "type": "text",
- "primaryKey": false,
- "notNull": false,
- "autoincrement": false
- },
- "created_at": {
- "name": "created_at",
- "type": "integer",
- "primaryKey": false,
- "notNull": true,
- "autoincrement": false
- },
- "updated_at": {
- "name": "updated_at",
- "type": "integer",
- "primaryKey": false,
- "notNull": true,
- "autoincrement": false
- }
- },
- "indexes": {},
- "foreignKeys": {
- "items_category_id_categories_id_fk": {
- "name": "items_category_id_categories_id_fk",
- "tableFrom": "items",
- "tableTo": "categories",
- "columnsFrom": [
- "category_id"
- ],
- "columnsTo": [
- "id"
- ],
- "onDelete": "no action",
- "onUpdate": "no action"
- }
- },
- "compositePrimaryKeys": {},
- "uniqueConstraints": {},
- "checkConstraints": {}
- },
- "settings": {
- "name": "settings",
- "columns": {
- "key": {
- "name": "key",
- "type": "text",
- "primaryKey": true,
- "notNull": true,
- "autoincrement": false
- },
- "value": {
- "name": "value",
- "type": "text",
- "primaryKey": false,
- "notNull": true,
- "autoincrement": false
- }
- },
- "indexes": {},
- "foreignKeys": {},
- "compositePrimaryKeys": {},
- "uniqueConstraints": {},
- "checkConstraints": {}
- },
- "setup_items": {
- "name": "setup_items",
- "columns": {
- "id": {
- "name": "id",
- "type": "integer",
- "primaryKey": true,
- "notNull": true,
- "autoincrement": true
- },
- "setup_id": {
- "name": "setup_id",
- "type": "integer",
- "primaryKey": false,
- "notNull": true,
- "autoincrement": false
- },
- "item_id": {
- "name": "item_id",
- "type": "integer",
- "primaryKey": false,
- "notNull": true,
- "autoincrement": false
- }
- },
- "indexes": {},
- "foreignKeys": {
- "setup_items_setup_id_setups_id_fk": {
- "name": "setup_items_setup_id_setups_id_fk",
- "tableFrom": "setup_items",
- "tableTo": "setups",
- "columnsFrom": [
- "setup_id"
- ],
- "columnsTo": [
- "id"
- ],
- "onDelete": "cascade",
- "onUpdate": "no action"
- },
- "setup_items_item_id_items_id_fk": {
- "name": "setup_items_item_id_items_id_fk",
- "tableFrom": "setup_items",
- "tableTo": "items",
- "columnsFrom": [
- "item_id"
- ],
- "columnsTo": [
- "id"
- ],
- "onDelete": "cascade",
- "onUpdate": "no action"
- }
- },
- "compositePrimaryKeys": {},
- "uniqueConstraints": {},
- "checkConstraints": {}
- },
- "setups": {
- "name": "setups",
- "columns": {
- "id": {
- "name": "id",
- "type": "integer",
- "primaryKey": true,
- "notNull": true,
- "autoincrement": true
- },
- "name": {
- "name": "name",
- "type": "text",
- "primaryKey": false,
- "notNull": true,
- "autoincrement": false
- },
- "created_at": {
- "name": "created_at",
- "type": "integer",
- "primaryKey": false,
- "notNull": true,
- "autoincrement": false
- },
- "updated_at": {
- "name": "updated_at",
- "type": "integer",
- "primaryKey": false,
- "notNull": true,
- "autoincrement": false
- }
- },
- "indexes": {},
- "foreignKeys": {},
- "compositePrimaryKeys": {},
- "uniqueConstraints": {},
- "checkConstraints": {}
- },
- "thread_candidates": {
- "name": "thread_candidates",
- "columns": {
- "id": {
- "name": "id",
- "type": "integer",
- "primaryKey": true,
- "notNull": true,
- "autoincrement": true
- },
- "thread_id": {
- "name": "thread_id",
- "type": "integer",
- "primaryKey": false,
- "notNull": true,
- "autoincrement": false
- },
- "name": {
- "name": "name",
- "type": "text",
- "primaryKey": false,
- "notNull": true,
- "autoincrement": false
- },
- "weight_grams": {
- "name": "weight_grams",
- "type": "real",
- "primaryKey": false,
- "notNull": false,
- "autoincrement": false
- },
- "price_cents": {
- "name": "price_cents",
- "type": "integer",
- "primaryKey": false,
- "notNull": false,
- "autoincrement": false
- },
- "category_id": {
- "name": "category_id",
- "type": "integer",
- "primaryKey": false,
- "notNull": true,
- "autoincrement": false
- },
- "notes": {
- "name": "notes",
- "type": "text",
- "primaryKey": false,
- "notNull": false,
- "autoincrement": false
- },
- "product_url": {
- "name": "product_url",
- "type": "text",
- "primaryKey": false,
- "notNull": false,
- "autoincrement": false
- },
- "image_filename": {
- "name": "image_filename",
- "type": "text",
- "primaryKey": false,
- "notNull": false,
- "autoincrement": false
- },
- "created_at": {
- "name": "created_at",
- "type": "integer",
- "primaryKey": false,
- "notNull": true,
- "autoincrement": false
- },
- "updated_at": {
- "name": "updated_at",
- "type": "integer",
- "primaryKey": false,
- "notNull": true,
- "autoincrement": false
- }
- },
- "indexes": {},
- "foreignKeys": {
- "thread_candidates_thread_id_threads_id_fk": {
- "name": "thread_candidates_thread_id_threads_id_fk",
- "tableFrom": "thread_candidates",
- "tableTo": "threads",
- "columnsFrom": [
- "thread_id"
- ],
- "columnsTo": [
- "id"
- ],
- "onDelete": "cascade",
- "onUpdate": "no action"
- },
- "thread_candidates_category_id_categories_id_fk": {
- "name": "thread_candidates_category_id_categories_id_fk",
- "tableFrom": "thread_candidates",
- "tableTo": "categories",
- "columnsFrom": [
- "category_id"
- ],
- "columnsTo": [
- "id"
- ],
- "onDelete": "no action",
- "onUpdate": "no action"
- }
- },
- "compositePrimaryKeys": {},
- "uniqueConstraints": {},
- "checkConstraints": {}
- },
- "threads": {
- "name": "threads",
- "columns": {
- "id": {
- "name": "id",
- "type": "integer",
- "primaryKey": true,
- "notNull": true,
- "autoincrement": true
- },
- "name": {
- "name": "name",
- "type": "text",
- "primaryKey": false,
- "notNull": true,
- "autoincrement": false
- },
- "status": {
- "name": "status",
- "type": "text",
- "primaryKey": false,
- "notNull": true,
- "autoincrement": false,
- "default": "'active'"
- },
- "resolved_candidate_id": {
- "name": "resolved_candidate_id",
- "type": "integer",
- "primaryKey": false,
- "notNull": false,
- "autoincrement": false
- },
- "category_id": {
- "name": "category_id",
- "type": "integer",
- "primaryKey": false,
- "notNull": true,
- "autoincrement": false
- },
- "created_at": {
- "name": "created_at",
- "type": "integer",
- "primaryKey": false,
- "notNull": true,
- "autoincrement": false
- },
- "updated_at": {
- "name": "updated_at",
- "type": "integer",
- "primaryKey": false,
- "notNull": true,
- "autoincrement": false
- }
- },
- "indexes": {},
- "foreignKeys": {
- "threads_category_id_categories_id_fk": {
- "name": "threads_category_id_categories_id_fk",
- "tableFrom": "threads",
- "tableTo": "categories",
- "columnsFrom": [
- "category_id"
- ],
- "columnsTo": [
- "id"
- ],
- "onDelete": "no action",
- "onUpdate": "no action"
- }
- },
- "compositePrimaryKeys": {},
- "uniqueConstraints": {},
- "checkConstraints": {}
- }
- },
- "views": {},
- "enums": {},
- "_meta": {
- "schemas": {},
- "tables": {},
- "columns": {}
- },
- "internal": {
- "indexes": {}
- }
-}
\ No newline at end of file
+ "version": "6",
+ "dialect": "sqlite",
+ "id": "78e5f5c8-f8f0-43f4-93f8-5ef68154ed17",
+ "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
+ },
+ "emoji": {
+ "name": "emoji",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'📦'"
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "categories_name_unique": {
+ "name": "categories_name_unique",
+ "columns": ["name"],
+ "isUnique": true
+ }
+ },
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "items": {
+ "name": "items",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "integer",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": true
+ },
+ "name": {
+ "name": "name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "weight_grams": {
+ "name": "weight_grams",
+ "type": "real",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "price_cents": {
+ "name": "price_cents",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "category_id": {
+ "name": "category_id",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "notes": {
+ "name": "notes",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "product_url": {
+ "name": "product_url",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "image_filename": {
+ "name": "image_filename",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "items_category_id_categories_id_fk": {
+ "name": "items_category_id_categories_id_fk",
+ "tableFrom": "items",
+ "tableTo": "categories",
+ "columnsFrom": ["category_id"],
+ "columnsTo": ["id"],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "settings": {
+ "name": "settings",
+ "columns": {
+ "key": {
+ "name": "key",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "value": {
+ "name": "value",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "setup_items": {
+ "name": "setup_items",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "integer",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": true
+ },
+ "setup_id": {
+ "name": "setup_id",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "item_id": {
+ "name": "item_id",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "setup_items_setup_id_setups_id_fk": {
+ "name": "setup_items_setup_id_setups_id_fk",
+ "tableFrom": "setup_items",
+ "tableTo": "setups",
+ "columnsFrom": ["setup_id"],
+ "columnsTo": ["id"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "setup_items_item_id_items_id_fk": {
+ "name": "setup_items_item_id_items_id_fk",
+ "tableFrom": "setup_items",
+ "tableTo": "items",
+ "columnsFrom": ["item_id"],
+ "columnsTo": ["id"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "setups": {
+ "name": "setups",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "integer",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": true
+ },
+ "name": {
+ "name": "name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "thread_candidates": {
+ "name": "thread_candidates",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "integer",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": true
+ },
+ "thread_id": {
+ "name": "thread_id",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "name": {
+ "name": "name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "weight_grams": {
+ "name": "weight_grams",
+ "type": "real",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "price_cents": {
+ "name": "price_cents",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "category_id": {
+ "name": "category_id",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "notes": {
+ "name": "notes",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "product_url": {
+ "name": "product_url",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "image_filename": {
+ "name": "image_filename",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "thread_candidates_thread_id_threads_id_fk": {
+ "name": "thread_candidates_thread_id_threads_id_fk",
+ "tableFrom": "thread_candidates",
+ "tableTo": "threads",
+ "columnsFrom": ["thread_id"],
+ "columnsTo": ["id"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "thread_candidates_category_id_categories_id_fk": {
+ "name": "thread_candidates_category_id_categories_id_fk",
+ "tableFrom": "thread_candidates",
+ "tableTo": "categories",
+ "columnsFrom": ["category_id"],
+ "columnsTo": ["id"],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "threads": {
+ "name": "threads",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "integer",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": true
+ },
+ "name": {
+ "name": "name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "status": {
+ "name": "status",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'active'"
+ },
+ "resolved_candidate_id": {
+ "name": "resolved_candidate_id",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "category_id": {
+ "name": "category_id",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "threads_category_id_categories_id_fk": {
+ "name": "threads_category_id_categories_id_fk",
+ "tableFrom": "threads",
+ "tableTo": "categories",
+ "columnsFrom": ["category_id"],
+ "columnsTo": ["id"],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ }
+ },
+ "views": {},
+ "enums": {},
+ "_meta": {
+ "schemas": {},
+ "tables": {},
+ "columns": {}
+ },
+ "internal": {
+ "indexes": {}
+ }
+}
diff --git a/drizzle/meta/0001_snapshot.json b/drizzle/meta/0001_snapshot.json
index f70b759..defad65 100644
--- a/drizzle/meta/0001_snapshot.json
+++ b/drizzle/meta/0001_snapshot.json
@@ -1,467 +1,441 @@
{
- "version": "6",
- "dialect": "sqlite",
- "id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
- "prevId": "78e5f5c8-f8f0-43f4-93f8-5ef68154ed17",
- "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
- },
- "icon": {
- "name": "icon",
- "type": "text",
- "primaryKey": false,
- "notNull": true,
- "autoincrement": false,
- "default": "'package'"
- },
- "created_at": {
- "name": "created_at",
- "type": "integer",
- "primaryKey": false,
- "notNull": true,
- "autoincrement": false
- }
- },
- "indexes": {
- "categories_name_unique": {
- "name": "categories_name_unique",
- "columns": [
- "name"
- ],
- "isUnique": true
- }
- },
- "foreignKeys": {},
- "compositePrimaryKeys": {},
- "uniqueConstraints": {},
- "checkConstraints": {}
- },
- "items": {
- "name": "items",
- "columns": {
- "id": {
- "name": "id",
- "type": "integer",
- "primaryKey": true,
- "notNull": true,
- "autoincrement": true
- },
- "name": {
- "name": "name",
- "type": "text",
- "primaryKey": false,
- "notNull": true,
- "autoincrement": false
- },
- "weight_grams": {
- "name": "weight_grams",
- "type": "real",
- "primaryKey": false,
- "notNull": false,
- "autoincrement": false
- },
- "price_cents": {
- "name": "price_cents",
- "type": "integer",
- "primaryKey": false,
- "notNull": false,
- "autoincrement": false
- },
- "category_id": {
- "name": "category_id",
- "type": "integer",
- "primaryKey": false,
- "notNull": true,
- "autoincrement": false
- },
- "notes": {
- "name": "notes",
- "type": "text",
- "primaryKey": false,
- "notNull": false,
- "autoincrement": false
- },
- "product_url": {
- "name": "product_url",
- "type": "text",
- "primaryKey": false,
- "notNull": false,
- "autoincrement": false
- },
- "image_filename": {
- "name": "image_filename",
- "type": "text",
- "primaryKey": false,
- "notNull": false,
- "autoincrement": false
- },
- "created_at": {
- "name": "created_at",
- "type": "integer",
- "primaryKey": false,
- "notNull": true,
- "autoincrement": false
- },
- "updated_at": {
- "name": "updated_at",
- "type": "integer",
- "primaryKey": false,
- "notNull": true,
- "autoincrement": false
- }
- },
- "indexes": {},
- "foreignKeys": {
- "items_category_id_categories_id_fk": {
- "name": "items_category_id_categories_id_fk",
- "tableFrom": "items",
- "tableTo": "categories",
- "columnsFrom": [
- "category_id"
- ],
- "columnsTo": [
- "id"
- ],
- "onDelete": "no action",
- "onUpdate": "no action"
- }
- },
- "compositePrimaryKeys": {},
- "uniqueConstraints": {},
- "checkConstraints": {}
- },
- "settings": {
- "name": "settings",
- "columns": {
- "key": {
- "name": "key",
- "type": "text",
- "primaryKey": true,
- "notNull": true,
- "autoincrement": false
- },
- "value": {
- "name": "value",
- "type": "text",
- "primaryKey": false,
- "notNull": true,
- "autoincrement": false
- }
- },
- "indexes": {},
- "foreignKeys": {},
- "compositePrimaryKeys": {},
- "uniqueConstraints": {},
- "checkConstraints": {}
- },
- "setup_items": {
- "name": "setup_items",
- "columns": {
- "id": {
- "name": "id",
- "type": "integer",
- "primaryKey": true,
- "notNull": true,
- "autoincrement": true
- },
- "setup_id": {
- "name": "setup_id",
- "type": "integer",
- "primaryKey": false,
- "notNull": true,
- "autoincrement": false
- },
- "item_id": {
- "name": "item_id",
- "type": "integer",
- "primaryKey": false,
- "notNull": true,
- "autoincrement": false
- }
- },
- "indexes": {},
- "foreignKeys": {
- "setup_items_setup_id_setups_id_fk": {
- "name": "setup_items_setup_id_setups_id_fk",
- "tableFrom": "setup_items",
- "tableTo": "setups",
- "columnsFrom": [
- "setup_id"
- ],
- "columnsTo": [
- "id"
- ],
- "onDelete": "cascade",
- "onUpdate": "no action"
- },
- "setup_items_item_id_items_id_fk": {
- "name": "setup_items_item_id_items_id_fk",
- "tableFrom": "setup_items",
- "tableTo": "items",
- "columnsFrom": [
- "item_id"
- ],
- "columnsTo": [
- "id"
- ],
- "onDelete": "cascade",
- "onUpdate": "no action"
- }
- },
- "compositePrimaryKeys": {},
- "uniqueConstraints": {},
- "checkConstraints": {}
- },
- "setups": {
- "name": "setups",
- "columns": {
- "id": {
- "name": "id",
- "type": "integer",
- "primaryKey": true,
- "notNull": true,
- "autoincrement": true
- },
- "name": {
- "name": "name",
- "type": "text",
- "primaryKey": false,
- "notNull": true,
- "autoincrement": false
- },
- "created_at": {
- "name": "created_at",
- "type": "integer",
- "primaryKey": false,
- "notNull": true,
- "autoincrement": false
- },
- "updated_at": {
- "name": "updated_at",
- "type": "integer",
- "primaryKey": false,
- "notNull": true,
- "autoincrement": false
- }
- },
- "indexes": {},
- "foreignKeys": {},
- "compositePrimaryKeys": {},
- "uniqueConstraints": {},
- "checkConstraints": {}
- },
- "thread_candidates": {
- "name": "thread_candidates",
- "columns": {
- "id": {
- "name": "id",
- "type": "integer",
- "primaryKey": true,
- "notNull": true,
- "autoincrement": true
- },
- "thread_id": {
- "name": "thread_id",
- "type": "integer",
- "primaryKey": false,
- "notNull": true,
- "autoincrement": false
- },
- "name": {
- "name": "name",
- "type": "text",
- "primaryKey": false,
- "notNull": true,
- "autoincrement": false
- },
- "weight_grams": {
- "name": "weight_grams",
- "type": "real",
- "primaryKey": false,
- "notNull": false,
- "autoincrement": false
- },
- "price_cents": {
- "name": "price_cents",
- "type": "integer",
- "primaryKey": false,
- "notNull": false,
- "autoincrement": false
- },
- "category_id": {
- "name": "category_id",
- "type": "integer",
- "primaryKey": false,
- "notNull": true,
- "autoincrement": false
- },
- "notes": {
- "name": "notes",
- "type": "text",
- "primaryKey": false,
- "notNull": false,
- "autoincrement": false
- },
- "product_url": {
- "name": "product_url",
- "type": "text",
- "primaryKey": false,
- "notNull": false,
- "autoincrement": false
- },
- "image_filename": {
- "name": "image_filename",
- "type": "text",
- "primaryKey": false,
- "notNull": false,
- "autoincrement": false
- },
- "created_at": {
- "name": "created_at",
- "type": "integer",
- "primaryKey": false,
- "notNull": true,
- "autoincrement": false
- },
- "updated_at": {
- "name": "updated_at",
- "type": "integer",
- "primaryKey": false,
- "notNull": true,
- "autoincrement": false
- }
- },
- "indexes": {},
- "foreignKeys": {
- "thread_candidates_thread_id_threads_id_fk": {
- "name": "thread_candidates_thread_id_threads_id_fk",
- "tableFrom": "thread_candidates",
- "tableTo": "threads",
- "columnsFrom": [
- "thread_id"
- ],
- "columnsTo": [
- "id"
- ],
- "onDelete": "cascade",
- "onUpdate": "no action"
- },
- "thread_candidates_category_id_categories_id_fk": {
- "name": "thread_candidates_category_id_categories_id_fk",
- "tableFrom": "thread_candidates",
- "tableTo": "categories",
- "columnsFrom": [
- "category_id"
- ],
- "columnsTo": [
- "id"
- ],
- "onDelete": "no action",
- "onUpdate": "no action"
- }
- },
- "compositePrimaryKeys": {},
- "uniqueConstraints": {},
- "checkConstraints": {}
- },
- "threads": {
- "name": "threads",
- "columns": {
- "id": {
- "name": "id",
- "type": "integer",
- "primaryKey": true,
- "notNull": true,
- "autoincrement": true
- },
- "name": {
- "name": "name",
- "type": "text",
- "primaryKey": false,
- "notNull": true,
- "autoincrement": false
- },
- "status": {
- "name": "status",
- "type": "text",
- "primaryKey": false,
- "notNull": true,
- "autoincrement": false,
- "default": "'active'"
- },
- "resolved_candidate_id": {
- "name": "resolved_candidate_id",
- "type": "integer",
- "primaryKey": false,
- "notNull": false,
- "autoincrement": false
- },
- "category_id": {
- "name": "category_id",
- "type": "integer",
- "primaryKey": false,
- "notNull": true,
- "autoincrement": false
- },
- "created_at": {
- "name": "created_at",
- "type": "integer",
- "primaryKey": false,
- "notNull": true,
- "autoincrement": false
- },
- "updated_at": {
- "name": "updated_at",
- "type": "integer",
- "primaryKey": false,
- "notNull": true,
- "autoincrement": false
- }
- },
- "indexes": {},
- "foreignKeys": {
- "threads_category_id_categories_id_fk": {
- "name": "threads_category_id_categories_id_fk",
- "tableFrom": "threads",
- "tableTo": "categories",
- "columnsFrom": [
- "category_id"
- ],
- "columnsTo": [
- "id"
- ],
- "onDelete": "no action",
- "onUpdate": "no action"
- }
- },
- "compositePrimaryKeys": {},
- "uniqueConstraints": {},
- "checkConstraints": {}
- }
- },
- "views": {},
- "enums": {},
- "_meta": {
- "schemas": {},
- "tables": {},
- "columns": {}
- },
- "internal": {
- "indexes": {}
- }
-}
\ No newline at end of file
+ "version": "6",
+ "dialect": "sqlite",
+ "id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
+ "prevId": "78e5f5c8-f8f0-43f4-93f8-5ef68154ed17",
+ "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
+ },
+ "icon": {
+ "name": "icon",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'package'"
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "categories_name_unique": {
+ "name": "categories_name_unique",
+ "columns": ["name"],
+ "isUnique": true
+ }
+ },
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "items": {
+ "name": "items",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "integer",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": true
+ },
+ "name": {
+ "name": "name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "weight_grams": {
+ "name": "weight_grams",
+ "type": "real",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "price_cents": {
+ "name": "price_cents",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "category_id": {
+ "name": "category_id",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "notes": {
+ "name": "notes",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "product_url": {
+ "name": "product_url",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "image_filename": {
+ "name": "image_filename",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "items_category_id_categories_id_fk": {
+ "name": "items_category_id_categories_id_fk",
+ "tableFrom": "items",
+ "tableTo": "categories",
+ "columnsFrom": ["category_id"],
+ "columnsTo": ["id"],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "settings": {
+ "name": "settings",
+ "columns": {
+ "key": {
+ "name": "key",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "value": {
+ "name": "value",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "setup_items": {
+ "name": "setup_items",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "integer",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": true
+ },
+ "setup_id": {
+ "name": "setup_id",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "item_id": {
+ "name": "item_id",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "setup_items_setup_id_setups_id_fk": {
+ "name": "setup_items_setup_id_setups_id_fk",
+ "tableFrom": "setup_items",
+ "tableTo": "setups",
+ "columnsFrom": ["setup_id"],
+ "columnsTo": ["id"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "setup_items_item_id_items_id_fk": {
+ "name": "setup_items_item_id_items_id_fk",
+ "tableFrom": "setup_items",
+ "tableTo": "items",
+ "columnsFrom": ["item_id"],
+ "columnsTo": ["id"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "setups": {
+ "name": "setups",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "integer",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": true
+ },
+ "name": {
+ "name": "name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "thread_candidates": {
+ "name": "thread_candidates",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "integer",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": true
+ },
+ "thread_id": {
+ "name": "thread_id",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "name": {
+ "name": "name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "weight_grams": {
+ "name": "weight_grams",
+ "type": "real",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "price_cents": {
+ "name": "price_cents",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "category_id": {
+ "name": "category_id",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "notes": {
+ "name": "notes",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "product_url": {
+ "name": "product_url",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "image_filename": {
+ "name": "image_filename",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "thread_candidates_thread_id_threads_id_fk": {
+ "name": "thread_candidates_thread_id_threads_id_fk",
+ "tableFrom": "thread_candidates",
+ "tableTo": "threads",
+ "columnsFrom": ["thread_id"],
+ "columnsTo": ["id"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "thread_candidates_category_id_categories_id_fk": {
+ "name": "thread_candidates_category_id_categories_id_fk",
+ "tableFrom": "thread_candidates",
+ "tableTo": "categories",
+ "columnsFrom": ["category_id"],
+ "columnsTo": ["id"],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "threads": {
+ "name": "threads",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "integer",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": true
+ },
+ "name": {
+ "name": "name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "status": {
+ "name": "status",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'active'"
+ },
+ "resolved_candidate_id": {
+ "name": "resolved_candidate_id",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "category_id": {
+ "name": "category_id",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "threads_category_id_categories_id_fk": {
+ "name": "threads_category_id_categories_id_fk",
+ "tableFrom": "threads",
+ "tableTo": "categories",
+ "columnsFrom": ["category_id"],
+ "columnsTo": ["id"],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ }
+ },
+ "views": {},
+ "enums": {},
+ "_meta": {
+ "schemas": {},
+ "tables": {},
+ "columns": {}
+ },
+ "internal": {
+ "indexes": {}
+ }
+}
diff --git a/drizzle/meta/_journal.json b/drizzle/meta/_journal.json
index 5816e93..69a6011 100644
--- a/drizzle/meta/_journal.json
+++ b/drizzle/meta/_journal.json
@@ -1,20 +1,20 @@
{
- "version": "7",
- "dialect": "sqlite",
- "entries": [
- {
- "idx": 0,
- "version": "6",
- "when": 1773589489626,
- "tag": "0000_bitter_luckman",
- "breakpoints": true
- },
- {
- "idx": 1,
- "version": "6",
- "when": 1773593102000,
- "tag": "0001_rename_emoji_to_icon",
- "breakpoints": true
- }
- ]
+ "version": "7",
+ "dialect": "sqlite",
+ "entries": [
+ {
+ "idx": 0,
+ "version": "6",
+ "when": 1773589489626,
+ "tag": "0000_bitter_luckman",
+ "breakpoints": true
+ },
+ {
+ "idx": 1,
+ "version": "6",
+ "when": 1773593102000,
+ "tag": "0001_rename_emoji_to_icon",
+ "breakpoints": true
+ }
+ ]
}
diff --git a/package.json b/package.json
index e7e836b..0fab63b 100644
--- a/package.json
+++ b/package.json
@@ -1,47 +1,47 @@
{
- "name": "gearbox",
- "module": "index.ts",
- "type": "module",
- "private": true,
- "scripts": {
- "dev:client": "vite",
- "dev:server": "bun --hot src/server/index.ts",
- "build": "vite build",
- "db:generate": "bunx drizzle-kit generate",
- "db:push": "bunx drizzle-kit push",
- "test": "bun test",
- "lint": "bunx @biomejs/biome check ."
- },
- "devDependencies": {
- "@biomejs/biome": "^2.4.7",
- "@tanstack/react-query-devtools": "^5.91.3",
- "@tanstack/react-router-devtools": "^1.166.7",
- "@tanstack/router-plugin": "^1.166.9",
- "@types/better-sqlite3": "^7.6.13",
- "@types/bun": "latest",
- "@types/react": "^19.2.14",
- "@types/react-dom": "^19.2.3",
- "@vitejs/plugin-react": "^6.0.1",
- "better-sqlite3": "^12.8.0",
- "drizzle-kit": "^0.31.9",
- "vite": "^8.0.0"
- },
- "peerDependencies": {
- "typescript": "^5.9.3"
- },
- "dependencies": {
- "@hono/zod-validator": "^0.7.6",
- "@tailwindcss/vite": "^4.2.1",
- "@tanstack/react-query": "^5.90.21",
- "@tanstack/react-router": "^1.167.0",
- "clsx": "^2.1.1",
- "drizzle-orm": "^0.45.1",
- "hono": "^4.12.8",
- "lucide-react": "^0.577.0",
- "react": "^19.2.4",
- "react-dom": "^19.2.4",
- "tailwindcss": "^4.2.1",
- "zod": "^4.3.6",
- "zustand": "^5.0.11"
- }
+ "name": "gearbox",
+ "module": "index.ts",
+ "type": "module",
+ "private": true,
+ "scripts": {
+ "dev:client": "vite",
+ "dev:server": "bun --hot src/server/index.ts",
+ "build": "vite build",
+ "db:generate": "bunx drizzle-kit generate",
+ "db:push": "bunx drizzle-kit push",
+ "test": "bun test",
+ "lint": "bunx @biomejs/biome check ."
+ },
+ "devDependencies": {
+ "@biomejs/biome": "^2.4.7",
+ "@tanstack/react-query-devtools": "^5.91.3",
+ "@tanstack/react-router-devtools": "^1.166.7",
+ "@tanstack/router-plugin": "^1.166.9",
+ "@types/better-sqlite3": "^7.6.13",
+ "@types/bun": "latest",
+ "@types/react": "^19.2.14",
+ "@types/react-dom": "^19.2.3",
+ "@vitejs/plugin-react": "^6.0.1",
+ "better-sqlite3": "^12.8.0",
+ "drizzle-kit": "^0.31.9",
+ "vite": "^8.0.0"
+ },
+ "peerDependencies": {
+ "typescript": "^5.9.3"
+ },
+ "dependencies": {
+ "@hono/zod-validator": "^0.7.6",
+ "@tailwindcss/vite": "^4.2.1",
+ "@tanstack/react-query": "^5.90.21",
+ "@tanstack/react-router": "^1.167.0",
+ "clsx": "^2.1.1",
+ "drizzle-orm": "^0.45.1",
+ "hono": "^4.12.8",
+ "lucide-react": "^0.577.0",
+ "react": "^19.2.4",
+ "react-dom": "^19.2.4",
+ "tailwindcss": "^4.2.1",
+ "zod": "^4.3.6",
+ "zustand": "^5.0.11"
+ }
}
diff --git a/src/client/components/CandidateCard.tsx b/src/client/components/CandidateCard.tsx
index 30a84ac..c35efa4 100644
--- a/src/client/components/CandidateCard.tsx
+++ b/src/client/components/CandidateCard.tsx
@@ -73,7 +73,11 @@ export function CandidateCard({
/>
) : (
-
+
)}
@@ -93,7 +97,12 @@ export function CandidateCard({
)}
- {categoryName}
+ {" "}
+ {categoryName}
diff --git a/src/client/components/CandidateForm.tsx b/src/client/components/CandidateForm.tsx
index a34c273..6a2ac11 100644
--- a/src/client/components/CandidateForm.tsx
+++ b/src/client/components/CandidateForm.tsx
@@ -1,285 +1,281 @@
-import { useState, useEffect } from "react";
-import {
- useCreateCandidate,
- useUpdateCandidate,
-} from "../hooks/useCandidates";
+import { useEffect, useState } from "react";
+import { useCreateCandidate, useUpdateCandidate } from "../hooks/useCandidates";
import { useThread } from "../hooks/useThreads";
import { useUIStore } from "../stores/uiStore";
import { CategoryPicker } from "./CategoryPicker";
import { ImageUpload } from "./ImageUpload";
interface CandidateFormProps {
- mode: "add" | "edit";
- threadId: number;
- candidateId?: number | null;
+ mode: "add" | "edit";
+ threadId: number;
+ candidateId?: number | null;
}
interface FormData {
- name: string;
- weightGrams: string;
- priceDollars: string;
- categoryId: number;
- notes: string;
- productUrl: string;
- imageFilename: string | null;
+ name: string;
+ weightGrams: string;
+ priceDollars: string;
+ categoryId: number;
+ notes: string;
+ productUrl: string;
+ imageFilename: string | null;
}
const INITIAL_FORM: FormData = {
- name: "",
- weightGrams: "",
- priceDollars: "",
- categoryId: 1,
- notes: "",
- productUrl: "",
- imageFilename: null,
+ name: "",
+ weightGrams: "",
+ priceDollars: "",
+ categoryId: 1,
+ notes: "",
+ productUrl: "",
+ imageFilename: null,
};
export function CandidateForm({
- mode,
- threadId,
- candidateId,
+ mode,
+ threadId,
+ candidateId,
}: CandidateFormProps) {
- const { data: thread } = useThread(threadId);
- const createCandidate = useCreateCandidate(threadId);
- const updateCandidate = useUpdateCandidate(threadId);
- const closeCandidatePanel = useUIStore((s) => s.closeCandidatePanel);
+ const { data: thread } = useThread(threadId);
+ const createCandidate = useCreateCandidate(threadId);
+ const updateCandidate = useUpdateCandidate(threadId);
+ const closeCandidatePanel = useUIStore((s) => s.closeCandidatePanel);
- const [form, setForm] = useState
(INITIAL_FORM);
- const [errors, setErrors] = useState>({});
+ const [form, setForm] = useState(INITIAL_FORM);
+ const [errors, setErrors] = useState>({});
- // Pre-fill form when editing
- useEffect(() => {
- if (mode === "edit" && candidateId != null && thread?.candidates) {
- const candidate = thread.candidates.find((c) => c.id === candidateId);
- if (candidate) {
- setForm({
- name: candidate.name,
- weightGrams:
- candidate.weightGrams != null ? String(candidate.weightGrams) : "",
- priceDollars:
- candidate.priceCents != null
- ? (candidate.priceCents / 100).toFixed(2)
- : "",
- categoryId: candidate.categoryId,
- notes: candidate.notes ?? "",
- productUrl: candidate.productUrl ?? "",
- imageFilename: candidate.imageFilename,
- });
- }
- } else if (mode === "add") {
- setForm(INITIAL_FORM);
- }
- }, [mode, candidateId, thread?.candidates]);
+ // Pre-fill form when editing
+ useEffect(() => {
+ if (mode === "edit" && candidateId != null && thread?.candidates) {
+ const candidate = thread.candidates.find((c) => c.id === candidateId);
+ if (candidate) {
+ setForm({
+ name: candidate.name,
+ weightGrams:
+ candidate.weightGrams != null ? String(candidate.weightGrams) : "",
+ priceDollars:
+ candidate.priceCents != null
+ ? (candidate.priceCents / 100).toFixed(2)
+ : "",
+ categoryId: candidate.categoryId,
+ notes: candidate.notes ?? "",
+ productUrl: candidate.productUrl ?? "",
+ imageFilename: candidate.imageFilename,
+ });
+ }
+ } else if (mode === "add") {
+ setForm(INITIAL_FORM);
+ }
+ }, [mode, candidateId, thread?.candidates]);
- function validate(): boolean {
- const newErrors: Record = {};
- if (!form.name.trim()) {
- newErrors.name = "Name is required";
- }
- if (
- form.weightGrams &&
- (isNaN(Number(form.weightGrams)) || Number(form.weightGrams) < 0)
- ) {
- newErrors.weightGrams = "Must be a positive number";
- }
- if (
- form.priceDollars &&
- (isNaN(Number(form.priceDollars)) || Number(form.priceDollars) < 0)
- ) {
- newErrors.priceDollars = "Must be a positive number";
- }
- if (
- form.productUrl &&
- form.productUrl.trim() !== "" &&
- !form.productUrl.match(/^https?:\/\//)
- ) {
- newErrors.productUrl = "Must be a valid URL (https://...)";
- }
- setErrors(newErrors);
- return Object.keys(newErrors).length === 0;
- }
+ function validate(): boolean {
+ const newErrors: Record = {};
+ if (!form.name.trim()) {
+ newErrors.name = "Name is required";
+ }
+ if (
+ form.weightGrams &&
+ (Number.isNaN(Number(form.weightGrams)) || Number(form.weightGrams) < 0)
+ ) {
+ newErrors.weightGrams = "Must be a positive number";
+ }
+ if (
+ form.priceDollars &&
+ (Number.isNaN(Number(form.priceDollars)) || Number(form.priceDollars) < 0)
+ ) {
+ newErrors.priceDollars = "Must be a positive number";
+ }
+ if (
+ form.productUrl &&
+ form.productUrl.trim() !== "" &&
+ !form.productUrl.match(/^https?:\/\//)
+ ) {
+ newErrors.productUrl = "Must be a valid URL (https://...)";
+ }
+ setErrors(newErrors);
+ return Object.keys(newErrors).length === 0;
+ }
- function handleSubmit(e: React.FormEvent) {
- e.preventDefault();
- if (!validate()) return;
+ function handleSubmit(e: React.FormEvent) {
+ e.preventDefault();
+ if (!validate()) return;
- const payload = {
- name: form.name.trim(),
- weightGrams: form.weightGrams ? Number(form.weightGrams) : undefined,
- priceCents: form.priceDollars
- ? Math.round(Number(form.priceDollars) * 100)
- : undefined,
- categoryId: form.categoryId,
- notes: form.notes.trim() || undefined,
- productUrl: form.productUrl.trim() || undefined,
- imageFilename: form.imageFilename ?? undefined,
- };
+ const payload = {
+ name: form.name.trim(),
+ weightGrams: form.weightGrams ? Number(form.weightGrams) : undefined,
+ priceCents: form.priceDollars
+ ? Math.round(Number(form.priceDollars) * 100)
+ : undefined,
+ categoryId: form.categoryId,
+ notes: form.notes.trim() || undefined,
+ productUrl: form.productUrl.trim() || undefined,
+ imageFilename: form.imageFilename ?? undefined,
+ };
- if (mode === "add") {
- createCandidate.mutate(payload, {
- onSuccess: () => {
- setForm(INITIAL_FORM);
- closeCandidatePanel();
- },
- });
- } else if (candidateId != null) {
- updateCandidate.mutate(
- { candidateId, ...payload },
- { onSuccess: () => closeCandidatePanel() },
- );
- }
- }
+ if (mode === "add") {
+ createCandidate.mutate(payload, {
+ onSuccess: () => {
+ setForm(INITIAL_FORM);
+ closeCandidatePanel();
+ },
+ });
+ } else if (candidateId != null) {
+ updateCandidate.mutate(
+ { candidateId, ...payload },
+ { onSuccess: () => closeCandidatePanel() },
+ );
+ }
+ }
- const isPending = createCandidate.isPending || updateCandidate.isPending;
+ const isPending = createCandidate.isPending || updateCandidate.isPending;
- return (
-
+ );
}
diff --git a/src/client/components/CategoryHeader.tsx b/src/client/components/CategoryHeader.tsx
index fff2e49..fb4e41c 100644
--- a/src/client/components/CategoryHeader.tsx
+++ b/src/client/components/CategoryHeader.tsx
@@ -1,6 +1,6 @@
import { useState } from "react";
-import { formatWeight, formatPrice } from "../lib/formatters";
-import { useUpdateCategory, useDeleteCategory } from "../hooks/useCategories";
+import { useDeleteCategory, useUpdateCategory } from "../hooks/useCategories";
+import { formatPrice, formatWeight } from "../lib/formatters";
import { LucideIcon } from "../lib/iconData";
import { IconPicker } from "./IconPicker";
@@ -39,7 +39,9 @@ export function CategoryHeader({
function handleDelete() {
if (
- confirm(`Delete category "${name}"? Items will be moved to Uncategorized.`)
+ confirm(
+ `Delete category "${name}"? Items will be moved to Uncategorized.`,
+ )
) {
deleteCategory.mutate(categoryId);
}
@@ -58,7 +60,6 @@ export function CategoryHeader({
if (e.key === "Enter") handleSave();
if (e.key === "Escape") setIsEditing(false);
}}
- autoFocus
/>
= 0 && highlightIndex < filtered.length) {
handleSelect(filtered[highlightIndex].id);
- } else if (
- showCreateOption &&
- highlightIndex === filtered.length
- ) {
+ } else if (showCreateOption && highlightIndex === filtered.length) {
handleStartCreate();
}
break;
@@ -162,11 +156,7 @@ export function CategoryPicker({ value, onChange }: CategoryPickerProps) {
: undefined
}
value={
- isOpen
- ? inputValue
- : selectedCategory
- ? selectedCategory.name
- : ""
+ isOpen ? inputValue : selectedCategory ? selectedCategory.name : ""
}
placeholder="Search or create category..."
onChange={(e) => {
@@ -188,14 +178,12 @@ export function CategoryPicker({ value, onChange }: CategoryPickerProps) {
{filtered.map((cat, i) => (
s.confirmDeleteItemId);
- const closeConfirmDelete = useUIStore((s) => s.closeConfirmDelete);
- const deleteItem = useDeleteItem();
- const { data: items } = useItems();
+ const confirmDeleteItemId = useUIStore((s) => s.confirmDeleteItemId);
+ const closeConfirmDelete = useUIStore((s) => s.closeConfirmDelete);
+ const deleteItem = useDeleteItem();
+ const { data: items } = useItems();
- if (confirmDeleteItemId == null) return null;
+ if (confirmDeleteItemId == null) return null;
- const item = items?.find((i) => i.id === confirmDeleteItemId);
- const itemName = item?.name ?? "this item";
+ const item = items?.find((i) => i.id === confirmDeleteItemId);
+ const itemName = item?.name ?? "this item";
- function handleDelete() {
- if (confirmDeleteItemId == null) return;
- deleteItem.mutate(confirmDeleteItemId, {
- onSuccess: () => closeConfirmDelete(),
- });
- }
+ function handleDelete() {
+ if (confirmDeleteItemId == null) return;
+ deleteItem.mutate(confirmDeleteItemId, {
+ onSuccess: () => closeConfirmDelete(),
+ });
+ }
- return (
-
-
{
- if (e.key === "Escape") closeConfirmDelete();
- }}
- />
-
-
- Delete Item
-
-
- Are you sure you want to delete{" "}
- {itemName} ? This action cannot be
- undone.
-
-
-
- Cancel
-
-
- {deleteItem.isPending ? "Deleting..." : "Delete"}
-
-
-
-
- );
+ return (
+
+
{
+ if (e.key === "Escape") closeConfirmDelete();
+ }}
+ />
+
+
+ Delete Item
+
+
+ Are you sure you want to delete{" "}
+ {itemName} ? This action cannot be
+ undone.
+
+
+
+ Cancel
+
+
+ {deleteItem.isPending ? "Deleting..." : "Delete"}
+
+
+
+
+ );
}
diff --git a/src/client/components/ExternalLinkDialog.tsx b/src/client/components/ExternalLinkDialog.tsx
index 31efbd6..80f500a 100644
--- a/src/client/components/ExternalLinkDialog.tsx
+++ b/src/client/components/ExternalLinkDialog.tsx
@@ -37,9 +37,7 @@ export function ExternalLinkDialog() {
You are about to leave GearBox
-
- You will be redirected to:
-
+
You will be redirected to:
{externalLinkUrl}
diff --git a/src/client/components/IconPicker.tsx b/src/client/components/IconPicker.tsx
index 80c8ab6..e1b1516 100644
--- a/src/client/components/IconPicker.tsx
+++ b/src/client/components/IconPicker.tsx
@@ -8,11 +8,7 @@ interface IconPickerProps {
size?: "sm" | "md";
}
-export function IconPicker({
- value,
- onChange,
- size = "md",
-}: IconPickerProps) {
+export function IconPicker({ value, onChange, size = "md" }: IconPickerProps) {
const [isOpen, setIsOpen] = useState(false);
const [search, setSearch] = useState("");
const [activeGroup, setActiveGroup] = useState(0);
@@ -99,8 +95,7 @@ export function IconPicker({
const results = iconGroups.flatMap((group) =>
group.icons.filter(
(icon) =>
- icon.name.includes(q) ||
- icon.keywords.some((kw) => kw.includes(q)),
+ icon.name.includes(q) || icon.keywords.some((kw) => kw.includes(q)),
),
);
// Deduplicate by name (some icons appear in multiple groups)
@@ -118,8 +113,7 @@ export function IconPicker({
setSearch("");
}
- const buttonSize =
- size === "sm" ? "w-10 h-10" : "w-12 h-12";
+ const buttonSize = size === "sm" ? "w-10 h-10" : "w-12 h-12";
const iconSize = size === "sm" ? 20 : 24;
return (
@@ -179,9 +173,7 @@ export function IconPicker({
name={group.icon}
size={16}
className={
- i === activeGroup
- ? "text-blue-700"
- : "text-gray-400"
+ i === activeGroup ? "text-blue-700" : "text-gray-400"
}
/>
diff --git a/src/client/components/ImageUpload.tsx b/src/client/components/ImageUpload.tsx
index 91bf8cf..2b8b065 100644
--- a/src/client/components/ImageUpload.tsx
+++ b/src/client/components/ImageUpload.tsx
@@ -1,4 +1,4 @@
-import { useState, useRef } from "react";
+import { useRef, useState } from "react";
import { apiUpload } from "../lib/api";
interface ImageUploadProps {
@@ -32,10 +32,7 @@ export function ImageUpload({ value, onChange }: ImageUploadProps) {
setUploading(true);
try {
- const result = await apiUpload<{ filename: string }>(
- "/api/images",
- file,
- );
+ const result = await apiUpload<{ filename: string }>("/api/images", file);
onChange(result.filename);
} catch {
setError("Upload failed. Please try again.");
diff --git a/src/client/components/ItemCard.tsx b/src/client/components/ItemCard.tsx
index 77fd23d..3112959 100644
--- a/src/client/components/ItemCard.tsx
+++ b/src/client/components/ItemCard.tsx
@@ -107,7 +107,11 @@ export function ItemCard({
/>
) : (
-
+
)}
@@ -127,7 +131,12 @@ export function ItemCard({
)}
- {categoryName}
+ {" "}
+ {categoryName}
diff --git a/src/client/components/ItemForm.tsx b/src/client/components/ItemForm.tsx
index 15e8211..5d39e9b 100644
--- a/src/client/components/ItemForm.tsx
+++ b/src/client/components/ItemForm.tsx
@@ -1,278 +1,282 @@
-import { useState, useEffect } from "react";
-import { useCreateItem, useUpdateItem, useItems } from "../hooks/useItems";
+import { useEffect, useState } from "react";
+import { useCreateItem, useItems, useUpdateItem } from "../hooks/useItems";
import { useUIStore } from "../stores/uiStore";
import { CategoryPicker } from "./CategoryPicker";
import { ImageUpload } from "./ImageUpload";
interface ItemFormProps {
- mode: "add" | "edit";
- itemId?: number | null;
+ mode: "add" | "edit";
+ itemId?: number | null;
}
interface FormData {
- name: string;
- weightGrams: string;
- priceDollars: string;
- categoryId: number;
- notes: string;
- productUrl: string;
- imageFilename: string | null;
+ name: string;
+ weightGrams: string;
+ priceDollars: string;
+ categoryId: number;
+ notes: string;
+ productUrl: string;
+ imageFilename: string | null;
}
const INITIAL_FORM: FormData = {
- name: "",
- weightGrams: "",
- priceDollars: "",
- categoryId: 1,
- notes: "",
- productUrl: "",
- imageFilename: null,
+ name: "",
+ weightGrams: "",
+ priceDollars: "",
+ categoryId: 1,
+ notes: "",
+ productUrl: "",
+ imageFilename: null,
};
export function ItemForm({ mode, itemId }: ItemFormProps) {
- const { data: items } = useItems();
- const createItem = useCreateItem();
- const updateItem = useUpdateItem();
- const closePanel = useUIStore((s) => s.closePanel);
- const openConfirmDelete = useUIStore((s) => s.openConfirmDelete);
+ const { data: items } = useItems();
+ const createItem = useCreateItem();
+ const updateItem = useUpdateItem();
+ const closePanel = useUIStore((s) => s.closePanel);
+ const openConfirmDelete = useUIStore((s) => s.openConfirmDelete);
- const [form, setForm] = useState(INITIAL_FORM);
- const [errors, setErrors] = useState>({});
+ const [form, setForm] = useState(INITIAL_FORM);
+ const [errors, setErrors] = useState>({});
- // Pre-fill form when editing
- useEffect(() => {
- if (mode === "edit" && itemId != null && items) {
- const item = items.find((i) => i.id === itemId);
- if (item) {
- setForm({
- name: item.name,
- weightGrams:
- item.weightGrams != null ? String(item.weightGrams) : "",
- priceDollars:
- item.priceCents != null ? (item.priceCents / 100).toFixed(2) : "",
- categoryId: item.categoryId,
- notes: item.notes ?? "",
- productUrl: item.productUrl ?? "",
- imageFilename: item.imageFilename,
- });
- }
- } else if (mode === "add") {
- setForm(INITIAL_FORM);
- }
- }, [mode, itemId, items]);
+ // Pre-fill form when editing
+ useEffect(() => {
+ if (mode === "edit" && itemId != null && items) {
+ const item = items.find((i) => i.id === itemId);
+ if (item) {
+ setForm({
+ name: item.name,
+ weightGrams: item.weightGrams != null ? String(item.weightGrams) : "",
+ priceDollars:
+ item.priceCents != null ? (item.priceCents / 100).toFixed(2) : "",
+ categoryId: item.categoryId,
+ notes: item.notes ?? "",
+ productUrl: item.productUrl ?? "",
+ imageFilename: item.imageFilename,
+ });
+ }
+ } else if (mode === "add") {
+ setForm(INITIAL_FORM);
+ }
+ }, [mode, itemId, items]);
- function validate(): boolean {
- const newErrors: Record = {};
- if (!form.name.trim()) {
- newErrors.name = "Name is required";
- }
- if (form.weightGrams && (isNaN(Number(form.weightGrams)) || Number(form.weightGrams) < 0)) {
- newErrors.weightGrams = "Must be a positive number";
- }
- if (form.priceDollars && (isNaN(Number(form.priceDollars)) || Number(form.priceDollars) < 0)) {
- newErrors.priceDollars = "Must be a positive number";
- }
- if (
- form.productUrl &&
- form.productUrl.trim() !== "" &&
- !form.productUrl.match(/^https?:\/\//)
- ) {
- newErrors.productUrl = "Must be a valid URL (https://...)";
- }
- setErrors(newErrors);
- return Object.keys(newErrors).length === 0;
- }
+ function validate(): boolean {
+ const newErrors: Record = {};
+ if (!form.name.trim()) {
+ newErrors.name = "Name is required";
+ }
+ if (
+ form.weightGrams &&
+ (Number.isNaN(Number(form.weightGrams)) || Number(form.weightGrams) < 0)
+ ) {
+ newErrors.weightGrams = "Must be a positive number";
+ }
+ if (
+ form.priceDollars &&
+ (Number.isNaN(Number(form.priceDollars)) || Number(form.priceDollars) < 0)
+ ) {
+ newErrors.priceDollars = "Must be a positive number";
+ }
+ if (
+ form.productUrl &&
+ form.productUrl.trim() !== "" &&
+ !form.productUrl.match(/^https?:\/\//)
+ ) {
+ newErrors.productUrl = "Must be a valid URL (https://...)";
+ }
+ setErrors(newErrors);
+ return Object.keys(newErrors).length === 0;
+ }
- function handleSubmit(e: React.FormEvent) {
- e.preventDefault();
- if (!validate()) return;
+ function handleSubmit(e: React.FormEvent) {
+ e.preventDefault();
+ if (!validate()) return;
- const payload = {
- name: form.name.trim(),
- weightGrams: form.weightGrams ? Number(form.weightGrams) : undefined,
- priceCents: form.priceDollars
- ? Math.round(Number(form.priceDollars) * 100)
- : undefined,
- categoryId: form.categoryId,
- notes: form.notes.trim() || undefined,
- productUrl: form.productUrl.trim() || undefined,
- imageFilename: form.imageFilename ?? undefined,
- };
+ const payload = {
+ name: form.name.trim(),
+ weightGrams: form.weightGrams ? Number(form.weightGrams) : undefined,
+ priceCents: form.priceDollars
+ ? Math.round(Number(form.priceDollars) * 100)
+ : undefined,
+ categoryId: form.categoryId,
+ notes: form.notes.trim() || undefined,
+ productUrl: form.productUrl.trim() || undefined,
+ imageFilename: form.imageFilename ?? undefined,
+ };
- if (mode === "add") {
- createItem.mutate(payload, {
- onSuccess: () => {
- setForm(INITIAL_FORM);
- closePanel();
- },
- });
- } else if (itemId != null) {
- updateItem.mutate(
- { id: itemId, ...payload },
- { onSuccess: () => closePanel() },
- );
- }
- }
+ if (mode === "add") {
+ createItem.mutate(payload, {
+ onSuccess: () => {
+ setForm(INITIAL_FORM);
+ closePanel();
+ },
+ });
+ } else if (itemId != null) {
+ updateItem.mutate(
+ { id: itemId, ...payload },
+ { onSuccess: () => closePanel() },
+ );
+ }
+ }
- const isPending = createItem.isPending || updateItem.isPending;
+ const isPending = createItem.isPending || updateItem.isPending;
- return (
-
- {/* Image */}
-
- setForm((f) => ({ ...f, imageFilename: filename }))
- }
- />
+ return (
+
+ {/* Image */}
+
+ setForm((f) => ({ ...f, imageFilename: filename }))
+ }
+ />
- {/* Name */}
-
-
- Name *
-
-
setForm((f) => ({ ...f, name: e.target.value }))}
- className="w-full px-3 py-2 border border-gray-200 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent"
- placeholder="e.g. Osprey Talon 22"
- autoFocus
- />
- {errors.name && (
-
{errors.name}
- )}
-
+ {/* Name */}
+
+
+ Name *
+
+
setForm((f) => ({ ...f, name: e.target.value }))}
+ className="w-full px-3 py-2 border border-gray-200 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent"
+ placeholder="e.g. Osprey Talon 22"
+ />
+ {errors.name && (
+
{errors.name}
+ )}
+
- {/* Weight */}
-
-
- Weight (g)
-
-
- setForm((f) => ({ ...f, weightGrams: e.target.value }))
- }
- className="w-full px-3 py-2 border border-gray-200 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent"
- placeholder="e.g. 680"
- />
- {errors.weightGrams && (
-
{errors.weightGrams}
- )}
-
+ {/* Weight */}
+
+
+ Weight (g)
+
+
+ setForm((f) => ({ ...f, weightGrams: e.target.value }))
+ }
+ className="w-full px-3 py-2 border border-gray-200 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent"
+ placeholder="e.g. 680"
+ />
+ {errors.weightGrams && (
+
{errors.weightGrams}
+ )}
+
- {/* Price */}
-
-
- Price ($)
-
-
- setForm((f) => ({ ...f, priceDollars: e.target.value }))
- }
- className="w-full px-3 py-2 border border-gray-200 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent"
- placeholder="e.g. 129.99"
- />
- {errors.priceDollars && (
-
{errors.priceDollars}
- )}
-
+ {/* Price */}
+
+
+ Price ($)
+
+
+ setForm((f) => ({ ...f, priceDollars: e.target.value }))
+ }
+ className="w-full px-3 py-2 border border-gray-200 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent"
+ placeholder="e.g. 129.99"
+ />
+ {errors.priceDollars && (
+
{errors.priceDollars}
+ )}
+
- {/* Category */}
-
-
- Category
-
- setForm((f) => ({ ...f, categoryId: id }))}
- />
-
+ {/* Category */}
+
+
+ Category
+
+ setForm((f) => ({ ...f, categoryId: id }))}
+ />
+
- {/* Notes */}
-
-
- Notes
-
- setForm((f) => ({ ...f, notes: e.target.value }))}
- rows={3}
- className="w-full px-3 py-2 border border-gray-200 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent resize-none"
- placeholder="Any additional notes..."
- />
-
+ {/* Notes */}
+
+
+ Notes
+
+ setForm((f) => ({ ...f, notes: e.target.value }))}
+ rows={3}
+ className="w-full px-3 py-2 border border-gray-200 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent resize-none"
+ placeholder="Any additional notes..."
+ />
+
- {/* Product Link */}
-
-
- Product Link
-
-
- setForm((f) => ({ ...f, productUrl: e.target.value }))
- }
- className="w-full px-3 py-2 border border-gray-200 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent"
- placeholder="https://..."
- />
- {errors.productUrl && (
-
{errors.productUrl}
- )}
-
+ {/* Product Link */}
+
+
+ Product Link
+
+
+ setForm((f) => ({ ...f, productUrl: e.target.value }))
+ }
+ className="w-full px-3 py-2 border border-gray-200 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent"
+ placeholder="https://..."
+ />
+ {errors.productUrl && (
+
{errors.productUrl}
+ )}
+
- {/* Actions */}
-
-
- {isPending
- ? "Saving..."
- : mode === "add"
- ? "Add Item"
- : "Save Changes"}
-
- {mode === "edit" && itemId != null && (
- openConfirmDelete(itemId)}
- className="py-2.5 px-4 text-red-600 hover:bg-red-50 text-sm font-medium rounded-lg transition-colors"
- >
- Delete
-
- )}
-
-
- );
+ {/* Actions */}
+
+
+ {isPending
+ ? "Saving..."
+ : mode === "add"
+ ? "Add Item"
+ : "Save Changes"}
+
+ {mode === "edit" && itemId != null && (
+ openConfirmDelete(itemId)}
+ className="py-2.5 px-4 text-red-600 hover:bg-red-50 text-sm font-medium rounded-lg transition-colors"
+ >
+ Delete
+
+ )}
+
+
+ );
}
diff --git a/src/client/components/ItemPicker.tsx b/src/client/components/ItemPicker.tsx
index 6013d3a..4ab36c8 100644
--- a/src/client/components/ItemPicker.tsx
+++ b/src/client/components/ItemPicker.tsx
@@ -1,142 +1,154 @@
-import { useState, useEffect } from "react";
-import { SlideOutPanel } from "./SlideOutPanel";
+import { useEffect, useState } from "react";
import { useItems } from "../hooks/useItems";
import { useSyncSetupItems } from "../hooks/useSetups";
-import { formatWeight, formatPrice } from "../lib/formatters";
+import { formatPrice, formatWeight } from "../lib/formatters";
import { LucideIcon } from "../lib/iconData";
+import { SlideOutPanel } from "./SlideOutPanel";
interface ItemPickerProps {
- setupId: number;
- currentItemIds: number[];
- isOpen: boolean;
- onClose: () => void;
+ setupId: number;
+ currentItemIds: number[];
+ isOpen: boolean;
+ onClose: () => void;
}
export function ItemPicker({
- setupId,
- currentItemIds,
- isOpen,
- onClose,
+ setupId,
+ currentItemIds,
+ isOpen,
+ onClose,
}: ItemPickerProps) {
- const { data: items } = useItems();
- const syncItems = useSyncSetupItems(setupId);
- const [selectedIds, setSelectedIds] = useState>(new Set());
+ const { data: items } = useItems();
+ const syncItems = useSyncSetupItems(setupId);
+ const [selectedIds, setSelectedIds] = useState>(new Set());
- // Reset selected IDs when panel opens
- useEffect(() => {
- if (isOpen) {
- setSelectedIds(new Set(currentItemIds));
- }
- }, [isOpen, currentItemIds]);
+ // Reset selected IDs when panel opens
+ useEffect(() => {
+ if (isOpen) {
+ setSelectedIds(new Set(currentItemIds));
+ }
+ }, [isOpen, currentItemIds]);
- function handleToggle(itemId: number) {
- setSelectedIds((prev) => {
- const next = new Set(prev);
- if (next.has(itemId)) {
- next.delete(itemId);
- } else {
- next.add(itemId);
- }
- return next;
- });
- }
+ function handleToggle(itemId: number) {
+ setSelectedIds((prev) => {
+ const next = new Set(prev);
+ if (next.has(itemId)) {
+ next.delete(itemId);
+ } else {
+ next.add(itemId);
+ }
+ return next;
+ });
+ }
- function handleDone() {
- syncItems.mutate(Array.from(selectedIds), {
- onSuccess: () => onClose(),
- });
- }
+ function handleDone() {
+ syncItems.mutate(Array.from(selectedIds), {
+ onSuccess: () => onClose(),
+ });
+ }
- // Group items by category
- const grouped = new Map<
- number,
- {
- categoryName: string;
- categoryIcon: string;
- items: NonNullable;
- }
- >();
+ // Group items by category
+ const grouped = new Map<
+ number,
+ {
+ categoryName: string;
+ categoryIcon: string;
+ items: NonNullable;
+ }
+ >();
- if (items) {
- for (const item of items) {
- const group = grouped.get(item.categoryId);
- if (group) {
- group.items.push(item);
- } else {
- grouped.set(item.categoryId, {
- categoryName: item.categoryName,
- categoryIcon: item.categoryIcon,
- items: [item],
- });
- }
- }
- }
+ if (items) {
+ for (const item of items) {
+ const group = grouped.get(item.categoryId);
+ if (group) {
+ group.items.push(item);
+ } else {
+ grouped.set(item.categoryId, {
+ categoryName: item.categoryName,
+ categoryIcon: item.categoryIcon,
+ items: [item],
+ });
+ }
+ }
+ }
- return (
-
-
-
- {!items || items.length === 0 ? (
-
-
- No items in your collection yet.
-
-
- ) : (
- Array.from(grouped.entries()).map(
- ([categoryId, { categoryName, categoryIcon, items: catItems }]) => (
-
-
- {categoryName}
-
-
- {catItems.map((item) => (
-
- handleToggle(item.id)}
- className="rounded border-gray-300 text-blue-600 focus:ring-blue-500"
- />
-
- {item.name}
-
-
- {item.weightGrams != null && formatWeight(item.weightGrams)}
- {item.weightGrams != null && item.priceCents != null && " · "}
- {item.priceCents != null && formatPrice(item.priceCents)}
-
-
- ))}
-
-
- ),
- )
- )}
-
+ return (
+
+
+
+ {!items || items.length === 0 ? (
+
+
+ No items in your collection yet.
+
+
+ ) : (
+ Array.from(grouped.entries()).map(
+ ([
+ categoryId,
+ { categoryName, categoryIcon, items: catItems },
+ ]) => (
+
+
+ {" "}
+ {categoryName}
+
+
+ {catItems.map((item) => (
+
+ handleToggle(item.id)}
+ className="rounded border-gray-300 text-blue-600 focus:ring-blue-500"
+ />
+
+ {item.name}
+
+
+ {item.weightGrams != null &&
+ formatWeight(item.weightGrams)}
+ {item.weightGrams != null &&
+ item.priceCents != null &&
+ " · "}
+ {item.priceCents != null &&
+ formatPrice(item.priceCents)}
+
+
+ ))}
+
+
+ ),
+ )
+ )}
+
- {/* Action buttons */}
-
-
- Cancel
-
-
- {syncItems.isPending ? "Saving..." : "Done"}
-
-
-
-
- );
+ {/* Action buttons */}
+
+
+ Cancel
+
+
+ {syncItems.isPending ? "Saving..." : "Done"}
+
+
+
+
+ );
}
diff --git a/src/client/components/OnboardingWizard.tsx b/src/client/components/OnboardingWizard.tsx
index a3ebc6a..300db1f 100644
--- a/src/client/components/OnboardingWizard.tsx
+++ b/src/client/components/OnboardingWizard.tsx
@@ -161,7 +161,6 @@ export function OnboardingWizard({ onComplete }: OnboardingWizardProps) {
onChange={(e) => setCategoryName(e.target.value)}
className="w-full px-3 py-2 border border-gray-200 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent"
placeholder="e.g. Shelter"
- autoFocus
/>
@@ -224,7 +223,6 @@ export function OnboardingWizard({ onComplete }: OnboardingWizardProps) {
onChange={(e) => setItemName(e.target.value)}
className="w-full px-3 py-2 border border-gray-200 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent"
placeholder="e.g. Big Agnes Copper Spur"
- autoFocus
/>
diff --git a/src/client/components/SetupCard.tsx b/src/client/components/SetupCard.tsx
index 834f254..209f2e9 100644
--- a/src/client/components/SetupCard.tsx
+++ b/src/client/components/SetupCard.tsx
@@ -1,43 +1,41 @@
import { Link } from "@tanstack/react-router";
-import { formatWeight, formatPrice } from "../lib/formatters";
+import { formatPrice, formatWeight } from "../lib/formatters";
interface SetupCardProps {
- id: number;
- name: string;
- itemCount: number;
- totalWeight: number;
- totalCost: number;
+ id: number;
+ name: string;
+ itemCount: number;
+ totalWeight: number;
+ totalCost: number;
}
export function SetupCard({
- id,
- name,
- itemCount,
- totalWeight,
- totalCost,
+ id,
+ name,
+ itemCount,
+ totalWeight,
+ totalCost,
}: SetupCardProps) {
- return (
-
-
-
- {name}
-
-
- {itemCount} {itemCount === 1 ? "item" : "items"}
-
-
-
-
- {formatWeight(totalWeight)}
-
-
- {formatPrice(totalCost)}
-
-
-
- );
+ return (
+
+
+
{name}
+
+ {itemCount} {itemCount === 1 ? "item" : "items"}
+
+
+
+
+ {formatWeight(totalWeight)}
+
+
+ {formatPrice(totalCost)}
+
+
+
+ );
}
diff --git a/src/client/components/SlideOutPanel.tsx b/src/client/components/SlideOutPanel.tsx
index 0e80151..f7977ec 100644
--- a/src/client/components/SlideOutPanel.tsx
+++ b/src/client/components/SlideOutPanel.tsx
@@ -1,76 +1,76 @@
import { useEffect } from "react";
interface SlideOutPanelProps {
- isOpen: boolean;
- onClose: () => void;
- title: string;
- children: React.ReactNode;
+ isOpen: boolean;
+ onClose: () => void;
+ title: string;
+ children: React.ReactNode;
}
export function SlideOutPanel({
- isOpen,
- onClose,
- title,
- children,
+ isOpen,
+ onClose,
+ title,
+ children,
}: SlideOutPanelProps) {
- // Close on Escape key
- useEffect(() => {
- function handleKeyDown(e: KeyboardEvent) {
- if (e.key === "Escape") onClose();
- }
- if (isOpen) {
- document.addEventListener("keydown", handleKeyDown);
- return () => document.removeEventListener("keydown", handleKeyDown);
- }
- }, [isOpen, onClose]);
+ // Close on Escape key
+ useEffect(() => {
+ function handleKeyDown(e: KeyboardEvent) {
+ if (e.key === "Escape") onClose();
+ }
+ if (isOpen) {
+ document.addEventListener("keydown", handleKeyDown);
+ return () => document.removeEventListener("keydown", handleKeyDown);
+ }
+ }, [isOpen, onClose]);
- return (
- <>
- {/* Backdrop */}
-
+ return (
+ <>
+ {/* Backdrop */}
+
- {/* Panel */}
-
- {/* Header */}
-
+ {/* Panel */}
+
+ {/* Header */}
+
- {/* Content */}
-
- {children}
-
-
- >
- );
+ {/* Content */}
+
+ {children}
+
+
+ >
+ );
}
diff --git a/src/client/components/ThreadCard.tsx b/src/client/components/ThreadCard.tsx
index 7be0a17..53df7dc 100644
--- a/src/client/components/ThreadCard.tsx
+++ b/src/client/components/ThreadCard.tsx
@@ -67,7 +67,12 @@ export function ThreadCard({
- {categoryName}
+ {" "}
+ {categoryName}
{candidateCount} {candidateCount === 1 ? "candidate" : "candidates"}
diff --git a/src/client/components/ThreadTabs.tsx b/src/client/components/ThreadTabs.tsx
index a28d5b4..81177fc 100644
--- a/src/client/components/ThreadTabs.tsx
+++ b/src/client/components/ThreadTabs.tsx
@@ -1,33 +1,33 @@
interface ThreadTabsProps {
- active: "gear" | "planning";
- onChange: (tab: "gear" | "planning") => void;
+ active: "gear" | "planning";
+ onChange: (tab: "gear" | "planning") => void;
}
const tabs = [
- { key: "gear" as const, label: "My Gear" },
- { key: "planning" as const, label: "Planning" },
+ { key: "gear" as const, label: "My Gear" },
+ { key: "planning" as const, label: "Planning" },
];
export function ThreadTabs({ active, onChange }: ThreadTabsProps) {
- return (
-
- {tabs.map((tab) => (
- onChange(tab.key)}
- className={`px-4 py-2.5 text-sm font-medium transition-colors relative ${
- active === tab.key
- ? "text-blue-600"
- : "text-gray-500 hover:text-gray-700"
- }`}
- >
- {tab.label}
- {active === tab.key && (
-
- )}
-
- ))}
-
- );
+ return (
+
+ {tabs.map((tab) => (
+ onChange(tab.key)}
+ className={`px-4 py-2.5 text-sm font-medium transition-colors relative ${
+ active === tab.key
+ ? "text-blue-600"
+ : "text-gray-500 hover:text-gray-700"
+ }`}
+ >
+ {tab.label}
+ {active === tab.key && (
+
+ )}
+
+ ))}
+
+ );
}
diff --git a/src/client/components/TotalsBar.tsx b/src/client/components/TotalsBar.tsx
index dab715b..cef5592 100644
--- a/src/client/components/TotalsBar.tsx
+++ b/src/client/components/TotalsBar.tsx
@@ -1,59 +1,68 @@
import { Link } from "@tanstack/react-router";
import { useTotals } from "../hooks/useTotals";
-import { formatWeight, formatPrice } from "../lib/formatters";
+import { formatPrice, formatWeight } from "../lib/formatters";
interface TotalsBarProps {
- title?: string;
- stats?: Array<{ label: string; value: string }>;
- linkTo?: string;
+ title?: string;
+ stats?: Array<{ label: string; value: string }>;
+ linkTo?: string;
}
-export function TotalsBar({ title = "GearBox", stats, linkTo }: TotalsBarProps) {
- const { data } = useTotals();
+export function TotalsBar({
+ title = "GearBox",
+ stats,
+ linkTo,
+}: TotalsBarProps) {
+ const { data } = useTotals();
- // When no stats provided, use global totals (backward compatible)
- const displayStats = stats ?? (data?.global
- ? [
- { label: "items", value: String(data.global.itemCount) },
- { label: "total", value: formatWeight(data.global.totalWeight) },
- { label: "spent", value: formatPrice(data.global.totalCost) },
- ]
- : [
- { label: "items", value: "0" },
- { label: "total", value: formatWeight(null) },
- { label: "spent", value: formatPrice(null) },
- ]);
+ // When no stats provided, use global totals (backward compatible)
+ const displayStats =
+ stats ??
+ (data?.global
+ ? [
+ { label: "items", value: String(data.global.itemCount) },
+ { label: "total", value: formatWeight(data.global.totalWeight) },
+ { label: "spent", value: formatPrice(data.global.totalCost) },
+ ]
+ : [
+ { label: "items", value: "0" },
+ { label: "total", value: formatWeight(null) },
+ { label: "spent", value: formatPrice(null) },
+ ]);
- const titleElement = linkTo ? (
-
- {title}
-
- ) : (
- {title}
- );
+ const titleElement = linkTo ? (
+
+ {title}
+
+ ) : (
+ {title}
+ );
- // If stats prop is explicitly an empty array, show title only (dashboard mode)
- const showStats = stats === undefined || stats.length > 0;
+ // If stats prop is explicitly an empty array, show title only (dashboard mode)
+ const showStats = stats === undefined || stats.length > 0;
- return (
-
-
-
- {titleElement}
- {showStats && (
-
- {displayStats.map((stat) => (
-
-
- {stat.value}
- {" "}
- {stat.label}
-
- ))}
-
- )}
-
-
-
- );
+ return (
+
+
+
+ {titleElement}
+ {showStats && (
+
+ {displayStats.map((stat) => (
+
+
+ {stat.value}
+ {" "}
+ {stat.label}
+
+ ))}
+
+ )}
+
+
+
+ );
}
diff --git a/src/client/hooks/useCandidates.ts b/src/client/hooks/useCandidates.ts
index 4ddcfe1..24f4c4b 100644
--- a/src/client/hooks/useCandidates.ts
+++ b/src/client/hooks/useCandidates.ts
@@ -1,61 +1,61 @@
import { useMutation, useQueryClient } from "@tanstack/react-query";
-import { apiPost, apiPut, apiDelete } from "../lib/api";
import type { CreateCandidate, UpdateCandidate } from "../../shared/types";
+import { apiDelete, apiPost, apiPut } from "../lib/api";
interface CandidateResponse {
- id: number;
- threadId: number;
- name: string;
- weightGrams: number | null;
- priceCents: number | null;
- categoryId: number;
- notes: string | null;
- productUrl: string | null;
- imageFilename: string | null;
- createdAt: string;
- updatedAt: string;
+ id: number;
+ threadId: number;
+ name: string;
+ weightGrams: number | null;
+ priceCents: number | null;
+ categoryId: number;
+ notes: string | null;
+ productUrl: string | null;
+ imageFilename: string | null;
+ createdAt: string;
+ updatedAt: string;
}
export function useCreateCandidate(threadId: number) {
- const queryClient = useQueryClient();
- return useMutation({
- mutationFn: (data: CreateCandidate & { imageFilename?: string }) =>
- apiPost(`/api/threads/${threadId}/candidates`, data),
- onSuccess: () => {
- queryClient.invalidateQueries({ queryKey: ["threads", threadId] });
- queryClient.invalidateQueries({ queryKey: ["threads"] });
- },
- });
+ const queryClient = useQueryClient();
+ return useMutation({
+ mutationFn: (data: CreateCandidate & { imageFilename?: string }) =>
+ apiPost(`/api/threads/${threadId}/candidates`, data),
+ onSuccess: () => {
+ queryClient.invalidateQueries({ queryKey: ["threads", threadId] });
+ queryClient.invalidateQueries({ queryKey: ["threads"] });
+ },
+ });
}
export function useUpdateCandidate(threadId: number) {
- const queryClient = useQueryClient();
- return useMutation({
- mutationFn: ({
- candidateId,
- ...data
- }: UpdateCandidate & { candidateId: number; imageFilename?: string }) =>
- apiPut(
- `/api/threads/${threadId}/candidates/${candidateId}`,
- data,
- ),
- onSuccess: () => {
- queryClient.invalidateQueries({ queryKey: ["threads", threadId] });
- queryClient.invalidateQueries({ queryKey: ["threads"] });
- },
- });
+ const queryClient = useQueryClient();
+ return useMutation({
+ mutationFn: ({
+ candidateId,
+ ...data
+ }: UpdateCandidate & { candidateId: number; imageFilename?: string }) =>
+ apiPut(
+ `/api/threads/${threadId}/candidates/${candidateId}`,
+ data,
+ ),
+ onSuccess: () => {
+ queryClient.invalidateQueries({ queryKey: ["threads", threadId] });
+ queryClient.invalidateQueries({ queryKey: ["threads"] });
+ },
+ });
}
export function useDeleteCandidate(threadId: number) {
- const queryClient = useQueryClient();
- return useMutation({
- mutationFn: (candidateId: number) =>
- apiDelete<{ success: boolean }>(
- `/api/threads/${threadId}/candidates/${candidateId}`,
- ),
- onSuccess: () => {
- queryClient.invalidateQueries({ queryKey: ["threads", threadId] });
- queryClient.invalidateQueries({ queryKey: ["threads"] });
- },
- });
+ const queryClient = useQueryClient();
+ return useMutation({
+ mutationFn: (candidateId: number) =>
+ apiDelete<{ success: boolean }>(
+ `/api/threads/${threadId}/candidates/${candidateId}`,
+ ),
+ onSuccess: () => {
+ queryClient.invalidateQueries({ queryKey: ["threads", threadId] });
+ queryClient.invalidateQueries({ queryKey: ["threads"] });
+ },
+ });
}
diff --git a/src/client/hooks/useCategories.ts b/src/client/hooks/useCategories.ts
index 4a09694..e1be3fb 100644
--- a/src/client/hooks/useCategories.ts
+++ b/src/client/hooks/useCategories.ts
@@ -1,53 +1,53 @@
-import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
-import { apiGet, apiPost, apiPut, apiDelete } from "../lib/api";
+import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import type { Category, CreateCategory } from "../../shared/types";
+import { apiDelete, apiGet, apiPost, apiPut } from "../lib/api";
export function useCategories() {
- return useQuery({
- queryKey: ["categories"],
- queryFn: () => apiGet("/api/categories"),
- });
+ return useQuery({
+ queryKey: ["categories"],
+ queryFn: () => apiGet("/api/categories"),
+ });
}
export function useCreateCategory() {
- const queryClient = useQueryClient();
- return useMutation({
- mutationFn: (data: CreateCategory) =>
- apiPost("/api/categories", data),
- onSuccess: () => {
- queryClient.invalidateQueries({ queryKey: ["categories"] });
- },
- });
+ const queryClient = useQueryClient();
+ return useMutation({
+ mutationFn: (data: CreateCategory) =>
+ apiPost("/api/categories", data),
+ onSuccess: () => {
+ queryClient.invalidateQueries({ queryKey: ["categories"] });
+ },
+ });
}
export function useUpdateCategory() {
- const queryClient = useQueryClient();
- return useMutation({
- mutationFn: ({
- id,
- ...data
- }: {
- id: number;
- name?: string;
- icon?: string;
- }) => apiPut(`/api/categories/${id}`, data),
- onSuccess: () => {
- queryClient.invalidateQueries({ queryKey: ["categories"] });
- queryClient.invalidateQueries({ queryKey: ["items"] });
- queryClient.invalidateQueries({ queryKey: ["totals"] });
- },
- });
+ const queryClient = useQueryClient();
+ return useMutation({
+ mutationFn: ({
+ id,
+ ...data
+ }: {
+ id: number;
+ name?: string;
+ icon?: string;
+ }) => apiPut(`/api/categories/${id}`, data),
+ onSuccess: () => {
+ queryClient.invalidateQueries({ queryKey: ["categories"] });
+ queryClient.invalidateQueries({ queryKey: ["items"] });
+ queryClient.invalidateQueries({ queryKey: ["totals"] });
+ },
+ });
}
export function useDeleteCategory() {
- const queryClient = useQueryClient();
- return useMutation({
- mutationFn: (id: number) =>
- apiDelete<{ success: boolean }>(`/api/categories/${id}`),
- onSuccess: () => {
- queryClient.invalidateQueries({ queryKey: ["categories"] });
- queryClient.invalidateQueries({ queryKey: ["items"] });
- queryClient.invalidateQueries({ queryKey: ["totals"] });
- },
- });
+ const queryClient = useQueryClient();
+ return useMutation({
+ mutationFn: (id: number) =>
+ apiDelete<{ success: boolean }>(`/api/categories/${id}`),
+ onSuccess: () => {
+ queryClient.invalidateQueries({ queryKey: ["categories"] });
+ queryClient.invalidateQueries({ queryKey: ["items"] });
+ queryClient.invalidateQueries({ queryKey: ["totals"] });
+ },
+ });
}
diff --git a/src/client/hooks/useItems.ts b/src/client/hooks/useItems.ts
index d2c6f18..cd665cf 100644
--- a/src/client/hooks/useItems.ts
+++ b/src/client/hooks/useItems.ts
@@ -1,71 +1,71 @@
-import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
-import { apiGet, apiPost, apiPut, apiDelete } from "../lib/api";
+import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import type { CreateItem } from "../../shared/types";
+import { apiDelete, apiGet, apiPost, apiPut } from "../lib/api";
interface ItemWithCategory {
- id: number;
- name: string;
- weightGrams: number | null;
- priceCents: number | null;
- categoryId: number;
- notes: string | null;
- productUrl: string | null;
- imageFilename: string | null;
- createdAt: string;
- updatedAt: string;
- categoryName: string;
- categoryIcon: string;
+ id: number;
+ name: string;
+ weightGrams: number | null;
+ priceCents: number | null;
+ categoryId: number;
+ notes: string | null;
+ productUrl: string | null;
+ imageFilename: string | null;
+ createdAt: string;
+ updatedAt: string;
+ categoryName: string;
+ categoryIcon: string;
}
export function useItems() {
- return useQuery({
- queryKey: ["items"],
- queryFn: () => apiGet("/api/items"),
- });
+ return useQuery({
+ queryKey: ["items"],
+ queryFn: () => apiGet("/api/items"),
+ });
}
export function useItem(id: number | null) {
- return useQuery({
- queryKey: ["items", id],
- queryFn: () => apiGet(`/api/items/${id}`),
- enabled: id != null,
- });
+ return useQuery({
+ queryKey: ["items", id],
+ queryFn: () => apiGet(`/api/items/${id}`),
+ enabled: id != null,
+ });
}
export function useCreateItem() {
- const queryClient = useQueryClient();
- return useMutation({
- mutationFn: (data: CreateItem) =>
- apiPost("/api/items", data),
- onSuccess: () => {
- queryClient.invalidateQueries({ queryKey: ["items"] });
- queryClient.invalidateQueries({ queryKey: ["totals"] });
- },
- });
+ const queryClient = useQueryClient();
+ return useMutation({
+ mutationFn: (data: CreateItem) =>
+ apiPost("/api/items", data),
+ onSuccess: () => {
+ queryClient.invalidateQueries({ queryKey: ["items"] });
+ queryClient.invalidateQueries({ queryKey: ["totals"] });
+ },
+ });
}
export function useUpdateItem() {
- const queryClient = useQueryClient();
- return useMutation({
- mutationFn: ({ id, ...data }: { id: number } & Partial) =>
- apiPut(`/api/items/${id}`, data),
- onSuccess: () => {
- queryClient.invalidateQueries({ queryKey: ["items"] });
- queryClient.invalidateQueries({ queryKey: ["totals"] });
- queryClient.invalidateQueries({ queryKey: ["setups"] });
- },
- });
+ const queryClient = useQueryClient();
+ return useMutation({
+ mutationFn: ({ id, ...data }: { id: number } & Partial) =>
+ apiPut(`/api/items/${id}`, data),
+ onSuccess: () => {
+ queryClient.invalidateQueries({ queryKey: ["items"] });
+ queryClient.invalidateQueries({ queryKey: ["totals"] });
+ queryClient.invalidateQueries({ queryKey: ["setups"] });
+ },
+ });
}
export function useDeleteItem() {
- const queryClient = useQueryClient();
- return useMutation({
- mutationFn: (id: number) =>
- apiDelete<{ success: boolean }>(`/api/items/${id}`),
- onSuccess: () => {
- queryClient.invalidateQueries({ queryKey: ["items"] });
- queryClient.invalidateQueries({ queryKey: ["totals"] });
- queryClient.invalidateQueries({ queryKey: ["setups"] });
- },
- });
+ const queryClient = useQueryClient();
+ return useMutation({
+ mutationFn: (id: number) =>
+ apiDelete<{ success: boolean }>(`/api/items/${id}`),
+ onSuccess: () => {
+ queryClient.invalidateQueries({ queryKey: ["items"] });
+ queryClient.invalidateQueries({ queryKey: ["totals"] });
+ queryClient.invalidateQueries({ queryKey: ["setups"] });
+ },
+ });
}
diff --git a/src/client/hooks/useSettings.ts b/src/client/hooks/useSettings.ts
index 47d9a0a..b26f905 100644
--- a/src/client/hooks/useSettings.ts
+++ b/src/client/hooks/useSettings.ts
@@ -1,37 +1,37 @@
-import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
+import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { apiGet, apiPut } from "../lib/api";
interface Setting {
- key: string;
- value: string;
+ key: string;
+ value: string;
}
export function useSetting(key: string) {
- return useQuery({
- queryKey: ["settings", key],
- queryFn: async () => {
- try {
- const result = await apiGet(`/api/settings/${key}`);
- return result.value;
- } catch (err: any) {
- if (err?.status === 404) return null;
- throw err;
- }
- },
- });
+ return useQuery({
+ queryKey: ["settings", key],
+ queryFn: async () => {
+ try {
+ const result = await apiGet(`/api/settings/${key}`);
+ return result.value;
+ } catch (err: any) {
+ if (err?.status === 404) return null;
+ throw err;
+ }
+ },
+ });
}
export function useUpdateSetting() {
- const queryClient = useQueryClient();
- return useMutation({
- mutationFn: ({ key, value }: { key: string; value: string }) =>
- apiPut(`/api/settings/${key}`, { value }),
- onSuccess: (_data, variables) => {
- queryClient.invalidateQueries({ queryKey: ["settings", variables.key] });
- },
- });
+ const queryClient = useQueryClient();
+ return useMutation({
+ mutationFn: ({ key, value }: { key: string; value: string }) =>
+ apiPut(`/api/settings/${key}`, { value }),
+ onSuccess: (_data, variables) => {
+ queryClient.invalidateQueries({ queryKey: ["settings", variables.key] });
+ },
+ });
}
export function useOnboardingComplete() {
- return useSetting("onboardingComplete");
+ return useSetting("onboardingComplete");
}
diff --git a/src/client/hooks/useSetups.ts b/src/client/hooks/useSetups.ts
index e15f500..d4998a8 100644
--- a/src/client/hooks/useSetups.ts
+++ b/src/client/hooks/useSetups.ts
@@ -1,107 +1,107 @@
-import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
-import { apiGet, apiPost, apiPut, apiDelete } from "../lib/api";
+import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
+import { apiDelete, apiGet, apiPost, apiPut } from "../lib/api";
interface SetupListItem {
- id: number;
- name: string;
- createdAt: string;
- updatedAt: string;
- itemCount: number;
- totalWeight: number;
- totalCost: number;
+ id: number;
+ name: string;
+ createdAt: string;
+ updatedAt: string;
+ itemCount: number;
+ totalWeight: number;
+ totalCost: number;
}
interface SetupItemWithCategory {
- id: number;
- name: string;
- weightGrams: number | null;
- priceCents: number | null;
- categoryId: number;
- notes: string | null;
- productUrl: string | null;
- imageFilename: string | null;
- createdAt: string;
- updatedAt: string;
- categoryName: string;
- categoryIcon: string;
+ id: number;
+ name: string;
+ weightGrams: number | null;
+ priceCents: number | null;
+ categoryId: number;
+ notes: string | null;
+ productUrl: string | null;
+ imageFilename: string | null;
+ createdAt: string;
+ updatedAt: string;
+ categoryName: string;
+ categoryIcon: string;
}
interface SetupWithItems {
- id: number;
- name: string;
- createdAt: string;
- updatedAt: string;
- items: SetupItemWithCategory[];
+ id: number;
+ name: string;
+ createdAt: string;
+ updatedAt: string;
+ items: SetupItemWithCategory[];
}
-export type { SetupListItem, SetupWithItems, SetupItemWithCategory };
+export type { SetupItemWithCategory, SetupListItem, SetupWithItems };
export function useSetups() {
- return useQuery({
- queryKey: ["setups"],
- queryFn: () => apiGet("/api/setups"),
- });
+ return useQuery({
+ queryKey: ["setups"],
+ queryFn: () => apiGet("/api/setups"),
+ });
}
export function useSetup(setupId: number | null) {
- return useQuery({
- queryKey: ["setups", setupId],
- queryFn: () => apiGet(`/api/setups/${setupId}`),
- enabled: setupId != null,
- });
+ return useQuery({
+ queryKey: ["setups", setupId],
+ queryFn: () => apiGet(`/api/setups/${setupId}`),
+ enabled: setupId != null,
+ });
}
export function useCreateSetup() {
- const queryClient = useQueryClient();
- return useMutation({
- mutationFn: (data: { name: string }) =>
- apiPost("/api/setups", data),
- onSuccess: () => {
- queryClient.invalidateQueries({ queryKey: ["setups"] });
- },
- });
+ const queryClient = useQueryClient();
+ return useMutation({
+ mutationFn: (data: { name: string }) =>
+ apiPost("/api/setups", data),
+ onSuccess: () => {
+ queryClient.invalidateQueries({ queryKey: ["setups"] });
+ },
+ });
}
export function useUpdateSetup(setupId: number) {
- const queryClient = useQueryClient();
- return useMutation({
- mutationFn: (data: { name?: string }) =>
- apiPut(`/api/setups/${setupId}`, data),
- onSuccess: () => {
- queryClient.invalidateQueries({ queryKey: ["setups"] });
- },
- });
+ const queryClient = useQueryClient();
+ return useMutation({
+ mutationFn: (data: { name?: string }) =>
+ apiPut(`/api/setups/${setupId}`, data),
+ onSuccess: () => {
+ queryClient.invalidateQueries({ queryKey: ["setups"] });
+ },
+ });
}
export function useDeleteSetup() {
- const queryClient = useQueryClient();
- return useMutation({
- mutationFn: (id: number) =>
- apiDelete<{ success: boolean }>(`/api/setups/${id}`),
- onSuccess: () => {
- queryClient.invalidateQueries({ queryKey: ["setups"] });
- },
- });
+ const queryClient = useQueryClient();
+ return useMutation({
+ mutationFn: (id: number) =>
+ apiDelete<{ success: boolean }>(`/api/setups/${id}`),
+ onSuccess: () => {
+ queryClient.invalidateQueries({ queryKey: ["setups"] });
+ },
+ });
}
export function useSyncSetupItems(setupId: number) {
- const queryClient = useQueryClient();
- return useMutation({
- mutationFn: (itemIds: number[]) =>
- apiPut<{ success: boolean }>(`/api/setups/${setupId}/items`, { itemIds }),
- onSuccess: () => {
- queryClient.invalidateQueries({ queryKey: ["setups"] });
- },
- });
+ const queryClient = useQueryClient();
+ return useMutation({
+ mutationFn: (itemIds: number[]) =>
+ apiPut<{ success: boolean }>(`/api/setups/${setupId}/items`, { itemIds }),
+ onSuccess: () => {
+ queryClient.invalidateQueries({ queryKey: ["setups"] });
+ },
+ });
}
export function useRemoveSetupItem(setupId: number) {
- const queryClient = useQueryClient();
- return useMutation({
- mutationFn: (itemId: number) =>
- apiDelete<{ success: boolean }>(`/api/setups/${setupId}/items/${itemId}`),
- onSuccess: () => {
- queryClient.invalidateQueries({ queryKey: ["setups"] });
- },
- });
+ const queryClient = useQueryClient();
+ return useMutation({
+ mutationFn: (itemId: number) =>
+ apiDelete<{ success: boolean }>(`/api/setups/${setupId}/items/${itemId}`),
+ onSuccess: () => {
+ queryClient.invalidateQueries({ queryKey: ["setups"] });
+ },
+ });
}
diff --git a/src/client/hooks/useThreads.ts b/src/client/hooks/useThreads.ts
index c1ec41f..e7222d9 100644
--- a/src/client/hooks/useThreads.ts
+++ b/src/client/hooks/useThreads.ts
@@ -1,116 +1,116 @@
-import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
-import { apiGet, apiPost, apiPut, apiDelete } from "../lib/api";
+import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
+import { apiDelete, apiGet, apiPost, apiPut } from "../lib/api";
interface ThreadListItem {
- id: number;
- name: string;
- status: "active" | "resolved";
- resolvedCandidateId: number | null;
- categoryId: number;
- categoryName: string;
- categoryIcon: string;
- createdAt: string;
- updatedAt: string;
- candidateCount: number;
- minPriceCents: number | null;
- maxPriceCents: number | null;
+ id: number;
+ name: string;
+ status: "active" | "resolved";
+ resolvedCandidateId: number | null;
+ categoryId: number;
+ categoryName: string;
+ categoryIcon: string;
+ createdAt: string;
+ updatedAt: string;
+ candidateCount: number;
+ minPriceCents: number | null;
+ maxPriceCents: number | null;
}
interface CandidateWithCategory {
- id: number;
- threadId: number;
- name: string;
- weightGrams: number | null;
- priceCents: number | null;
- categoryId: number;
- notes: string | null;
- productUrl: string | null;
- imageFilename: string | null;
- createdAt: string;
- updatedAt: string;
- categoryName: string;
- categoryIcon: string;
+ id: number;
+ threadId: number;
+ name: string;
+ weightGrams: number | null;
+ priceCents: number | null;
+ categoryId: number;
+ notes: string | null;
+ productUrl: string | null;
+ imageFilename: string | null;
+ createdAt: string;
+ updatedAt: string;
+ categoryName: string;
+ categoryIcon: string;
}
interface ThreadWithCandidates {
- id: number;
- name: string;
- status: "active" | "resolved";
- resolvedCandidateId: number | null;
- createdAt: string;
- updatedAt: string;
- candidates: CandidateWithCategory[];
+ id: number;
+ name: string;
+ status: "active" | "resolved";
+ resolvedCandidateId: number | null;
+ createdAt: string;
+ updatedAt: string;
+ candidates: CandidateWithCategory[];
}
export function useThreads(includeResolved = false) {
- return useQuery({
- queryKey: ["threads", { includeResolved }],
- queryFn: () =>
- apiGet(
- `/api/threads${includeResolved ? "?includeResolved=true" : ""}`,
- ),
- });
+ return useQuery({
+ queryKey: ["threads", { includeResolved }],
+ queryFn: () =>
+ apiGet(
+ `/api/threads${includeResolved ? "?includeResolved=true" : ""}`,
+ ),
+ });
}
export function useThread(threadId: number | null) {
- return useQuery({
- queryKey: ["threads", threadId],
- queryFn: () => apiGet(`/api/threads/${threadId}`),
- enabled: threadId != null,
- });
+ return useQuery({
+ queryKey: ["threads", threadId],
+ queryFn: () => apiGet(`/api/threads/${threadId}`),
+ enabled: threadId != null,
+ });
}
export function useCreateThread() {
- const queryClient = useQueryClient();
- return useMutation({
- mutationFn: (data: { name: string; categoryId: number }) =>
- apiPost("/api/threads", data),
- onSuccess: () => {
- queryClient.invalidateQueries({ queryKey: ["threads"] });
- },
- });
+ const queryClient = useQueryClient();
+ return useMutation({
+ mutationFn: (data: { name: string; categoryId: number }) =>
+ apiPost("/api/threads", data),
+ onSuccess: () => {
+ queryClient.invalidateQueries({ queryKey: ["threads"] });
+ },
+ });
}
export function useUpdateThread() {
- const queryClient = useQueryClient();
- return useMutation({
- mutationFn: ({ id, ...data }: { id: number; name?: string }) =>
- apiPut(`/api/threads/${id}`, data),
- onSuccess: () => {
- queryClient.invalidateQueries({ queryKey: ["threads"] });
- },
- });
+ const queryClient = useQueryClient();
+ return useMutation({
+ mutationFn: ({ id, ...data }: { id: number; name?: string }) =>
+ apiPut(`/api/threads/${id}`, data),
+ onSuccess: () => {
+ queryClient.invalidateQueries({ queryKey: ["threads"] });
+ },
+ });
}
export function useDeleteThread() {
- const queryClient = useQueryClient();
- return useMutation({
- mutationFn: (id: number) =>
- apiDelete<{ success: boolean }>(`/api/threads/${id}`),
- onSuccess: () => {
- queryClient.invalidateQueries({ queryKey: ["threads"] });
- },
- });
+ const queryClient = useQueryClient();
+ return useMutation({
+ mutationFn: (id: number) =>
+ apiDelete<{ success: boolean }>(`/api/threads/${id}`),
+ onSuccess: () => {
+ queryClient.invalidateQueries({ queryKey: ["threads"] });
+ },
+ });
}
export function useResolveThread() {
- const queryClient = useQueryClient();
- return useMutation({
- mutationFn: ({
- threadId,
- candidateId,
- }: {
- threadId: number;
- candidateId: number;
- }) =>
- apiPost<{ success: boolean; item: unknown }>(
- `/api/threads/${threadId}/resolve`,
- { candidateId },
- ),
- onSuccess: () => {
- queryClient.invalidateQueries({ queryKey: ["threads"] });
- queryClient.invalidateQueries({ queryKey: ["items"] });
- queryClient.invalidateQueries({ queryKey: ["totals"] });
- },
- });
+ const queryClient = useQueryClient();
+ return useMutation({
+ mutationFn: ({
+ threadId,
+ candidateId,
+ }: {
+ threadId: number;
+ candidateId: number;
+ }) =>
+ apiPost<{ success: boolean; item: unknown }>(
+ `/api/threads/${threadId}/resolve`,
+ { candidateId },
+ ),
+ onSuccess: () => {
+ queryClient.invalidateQueries({ queryKey: ["threads"] });
+ queryClient.invalidateQueries({ queryKey: ["items"] });
+ queryClient.invalidateQueries({ queryKey: ["totals"] });
+ },
+ });
}
diff --git a/src/client/hooks/useTotals.ts b/src/client/hooks/useTotals.ts
index f5486e7..924ffa6 100644
--- a/src/client/hooks/useTotals.ts
+++ b/src/client/hooks/useTotals.ts
@@ -2,30 +2,30 @@ import { useQuery } from "@tanstack/react-query";
import { apiGet } from "../lib/api";
interface CategoryTotals {
- categoryId: number;
- categoryName: string;
- categoryIcon: string;
- totalWeight: number;
- totalCost: number;
- itemCount: number;
+ categoryId: number;
+ categoryName: string;
+ categoryIcon: string;
+ totalWeight: number;
+ totalCost: number;
+ itemCount: number;
}
interface GlobalTotals {
- totalWeight: number;
- totalCost: number;
- itemCount: number;
+ totalWeight: number;
+ totalCost: number;
+ itemCount: number;
}
interface TotalsResponse {
- categories: CategoryTotals[];
- global: GlobalTotals;
+ categories: CategoryTotals[];
+ global: GlobalTotals;
}
export type { CategoryTotals, GlobalTotals, TotalsResponse };
export function useTotals() {
- return useQuery({
- queryKey: ["totals"],
- queryFn: () => apiGet("/api/totals"),
- });
+ return useQuery({
+ queryKey: ["totals"],
+ queryFn: () => apiGet("/api/totals"),
+ });
}
diff --git a/src/client/lib/api.ts b/src/client/lib/api.ts
index c81524f..fd701a8 100644
--- a/src/client/lib/api.ts
+++ b/src/client/lib/api.ts
@@ -1,61 +1,61 @@
class ApiError extends Error {
- constructor(
- message: string,
- public status: number,
- ) {
- super(message);
- this.name = "ApiError";
- }
+ constructor(
+ message: string,
+ public status: number,
+ ) {
+ super(message);
+ this.name = "ApiError";
+ }
}
async function handleResponse(res: Response): Promise {
- if (!res.ok) {
- let message = `Request failed with status ${res.status}`;
- try {
- const body = await res.json();
- if (body.error) message = body.error;
- } catch {
- // Use default message
- }
- throw new ApiError(message, res.status);
- }
- return res.json() as Promise;
+ if (!res.ok) {
+ let message = `Request failed with status ${res.status}`;
+ try {
+ const body = await res.json();
+ if (body.error) message = body.error;
+ } catch {
+ // Use default message
+ }
+ throw new ApiError(message, res.status);
+ }
+ return res.json() as Promise;
}
export async function apiGet(url: string): Promise {
- const res = await fetch(url);
- return handleResponse(res);
+ const res = await fetch(url);
+ return handleResponse(res);
}
export async function apiPost(url: string, body: unknown): Promise {
- const res = await fetch(url, {
- method: "POST",
- headers: { "Content-Type": "application/json" },
- body: JSON.stringify(body),
- });
- return handleResponse(res);
+ const res = await fetch(url, {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify(body),
+ });
+ return handleResponse(res);
}
export async function apiPut(url: string, body: unknown): Promise {
- const res = await fetch(url, {
- method: "PUT",
- headers: { "Content-Type": "application/json" },
- body: JSON.stringify(body),
- });
- return handleResponse(res);
+ const res = await fetch(url, {
+ method: "PUT",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify(body),
+ });
+ return handleResponse(res);
}
export async function apiDelete(url: string): Promise {
- const res = await fetch(url, { method: "DELETE" });
- return handleResponse(res);
+ const res = await fetch(url, { method: "DELETE" });
+ return handleResponse(res);
}
export async function apiUpload(url: string, file: File): Promise {
- const formData = new FormData();
- formData.append("image", file);
- const res = await fetch(url, {
- method: "POST",
- body: formData,
- });
- return handleResponse(res);
+ const formData = new FormData();
+ formData.append("image", file);
+ const res = await fetch(url, {
+ method: "POST",
+ body: formData,
+ });
+ return handleResponse(res);
}
diff --git a/src/client/lib/formatters.ts b/src/client/lib/formatters.ts
index 7167d83..42961ce 100644
--- a/src/client/lib/formatters.ts
+++ b/src/client/lib/formatters.ts
@@ -1,9 +1,9 @@
export function formatWeight(grams: number | null | undefined): string {
- if (grams == null) return "--";
- return `${Math.round(grams)}g`;
+ if (grams == null) return "--";
+ return `${Math.round(grams)}g`;
}
export function formatPrice(cents: number | null | undefined): string {
- if (cents == null) return "--";
- return `$${(cents / 100).toFixed(2)}`;
+ if (cents == null) return "--";
+ return `$${(cents / 100).toFixed(2)}`;
}
diff --git a/src/client/main.tsx b/src/client/main.tsx
index b827376..e72c4d8 100644
--- a/src/client/main.tsx
+++ b/src/client/main.tsx
@@ -1,29 +1,29 @@
+import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
+import { createRouter, RouterProvider } from "@tanstack/react-router";
import { StrictMode } from "react";
import { createRoot } from "react-dom/client";
-import { RouterProvider, createRouter } from "@tanstack/react-router";
-import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { routeTree } from "./routeTree.gen";
const queryClient = new QueryClient();
const router = createRouter({
- routeTree,
- context: {},
+ routeTree,
+ context: {},
});
declare module "@tanstack/react-router" {
- interface Register {
- router: typeof router;
- }
+ interface Register {
+ router: typeof router;
+ }
}
const rootElement = document.getElementById("root");
if (!rootElement) throw new Error("Root element not found");
createRoot(rootElement).render(
-
-
-
-
- ,
+
+
+
+
+ ,
);
diff --git a/src/client/routes/__root.tsx b/src/client/routes/__root.tsx
index 60bb39b..0d187c0 100644
--- a/src/client/routes/__root.tsx
+++ b/src/client/routes/__root.tsx
@@ -1,323 +1,328 @@
-import { useState } from "react";
import {
- createRootRoute,
- Outlet,
- useMatchRoute,
- useNavigate,
+ createRootRoute,
+ Outlet,
+ useMatchRoute,
+ useNavigate,
} from "@tanstack/react-router";
+import { useState } from "react";
import "../app.css";
-import { TotalsBar } from "../components/TotalsBar";
-import { SlideOutPanel } from "../components/SlideOutPanel";
-import { ItemForm } from "../components/ItemForm";
import { CandidateForm } from "../components/CandidateForm";
import { ConfirmDialog } from "../components/ConfirmDialog";
import { ExternalLinkDialog } from "../components/ExternalLinkDialog";
+import { ItemForm } from "../components/ItemForm";
import { OnboardingWizard } from "../components/OnboardingWizard";
-import { useUIStore } from "../stores/uiStore";
-import { useOnboardingComplete } from "../hooks/useSettings";
-import { useThread, useResolveThread } from "../hooks/useThreads";
+import { SlideOutPanel } from "../components/SlideOutPanel";
+import { TotalsBar } from "../components/TotalsBar";
import { useDeleteCandidate } from "../hooks/useCandidates";
+import { useOnboardingComplete } from "../hooks/useSettings";
+import { useResolveThread, useThread } from "../hooks/useThreads";
+import { useUIStore } from "../stores/uiStore";
export const Route = createRootRoute({
- component: RootLayout,
+ component: RootLayout,
});
function RootLayout() {
- const navigate = useNavigate();
+ const navigate = useNavigate();
- // Item panel state
- const panelMode = useUIStore((s) => s.panelMode);
- const editingItemId = useUIStore((s) => s.editingItemId);
- const openAddPanel = useUIStore((s) => s.openAddPanel);
- const closePanel = useUIStore((s) => s.closePanel);
+ // Item panel state
+ const panelMode = useUIStore((s) => s.panelMode);
+ const editingItemId = useUIStore((s) => s.editingItemId);
+ const openAddPanel = useUIStore((s) => s.openAddPanel);
+ const closePanel = useUIStore((s) => s.closePanel);
- // Candidate panel state
- const candidatePanelMode = useUIStore((s) => s.candidatePanelMode);
- const editingCandidateId = useUIStore((s) => s.editingCandidateId);
- const closeCandidatePanel = useUIStore((s) => s.closeCandidatePanel);
+ // Candidate panel state
+ const candidatePanelMode = useUIStore((s) => s.candidatePanelMode);
+ const editingCandidateId = useUIStore((s) => s.editingCandidateId);
+ const closeCandidatePanel = useUIStore((s) => s.closeCandidatePanel);
- // Candidate delete state
- const confirmDeleteCandidateId = useUIStore(
- (s) => s.confirmDeleteCandidateId,
- );
- const closeConfirmDeleteCandidate = useUIStore(
- (s) => s.closeConfirmDeleteCandidate,
- );
+ // Candidate delete state
+ const confirmDeleteCandidateId = useUIStore(
+ (s) => s.confirmDeleteCandidateId,
+ );
+ const closeConfirmDeleteCandidate = useUIStore(
+ (s) => s.closeConfirmDeleteCandidate,
+ );
- // Resolution dialog state
- const resolveThreadId = useUIStore((s) => s.resolveThreadId);
- const resolveCandidateId = useUIStore((s) => s.resolveCandidateId);
- const closeResolveDialog = useUIStore((s) => s.closeResolveDialog);
+ // Resolution dialog state
+ const resolveThreadId = useUIStore((s) => s.resolveThreadId);
+ const resolveCandidateId = useUIStore((s) => s.resolveCandidateId);
+ const closeResolveDialog = useUIStore((s) => s.closeResolveDialog);
- // Onboarding
- const { data: onboardingComplete, isLoading: onboardingLoading } =
- useOnboardingComplete();
- const [wizardDismissed, setWizardDismissed] = useState(false);
+ // Onboarding
+ const { data: onboardingComplete, isLoading: onboardingLoading } =
+ useOnboardingComplete();
+ const [wizardDismissed, setWizardDismissed] = useState(false);
- const showWizard =
- !onboardingLoading && onboardingComplete !== "true" && !wizardDismissed;
+ const showWizard =
+ !onboardingLoading && onboardingComplete !== "true" && !wizardDismissed;
- const isItemPanelOpen = panelMode !== "closed";
- const isCandidatePanelOpen = candidatePanelMode !== "closed";
+ const isItemPanelOpen = panelMode !== "closed";
+ const isCandidatePanelOpen = candidatePanelMode !== "closed";
- // Route matching for contextual behavior
- const matchRoute = useMatchRoute();
+ // Route matching for contextual behavior
+ const matchRoute = useMatchRoute();
- const threadMatch = matchRoute({
- to: "/threads/$threadId",
- fuzzy: true,
- }) as { threadId?: string } | false;
- const currentThreadId = threadMatch ? Number(threadMatch.threadId) : null;
+ const threadMatch = matchRoute({
+ to: "/threads/$threadId",
+ fuzzy: true,
+ }) as { threadId?: string } | false;
+ const currentThreadId = threadMatch ? Number(threadMatch.threadId) : null;
- const isDashboard = !!matchRoute({ to: "/" });
- const isCollection = !!matchRoute({ to: "/collection", fuzzy: true });
- const isSetupDetail = !!matchRoute({ to: "/setups/$setupId", fuzzy: true });
+ const isDashboard = !!matchRoute({ to: "/" });
+ const isCollection = !!matchRoute({ to: "/collection", fuzzy: true });
+ const isSetupDetail = !!matchRoute({ to: "/setups/$setupId", fuzzy: true });
- // Determine TotalsBar props based on current route
- const totalsBarProps = isDashboard
- ? { stats: [] as Array<{ label: string; value: string }> } // Title only, no stats, no link
- : isSetupDetail
- ? { linkTo: "/" } // Setup detail will render its own local bar; root bar just has link
- : { linkTo: "/" }; // All other pages: default stats + link to dashboard
+ // Determine TotalsBar props based on current route
+ const _totalsBarProps = isDashboard
+ ? { stats: [] as Array<{ label: string; value: string }> } // Title only, no stats, no link
+ : isSetupDetail
+ ? { linkTo: "/" } // Setup detail will render its own local bar; root bar just has link
+ : { linkTo: "/" }; // All other pages: default stats + link to dashboard
- // On dashboard, don't show the default global stats - pass empty stats
- // On collection, let TotalsBar fetch its own global stats (default behavior)
- const finalTotalsProps = isDashboard
- ? { stats: [] as Array<{ label: string; value: string }> }
- : isCollection
- ? { linkTo: "/" }
- : { linkTo: "/" };
+ // On dashboard, don't show the default global stats - pass empty stats
+ // On collection, let TotalsBar fetch its own global stats (default behavior)
+ const finalTotalsProps = isDashboard
+ ? { stats: [] as Array<{ label: string; value: string }> }
+ : isCollection
+ ? { linkTo: "/" }
+ : { linkTo: "/" };
- // FAB visibility: only show on /collection route when gear tab is active
- const collectionSearch = matchRoute({ to: "/collection" }) as { tab?: string } | false;
- const showFab = isCollection && (!collectionSearch || (collectionSearch as Record).tab !== "planning");
+ // FAB visibility: only show on /collection route when gear tab is active
+ const collectionSearch = matchRoute({ to: "/collection" }) as
+ | { tab?: string }
+ | false;
+ const showFab =
+ isCollection &&
+ (!collectionSearch ||
+ (collectionSearch as Record).tab !== "planning");
- // Show a minimal loading state while checking onboarding status
- if (onboardingLoading) {
- return (
-
- );
- }
+ // Show a minimal loading state while checking onboarding status
+ if (onboardingLoading) {
+ return (
+
+ );
+ }
- return (
-
-
-
+ return (
+
+
+
- {/* Item Slide-out Panel */}
-
- {panelMode === "add" && }
- {panelMode === "edit" && (
-
- )}
-
+ {/* Item Slide-out Panel */}
+
+ {panelMode === "add" && }
+ {panelMode === "edit" && (
+
+ )}
+
- {/* Candidate Slide-out Panel */}
- {currentThreadId != null && (
-
- {candidatePanelMode === "add" && (
-
- )}
- {candidatePanelMode === "edit" && (
-
- )}
-
- )}
+ {/* Candidate Slide-out Panel */}
+ {currentThreadId != null && (
+
+ {candidatePanelMode === "add" && (
+
+ )}
+ {candidatePanelMode === "edit" && (
+
+ )}
+
+ )}
- {/* Item Confirm Delete Dialog */}
-
+ {/* Item Confirm Delete Dialog */}
+
- {/* External Link Confirmation Dialog */}
-
+ {/* External Link Confirmation Dialog */}
+
- {/* Candidate Delete Confirm Dialog */}
- {confirmDeleteCandidateId != null && currentThreadId != null && (
-
- )}
+ {/* Candidate Delete Confirm Dialog */}
+ {confirmDeleteCandidateId != null && currentThreadId != null && (
+
+ )}
- {/* Resolution Confirm Dialog */}
- {resolveThreadId != null && resolveCandidateId != null && (
-
{
- closeResolveDialog();
- navigate({ to: "/collection", search: { tab: "planning" } });
- }}
- />
- )}
+ {/* Resolution Confirm Dialog */}
+ {resolveThreadId != null && resolveCandidateId != null && (
+ {
+ closeResolveDialog();
+ navigate({ to: "/collection", search: { tab: "planning" } });
+ }}
+ />
+ )}
- {/* Floating Add Button - only on collection gear tab */}
- {showFab && (
-
-
-
-
-
- )}
+ {/* Floating Add Button - only on collection gear tab */}
+ {showFab && (
+
+
+
+
+
+ )}
- {/* Onboarding Wizard */}
- {showWizard && (
- setWizardDismissed(true)} />
- )}
-
- );
+ {/* Onboarding Wizard */}
+ {showWizard && (
+
setWizardDismissed(true)} />
+ )}
+
+ );
}
function CandidateDeleteDialog({
- candidateId,
- threadId,
- onClose,
+ candidateId,
+ threadId,
+ onClose,
}: {
- candidateId: number;
- threadId: number;
- onClose: () => void;
+ candidateId: number;
+ threadId: number;
+ onClose: () => void;
}) {
- const deleteCandidate = useDeleteCandidate(threadId);
- const { data: thread } = useThread(threadId);
- const candidate = thread?.candidates.find((c) => c.id === candidateId);
- const candidateName = candidate?.name ?? "this candidate";
+ const deleteCandidate = useDeleteCandidate(threadId);
+ const { data: thread } = useThread(threadId);
+ const candidate = thread?.candidates.find((c) => c.id === candidateId);
+ const candidateName = candidate?.name ?? "this candidate";
- function handleDelete() {
- deleteCandidate.mutate(candidateId, {
- onSuccess: () => onClose(),
- });
- }
+ function handleDelete() {
+ deleteCandidate.mutate(candidateId, {
+ onSuccess: () => onClose(),
+ });
+ }
- return (
-
-
{
- if (e.key === "Escape") onClose();
- }}
- />
-
-
- Delete Candidate
-
-
- Are you sure you want to delete{" "}
- {candidateName} ? This action
- cannot be undone.
-
-
-
- Cancel
-
-
- {deleteCandidate.isPending ? "Deleting..." : "Delete"}
-
-
-
-
- );
+ return (
+
+
{
+ if (e.key === "Escape") onClose();
+ }}
+ />
+
+
+ Delete Candidate
+
+
+ Are you sure you want to delete{" "}
+ {candidateName} ? This action
+ cannot be undone.
+
+
+
+ Cancel
+
+
+ {deleteCandidate.isPending ? "Deleting..." : "Delete"}
+
+
+
+
+ );
}
function ResolveDialog({
- threadId,
- candidateId,
- onClose,
- onResolved,
+ threadId,
+ candidateId,
+ onClose,
+ onResolved,
}: {
- threadId: number;
- candidateId: number;
- onClose: () => void;
- onResolved: () => void;
+ threadId: number;
+ candidateId: number;
+ onClose: () => void;
+ onResolved: () => void;
}) {
- const resolveThread = useResolveThread();
- const { data: thread } = useThread(threadId);
- const candidate = thread?.candidates.find((c) => c.id === candidateId);
- const candidateName = candidate?.name ?? "this candidate";
+ const resolveThread = useResolveThread();
+ const { data: thread } = useThread(threadId);
+ const candidate = thread?.candidates.find((c) => c.id === candidateId);
+ const candidateName = candidate?.name ?? "this candidate";
- function handleResolve() {
- resolveThread.mutate(
- { threadId, candidateId },
- { onSuccess: () => onResolved() },
- );
- }
+ function handleResolve() {
+ resolveThread.mutate(
+ { threadId, candidateId },
+ { onSuccess: () => onResolved() },
+ );
+ }
- return (
-
-
{
- if (e.key === "Escape") onClose();
- }}
- />
-
-
- Pick Winner
-
-
- Pick {candidateName} as the
- winner? This will add it to your collection and archive the thread.
-
-
-
- Cancel
-
-
- {resolveThread.isPending ? "Resolving..." : "Pick Winner"}
-
-
-
-
- );
+ return (
+
+
{
+ if (e.key === "Escape") onClose();
+ }}
+ />
+
+
+ Pick Winner
+
+
+ Pick {candidateName} as the
+ winner? This will add it to your collection and archive the thread.
+
+
+
+ Cancel
+
+
+ {resolveThread.isPending ? "Resolving..." : "Pick Winner"}
+
+
+
+
+ );
}
diff --git a/src/db/schema.ts b/src/db/schema.ts
index 8d2e618..f1f7b6c 100644
--- a/src/db/schema.ts
+++ b/src/db/schema.ts
@@ -1,93 +1,93 @@
-import { sqliteTable, text, integer, real } from "drizzle-orm/sqlite-core";
+import { integer, real, sqliteTable, text } from "drizzle-orm/sqlite-core";
export const categories = sqliteTable("categories", {
- id: integer("id").primaryKey({ autoIncrement: true }),
- name: text("name").notNull().unique(),
- icon: text("icon").notNull().default("package"),
- createdAt: integer("created_at", { mode: "timestamp" })
- .notNull()
- .$defaultFn(() => new Date()),
+ id: integer("id").primaryKey({ autoIncrement: true }),
+ name: text("name").notNull().unique(),
+ icon: text("icon").notNull().default("package"),
+ createdAt: integer("created_at", { mode: "timestamp" })
+ .notNull()
+ .$defaultFn(() => new Date()),
});
export const items = sqliteTable("items", {
- id: integer("id").primaryKey({ autoIncrement: true }),
- name: text("name").notNull(),
- weightGrams: real("weight_grams"),
- priceCents: integer("price_cents"),
- categoryId: integer("category_id")
- .notNull()
- .references(() => categories.id),
- notes: text("notes"),
- productUrl: text("product_url"),
- imageFilename: text("image_filename"),
- createdAt: integer("created_at", { mode: "timestamp" })
- .notNull()
- .$defaultFn(() => new Date()),
- updatedAt: integer("updated_at", { mode: "timestamp" })
- .notNull()
- .$defaultFn(() => new Date()),
+ id: integer("id").primaryKey({ autoIncrement: true }),
+ name: text("name").notNull(),
+ weightGrams: real("weight_grams"),
+ priceCents: integer("price_cents"),
+ categoryId: integer("category_id")
+ .notNull()
+ .references(() => categories.id),
+ notes: text("notes"),
+ productUrl: text("product_url"),
+ imageFilename: text("image_filename"),
+ createdAt: integer("created_at", { mode: "timestamp" })
+ .notNull()
+ .$defaultFn(() => new Date()),
+ updatedAt: integer("updated_at", { mode: "timestamp" })
+ .notNull()
+ .$defaultFn(() => new Date()),
});
export const threads = sqliteTable("threads", {
- id: integer("id").primaryKey({ autoIncrement: true }),
- name: text("name").notNull(),
- status: text("status").notNull().default("active"),
- resolvedCandidateId: integer("resolved_candidate_id"),
- categoryId: integer("category_id")
- .notNull()
- .references(() => categories.id),
- createdAt: integer("created_at", { mode: "timestamp" })
- .notNull()
- .$defaultFn(() => new Date()),
- updatedAt: integer("updated_at", { mode: "timestamp" })
- .notNull()
- .$defaultFn(() => new Date()),
+ id: integer("id").primaryKey({ autoIncrement: true }),
+ name: text("name").notNull(),
+ status: text("status").notNull().default("active"),
+ resolvedCandidateId: integer("resolved_candidate_id"),
+ categoryId: integer("category_id")
+ .notNull()
+ .references(() => categories.id),
+ createdAt: integer("created_at", { mode: "timestamp" })
+ .notNull()
+ .$defaultFn(() => new Date()),
+ updatedAt: integer("updated_at", { mode: "timestamp" })
+ .notNull()
+ .$defaultFn(() => new Date()),
});
export const threadCandidates = sqliteTable("thread_candidates", {
- id: integer("id").primaryKey({ autoIncrement: true }),
- threadId: integer("thread_id")
- .notNull()
- .references(() => threads.id, { onDelete: "cascade" }),
- name: text("name").notNull(),
- weightGrams: real("weight_grams"),
- priceCents: integer("price_cents"),
- categoryId: integer("category_id")
- .notNull()
- .references(() => categories.id),
- notes: text("notes"),
- productUrl: text("product_url"),
- imageFilename: text("image_filename"),
- createdAt: integer("created_at", { mode: "timestamp" })
- .notNull()
- .$defaultFn(() => new Date()),
- updatedAt: integer("updated_at", { mode: "timestamp" })
- .notNull()
- .$defaultFn(() => new Date()),
+ id: integer("id").primaryKey({ autoIncrement: true }),
+ threadId: integer("thread_id")
+ .notNull()
+ .references(() => threads.id, { onDelete: "cascade" }),
+ name: text("name").notNull(),
+ weightGrams: real("weight_grams"),
+ priceCents: integer("price_cents"),
+ categoryId: integer("category_id")
+ .notNull()
+ .references(() => categories.id),
+ notes: text("notes"),
+ productUrl: text("product_url"),
+ imageFilename: text("image_filename"),
+ createdAt: integer("created_at", { mode: "timestamp" })
+ .notNull()
+ .$defaultFn(() => new Date()),
+ updatedAt: integer("updated_at", { mode: "timestamp" })
+ .notNull()
+ .$defaultFn(() => new Date()),
});
export const setups = sqliteTable("setups", {
- id: integer("id").primaryKey({ autoIncrement: true }),
- name: text("name").notNull(),
- createdAt: integer("created_at", { mode: "timestamp" })
- .notNull()
- .$defaultFn(() => new Date()),
- updatedAt: integer("updated_at", { mode: "timestamp" })
- .notNull()
- .$defaultFn(() => new Date()),
+ id: integer("id").primaryKey({ autoIncrement: true }),
+ name: text("name").notNull(),
+ createdAt: integer("created_at", { mode: "timestamp" })
+ .notNull()
+ .$defaultFn(() => new Date()),
+ updatedAt: integer("updated_at", { mode: "timestamp" })
+ .notNull()
+ .$defaultFn(() => new Date()),
});
export const setupItems = sqliteTable("setup_items", {
- id: integer("id").primaryKey({ autoIncrement: true }),
- setupId: integer("setup_id")
- .notNull()
- .references(() => setups.id, { onDelete: "cascade" }),
- itemId: integer("item_id")
- .notNull()
- .references(() => items.id, { onDelete: "cascade" }),
+ id: integer("id").primaryKey({ autoIncrement: true }),
+ setupId: integer("setup_id")
+ .notNull()
+ .references(() => setups.id, { onDelete: "cascade" }),
+ itemId: integer("item_id")
+ .notNull()
+ .references(() => items.id, { onDelete: "cascade" }),
});
export const settings = sqliteTable("settings", {
- key: text("key").primaryKey(),
- value: text("value").notNull(),
+ key: text("key").primaryKey(),
+ value: text("value").notNull(),
});
diff --git a/src/db/seed.ts b/src/db/seed.ts
index dd512fa..c7cf900 100644
--- a/src/db/seed.ts
+++ b/src/db/seed.ts
@@ -2,13 +2,13 @@ import { db } from "./index.ts";
import { categories } from "./schema.ts";
export function seedDefaults() {
- const existing = db.select().from(categories).all();
- if (existing.length === 0) {
- db.insert(categories)
- .values({
- name: "Uncategorized",
- icon: "package",
- })
- .run();
- }
+ const existing = db.select().from(categories).all();
+ if (existing.length === 0) {
+ db.insert(categories)
+ .values({
+ name: "Uncategorized",
+ icon: "package",
+ })
+ .run();
+ }
}
diff --git a/src/server/index.ts b/src/server/index.ts
index 0e6f2c9..3eaacf6 100644
--- a/src/server/index.ts
+++ b/src/server/index.ts
@@ -1,13 +1,13 @@
import { Hono } from "hono";
import { serveStatic } from "hono/bun";
import { seedDefaults } from "../db/seed.ts";
-import { itemRoutes } from "./routes/items.ts";
import { categoryRoutes } from "./routes/categories.ts";
-import { totalRoutes } from "./routes/totals.ts";
import { imageRoutes } from "./routes/images.ts";
+import { itemRoutes } from "./routes/items.ts";
import { settingsRoutes } from "./routes/settings.ts";
-import { threadRoutes } from "./routes/threads.ts";
import { setupRoutes } from "./routes/setups.ts";
+import { threadRoutes } from "./routes/threads.ts";
+import { totalRoutes } from "./routes/totals.ts";
// Seed default data on startup
seedDefaults();
@@ -16,7 +16,7 @@ const app = new Hono();
// Health check
app.get("/api/health", (c) => {
- return c.json({ status: "ok" });
+ return c.json({ status: "ok" });
});
// API routes
@@ -33,8 +33,8 @@ app.use("/uploads/*", serveStatic({ root: "./" }));
// Serve Vite-built SPA in production
if (process.env.NODE_ENV === "production") {
- app.use("/*", serveStatic({ root: "./dist/client" }));
- app.get("*", serveStatic({ path: "./dist/client/index.html" }));
+ app.use("/*", serveStatic({ root: "./dist/client" }));
+ app.get("*", serveStatic({ path: "./dist/client/index.html" }));
}
export default { port: 3000, fetch: app.fetch };
diff --git a/src/server/routes/categories.ts b/src/server/routes/categories.ts
index c92fb74..4a1f149 100644
--- a/src/server/routes/categories.ts
+++ b/src/server/routes/categories.ts
@@ -1,14 +1,14 @@
-import { Hono } from "hono";
import { zValidator } from "@hono/zod-validator";
+import { Hono } from "hono";
import {
- createCategorySchema,
- updateCategorySchema,
+ createCategorySchema,
+ updateCategorySchema,
} from "../../shared/schemas.ts";
import {
- getAllCategories,
- createCategory,
- updateCategory,
- deleteCategory,
+ createCategory,
+ deleteCategory,
+ getAllCategories,
+ updateCategory,
} from "../services/category.service.ts";
type Env = { Variables: { db?: any } };
@@ -16,44 +16,44 @@ type Env = { Variables: { db?: any } };
const app = new Hono
();
app.get("/", (c) => {
- const db = c.get("db");
- const cats = getAllCategories(db);
- return c.json(cats);
+ const db = c.get("db");
+ const cats = getAllCategories(db);
+ return c.json(cats);
});
app.post("/", zValidator("json", createCategorySchema), (c) => {
- const db = c.get("db");
- const data = c.req.valid("json");
- const cat = createCategory(db, data);
- return c.json(cat, 201);
+ const db = c.get("db");
+ const data = c.req.valid("json");
+ const cat = createCategory(db, data);
+ return c.json(cat, 201);
});
app.put(
- "/:id",
- zValidator("json", updateCategorySchema.omit({ id: true })),
- (c) => {
- const db = c.get("db");
- const id = Number(c.req.param("id"));
- const data = c.req.valid("json");
- const cat = updateCategory(db, id, data);
- if (!cat) return c.json({ error: "Category not found" }, 404);
- return c.json(cat);
- },
+ "/:id",
+ zValidator("json", updateCategorySchema.omit({ id: true })),
+ (c) => {
+ const db = c.get("db");
+ const id = Number(c.req.param("id"));
+ const data = c.req.valid("json");
+ const cat = updateCategory(db, id, data);
+ if (!cat) return c.json({ error: "Category not found" }, 404);
+ return c.json(cat);
+ },
);
app.delete("/:id", (c) => {
- const db = c.get("db");
- const id = Number(c.req.param("id"));
- const result = deleteCategory(db, id);
+ const db = c.get("db");
+ const id = Number(c.req.param("id"));
+ const result = deleteCategory(db, id);
- if (!result.success) {
- if (result.error === "Cannot delete the Uncategorized category") {
- return c.json({ error: result.error }, 400);
- }
- return c.json({ error: result.error }, 404);
- }
+ if (!result.success) {
+ if (result.error === "Cannot delete the Uncategorized category") {
+ return c.json({ error: result.error }, 400);
+ }
+ return c.json({ error: result.error }, 404);
+ }
- return c.json({ success: true });
+ return c.json({ success: true });
});
export { app as categoryRoutes };
diff --git a/src/server/routes/images.ts b/src/server/routes/images.ts
index d1a1f91..16c83d2 100644
--- a/src/server/routes/images.ts
+++ b/src/server/routes/images.ts
@@ -1,7 +1,7 @@
-import { Hono } from "hono";
import { randomUUID } from "node:crypto";
-import { join } from "node:path";
import { mkdir } from "node:fs/promises";
+import { join } from "node:path";
+import { Hono } from "hono";
const ALLOWED_TYPES = ["image/jpeg", "image/png", "image/webp"];
const MAX_SIZE = 5 * 1024 * 1024; // 5MB
@@ -9,38 +9,39 @@ const MAX_SIZE = 5 * 1024 * 1024; // 5MB
const app = new Hono();
app.post("/", async (c) => {
- const body = await c.req.parseBody();
- const file = body["image"];
+ const body = await c.req.parseBody();
+ const file = body.image;
- if (!file || typeof file === "string") {
- return c.json({ error: "No image file provided" }, 400);
- }
+ if (!file || typeof file === "string") {
+ return c.json({ error: "No image file provided" }, 400);
+ }
- // Validate file type
- if (!ALLOWED_TYPES.includes(file.type)) {
- return c.json(
- { error: "Invalid file type. Accepted: jpeg, png, webp" },
- 400,
- );
- }
+ // Validate file type
+ if (!ALLOWED_TYPES.includes(file.type)) {
+ return c.json(
+ { error: "Invalid file type. Accepted: jpeg, png, webp" },
+ 400,
+ );
+ }
- // Validate file size
- if (file.size > MAX_SIZE) {
- return c.json({ error: "File too large. Maximum size is 5MB" }, 400);
- }
+ // Validate file size
+ if (file.size > MAX_SIZE) {
+ return c.json({ error: "File too large. Maximum size is 5MB" }, 400);
+ }
- // Generate unique filename
- const ext = file.type.split("/")[1] === "jpeg" ? "jpg" : file.type.split("/")[1];
- const filename = `${Date.now()}-${randomUUID()}.${ext}`;
+ // Generate unique filename
+ const ext =
+ file.type.split("/")[1] === "jpeg" ? "jpg" : file.type.split("/")[1];
+ const filename = `${Date.now()}-${randomUUID()}.${ext}`;
- // Ensure uploads directory exists
- await mkdir("uploads", { recursive: true });
+ // Ensure uploads directory exists
+ await mkdir("uploads", { recursive: true });
- // Write file
- const buffer = await file.arrayBuffer();
- await Bun.write(join("uploads", filename), buffer);
+ // Write file
+ const buffer = await file.arrayBuffer();
+ await Bun.write(join("uploads", filename), buffer);
- return c.json({ filename }, 201);
+ return c.json({ filename }, 201);
});
export { app as imageRoutes };
diff --git a/src/server/routes/items.ts b/src/server/routes/items.ts
index c919fb8..f0beb30 100644
--- a/src/server/routes/items.ts
+++ b/src/server/routes/items.ts
@@ -1,66 +1,70 @@
-import { Hono } from "hono";
-import { zValidator } from "@hono/zod-validator";
-import { createItemSchema, updateItemSchema } from "../../shared/schemas.ts";
-import {
- getAllItems,
- getItemById,
- createItem,
- updateItem,
- deleteItem,
-} from "../services/item.service.ts";
import { unlink } from "node:fs/promises";
import { join } from "node:path";
+import { zValidator } from "@hono/zod-validator";
+import { Hono } from "hono";
+import { createItemSchema, updateItemSchema } from "../../shared/schemas.ts";
+import {
+ createItem,
+ deleteItem,
+ getAllItems,
+ getItemById,
+ updateItem,
+} from "../services/item.service.ts";
type Env = { Variables: { db?: any } };
const app = new Hono();
app.get("/", (c) => {
- const db = c.get("db");
- const items = getAllItems(db);
- return c.json(items);
+ const db = c.get("db");
+ const items = getAllItems(db);
+ return c.json(items);
});
app.get("/:id", (c) => {
- const db = c.get("db");
- const id = Number(c.req.param("id"));
- const item = getItemById(db, id);
- if (!item) return c.json({ error: "Item not found" }, 404);
- return c.json(item);
+ const db = c.get("db");
+ const id = Number(c.req.param("id"));
+ const item = getItemById(db, id);
+ if (!item) return c.json({ error: "Item not found" }, 404);
+ return c.json(item);
});
app.post("/", zValidator("json", createItemSchema), (c) => {
- const db = c.get("db");
- const data = c.req.valid("json");
- const item = createItem(db, data);
- return c.json(item, 201);
+ const db = c.get("db");
+ const data = c.req.valid("json");
+ const item = createItem(db, data);
+ return c.json(item, 201);
});
-app.put("/:id", zValidator("json", updateItemSchema.omit({ id: true })), (c) => {
- const db = c.get("db");
- const id = Number(c.req.param("id"));
- const data = c.req.valid("json");
- const item = updateItem(db, id, data);
- if (!item) return c.json({ error: "Item not found" }, 404);
- return c.json(item);
-});
+app.put(
+ "/:id",
+ zValidator("json", updateItemSchema.omit({ id: true })),
+ (c) => {
+ const db = c.get("db");
+ const id = Number(c.req.param("id"));
+ const data = c.req.valid("json");
+ const item = updateItem(db, id, data);
+ if (!item) return c.json({ error: "Item not found" }, 404);
+ return c.json(item);
+ },
+);
app.delete("/:id", async (c) => {
- const db = c.get("db");
- const id = Number(c.req.param("id"));
- const deleted = deleteItem(db, id);
- if (!deleted) return c.json({ error: "Item not found" }, 404);
+ const db = c.get("db");
+ const id = Number(c.req.param("id"));
+ const deleted = deleteItem(db, id);
+ if (!deleted) return c.json({ error: "Item not found" }, 404);
- // Clean up image file if exists
- if (deleted.imageFilename) {
- try {
- await unlink(join("uploads", deleted.imageFilename));
- } catch {
- // File missing is not an error worth failing the delete over
- }
- }
+ // Clean up image file if exists
+ if (deleted.imageFilename) {
+ try {
+ await unlink(join("uploads", deleted.imageFilename));
+ } catch {
+ // File missing is not an error worth failing the delete over
+ }
+ }
- return c.json({ success: true });
+ return c.json({ success: true });
});
export { app as itemRoutes };
diff --git a/src/server/routes/settings.ts b/src/server/routes/settings.ts
index 9fb66ea..89a921c 100644
--- a/src/server/routes/settings.ts
+++ b/src/server/routes/settings.ts
@@ -1,5 +1,5 @@
-import { Hono } from "hono";
import { eq } from "drizzle-orm";
+import { Hono } from "hono";
import { db as prodDb } from "../../db/index.ts";
import { settings } from "../../db/schema.ts";
@@ -8,30 +8,38 @@ type Env = { Variables: { db?: any } };
const app = new Hono();
app.get("/:key", (c) => {
- const database = c.get("db") ?? prodDb;
- const key = c.req.param("key");
- const row = database.select().from(settings).where(eq(settings.key, key)).get();
- if (!row) return c.json({ error: "Setting not found" }, 404);
- return c.json(row);
+ const database = c.get("db") ?? prodDb;
+ const key = c.req.param("key");
+ const row = database
+ .select()
+ .from(settings)
+ .where(eq(settings.key, key))
+ .get();
+ if (!row) return c.json({ error: "Setting not found" }, 404);
+ return c.json(row);
});
app.put("/:key", async (c) => {
- const database = c.get("db") ?? prodDb;
- const key = c.req.param("key");
- const body = await c.req.json<{ value: string }>();
+ const database = c.get("db") ?? prodDb;
+ const key = c.req.param("key");
+ const body = await c.req.json<{ value: string }>();
- if (!body.value && body.value !== "") {
- return c.json({ error: "value is required" }, 400);
- }
+ if (!body.value && body.value !== "") {
+ return c.json({ error: "value is required" }, 400);
+ }
- database
- .insert(settings)
- .values({ key, value: body.value })
- .onConflictDoUpdate({ target: settings.key, set: { value: body.value } })
- .run();
+ database
+ .insert(settings)
+ .values({ key, value: body.value })
+ .onConflictDoUpdate({ target: settings.key, set: { value: body.value } })
+ .run();
- const row = database.select().from(settings).where(eq(settings.key, key)).get();
- return c.json(row);
+ const row = database
+ .select()
+ .from(settings)
+ .where(eq(settings.key, key))
+ .get();
+ return c.json(row);
});
export { app as settingsRoutes };
diff --git a/src/server/routes/setups.ts b/src/server/routes/setups.ts
index c553754..f28ca02 100644
--- a/src/server/routes/setups.ts
+++ b/src/server/routes/setups.ts
@@ -1,18 +1,18 @@
-import { Hono } from "hono";
import { zValidator } from "@hono/zod-validator";
+import { Hono } from "hono";
import {
- createSetupSchema,
- updateSetupSchema,
- syncSetupItemsSchema,
+ createSetupSchema,
+ syncSetupItemsSchema,
+ updateSetupSchema,
} from "../../shared/schemas.ts";
import {
- getAllSetups,
- getSetupWithItems,
- createSetup,
- updateSetup,
- deleteSetup,
- syncSetupItems,
- removeSetupItem,
+ createSetup,
+ deleteSetup,
+ getAllSetups,
+ getSetupWithItems,
+ removeSetupItem,
+ syncSetupItems,
+ updateSetup,
} from "../services/setup.service.ts";
type Env = { Variables: { db?: any } };
@@ -22,63 +22,63 @@ const app = new Hono();
// Setup CRUD
app.get("/", (c) => {
- const db = c.get("db");
- const setups = getAllSetups(db);
- return c.json(setups);
+ const db = c.get("db");
+ const setups = getAllSetups(db);
+ return c.json(setups);
});
app.post("/", zValidator("json", createSetupSchema), (c) => {
- const db = c.get("db");
- const data = c.req.valid("json");
- const setup = createSetup(db, data);
- return c.json(setup, 201);
+ const db = c.get("db");
+ const data = c.req.valid("json");
+ const setup = createSetup(db, data);
+ return c.json(setup, 201);
});
app.get("/:id", (c) => {
- const db = c.get("db");
- const id = Number(c.req.param("id"));
- const setup = getSetupWithItems(db, id);
- if (!setup) return c.json({ error: "Setup not found" }, 404);
- return c.json(setup);
+ const db = c.get("db");
+ const id = Number(c.req.param("id"));
+ const setup = getSetupWithItems(db, id);
+ if (!setup) return c.json({ error: "Setup not found" }, 404);
+ return c.json(setup);
});
app.put("/:id", zValidator("json", updateSetupSchema), (c) => {
- const db = c.get("db");
- const id = Number(c.req.param("id"));
- const data = c.req.valid("json");
- const setup = updateSetup(db, id, data);
- if (!setup) return c.json({ error: "Setup not found" }, 404);
- return c.json(setup);
+ const db = c.get("db");
+ const id = Number(c.req.param("id"));
+ const data = c.req.valid("json");
+ const setup = updateSetup(db, id, data);
+ if (!setup) return c.json({ error: "Setup not found" }, 404);
+ return c.json(setup);
});
app.delete("/:id", (c) => {
- const db = c.get("db");
- const id = Number(c.req.param("id"));
- const deleted = deleteSetup(db, id);
- if (!deleted) return c.json({ error: "Setup not found" }, 404);
- return c.json({ success: true });
+ const db = c.get("db");
+ const id = Number(c.req.param("id"));
+ const deleted = deleteSetup(db, id);
+ if (!deleted) return c.json({ error: "Setup not found" }, 404);
+ return c.json({ success: true });
});
// Setup Items
app.put("/:id/items", zValidator("json", syncSetupItemsSchema), (c) => {
- const db = c.get("db");
- const id = Number(c.req.param("id"));
- const { itemIds } = c.req.valid("json");
+ const db = c.get("db");
+ const id = Number(c.req.param("id"));
+ const { itemIds } = c.req.valid("json");
- const setup = getSetupWithItems(db, id);
- if (!setup) return c.json({ error: "Setup not found" }, 404);
+ const setup = getSetupWithItems(db, id);
+ if (!setup) return c.json({ error: "Setup not found" }, 404);
- syncSetupItems(db, id, itemIds);
- return c.json({ success: true });
+ syncSetupItems(db, id, itemIds);
+ return c.json({ success: true });
});
app.delete("/:id/items/:itemId", (c) => {
- const db = c.get("db");
- const setupId = Number(c.req.param("id"));
- const itemId = Number(c.req.param("itemId"));
- removeSetupItem(db, setupId, itemId);
- return c.json({ success: true });
+ const db = c.get("db");
+ const setupId = Number(c.req.param("id"));
+ const itemId = Number(c.req.param("itemId"));
+ removeSetupItem(db, setupId, itemId);
+ return c.json({ success: true });
});
export { app as setupRoutes };
diff --git a/src/server/routes/threads.ts b/src/server/routes/threads.ts
index 6e64940..355fa03 100644
--- a/src/server/routes/threads.ts
+++ b/src/server/routes/threads.ts
@@ -1,25 +1,25 @@
-import { Hono } from "hono";
-import { zValidator } from "@hono/zod-validator";
-import {
- createThreadSchema,
- updateThreadSchema,
- createCandidateSchema,
- updateCandidateSchema,
- resolveThreadSchema,
-} from "../../shared/schemas.ts";
-import {
- getAllThreads,
- getThreadWithCandidates,
- createThread,
- updateThread,
- deleteThread,
- createCandidate,
- updateCandidate,
- deleteCandidate,
- resolveThread,
-} from "../services/thread.service.ts";
import { unlink } from "node:fs/promises";
import { join } from "node:path";
+import { zValidator } from "@hono/zod-validator";
+import { Hono } from "hono";
+import {
+ createCandidateSchema,
+ createThreadSchema,
+ resolveThreadSchema,
+ updateCandidateSchema,
+ updateThreadSchema,
+} from "../../shared/schemas.ts";
+import {
+ createCandidate,
+ createThread,
+ deleteCandidate,
+ deleteThread,
+ getAllThreads,
+ getThreadWithCandidates,
+ resolveThread,
+ updateCandidate,
+ updateThread,
+} from "../services/thread.service.ts";
type Env = { Variables: { db?: any } };
@@ -28,109 +28,113 @@ const app = new Hono();
// Thread CRUD
app.get("/", (c) => {
- const db = c.get("db");
- const includeResolved = c.req.query("includeResolved") === "true";
- const threads = getAllThreads(db, includeResolved);
- return c.json(threads);
+ const db = c.get("db");
+ const includeResolved = c.req.query("includeResolved") === "true";
+ const threads = getAllThreads(db, includeResolved);
+ return c.json(threads);
});
app.post("/", zValidator("json", createThreadSchema), (c) => {
- const db = c.get("db");
- const data = c.req.valid("json");
- const thread = createThread(db, data);
- return c.json(thread, 201);
+ const db = c.get("db");
+ const data = c.req.valid("json");
+ const thread = createThread(db, data);
+ return c.json(thread, 201);
});
app.get("/:id", (c) => {
- const db = c.get("db");
- const id = Number(c.req.param("id"));
- const thread = getThreadWithCandidates(db, id);
- if (!thread) return c.json({ error: "Thread not found" }, 404);
- return c.json(thread);
+ const db = c.get("db");
+ const id = Number(c.req.param("id"));
+ const thread = getThreadWithCandidates(db, id);
+ if (!thread) return c.json({ error: "Thread not found" }, 404);
+ return c.json(thread);
});
app.put("/:id", zValidator("json", updateThreadSchema), (c) => {
- const db = c.get("db");
- const id = Number(c.req.param("id"));
- const data = c.req.valid("json");
- const thread = updateThread(db, id, data);
- if (!thread) return c.json({ error: "Thread not found" }, 404);
- return c.json(thread);
+ const db = c.get("db");
+ const id = Number(c.req.param("id"));
+ const data = c.req.valid("json");
+ const thread = updateThread(db, id, data);
+ if (!thread) return c.json({ error: "Thread not found" }, 404);
+ return c.json(thread);
});
app.delete("/:id", async (c) => {
- const db = c.get("db");
- const id = Number(c.req.param("id"));
- const deleted = deleteThread(db, id);
- if (!deleted) return c.json({ error: "Thread not found" }, 404);
+ const db = c.get("db");
+ const id = Number(c.req.param("id"));
+ const deleted = deleteThread(db, id);
+ if (!deleted) return c.json({ error: "Thread not found" }, 404);
- // Clean up candidate image files
- for (const filename of deleted.candidateImages) {
- try {
- await unlink(join("uploads", filename));
- } catch {
- // File missing is not an error worth failing the delete over
- }
- }
+ // Clean up candidate image files
+ for (const filename of deleted.candidateImages) {
+ try {
+ await unlink(join("uploads", filename));
+ } catch {
+ // File missing is not an error worth failing the delete over
+ }
+ }
- return c.json({ success: true });
+ return c.json({ success: true });
});
// Candidate CRUD (nested under thread)
app.post("/:id/candidates", zValidator("json", createCandidateSchema), (c) => {
- const db = c.get("db");
- const threadId = Number(c.req.param("id"));
+ const db = c.get("db");
+ const threadId = Number(c.req.param("id"));
- // Verify thread exists
- const thread = getThreadWithCandidates(db, threadId);
- if (!thread) return c.json({ error: "Thread not found" }, 404);
+ // Verify thread exists
+ const thread = getThreadWithCandidates(db, threadId);
+ if (!thread) return c.json({ error: "Thread not found" }, 404);
- const data = c.req.valid("json");
- const candidate = createCandidate(db, threadId, data);
- return c.json(candidate, 201);
+ const data = c.req.valid("json");
+ const candidate = createCandidate(db, threadId, data);
+ return c.json(candidate, 201);
});
-app.put("/:threadId/candidates/:candidateId", zValidator("json", updateCandidateSchema), (c) => {
- const db = c.get("db");
- const candidateId = Number(c.req.param("candidateId"));
- const data = c.req.valid("json");
- const candidate = updateCandidate(db, candidateId, data);
- if (!candidate) return c.json({ error: "Candidate not found" }, 404);
- return c.json(candidate);
-});
+app.put(
+ "/:threadId/candidates/:candidateId",
+ zValidator("json", updateCandidateSchema),
+ (c) => {
+ const db = c.get("db");
+ const candidateId = Number(c.req.param("candidateId"));
+ const data = c.req.valid("json");
+ const candidate = updateCandidate(db, candidateId, data);
+ if (!candidate) return c.json({ error: "Candidate not found" }, 404);
+ return c.json(candidate);
+ },
+);
app.delete("/:threadId/candidates/:candidateId", async (c) => {
- const db = c.get("db");
- const candidateId = Number(c.req.param("candidateId"));
- const deleted = deleteCandidate(db, candidateId);
- if (!deleted) return c.json({ error: "Candidate not found" }, 404);
+ const db = c.get("db");
+ const candidateId = Number(c.req.param("candidateId"));
+ const deleted = deleteCandidate(db, candidateId);
+ if (!deleted) return c.json({ error: "Candidate not found" }, 404);
- // Clean up image file if exists
- if (deleted.imageFilename) {
- try {
- await unlink(join("uploads", deleted.imageFilename));
- } catch {
- // File missing is not an error
- }
- }
+ // Clean up image file if exists
+ if (deleted.imageFilename) {
+ try {
+ await unlink(join("uploads", deleted.imageFilename));
+ } catch {
+ // File missing is not an error
+ }
+ }
- return c.json({ success: true });
+ return c.json({ success: true });
});
// Resolution
app.post("/:id/resolve", zValidator("json", resolveThreadSchema), (c) => {
- const db = c.get("db");
- const threadId = Number(c.req.param("id"));
- const { candidateId } = c.req.valid("json");
+ const db = c.get("db");
+ const threadId = Number(c.req.param("id"));
+ const { candidateId } = c.req.valid("json");
- const result = resolveThread(db, threadId, candidateId);
- if (!result.success) {
- return c.json({ error: result.error }, 400);
- }
+ const result = resolveThread(db, threadId, candidateId);
+ if (!result.success) {
+ return c.json({ error: result.error }, 400);
+ }
- return c.json({ success: true, item: result.item });
+ return c.json({ success: true, item: result.item });
});
export { app as threadRoutes };
diff --git a/src/server/routes/totals.ts b/src/server/routes/totals.ts
index 2590f6e..4f2fffa 100644
--- a/src/server/routes/totals.ts
+++ b/src/server/routes/totals.ts
@@ -1,7 +1,7 @@
import { Hono } from "hono";
import {
- getCategoryTotals,
- getGlobalTotals,
+ getCategoryTotals,
+ getGlobalTotals,
} from "../services/totals.service.ts";
type Env = { Variables: { db?: any } };
@@ -9,10 +9,10 @@ type Env = { Variables: { db?: any } };
const app = new Hono();
app.get("/", (c) => {
- const db = c.get("db");
- const categoryTotals = getCategoryTotals(db);
- const globalTotals = getGlobalTotals(db);
- return c.json({ categories: categoryTotals, global: globalTotals });
+ const db = c.get("db");
+ const categoryTotals = getCategoryTotals(db);
+ const globalTotals = getGlobalTotals(db);
+ return c.json({ categories: categoryTotals, global: globalTotals });
});
export { app as totalRoutes };
diff --git a/src/server/services/category.service.ts b/src/server/services/category.service.ts
index 30860e9..3b35396 100644
--- a/src/server/services/category.service.ts
+++ b/src/server/services/category.service.ts
@@ -1,77 +1,80 @@
-import { eq, asc } from "drizzle-orm";
-import { categories, items } from "../../db/schema.ts";
+import { asc, eq } from "drizzle-orm";
import { db as prodDb } from "../../db/index.ts";
+import { categories, items } from "../../db/schema.ts";
type Db = typeof prodDb;
export function getAllCategories(db: Db = prodDb) {
- return db.select().from(categories).orderBy(asc(categories.name)).all();
+ return db.select().from(categories).orderBy(asc(categories.name)).all();
}
export function createCategory(
- db: Db = prodDb,
- data: { name: string; icon?: string },
+ db: Db = prodDb,
+ data: { name: string; icon?: string },
) {
- return db
- .insert(categories)
- .values({
- name: data.name,
- ...(data.icon ? { icon: data.icon } : {}),
- })
- .returning()
- .get();
+ return db
+ .insert(categories)
+ .values({
+ name: data.name,
+ ...(data.icon ? { icon: data.icon } : {}),
+ })
+ .returning()
+ .get();
}
export function updateCategory(
- db: Db = prodDb,
- id: number,
- data: { name?: string; icon?: string },
+ db: Db = prodDb,
+ id: number,
+ data: { name?: string; icon?: string },
) {
- const existing = db
- .select({ id: categories.id })
- .from(categories)
- .where(eq(categories.id, id))
- .get();
+ const existing = db
+ .select({ id: categories.id })
+ .from(categories)
+ .where(eq(categories.id, id))
+ .get();
- if (!existing) return null;
+ if (!existing) return null;
- return db
- .update(categories)
- .set(data)
- .where(eq(categories.id, id))
- .returning()
- .get();
+ return db
+ .update(categories)
+ .set(data)
+ .where(eq(categories.id, id))
+ .returning()
+ .get();
}
export function deleteCategory(
- db: Db = prodDb,
- id: number,
+ db: Db = prodDb,
+ id: number,
): { success: boolean; error?: string } {
- // Guard: cannot delete Uncategorized (id=1)
- if (id === 1) {
- return { success: false, error: "Cannot delete the Uncategorized category" };
- }
+ // Guard: cannot delete Uncategorized (id=1)
+ if (id === 1) {
+ return {
+ success: false,
+ error: "Cannot delete the Uncategorized category",
+ };
+ }
- // Check if category exists
- const existing = db
- .select({ id: categories.id })
- .from(categories)
- .where(eq(categories.id, id))
- .get();
+ // Check if category exists
+ const existing = db
+ .select({ id: categories.id })
+ .from(categories)
+ .where(eq(categories.id, id))
+ .get();
- if (!existing) {
- return { success: false, error: "Category not found" };
- }
+ if (!existing) {
+ return { success: false, error: "Category not found" };
+ }
- // Reassign items to Uncategorized (id=1), then delete atomically
- db.transaction(() => {
- db.update(items)
- .set({ categoryId: 1 })
- .where(eq(items.categoryId, id))
- .run();
+ // Reassign items to Uncategorized (id=1), then delete atomically
+ db.transaction(() => {
+ db.update(items)
+ .set({ categoryId: 1 })
+ .where(eq(items.categoryId, id))
+ .run();
- db.delete(categories).where(eq(categories.id, id)).run();
- });
+ db.delete(categories).where(eq(categories.id, id)).run();
+ });
- return { success: true };
+ return { success: true };
}
diff --git a/src/server/services/item.service.ts b/src/server/services/item.service.ts
index 7d484a3..684003b 100644
--- a/src/server/services/item.service.ts
+++ b/src/server/services/item.service.ts
@@ -1,112 +1,112 @@
-import { eq, sql } from "drizzle-orm";
-import { items, categories } from "../../db/schema.ts";
+import { eq } from "drizzle-orm";
import { db as prodDb } from "../../db/index.ts";
+import { categories, items } from "../../db/schema.ts";
import type { CreateItem } from "../../shared/types.ts";
type Db = typeof prodDb;
export function getAllItems(db: Db = prodDb) {
- return db
- .select({
- id: items.id,
- name: items.name,
- weightGrams: items.weightGrams,
- priceCents: items.priceCents,
- categoryId: items.categoryId,
- notes: items.notes,
- productUrl: items.productUrl,
- imageFilename: items.imageFilename,
- createdAt: items.createdAt,
- updatedAt: items.updatedAt,
- categoryName: categories.name,
- categoryIcon: categories.icon,
- })
- .from(items)
- .innerJoin(categories, eq(items.categoryId, categories.id))
- .all();
+ return db
+ .select({
+ id: items.id,
+ name: items.name,
+ weightGrams: items.weightGrams,
+ priceCents: items.priceCents,
+ categoryId: items.categoryId,
+ notes: items.notes,
+ productUrl: items.productUrl,
+ imageFilename: items.imageFilename,
+ createdAt: items.createdAt,
+ updatedAt: items.updatedAt,
+ categoryName: categories.name,
+ categoryIcon: categories.icon,
+ })
+ .from(items)
+ .innerJoin(categories, eq(items.categoryId, categories.id))
+ .all();
}
export function getItemById(db: Db = prodDb, id: number) {
- return (
- db
- .select({
- id: items.id,
- name: items.name,
- weightGrams: items.weightGrams,
- priceCents: items.priceCents,
- categoryId: items.categoryId,
- notes: items.notes,
- productUrl: items.productUrl,
- imageFilename: items.imageFilename,
- createdAt: items.createdAt,
- updatedAt: items.updatedAt,
- })
- .from(items)
- .where(eq(items.id, id))
- .get() ?? null
- );
+ return (
+ db
+ .select({
+ id: items.id,
+ name: items.name,
+ weightGrams: items.weightGrams,
+ priceCents: items.priceCents,
+ categoryId: items.categoryId,
+ notes: items.notes,
+ productUrl: items.productUrl,
+ imageFilename: items.imageFilename,
+ createdAt: items.createdAt,
+ updatedAt: items.updatedAt,
+ })
+ .from(items)
+ .where(eq(items.id, id))
+ .get() ?? null
+ );
}
export function createItem(
- db: Db = prodDb,
- data: Partial & { name: string; categoryId: number; imageFilename?: string },
+ db: Db = prodDb,
+ data: Partial & {
+ name: string;
+ categoryId: number;
+ imageFilename?: string;
+ },
) {
- return db
- .insert(items)
- .values({
- name: data.name,
- weightGrams: data.weightGrams ?? null,
- priceCents: data.priceCents ?? null,
- categoryId: data.categoryId,
- notes: data.notes ?? null,
- productUrl: data.productUrl ?? null,
- imageFilename: data.imageFilename ?? null,
- })
- .returning()
- .get();
+ return db
+ .insert(items)
+ .values({
+ name: data.name,
+ weightGrams: data.weightGrams ?? null,
+ priceCents: data.priceCents ?? null,
+ categoryId: data.categoryId,
+ notes: data.notes ?? null,
+ productUrl: data.productUrl ?? null,
+ imageFilename: data.imageFilename ?? null,
+ })
+ .returning()
+ .get();
}
export function updateItem(
- db: Db = prodDb,
- id: number,
- data: Partial<{
- name: string;
- weightGrams: number;
- priceCents: number;
- categoryId: number;
- notes: string;
- productUrl: string;
- imageFilename: string;
- }>,
+ db: Db = prodDb,
+ id: number,
+ data: Partial<{
+ name: string;
+ weightGrams: number;
+ priceCents: number;
+ categoryId: number;
+ notes: string;
+ productUrl: string;
+ imageFilename: string;
+ }>,
) {
- // Check if item exists first
- const existing = db
- .select({ id: items.id })
- .from(items)
- .where(eq(items.id, id))
- .get();
+ // Check if item exists first
+ const existing = db
+ .select({ id: items.id })
+ .from(items)
+ .where(eq(items.id, id))
+ .get();
- if (!existing) return null;
+ if (!existing) return null;
- return db
- .update(items)
- .set({ ...data, updatedAt: new Date() })
- .where(eq(items.id, id))
- .returning()
- .get();
+ return db
+ .update(items)
+ .set({ ...data, updatedAt: new Date() })
+ .where(eq(items.id, id))
+ .returning()
+ .get();
}
export function deleteItem(db: Db = prodDb, id: number) {
- // Get item first (for image cleanup info)
- const item = db
- .select()
- .from(items)
- .where(eq(items.id, id))
- .get();
+ // Get item first (for image cleanup info)
+ const item = db.select().from(items).where(eq(items.id, id)).get();
- if (!item) return null;
+ if (!item) return null;
- db.delete(items).where(eq(items.id, id)).run();
+ db.delete(items).where(eq(items.id, id)).run();
- return item;
+ return item;
}
diff --git a/src/server/services/setup.service.ts b/src/server/services/setup.service.ts
index 4e7543a..7150511 100644
--- a/src/server/services/setup.service.ts
+++ b/src/server/services/setup.service.ts
@@ -1,111 +1,124 @@
import { eq, sql } from "drizzle-orm";
-import { setups, setupItems, items, categories } from "../../db/schema.ts";
import { db as prodDb } from "../../db/index.ts";
+import { categories, items, setupItems, setups } from "../../db/schema.ts";
import type { CreateSetup, UpdateSetup } from "../../shared/types.ts";
type Db = typeof prodDb;
export function createSetup(db: Db = prodDb, data: CreateSetup) {
- return db
- .insert(setups)
- .values({ name: data.name })
- .returning()
- .get();
+ return db.insert(setups).values({ name: data.name }).returning().get();
}
export function getAllSetups(db: Db = prodDb) {
- return db
- .select({
- id: setups.id,
- name: setups.name,
- createdAt: setups.createdAt,
- updatedAt: setups.updatedAt,
- itemCount: sql`COALESCE((
+ return db
+ .select({
+ id: setups.id,
+ name: setups.name,
+ createdAt: setups.createdAt,
+ updatedAt: setups.updatedAt,
+ itemCount: sql`COALESCE((
SELECT COUNT(*) FROM setup_items
WHERE setup_items.setup_id = setups.id
), 0)`.as("item_count"),
- totalWeight: sql`COALESCE((
+ totalWeight: sql`COALESCE((
SELECT SUM(items.weight_grams) FROM setup_items
JOIN items ON items.id = setup_items.item_id
WHERE setup_items.setup_id = setups.id
), 0)`.as("total_weight"),
- totalCost: sql`COALESCE((
+ totalCost: sql`COALESCE((
SELECT SUM(items.price_cents) FROM setup_items
JOIN items ON items.id = setup_items.item_id
WHERE setup_items.setup_id = setups.id
), 0)`.as("total_cost"),
- })
- .from(setups)
- .all();
+ })
+ .from(setups)
+ .all();
}
export function getSetupWithItems(db: Db = prodDb, setupId: number) {
- const setup = db.select().from(setups)
- .where(eq(setups.id, setupId)).get();
- if (!setup) return null;
+ const setup = db.select().from(setups).where(eq(setups.id, setupId)).get();
+ if (!setup) return null;
- const itemList = db
- .select({
- id: items.id,
- name: items.name,
- weightGrams: items.weightGrams,
- priceCents: items.priceCents,
- categoryId: items.categoryId,
- notes: items.notes,
- productUrl: items.productUrl,
- imageFilename: items.imageFilename,
- createdAt: items.createdAt,
- updatedAt: items.updatedAt,
- categoryName: categories.name,
- categoryIcon: categories.icon,
- })
- .from(setupItems)
- .innerJoin(items, eq(setupItems.itemId, items.id))
- .innerJoin(categories, eq(items.categoryId, categories.id))
- .where(eq(setupItems.setupId, setupId))
- .all();
+ const itemList = db
+ .select({
+ id: items.id,
+ name: items.name,
+ weightGrams: items.weightGrams,
+ priceCents: items.priceCents,
+ categoryId: items.categoryId,
+ notes: items.notes,
+ productUrl: items.productUrl,
+ imageFilename: items.imageFilename,
+ createdAt: items.createdAt,
+ updatedAt: items.updatedAt,
+ categoryName: categories.name,
+ categoryIcon: categories.icon,
+ })
+ .from(setupItems)
+ .innerJoin(items, eq(setupItems.itemId, items.id))
+ .innerJoin(categories, eq(items.categoryId, categories.id))
+ .where(eq(setupItems.setupId, setupId))
+ .all();
- return { ...setup, items: itemList };
+ return { ...setup, items: itemList };
}
-export function updateSetup(db: Db = prodDb, setupId: number, data: UpdateSetup) {
- const existing = db.select({ id: setups.id }).from(setups)
- .where(eq(setups.id, setupId)).get();
- if (!existing) return null;
+export function updateSetup(
+ db: Db = prodDb,
+ setupId: number,
+ data: UpdateSetup,
+) {
+ const existing = db
+ .select({ id: setups.id })
+ .from(setups)
+ .where(eq(setups.id, setupId))
+ .get();
+ if (!existing) return null;
- return db
- .update(setups)
- .set({ name: data.name, updatedAt: new Date() })
- .where(eq(setups.id, setupId))
- .returning()
- .get();
+ return db
+ .update(setups)
+ .set({ name: data.name, updatedAt: new Date() })
+ .where(eq(setups.id, setupId))
+ .returning()
+ .get();
}
export function deleteSetup(db: Db = prodDb, setupId: number) {
- const existing = db.select({ id: setups.id }).from(setups)
- .where(eq(setups.id, setupId)).get();
- if (!existing) return false;
+ const existing = db
+ .select({ id: setups.id })
+ .from(setups)
+ .where(eq(setups.id, setupId))
+ .get();
+ if (!existing) return false;
- db.delete(setups).where(eq(setups.id, setupId)).run();
- return true;
+ db.delete(setups).where(eq(setups.id, setupId)).run();
+ return true;
}
-export function syncSetupItems(db: Db = prodDb, setupId: number, itemIds: number[]) {
- return db.transaction((tx) => {
- // Delete all existing items for this setup
- tx.delete(setupItems).where(eq(setupItems.setupId, setupId)).run();
+export function syncSetupItems(
+ db: Db = prodDb,
+ setupId: number,
+ itemIds: number[],
+) {
+ return db.transaction((tx) => {
+ // Delete all existing items for this setup
+ tx.delete(setupItems).where(eq(setupItems.setupId, setupId)).run();
- // Re-insert new items
- for (const itemId of itemIds) {
- tx.insert(setupItems).values({ setupId, itemId }).run();
- }
- });
+ // Re-insert new items
+ for (const itemId of itemIds) {
+ tx.insert(setupItems).values({ setupId, itemId }).run();
+ }
+ });
}
-export function removeSetupItem(db: Db = prodDb, setupId: number, itemId: number) {
- db.delete(setupItems)
- .where(
- sql`${setupItems.setupId} = ${setupId} AND ${setupItems.itemId} = ${itemId}`
- )
- .run();
+export function removeSetupItem(
+ db: Db = prodDb,
+ setupId: number,
+ itemId: number,
+) {
+ db.delete(setupItems)
+ .where(
+ sql`${setupItems.setupId} = ${setupId} AND ${setupItems.itemId} = ${itemId}`,
+ )
+ .run();
}
diff --git a/src/server/services/thread.service.ts b/src/server/services/thread.service.ts
index c1d092b..4baafda 100644
--- a/src/server/services/thread.service.ts
+++ b/src/server/services/thread.service.ts
@@ -1,221 +1,261 @@
-import { eq, desc, sql } from "drizzle-orm";
-import { threads, threadCandidates, items, categories } from "../../db/schema.ts";
+import { desc, eq, sql } from "drizzle-orm";
import { db as prodDb } from "../../db/index.ts";
-import type { CreateThread, UpdateThread, CreateCandidate } from "../../shared/types.ts";
+import {
+ categories,
+ items,
+ threadCandidates,
+ threads,
+} from "../../db/schema.ts";
+import type { CreateCandidate, CreateThread } from "../../shared/types.ts";
type Db = typeof prodDb;
export function createThread(db: Db = prodDb, data: CreateThread) {
- return db
- .insert(threads)
- .values({ name: data.name, categoryId: data.categoryId })
- .returning()
- .get();
+ return db
+ .insert(threads)
+ .values({ name: data.name, categoryId: data.categoryId })
+ .returning()
+ .get();
}
export function getAllThreads(db: Db = prodDb, includeResolved = false) {
- const query = db
- .select({
- id: threads.id,
- name: threads.name,
- status: threads.status,
- resolvedCandidateId: threads.resolvedCandidateId,
- categoryId: threads.categoryId,
- categoryName: categories.name,
- categoryIcon: categories.icon,
- createdAt: threads.createdAt,
- updatedAt: threads.updatedAt,
- candidateCount: sql`(
+ const query = db
+ .select({
+ id: threads.id,
+ name: threads.name,
+ status: threads.status,
+ resolvedCandidateId: threads.resolvedCandidateId,
+ categoryId: threads.categoryId,
+ categoryName: categories.name,
+ categoryIcon: categories.icon,
+ createdAt: threads.createdAt,
+ updatedAt: threads.updatedAt,
+ candidateCount: sql`(
SELECT COUNT(*) FROM thread_candidates
WHERE thread_candidates.thread_id = threads.id
)`.as("candidate_count"),
- minPriceCents: sql`(
+ minPriceCents: sql`(
SELECT MIN(price_cents) FROM thread_candidates
WHERE thread_candidates.thread_id = threads.id
)`.as("min_price_cents"),
- maxPriceCents: sql`(
+ maxPriceCents: sql`(
SELECT MAX(price_cents) FROM thread_candidates
WHERE thread_candidates.thread_id = threads.id
)`.as("max_price_cents"),
- })
- .from(threads)
- .innerJoin(categories, eq(threads.categoryId, categories.id))
- .orderBy(desc(threads.createdAt));
+ })
+ .from(threads)
+ .innerJoin(categories, eq(threads.categoryId, categories.id))
+ .orderBy(desc(threads.createdAt));
- if (!includeResolved) {
- return query.where(eq(threads.status, "active")).all();
- }
- return query.all();
+ if (!includeResolved) {
+ return query.where(eq(threads.status, "active")).all();
+ }
+ return query.all();
}
export function getThreadWithCandidates(db: Db = prodDb, threadId: number) {
- const thread = db.select().from(threads)
- .where(eq(threads.id, threadId)).get();
- if (!thread) return null;
+ const thread = db
+ .select()
+ .from(threads)
+ .where(eq(threads.id, threadId))
+ .get();
+ if (!thread) return null;
- const candidateList = db
- .select({
- id: threadCandidates.id,
- threadId: threadCandidates.threadId,
- name: threadCandidates.name,
- weightGrams: threadCandidates.weightGrams,
- priceCents: threadCandidates.priceCents,
- categoryId: threadCandidates.categoryId,
- notes: threadCandidates.notes,
- productUrl: threadCandidates.productUrl,
- imageFilename: threadCandidates.imageFilename,
- createdAt: threadCandidates.createdAt,
- updatedAt: threadCandidates.updatedAt,
- categoryName: categories.name,
- categoryIcon: categories.icon,
- })
- .from(threadCandidates)
- .innerJoin(categories, eq(threadCandidates.categoryId, categories.id))
- .where(eq(threadCandidates.threadId, threadId))
- .all();
+ const candidateList = db
+ .select({
+ id: threadCandidates.id,
+ threadId: threadCandidates.threadId,
+ name: threadCandidates.name,
+ weightGrams: threadCandidates.weightGrams,
+ priceCents: threadCandidates.priceCents,
+ categoryId: threadCandidates.categoryId,
+ notes: threadCandidates.notes,
+ productUrl: threadCandidates.productUrl,
+ imageFilename: threadCandidates.imageFilename,
+ createdAt: threadCandidates.createdAt,
+ updatedAt: threadCandidates.updatedAt,
+ categoryName: categories.name,
+ categoryIcon: categories.icon,
+ })
+ .from(threadCandidates)
+ .innerJoin(categories, eq(threadCandidates.categoryId, categories.id))
+ .where(eq(threadCandidates.threadId, threadId))
+ .all();
- return { ...thread, candidates: candidateList };
+ return { ...thread, candidates: candidateList };
}
-export function updateThread(db: Db = prodDb, threadId: number, data: Partial<{ name: string; categoryId: number }>) {
- const existing = db.select({ id: threads.id }).from(threads)
- .where(eq(threads.id, threadId)).get();
- if (!existing) return null;
+export function updateThread(
+ db: Db = prodDb,
+ threadId: number,
+ data: Partial<{ name: string; categoryId: number }>,
+) {
+ const existing = db
+ .select({ id: threads.id })
+ .from(threads)
+ .where(eq(threads.id, threadId))
+ .get();
+ if (!existing) return null;
- return db
- .update(threads)
- .set({ ...data, updatedAt: new Date() })
- .where(eq(threads.id, threadId))
- .returning()
- .get();
+ return db
+ .update(threads)
+ .set({ ...data, updatedAt: new Date() })
+ .where(eq(threads.id, threadId))
+ .returning()
+ .get();
}
export function deleteThread(db: Db = prodDb, threadId: number) {
- const thread = db.select().from(threads)
- .where(eq(threads.id, threadId)).get();
- if (!thread) return null;
+ const thread = db
+ .select()
+ .from(threads)
+ .where(eq(threads.id, threadId))
+ .get();
+ if (!thread) return null;
- // Collect candidate image filenames for cleanup
- const candidatesWithImages = db
- .select({ imageFilename: threadCandidates.imageFilename })
- .from(threadCandidates)
- .where(eq(threadCandidates.threadId, threadId))
- .all()
- .filter((c) => c.imageFilename != null);
+ // Collect candidate image filenames for cleanup
+ const candidatesWithImages = db
+ .select({ imageFilename: threadCandidates.imageFilename })
+ .from(threadCandidates)
+ .where(eq(threadCandidates.threadId, threadId))
+ .all()
+ .filter((c) => c.imageFilename != null);
- db.delete(threads).where(eq(threads.id, threadId)).run();
+ db.delete(threads).where(eq(threads.id, threadId)).run();
- return { ...thread, candidateImages: candidatesWithImages.map((c) => c.imageFilename!) };
+ return {
+ ...thread,
+ candidateImages: candidatesWithImages.map((c) => c.imageFilename!),
+ };
}
export function createCandidate(
- db: Db = prodDb,
- threadId: number,
- data: Partial & { name: string; categoryId: number; imageFilename?: string },
+ db: Db = prodDb,
+ threadId: number,
+ data: Partial & {
+ name: string;
+ categoryId: number;
+ imageFilename?: string;
+ },
) {
- return db
- .insert(threadCandidates)
- .values({
- threadId,
- name: data.name,
- weightGrams: data.weightGrams ?? null,
- priceCents: data.priceCents ?? null,
- categoryId: data.categoryId,
- notes: data.notes ?? null,
- productUrl: data.productUrl ?? null,
- imageFilename: data.imageFilename ?? null,
- })
- .returning()
- .get();
+ return db
+ .insert(threadCandidates)
+ .values({
+ threadId,
+ name: data.name,
+ weightGrams: data.weightGrams ?? null,
+ priceCents: data.priceCents ?? null,
+ categoryId: data.categoryId,
+ notes: data.notes ?? null,
+ productUrl: data.productUrl ?? null,
+ imageFilename: data.imageFilename ?? null,
+ })
+ .returning()
+ .get();
}
export function updateCandidate(
- db: Db = prodDb,
- candidateId: number,
- data: Partial<{
- name: string;
- weightGrams: number;
- priceCents: number;
- categoryId: number;
- notes: string;
- productUrl: string;
- imageFilename: string;
- }>,
+ db: Db = prodDb,
+ candidateId: number,
+ data: Partial<{
+ name: string;
+ weightGrams: number;
+ priceCents: number;
+ categoryId: number;
+ notes: string;
+ productUrl: string;
+ imageFilename: string;
+ }>,
) {
- const existing = db.select({ id: threadCandidates.id }).from(threadCandidates)
- .where(eq(threadCandidates.id, candidateId)).get();
- if (!existing) return null;
+ const existing = db
+ .select({ id: threadCandidates.id })
+ .from(threadCandidates)
+ .where(eq(threadCandidates.id, candidateId))
+ .get();
+ if (!existing) return null;
- return db
- .update(threadCandidates)
- .set({ ...data, updatedAt: new Date() })
- .where(eq(threadCandidates.id, candidateId))
- .returning()
- .get();
+ return db
+ .update(threadCandidates)
+ .set({ ...data, updatedAt: new Date() })
+ .where(eq(threadCandidates.id, candidateId))
+ .returning()
+ .get();
}
export function deleteCandidate(db: Db = prodDb, candidateId: number) {
- const candidate = db.select().from(threadCandidates)
- .where(eq(threadCandidates.id, candidateId)).get();
- if (!candidate) return null;
+ const candidate = db
+ .select()
+ .from(threadCandidates)
+ .where(eq(threadCandidates.id, candidateId))
+ .get();
+ if (!candidate) return null;
- db.delete(threadCandidates).where(eq(threadCandidates.id, candidateId)).run();
- return candidate;
+ db.delete(threadCandidates).where(eq(threadCandidates.id, candidateId)).run();
+ return candidate;
}
export function resolveThread(
- db: Db = prodDb,
- threadId: number,
- candidateId: number,
+ db: Db = prodDb,
+ threadId: number,
+ candidateId: number,
): { success: boolean; item?: any; error?: string } {
- return db.transaction((tx) => {
- // 1. Check thread is active
- const thread = tx.select().from(threads)
- .where(eq(threads.id, threadId)).get();
- if (!thread || thread.status !== "active") {
- return { success: false, error: "Thread not active" };
- }
+ return db.transaction((tx) => {
+ // 1. Check thread is active
+ const thread = tx
+ .select()
+ .from(threads)
+ .where(eq(threads.id, threadId))
+ .get();
+ if (!thread || thread.status !== "active") {
+ return { success: false, error: "Thread not active" };
+ }
- // 2. Get the candidate data
- const candidate = tx.select().from(threadCandidates)
- .where(eq(threadCandidates.id, candidateId)).get();
- if (!candidate) {
- return { success: false, error: "Candidate not found" };
- }
- if (candidate.threadId !== threadId) {
- return { success: false, error: "Candidate not in thread" };
- }
+ // 2. Get the candidate data
+ const candidate = tx
+ .select()
+ .from(threadCandidates)
+ .where(eq(threadCandidates.id, candidateId))
+ .get();
+ if (!candidate) {
+ return { success: false, error: "Candidate not found" };
+ }
+ if (candidate.threadId !== threadId) {
+ return { success: false, error: "Candidate not in thread" };
+ }
- // 3. Verify categoryId still exists, fallback to Uncategorized (id=1)
- const category = tx.select({ id: categories.id }).from(categories)
- .where(eq(categories.id, candidate.categoryId)).get();
- const safeCategoryId = category ? candidate.categoryId : 1;
+ // 3. Verify categoryId still exists, fallback to Uncategorized (id=1)
+ const category = tx
+ .select({ id: categories.id })
+ .from(categories)
+ .where(eq(categories.id, candidate.categoryId))
+ .get();
+ const safeCategoryId = category ? candidate.categoryId : 1;
- // 4. Create collection item from candidate data
- const newItem = tx
- .insert(items)
- .values({
- name: candidate.name,
- weightGrams: candidate.weightGrams,
- priceCents: candidate.priceCents,
- categoryId: safeCategoryId,
- notes: candidate.notes,
- productUrl: candidate.productUrl,
- imageFilename: candidate.imageFilename,
- })
- .returning()
- .get();
+ // 4. Create collection item from candidate data
+ const newItem = tx
+ .insert(items)
+ .values({
+ name: candidate.name,
+ weightGrams: candidate.weightGrams,
+ priceCents: candidate.priceCents,
+ categoryId: safeCategoryId,
+ notes: candidate.notes,
+ productUrl: candidate.productUrl,
+ imageFilename: candidate.imageFilename,
+ })
+ .returning()
+ .get();
- // 5. Archive the thread
- tx.update(threads)
- .set({
- status: "resolved",
- resolvedCandidateId: candidateId,
- updatedAt: new Date(),
- })
- .where(eq(threads.id, threadId))
- .run();
+ // 5. Archive the thread
+ tx.update(threads)
+ .set({
+ status: "resolved",
+ resolvedCandidateId: candidateId,
+ updatedAt: new Date(),
+ })
+ .where(eq(threads.id, threadId))
+ .run();
- return { success: true, item: newItem };
- });
+ return { success: true, item: newItem };
+ });
}
diff --git a/src/server/services/totals.service.ts b/src/server/services/totals.service.ts
index 1f8a3b7..c226b0b 100644
--- a/src/server/services/totals.service.ts
+++ b/src/server/services/totals.service.ts
@@ -1,32 +1,32 @@
import { eq, sql } from "drizzle-orm";
-import { items, categories } from "../../db/schema.ts";
import { db as prodDb } from "../../db/index.ts";
+import { categories, items } from "../../db/schema.ts";
type Db = typeof prodDb;
export function getCategoryTotals(db: Db = prodDb) {
- return db
- .select({
- categoryId: items.categoryId,
- categoryName: categories.name,
- categoryIcon: categories.icon,
- totalWeight: sql`COALESCE(SUM(${items.weightGrams}), 0)`,
- totalCost: sql`COALESCE(SUM(${items.priceCents}), 0)`,
- itemCount: sql`COUNT(*)`,
- })
- .from(items)
- .innerJoin(categories, eq(items.categoryId, categories.id))
- .groupBy(items.categoryId)
- .all();
+ return db
+ .select({
+ categoryId: items.categoryId,
+ categoryName: categories.name,
+ categoryIcon: categories.icon,
+ totalWeight: sql`COALESCE(SUM(${items.weightGrams}), 0)`,
+ totalCost: sql`COALESCE(SUM(${items.priceCents}), 0)`,
+ itemCount: sql`COUNT(*)`,
+ })
+ .from(items)
+ .innerJoin(categories, eq(items.categoryId, categories.id))
+ .groupBy(items.categoryId)
+ .all();
}
export function getGlobalTotals(db: Db = prodDb) {
- return db
- .select({
- totalWeight: sql`COALESCE(SUM(${items.weightGrams}), 0)`,
- totalCost: sql`COALESCE(SUM(${items.priceCents}), 0)`,
- itemCount: sql`COUNT(*)`,
- })
- .from(items)
- .get();
+ return db
+ .select({
+ totalWeight: sql`COALESCE(SUM(${items.weightGrams}), 0)`,
+ totalCost: sql`COALESCE(SUM(${items.priceCents}), 0)`,
+ itemCount: sql`COUNT(*)`,
+ })
+ .from(items)
+ .get();
}
diff --git a/src/shared/schemas.ts b/src/shared/schemas.ts
index 0dd0b88..a63b520 100644
--- a/src/shared/schemas.ts
+++ b/src/shared/schemas.ts
@@ -1,67 +1,67 @@
import { z } from "zod";
export const createItemSchema = z.object({
- name: z.string().min(1, "Name is required"),
- weightGrams: z.number().nonnegative().optional(),
- priceCents: z.number().int().nonnegative().optional(),
- categoryId: z.number().int().positive(),
- notes: z.string().optional(),
- productUrl: z.string().url().optional().or(z.literal("")),
- imageFilename: z.string().optional(),
+ name: z.string().min(1, "Name is required"),
+ weightGrams: z.number().nonnegative().optional(),
+ priceCents: z.number().int().nonnegative().optional(),
+ categoryId: z.number().int().positive(),
+ notes: z.string().optional(),
+ productUrl: z.string().url().optional().or(z.literal("")),
+ imageFilename: z.string().optional(),
});
export const updateItemSchema = createItemSchema.partial().extend({
- id: z.number().int().positive(),
+ id: z.number().int().positive(),
});
export const createCategorySchema = z.object({
- name: z.string().min(1, "Category name is required"),
- icon: z.string().min(1).max(50).default("package"),
+ name: z.string().min(1, "Category name is required"),
+ icon: z.string().min(1).max(50).default("package"),
});
export const updateCategorySchema = z.object({
- id: z.number().int().positive(),
- name: z.string().min(1).optional(),
- icon: z.string().min(1).max(50).optional(),
+ id: z.number().int().positive(),
+ name: z.string().min(1).optional(),
+ icon: z.string().min(1).max(50).optional(),
});
// Thread schemas
export const createThreadSchema = z.object({
- name: z.string().min(1, "Thread name is required"),
- categoryId: z.number().int().positive(),
+ name: z.string().min(1, "Thread name is required"),
+ categoryId: z.number().int().positive(),
});
export const updateThreadSchema = z.object({
- name: z.string().min(1).optional(),
- categoryId: z.number().int().positive().optional(),
+ name: z.string().min(1).optional(),
+ categoryId: z.number().int().positive().optional(),
});
// Candidate schemas (same fields as items)
export const createCandidateSchema = z.object({
- name: z.string().min(1, "Name is required"),
- weightGrams: z.number().nonnegative().optional(),
- priceCents: z.number().int().nonnegative().optional(),
- categoryId: z.number().int().positive(),
- notes: z.string().optional(),
- productUrl: z.string().url().optional().or(z.literal("")),
- imageFilename: z.string().optional(),
+ name: z.string().min(1, "Name is required"),
+ weightGrams: z.number().nonnegative().optional(),
+ priceCents: z.number().int().nonnegative().optional(),
+ categoryId: z.number().int().positive(),
+ notes: z.string().optional(),
+ productUrl: z.string().url().optional().or(z.literal("")),
+ imageFilename: z.string().optional(),
});
export const updateCandidateSchema = createCandidateSchema.partial();
export const resolveThreadSchema = z.object({
- candidateId: z.number().int().positive(),
+ candidateId: z.number().int().positive(),
});
// Setup schemas
export const createSetupSchema = z.object({
- name: z.string().min(1, "Setup name is required"),
+ name: z.string().min(1, "Setup name is required"),
});
export const updateSetupSchema = z.object({
- name: z.string().min(1, "Setup name is required"),
+ name: z.string().min(1, "Setup name is required"),
});
export const syncSetupItemsSchema = z.object({
- itemIds: z.array(z.number().int().positive()),
+ itemIds: z.array(z.number().int().positive()),
});
diff --git a/src/shared/types.ts b/src/shared/types.ts
index 85ae3c5..0c867da 100644
--- a/src/shared/types.ts
+++ b/src/shared/types.ts
@@ -1,19 +1,26 @@
import type { z } from "zod";
import type {
- createItemSchema,
- updateItemSchema,
- createCategorySchema,
- updateCategorySchema,
- createThreadSchema,
- updateThreadSchema,
- createCandidateSchema,
- updateCandidateSchema,
- resolveThreadSchema,
- createSetupSchema,
- updateSetupSchema,
- syncSetupItemsSchema,
+ categories,
+ items,
+ setupItems,
+ setups,
+ threadCandidates,
+ threads,
+} from "../db/schema.ts";
+import type {
+ createCandidateSchema,
+ createCategorySchema,
+ createItemSchema,
+ createSetupSchema,
+ createThreadSchema,
+ resolveThreadSchema,
+ syncSetupItemsSchema,
+ updateCandidateSchema,
+ updateCategorySchema,
+ updateItemSchema,
+ updateSetupSchema,
+ updateThreadSchema,
} from "./schemas.ts";
-import type { items, categories, threads, threadCandidates, setups, setupItems } from "../db/schema.ts";
// Types inferred from Zod schemas
export type CreateItem = z.infer;
diff --git a/tests/helpers/db.ts b/tests/helpers/db.ts
index 90a688c..9cdc1f0 100644
--- a/tests/helpers/db.ts
+++ b/tests/helpers/db.ts
@@ -3,11 +3,11 @@ import { drizzle } from "drizzle-orm/bun-sqlite";
import * as schema from "../../src/db/schema.ts";
export function createTestDb() {
- const sqlite = new Database(":memory:");
- sqlite.run("PRAGMA foreign_keys = ON");
+ const sqlite = new Database(":memory:");
+ sqlite.run("PRAGMA foreign_keys = ON");
- // Create tables matching the Drizzle schema
- sqlite.run(`
+ // Create tables matching the Drizzle schema
+ sqlite.run(`
CREATE TABLE categories (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL UNIQUE,
@@ -16,7 +16,7 @@ export function createTestDb() {
)
`);
- sqlite.run(`
+ sqlite.run(`
CREATE TABLE items (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL,
@@ -31,7 +31,7 @@ export function createTestDb() {
)
`);
- sqlite.run(`
+ sqlite.run(`
CREATE TABLE threads (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL,
@@ -43,7 +43,7 @@ export function createTestDb() {
)
`);
- sqlite.run(`
+ sqlite.run(`
CREATE TABLE thread_candidates (
id INTEGER PRIMARY KEY AUTOINCREMENT,
thread_id INTEGER NOT NULL REFERENCES threads(id) ON DELETE CASCADE,
@@ -59,7 +59,7 @@ export function createTestDb() {
)
`);
- sqlite.run(`
+ sqlite.run(`
CREATE TABLE setups (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL,
@@ -68,7 +68,7 @@ export function createTestDb() {
)
`);
- sqlite.run(`
+ sqlite.run(`
CREATE TABLE setup_items (
id INTEGER PRIMARY KEY AUTOINCREMENT,
setup_id INTEGER NOT NULL REFERENCES setups(id) ON DELETE CASCADE,
@@ -76,19 +76,19 @@ export function createTestDb() {
)
`);
- sqlite.run(`
+ sqlite.run(`
CREATE TABLE settings (
key TEXT PRIMARY KEY,
value TEXT NOT NULL
)
`);
- const db = drizzle(sqlite, { schema });
+ const db = drizzle(sqlite, { schema });
- // Seed default Uncategorized category
- db.insert(schema.categories)
- .values({ name: "Uncategorized", icon: "package" })
- .run();
+ // Seed default Uncategorized category
+ db.insert(schema.categories)
+ .values({ name: "Uncategorized", icon: "package" })
+ .run();
- return db;
+ return db;
}
diff --git a/tests/routes/categories.test.ts b/tests/routes/categories.test.ts
index 216b9b8..37bcff9 100644
--- a/tests/routes/categories.test.ts
+++ b/tests/routes/categories.test.ts
@@ -1,91 +1,91 @@
-import { describe, it, expect, beforeEach } from "bun:test";
+import { beforeEach, describe, expect, it } from "bun:test";
import { Hono } from "hono";
-import { createTestDb } from "../helpers/db.ts";
import { categoryRoutes } from "../../src/server/routes/categories.ts";
import { itemRoutes } from "../../src/server/routes/items.ts";
+import { createTestDb } from "../helpers/db.ts";
function createTestApp() {
- const db = createTestDb();
- const app = new Hono();
+ const db = createTestDb();
+ const app = new Hono();
- // Inject test DB into context for all routes
- app.use("*", async (c, next) => {
- c.set("db", db);
- await next();
- });
+ // Inject test DB into context for all routes
+ app.use("*", async (c, next) => {
+ c.set("db", db);
+ await next();
+ });
- app.route("/api/categories", categoryRoutes);
- app.route("/api/items", itemRoutes);
- return { app, db };
+ app.route("/api/categories", categoryRoutes);
+ app.route("/api/items", itemRoutes);
+ return { app, db };
}
describe("Category Routes", () => {
- let app: Hono;
+ let app: Hono;
- beforeEach(() => {
- const testApp = createTestApp();
- app = testApp.app;
- });
+ beforeEach(() => {
+ const testApp = createTestApp();
+ app = testApp.app;
+ });
- it("POST /api/categories creates category", async () => {
- const res = await app.request("/api/categories", {
- method: "POST",
- headers: { "Content-Type": "application/json" },
- body: JSON.stringify({ name: "Shelter", icon: "tent" }),
- });
+ it("POST /api/categories creates category", async () => {
+ const res = await app.request("/api/categories", {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ name: "Shelter", icon: "tent" }),
+ });
- expect(res.status).toBe(201);
- const body = await res.json();
- expect(body.name).toBe("Shelter");
- expect(body.icon).toBe("tent");
- expect(body.id).toBeGreaterThan(0);
- });
+ expect(res.status).toBe(201);
+ const body = await res.json();
+ expect(body.name).toBe("Shelter");
+ expect(body.icon).toBe("tent");
+ expect(body.id).toBeGreaterThan(0);
+ });
- it("GET /api/categories returns all categories", async () => {
- const res = await app.request("/api/categories");
- expect(res.status).toBe(200);
- const body = await res.json();
- expect(Array.isArray(body)).toBe(true);
- // At minimum, Uncategorized is seeded
- expect(body.length).toBeGreaterThanOrEqual(1);
- });
+ it("GET /api/categories returns all categories", async () => {
+ const res = await app.request("/api/categories");
+ expect(res.status).toBe(200);
+ const body = await res.json();
+ expect(Array.isArray(body)).toBe(true);
+ // At minimum, Uncategorized is seeded
+ expect(body.length).toBeGreaterThanOrEqual(1);
+ });
- it("DELETE /api/categories/:id reassigns items", async () => {
- // Create category
- const catRes = await app.request("/api/categories", {
- method: "POST",
- headers: { "Content-Type": "application/json" },
- body: JSON.stringify({ name: "Shelter", icon: "tent" }),
- });
- const cat = await catRes.json();
+ it("DELETE /api/categories/:id reassigns items", async () => {
+ // Create category
+ const catRes = await app.request("/api/categories", {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ name: "Shelter", icon: "tent" }),
+ });
+ const cat = await catRes.json();
- // Create item in that category
- await app.request("/api/items", {
- method: "POST",
- headers: { "Content-Type": "application/json" },
- body: JSON.stringify({ name: "Tent", categoryId: cat.id }),
- });
+ // Create item in that category
+ await app.request("/api/items", {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ name: "Tent", categoryId: cat.id }),
+ });
- // Delete the category
- const delRes = await app.request(`/api/categories/${cat.id}`, {
- method: "DELETE",
- });
- expect(delRes.status).toBe(200);
+ // Delete the category
+ const delRes = await app.request(`/api/categories/${cat.id}`, {
+ method: "DELETE",
+ });
+ expect(delRes.status).toBe(200);
- // Verify items are now in Uncategorized
- const itemsRes = await app.request("/api/items");
- const items = await itemsRes.json();
- const tent = items.find((i: any) => i.name === "Tent");
- expect(tent.categoryId).toBe(1);
- });
+ // Verify items are now in Uncategorized
+ const itemsRes = await app.request("/api/items");
+ const items = await itemsRes.json();
+ const tent = items.find((i: any) => i.name === "Tent");
+ expect(tent.categoryId).toBe(1);
+ });
- it("DELETE /api/categories/1 returns 400 (cannot delete Uncategorized)", async () => {
- const res = await app.request("/api/categories/1", {
- method: "DELETE",
- });
+ it("DELETE /api/categories/1 returns 400 (cannot delete Uncategorized)", async () => {
+ const res = await app.request("/api/categories/1", {
+ method: "DELETE",
+ });
- expect(res.status).toBe(400);
- const body = await res.json();
- expect(body.error).toContain("Uncategorized");
- });
+ expect(res.status).toBe(400);
+ const body = await res.json();
+ expect(body.error).toContain("Uncategorized");
+ });
});
diff --git a/tests/routes/items.test.ts b/tests/routes/items.test.ts
index 62245b0..0b5ab6a 100644
--- a/tests/routes/items.test.ts
+++ b/tests/routes/items.test.ts
@@ -1,121 +1,121 @@
-import { describe, it, expect, beforeEach } from "bun:test";
+import { beforeEach, describe, expect, it } from "bun:test";
import { Hono } from "hono";
-import { createTestDb } from "../helpers/db.ts";
-import { itemRoutes } from "../../src/server/routes/items.ts";
import { categoryRoutes } from "../../src/server/routes/categories.ts";
+import { itemRoutes } from "../../src/server/routes/items.ts";
+import { createTestDb } from "../helpers/db.ts";
function createTestApp() {
- const db = createTestDb();
- const app = new Hono();
+ const db = createTestDb();
+ const app = new Hono();
- // Inject test DB into context for all routes
- app.use("*", async (c, next) => {
- c.set("db", db);
- await next();
- });
+ // Inject test DB into context for all routes
+ app.use("*", async (c, next) => {
+ c.set("db", db);
+ await next();
+ });
- app.route("/api/items", itemRoutes);
- app.route("/api/categories", categoryRoutes);
- return { app, db };
+ app.route("/api/items", itemRoutes);
+ app.route("/api/categories", categoryRoutes);
+ return { app, db };
}
describe("Item Routes", () => {
- let app: Hono;
+ let app: Hono;
- beforeEach(() => {
- const testApp = createTestApp();
- app = testApp.app;
- });
+ beforeEach(() => {
+ const testApp = createTestApp();
+ app = testApp.app;
+ });
- it("POST /api/items with valid data returns 201", async () => {
- const res = await app.request("/api/items", {
- method: "POST",
- headers: { "Content-Type": "application/json" },
- body: JSON.stringify({
- name: "Tent",
- weightGrams: 1200,
- priceCents: 35000,
- categoryId: 1,
- }),
- });
+ it("POST /api/items with valid data returns 201", async () => {
+ const res = await app.request("/api/items", {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({
+ name: "Tent",
+ weightGrams: 1200,
+ priceCents: 35000,
+ categoryId: 1,
+ }),
+ });
- expect(res.status).toBe(201);
- const body = await res.json();
- expect(body.name).toBe("Tent");
- expect(body.id).toBeGreaterThan(0);
- });
+ expect(res.status).toBe(201);
+ const body = await res.json();
+ expect(body.name).toBe("Tent");
+ expect(body.id).toBeGreaterThan(0);
+ });
- it("POST /api/items with missing name returns 400", async () => {
- const res = await app.request("/api/items", {
- method: "POST",
- headers: { "Content-Type": "application/json" },
- body: JSON.stringify({ categoryId: 1 }),
- });
+ it("POST /api/items with missing name returns 400", async () => {
+ const res = await app.request("/api/items", {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ categoryId: 1 }),
+ });
- expect(res.status).toBe(400);
- });
+ expect(res.status).toBe(400);
+ });
- it("GET /api/items returns array", async () => {
- // Create an item first
- await app.request("/api/items", {
- method: "POST",
- headers: { "Content-Type": "application/json" },
- body: JSON.stringify({ name: "Tent", categoryId: 1 }),
- });
+ it("GET /api/items returns array", async () => {
+ // Create an item first
+ await app.request("/api/items", {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ name: "Tent", categoryId: 1 }),
+ });
- const res = await app.request("/api/items");
- expect(res.status).toBe(200);
- const body = await res.json();
- expect(Array.isArray(body)).toBe(true);
- expect(body.length).toBeGreaterThanOrEqual(1);
- });
+ const res = await app.request("/api/items");
+ expect(res.status).toBe(200);
+ const body = await res.json();
+ expect(Array.isArray(body)).toBe(true);
+ expect(body.length).toBeGreaterThanOrEqual(1);
+ });
- it("PUT /api/items/:id updates fields", async () => {
- // Create first
- const createRes = await app.request("/api/items", {
- method: "POST",
- headers: { "Content-Type": "application/json" },
- body: JSON.stringify({
- name: "Tent",
- weightGrams: 1200,
- categoryId: 1,
- }),
- });
- const created = await createRes.json();
+ it("PUT /api/items/:id updates fields", async () => {
+ // Create first
+ const createRes = await app.request("/api/items", {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({
+ name: "Tent",
+ weightGrams: 1200,
+ categoryId: 1,
+ }),
+ });
+ const created = await createRes.json();
- // Update
- const res = await app.request(`/api/items/${created.id}`, {
- method: "PUT",
- headers: { "Content-Type": "application/json" },
- body: JSON.stringify({ name: "Big Agnes Tent", weightGrams: 1100 }),
- });
+ // Update
+ const res = await app.request(`/api/items/${created.id}`, {
+ method: "PUT",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ name: "Big Agnes Tent", weightGrams: 1100 }),
+ });
- expect(res.status).toBe(200);
- const body = await res.json();
- expect(body.name).toBe("Big Agnes Tent");
- expect(body.weightGrams).toBe(1100);
- });
+ expect(res.status).toBe(200);
+ const body = await res.json();
+ expect(body.name).toBe("Big Agnes Tent");
+ expect(body.weightGrams).toBe(1100);
+ });
- it("DELETE /api/items/:id returns success", async () => {
- // Create first
- const createRes = await app.request("/api/items", {
- method: "POST",
- headers: { "Content-Type": "application/json" },
- body: JSON.stringify({ name: "Tent", categoryId: 1 }),
- });
- const created = await createRes.json();
+ it("DELETE /api/items/:id returns success", async () => {
+ // Create first
+ const createRes = await app.request("/api/items", {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ name: "Tent", categoryId: 1 }),
+ });
+ const created = await createRes.json();
- const res = await app.request(`/api/items/${created.id}`, {
- method: "DELETE",
- });
+ const res = await app.request(`/api/items/${created.id}`, {
+ method: "DELETE",
+ });
- expect(res.status).toBe(200);
- const body = await res.json();
- expect(body.success).toBe(true);
- });
+ expect(res.status).toBe(200);
+ const body = await res.json();
+ expect(body.success).toBe(true);
+ });
- it("GET /api/items/:id returns 404 for non-existent item", async () => {
- const res = await app.request("/api/items/9999");
- expect(res.status).toBe(404);
- });
+ it("GET /api/items/:id returns 404 for non-existent item", async () => {
+ const res = await app.request("/api/items/9999");
+ expect(res.status).toBe(404);
+ });
});
diff --git a/tests/routes/setups.test.ts b/tests/routes/setups.test.ts
index 6505fdf..0082425 100644
--- a/tests/routes/setups.test.ts
+++ b/tests/routes/setups.test.ts
@@ -1,229 +1,244 @@
-import { describe, it, expect, beforeEach } from "bun:test";
+import { beforeEach, describe, expect, it } from "bun:test";
import { Hono } from "hono";
-import { createTestDb } from "../helpers/db.ts";
-import { setupRoutes } from "../../src/server/routes/setups.ts";
import { itemRoutes } from "../../src/server/routes/items.ts";
+import { setupRoutes } from "../../src/server/routes/setups.ts";
+import { createTestDb } from "../helpers/db.ts";
function createTestApp() {
- const db = createTestDb();
- const app = new Hono();
+ const db = createTestDb();
+ const app = new Hono();
- app.use("*", async (c, next) => {
- c.set("db", db);
- await next();
- });
+ app.use("*", async (c, next) => {
+ c.set("db", db);
+ await next();
+ });
- app.route("/api/setups", setupRoutes);
- app.route("/api/items", itemRoutes);
- return { app, db };
+ app.route("/api/setups", setupRoutes);
+ app.route("/api/items", itemRoutes);
+ return { app, db };
}
async function createSetupViaAPI(app: Hono, name: string) {
- const res = await app.request("/api/setups", {
- method: "POST",
- headers: { "Content-Type": "application/json" },
- body: JSON.stringify({ name }),
- });
- return res.json();
+ const res = await app.request("/api/setups", {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ name }),
+ });
+ return res.json();
}
async function createItemViaAPI(app: Hono, data: any) {
- const res = await app.request("/api/items", {
- method: "POST",
- headers: { "Content-Type": "application/json" },
- body: JSON.stringify(data),
- });
- return res.json();
+ const res = await app.request("/api/items", {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify(data),
+ });
+ return res.json();
}
describe("Setup Routes", () => {
- let app: Hono;
+ let app: Hono;
- beforeEach(() => {
- const testApp = createTestApp();
- app = testApp.app;
- });
+ beforeEach(() => {
+ const testApp = createTestApp();
+ app = testApp.app;
+ });
- describe("POST /api/setups", () => {
- it("with valid body returns 201 + setup object", async () => {
- const res = await app.request("/api/setups", {
- method: "POST",
- headers: { "Content-Type": "application/json" },
- body: JSON.stringify({ name: "Day Hike" }),
- });
+ describe("POST /api/setups", () => {
+ it("with valid body returns 201 + setup object", async () => {
+ const res = await app.request("/api/setups", {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ name: "Day Hike" }),
+ });
- expect(res.status).toBe(201);
- const body = await res.json();
- expect(body.name).toBe("Day Hike");
- expect(body.id).toBeGreaterThan(0);
- });
+ expect(res.status).toBe(201);
+ const body = await res.json();
+ expect(body.name).toBe("Day Hike");
+ expect(body.id).toBeGreaterThan(0);
+ });
- it("with empty name returns 400", async () => {
- const res = await app.request("/api/setups", {
- method: "POST",
- headers: { "Content-Type": "application/json" },
- body: JSON.stringify({ name: "" }),
- });
+ it("with empty name returns 400", async () => {
+ const res = await app.request("/api/setups", {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ name: "" }),
+ });
- expect(res.status).toBe(400);
- });
- });
+ expect(res.status).toBe(400);
+ });
+ });
- describe("GET /api/setups", () => {
- it("returns array of setups with totals", async () => {
- const setup = await createSetupViaAPI(app, "Backpacking");
- const item = await createItemViaAPI(app, {
- name: "Tent",
- categoryId: 1,
- weightGrams: 1200,
- priceCents: 30000,
- });
+ describe("GET /api/setups", () => {
+ it("returns array of setups with totals", async () => {
+ const setup = await createSetupViaAPI(app, "Backpacking");
+ const item = await createItemViaAPI(app, {
+ name: "Tent",
+ categoryId: 1,
+ weightGrams: 1200,
+ priceCents: 30000,
+ });
- // Sync items
- await app.request(`/api/setups/${setup.id}/items`, {
- method: "PUT",
- headers: { "Content-Type": "application/json" },
- body: JSON.stringify({ itemIds: [item.id] }),
- });
+ // Sync items
+ await app.request(`/api/setups/${setup.id}/items`, {
+ method: "PUT",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ itemIds: [item.id] }),
+ });
- const res = await app.request("/api/setups");
- expect(res.status).toBe(200);
- const body = await res.json();
- expect(Array.isArray(body)).toBe(true);
- expect(body.length).toBeGreaterThanOrEqual(1);
- expect(body[0].itemCount).toBeDefined();
- expect(body[0].totalWeight).toBeDefined();
- expect(body[0].totalCost).toBeDefined();
- });
- });
+ const res = await app.request("/api/setups");
+ expect(res.status).toBe(200);
+ const body = await res.json();
+ expect(Array.isArray(body)).toBe(true);
+ expect(body.length).toBeGreaterThanOrEqual(1);
+ expect(body[0].itemCount).toBeDefined();
+ expect(body[0].totalWeight).toBeDefined();
+ expect(body[0].totalCost).toBeDefined();
+ });
+ });
- describe("GET /api/setups/:id", () => {
- it("returns setup with items", async () => {
- const setup = await createSetupViaAPI(app, "Day Hike");
- const item = await createItemViaAPI(app, {
- name: "Water Bottle",
- categoryId: 1,
- weightGrams: 200,
- priceCents: 2500,
- });
+ describe("GET /api/setups/:id", () => {
+ it("returns setup with items", async () => {
+ const setup = await createSetupViaAPI(app, "Day Hike");
+ const item = await createItemViaAPI(app, {
+ name: "Water Bottle",
+ categoryId: 1,
+ weightGrams: 200,
+ priceCents: 2500,
+ });
- await app.request(`/api/setups/${setup.id}/items`, {
- method: "PUT",
- headers: { "Content-Type": "application/json" },
- body: JSON.stringify({ itemIds: [item.id] }),
- });
+ await app.request(`/api/setups/${setup.id}/items`, {
+ method: "PUT",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ itemIds: [item.id] }),
+ });
- const res = await app.request(`/api/setups/${setup.id}`);
- expect(res.status).toBe(200);
- const body = await res.json();
- expect(body.name).toBe("Day Hike");
- expect(body.items).toHaveLength(1);
- expect(body.items[0].name).toBe("Water Bottle");
- });
+ const res = await app.request(`/api/setups/${setup.id}`);
+ expect(res.status).toBe(200);
+ const body = await res.json();
+ expect(body.name).toBe("Day Hike");
+ expect(body.items).toHaveLength(1);
+ expect(body.items[0].name).toBe("Water Bottle");
+ });
- it("returns 404 for non-existent setup", async () => {
- const res = await app.request("/api/setups/9999");
- expect(res.status).toBe(404);
- });
- });
+ it("returns 404 for non-existent setup", async () => {
+ const res = await app.request("/api/setups/9999");
+ expect(res.status).toBe(404);
+ });
+ });
- describe("PUT /api/setups/:id", () => {
- it("updates setup name", async () => {
- const setup = await createSetupViaAPI(app, "Original");
+ describe("PUT /api/setups/:id", () => {
+ it("updates setup name", async () => {
+ const setup = await createSetupViaAPI(app, "Original");
- const res = await app.request(`/api/setups/${setup.id}`, {
- method: "PUT",
- headers: { "Content-Type": "application/json" },
- body: JSON.stringify({ name: "Renamed" }),
- });
+ const res = await app.request(`/api/setups/${setup.id}`, {
+ method: "PUT",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ name: "Renamed" }),
+ });
- expect(res.status).toBe(200);
- const body = await res.json();
- expect(body.name).toBe("Renamed");
- });
+ expect(res.status).toBe(200);
+ const body = await res.json();
+ expect(body.name).toBe("Renamed");
+ });
- it("returns 404 for non-existent setup", async () => {
- const res = await app.request("/api/setups/9999", {
- method: "PUT",
- headers: { "Content-Type": "application/json" },
- body: JSON.stringify({ name: "Ghost" }),
- });
+ it("returns 404 for non-existent setup", async () => {
+ const res = await app.request("/api/setups/9999", {
+ method: "PUT",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ name: "Ghost" }),
+ });
- expect(res.status).toBe(404);
- });
- });
+ expect(res.status).toBe(404);
+ });
+ });
- describe("DELETE /api/setups/:id", () => {
- it("removes setup", async () => {
- const setup = await createSetupViaAPI(app, "To Delete");
+ describe("DELETE /api/setups/:id", () => {
+ it("removes setup", async () => {
+ const setup = await createSetupViaAPI(app, "To Delete");
- const res = await app.request(`/api/setups/${setup.id}`, {
- method: "DELETE",
- });
+ const res = await app.request(`/api/setups/${setup.id}`, {
+ method: "DELETE",
+ });
- expect(res.status).toBe(200);
- const body = await res.json();
- expect(body.success).toBe(true);
+ expect(res.status).toBe(200);
+ const body = await res.json();
+ expect(body.success).toBe(true);
- // Verify gone
- const getRes = await app.request(`/api/setups/${setup.id}`);
- expect(getRes.status).toBe(404);
- });
+ // Verify gone
+ const getRes = await app.request(`/api/setups/${setup.id}`);
+ expect(getRes.status).toBe(404);
+ });
- it("returns 404 for non-existent setup", async () => {
- const res = await app.request("/api/setups/9999", { method: "DELETE" });
- expect(res.status).toBe(404);
- });
- });
+ it("returns 404 for non-existent setup", async () => {
+ const res = await app.request("/api/setups/9999", { method: "DELETE" });
+ expect(res.status).toBe(404);
+ });
+ });
- describe("PUT /api/setups/:id/items", () => {
- it("syncs items to setup", async () => {
- const setup = await createSetupViaAPI(app, "Kit");
- const item1 = await createItemViaAPI(app, { name: "Item 1", categoryId: 1 });
- const item2 = await createItemViaAPI(app, { name: "Item 2", categoryId: 1 });
+ describe("PUT /api/setups/:id/items", () => {
+ it("syncs items to setup", async () => {
+ const setup = await createSetupViaAPI(app, "Kit");
+ const item1 = await createItemViaAPI(app, {
+ name: "Item 1",
+ categoryId: 1,
+ });
+ const item2 = await createItemViaAPI(app, {
+ name: "Item 2",
+ categoryId: 1,
+ });
- const res = await app.request(`/api/setups/${setup.id}/items`, {
- method: "PUT",
- headers: { "Content-Type": "application/json" },
- body: JSON.stringify({ itemIds: [item1.id, item2.id] }),
- });
+ const res = await app.request(`/api/setups/${setup.id}/items`, {
+ method: "PUT",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ itemIds: [item1.id, item2.id] }),
+ });
- expect(res.status).toBe(200);
- const body = await res.json();
- expect(body.success).toBe(true);
+ expect(res.status).toBe(200);
+ const body = await res.json();
+ expect(body.success).toBe(true);
- // Verify items
- const getRes = await app.request(`/api/setups/${setup.id}`);
- const getBody = await getRes.json();
- expect(getBody.items).toHaveLength(2);
- });
- });
+ // Verify items
+ const getRes = await app.request(`/api/setups/${setup.id}`);
+ const getBody = await getRes.json();
+ expect(getBody.items).toHaveLength(2);
+ });
+ });
- describe("DELETE /api/setups/:id/items/:itemId", () => {
- it("removes single item from setup", async () => {
- const setup = await createSetupViaAPI(app, "Kit");
- const item1 = await createItemViaAPI(app, { name: "Item 1", categoryId: 1 });
- const item2 = await createItemViaAPI(app, { name: "Item 2", categoryId: 1 });
+ describe("DELETE /api/setups/:id/items/:itemId", () => {
+ it("removes single item from setup", async () => {
+ const setup = await createSetupViaAPI(app, "Kit");
+ const item1 = await createItemViaAPI(app, {
+ name: "Item 1",
+ categoryId: 1,
+ });
+ const item2 = await createItemViaAPI(app, {
+ name: "Item 2",
+ categoryId: 1,
+ });
- // Sync both items
- await app.request(`/api/setups/${setup.id}/items`, {
- method: "PUT",
- headers: { "Content-Type": "application/json" },
- body: JSON.stringify({ itemIds: [item1.id, item2.id] }),
- });
+ // Sync both items
+ await app.request(`/api/setups/${setup.id}/items`, {
+ method: "PUT",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ itemIds: [item1.id, item2.id] }),
+ });
- // Remove one
- const res = await app.request(`/api/setups/${setup.id}/items/${item1.id}`, {
- method: "DELETE",
- });
+ // Remove one
+ const res = await app.request(
+ `/api/setups/${setup.id}/items/${item1.id}`,
+ {
+ method: "DELETE",
+ },
+ );
- expect(res.status).toBe(200);
+ expect(res.status).toBe(200);
- // Verify only one remains
- const getRes = await app.request(`/api/setups/${setup.id}`);
- const getBody = await getRes.json();
- expect(getBody.items).toHaveLength(1);
- expect(getBody.items[0].name).toBe("Item 2");
- });
- });
+ // Verify only one remains
+ const getRes = await app.request(`/api/setups/${setup.id}`);
+ const getBody = await getRes.json();
+ expect(getBody.items).toHaveLength(1);
+ expect(getBody.items[0].name).toBe("Item 2");
+ });
+ });
});
diff --git a/tests/routes/threads.test.ts b/tests/routes/threads.test.ts
index 0a6e3e8..69dd782 100644
--- a/tests/routes/threads.test.ts
+++ b/tests/routes/threads.test.ts
@@ -1,300 +1,300 @@
-import { describe, it, expect, beforeEach } from "bun:test";
+import { beforeEach, describe, expect, it } from "bun:test";
import { Hono } from "hono";
-import { createTestDb } from "../helpers/db.ts";
import { threadRoutes } from "../../src/server/routes/threads.ts";
+import { createTestDb } from "../helpers/db.ts";
function createTestApp() {
- const db = createTestDb();
- const app = new Hono();
+ const db = createTestDb();
+ const app = new Hono();
- // Inject test DB into context for all routes
- app.use("*", async (c, next) => {
- c.set("db", db);
- await next();
- });
+ // Inject test DB into context for all routes
+ app.use("*", async (c, next) => {
+ c.set("db", db);
+ await next();
+ });
- app.route("/api/threads", threadRoutes);
- return { app, db };
+ app.route("/api/threads", threadRoutes);
+ return { app, db };
}
async function createThreadViaAPI(app: Hono, name: string, categoryId = 1) {
- const res = await app.request("/api/threads", {
- method: "POST",
- headers: { "Content-Type": "application/json" },
- body: JSON.stringify({ name, categoryId }),
- });
- return res.json();
+ const res = await app.request("/api/threads", {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ name, categoryId }),
+ });
+ return res.json();
}
async function createCandidateViaAPI(app: Hono, threadId: number, data: any) {
- const res = await app.request(`/api/threads/${threadId}/candidates`, {
- method: "POST",
- headers: { "Content-Type": "application/json" },
- body: JSON.stringify(data),
- });
- return res.json();
+ const res = await app.request(`/api/threads/${threadId}/candidates`, {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify(data),
+ });
+ return res.json();
}
describe("Thread Routes", () => {
- let app: Hono;
+ let app: Hono;
- beforeEach(() => {
- const testApp = createTestApp();
- app = testApp.app;
- });
+ beforeEach(() => {
+ const testApp = createTestApp();
+ app = testApp.app;
+ });
- describe("POST /api/threads", () => {
- it("with valid body returns 201 + thread object", async () => {
- const res = await app.request("/api/threads", {
- method: "POST",
- headers: { "Content-Type": "application/json" },
- body: JSON.stringify({ name: "New Tent", categoryId: 1 }),
- });
+ describe("POST /api/threads", () => {
+ it("with valid body returns 201 + thread object", async () => {
+ const res = await app.request("/api/threads", {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ name: "New Tent", categoryId: 1 }),
+ });
- expect(res.status).toBe(201);
- const body = await res.json();
- expect(body.name).toBe("New Tent");
- expect(body.id).toBeGreaterThan(0);
- expect(body.status).toBe("active");
- });
+ expect(res.status).toBe(201);
+ const body = await res.json();
+ expect(body.name).toBe("New Tent");
+ expect(body.id).toBeGreaterThan(0);
+ expect(body.status).toBe("active");
+ });
- it("with empty name returns 400", async () => {
- const res = await app.request("/api/threads", {
- method: "POST",
- headers: { "Content-Type": "application/json" },
- body: JSON.stringify({ name: "" }),
- });
+ it("with empty name returns 400", async () => {
+ const res = await app.request("/api/threads", {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ name: "" }),
+ });
- expect(res.status).toBe(400);
- });
- });
+ expect(res.status).toBe(400);
+ });
+ });
- describe("GET /api/threads", () => {
- it("returns array of active threads with metadata", async () => {
- const thread = await createThreadViaAPI(app, "Backpack Options");
- await createCandidateViaAPI(app, thread.id, {
- name: "Pack A",
- categoryId: 1,
- priceCents: 20000,
- });
+ describe("GET /api/threads", () => {
+ it("returns array of active threads with metadata", async () => {
+ const thread = await createThreadViaAPI(app, "Backpack Options");
+ await createCandidateViaAPI(app, thread.id, {
+ name: "Pack A",
+ categoryId: 1,
+ priceCents: 20000,
+ });
- const res = await app.request("/api/threads");
- expect(res.status).toBe(200);
- const body = await res.json();
- expect(Array.isArray(body)).toBe(true);
- expect(body.length).toBeGreaterThanOrEqual(1);
- expect(body[0].candidateCount).toBeDefined();
- });
+ const res = await app.request("/api/threads");
+ expect(res.status).toBe(200);
+ const body = await res.json();
+ expect(Array.isArray(body)).toBe(true);
+ expect(body.length).toBeGreaterThanOrEqual(1);
+ expect(body[0].candidateCount).toBeDefined();
+ });
- it("?includeResolved=true includes archived threads", async () => {
- const t1 = await createThreadViaAPI(app, "Active");
- const t2 = await createThreadViaAPI(app, "To Resolve");
- const candidate = await createCandidateViaAPI(app, t2.id, {
- name: "Winner",
- categoryId: 1,
- });
+ it("?includeResolved=true includes archived threads", async () => {
+ const _t1 = await createThreadViaAPI(app, "Active");
+ const t2 = await createThreadViaAPI(app, "To Resolve");
+ const candidate = await createCandidateViaAPI(app, t2.id, {
+ name: "Winner",
+ categoryId: 1,
+ });
- // Resolve thread
- await app.request(`/api/threads/${t2.id}/resolve`, {
- method: "POST",
- headers: { "Content-Type": "application/json" },
- body: JSON.stringify({ candidateId: candidate.id }),
- });
+ // Resolve thread
+ await app.request(`/api/threads/${t2.id}/resolve`, {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ candidateId: candidate.id }),
+ });
- // Default excludes resolved
- const defaultRes = await app.request("/api/threads");
- const defaultBody = await defaultRes.json();
- expect(defaultBody).toHaveLength(1);
+ // Default excludes resolved
+ const defaultRes = await app.request("/api/threads");
+ const defaultBody = await defaultRes.json();
+ expect(defaultBody).toHaveLength(1);
- // With includeResolved includes all
- const allRes = await app.request("/api/threads?includeResolved=true");
- const allBody = await allRes.json();
- expect(allBody).toHaveLength(2);
- });
- });
+ // With includeResolved includes all
+ const allRes = await app.request("/api/threads?includeResolved=true");
+ const allBody = await allRes.json();
+ expect(allBody).toHaveLength(2);
+ });
+ });
- describe("GET /api/threads/:id", () => {
- it("returns thread with candidates", async () => {
- const thread = await createThreadViaAPI(app, "Tent Options");
- await createCandidateViaAPI(app, thread.id, {
- name: "Tent A",
- categoryId: 1,
- priceCents: 30000,
- });
+ describe("GET /api/threads/:id", () => {
+ it("returns thread with candidates", async () => {
+ const thread = await createThreadViaAPI(app, "Tent Options");
+ await createCandidateViaAPI(app, thread.id, {
+ name: "Tent A",
+ categoryId: 1,
+ priceCents: 30000,
+ });
- const res = await app.request(`/api/threads/${thread.id}`);
- expect(res.status).toBe(200);
- const body = await res.json();
- expect(body.name).toBe("Tent Options");
- expect(body.candidates).toHaveLength(1);
- expect(body.candidates[0].name).toBe("Tent A");
- });
+ const res = await app.request(`/api/threads/${thread.id}`);
+ expect(res.status).toBe(200);
+ const body = await res.json();
+ expect(body.name).toBe("Tent Options");
+ expect(body.candidates).toHaveLength(1);
+ expect(body.candidates[0].name).toBe("Tent A");
+ });
- it("returns 404 for non-existent thread", async () => {
- const res = await app.request("/api/threads/9999");
- expect(res.status).toBe(404);
- });
- });
+ it("returns 404 for non-existent thread", async () => {
+ const res = await app.request("/api/threads/9999");
+ expect(res.status).toBe(404);
+ });
+ });
- describe("PUT /api/threads/:id", () => {
- it("updates thread name", async () => {
- const thread = await createThreadViaAPI(app, "Original");
+ describe("PUT /api/threads/:id", () => {
+ it("updates thread name", async () => {
+ const thread = await createThreadViaAPI(app, "Original");
- const res = await app.request(`/api/threads/${thread.id}`, {
- method: "PUT",
- headers: { "Content-Type": "application/json" },
- body: JSON.stringify({ name: "Renamed" }),
- });
+ const res = await app.request(`/api/threads/${thread.id}`, {
+ method: "PUT",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ name: "Renamed" }),
+ });
- expect(res.status).toBe(200);
- const body = await res.json();
- expect(body.name).toBe("Renamed");
- });
- });
+ expect(res.status).toBe(200);
+ const body = await res.json();
+ expect(body.name).toBe("Renamed");
+ });
+ });
- describe("DELETE /api/threads/:id", () => {
- it("removes thread", async () => {
- const thread = await createThreadViaAPI(app, "To Delete");
+ describe("DELETE /api/threads/:id", () => {
+ it("removes thread", async () => {
+ const thread = await createThreadViaAPI(app, "To Delete");
- const res = await app.request(`/api/threads/${thread.id}`, {
- method: "DELETE",
- });
+ const res = await app.request(`/api/threads/${thread.id}`, {
+ method: "DELETE",
+ });
- expect(res.status).toBe(200);
- const body = await res.json();
- expect(body.success).toBe(true);
+ expect(res.status).toBe(200);
+ const body = await res.json();
+ expect(body.success).toBe(true);
- // Verify gone
- const getRes = await app.request(`/api/threads/${thread.id}`);
- expect(getRes.status).toBe(404);
- });
- });
+ // Verify gone
+ const getRes = await app.request(`/api/threads/${thread.id}`);
+ expect(getRes.status).toBe(404);
+ });
+ });
- describe("POST /api/threads/:id/candidates", () => {
- it("adds candidate, returns 201", async () => {
- const thread = await createThreadViaAPI(app, "Test");
+ describe("POST /api/threads/:id/candidates", () => {
+ it("adds candidate, returns 201", async () => {
+ const thread = await createThreadViaAPI(app, "Test");
- const res = await app.request(`/api/threads/${thread.id}/candidates`, {
- method: "POST",
- headers: { "Content-Type": "application/json" },
- body: JSON.stringify({
- name: "Candidate A",
- categoryId: 1,
- priceCents: 25000,
- weightGrams: 500,
- }),
- });
+ const res = await app.request(`/api/threads/${thread.id}/candidates`, {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({
+ name: "Candidate A",
+ categoryId: 1,
+ priceCents: 25000,
+ weightGrams: 500,
+ }),
+ });
- expect(res.status).toBe(201);
- const body = await res.json();
- expect(body.name).toBe("Candidate A");
- expect(body.threadId).toBe(thread.id);
- });
- });
+ expect(res.status).toBe(201);
+ const body = await res.json();
+ expect(body.name).toBe("Candidate A");
+ expect(body.threadId).toBe(thread.id);
+ });
+ });
- describe("PUT /api/threads/:threadId/candidates/:candidateId", () => {
- it("updates candidate", async () => {
- const thread = await createThreadViaAPI(app, "Test");
- const candidate = await createCandidateViaAPI(app, thread.id, {
- name: "Original",
- categoryId: 1,
- });
+ describe("PUT /api/threads/:threadId/candidates/:candidateId", () => {
+ it("updates candidate", async () => {
+ const thread = await createThreadViaAPI(app, "Test");
+ const candidate = await createCandidateViaAPI(app, thread.id, {
+ name: "Original",
+ categoryId: 1,
+ });
- const res = await app.request(
- `/api/threads/${thread.id}/candidates/${candidate.id}`,
- {
- method: "PUT",
- headers: { "Content-Type": "application/json" },
- body: JSON.stringify({ name: "Updated" }),
- },
- );
+ const res = await app.request(
+ `/api/threads/${thread.id}/candidates/${candidate.id}`,
+ {
+ method: "PUT",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ name: "Updated" }),
+ },
+ );
- expect(res.status).toBe(200);
- const body = await res.json();
- expect(body.name).toBe("Updated");
- });
- });
+ expect(res.status).toBe(200);
+ const body = await res.json();
+ expect(body.name).toBe("Updated");
+ });
+ });
- describe("DELETE /api/threads/:threadId/candidates/:candidateId", () => {
- it("removes candidate", async () => {
- const thread = await createThreadViaAPI(app, "Test");
- const candidate = await createCandidateViaAPI(app, thread.id, {
- name: "To Remove",
- categoryId: 1,
- });
+ describe("DELETE /api/threads/:threadId/candidates/:candidateId", () => {
+ it("removes candidate", async () => {
+ const thread = await createThreadViaAPI(app, "Test");
+ const candidate = await createCandidateViaAPI(app, thread.id, {
+ name: "To Remove",
+ categoryId: 1,
+ });
- const res = await app.request(
- `/api/threads/${thread.id}/candidates/${candidate.id}`,
- { method: "DELETE" },
- );
+ const res = await app.request(
+ `/api/threads/${thread.id}/candidates/${candidate.id}`,
+ { method: "DELETE" },
+ );
- expect(res.status).toBe(200);
- const body = await res.json();
- expect(body.success).toBe(true);
- });
- });
+ expect(res.status).toBe(200);
+ const body = await res.json();
+ expect(body.success).toBe(true);
+ });
+ });
- describe("POST /api/threads/:id/resolve", () => {
- it("with valid candidateId returns 200 + created item", async () => {
- const thread = await createThreadViaAPI(app, "Tent Decision");
- const candidate = await createCandidateViaAPI(app, thread.id, {
- name: "Winner",
- categoryId: 1,
- priceCents: 30000,
- });
+ describe("POST /api/threads/:id/resolve", () => {
+ it("with valid candidateId returns 200 + created item", async () => {
+ const thread = await createThreadViaAPI(app, "Tent Decision");
+ const candidate = await createCandidateViaAPI(app, thread.id, {
+ name: "Winner",
+ categoryId: 1,
+ priceCents: 30000,
+ });
- const res = await app.request(`/api/threads/${thread.id}/resolve`, {
- method: "POST",
- headers: { "Content-Type": "application/json" },
- body: JSON.stringify({ candidateId: candidate.id }),
- });
+ const res = await app.request(`/api/threads/${thread.id}/resolve`, {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ candidateId: candidate.id }),
+ });
- expect(res.status).toBe(200);
- const body = await res.json();
- expect(body.item).toBeDefined();
- expect(body.item.name).toBe("Winner");
- expect(body.item.priceCents).toBe(30000);
- });
+ expect(res.status).toBe(200);
+ const body = await res.json();
+ expect(body.item).toBeDefined();
+ expect(body.item.name).toBe("Winner");
+ expect(body.item.priceCents).toBe(30000);
+ });
- it("on already-resolved thread returns 400", async () => {
- const thread = await createThreadViaAPI(app, "Already Resolved");
- const candidate = await createCandidateViaAPI(app, thread.id, {
- name: "Winner",
- categoryId: 1,
- });
+ it("on already-resolved thread returns 400", async () => {
+ const thread = await createThreadViaAPI(app, "Already Resolved");
+ const candidate = await createCandidateViaAPI(app, thread.id, {
+ name: "Winner",
+ categoryId: 1,
+ });
- // Resolve first time
- await app.request(`/api/threads/${thread.id}/resolve`, {
- method: "POST",
- headers: { "Content-Type": "application/json" },
- body: JSON.stringify({ candidateId: candidate.id }),
- });
+ // Resolve first time
+ await app.request(`/api/threads/${thread.id}/resolve`, {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ candidateId: candidate.id }),
+ });
- // Try again
- const res = await app.request(`/api/threads/${thread.id}/resolve`, {
- method: "POST",
- headers: { "Content-Type": "application/json" },
- body: JSON.stringify({ candidateId: candidate.id }),
- });
+ // Try again
+ const res = await app.request(`/api/threads/${thread.id}/resolve`, {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ candidateId: candidate.id }),
+ });
- expect(res.status).toBe(400);
- });
+ expect(res.status).toBe(400);
+ });
- it("with wrong candidateId returns 400", async () => {
- const t1 = await createThreadViaAPI(app, "Thread 1");
- const t2 = await createThreadViaAPI(app, "Thread 2");
- const candidate = await createCandidateViaAPI(app, t2.id, {
- name: "Wrong Thread",
- categoryId: 1,
- });
+ it("with wrong candidateId returns 400", async () => {
+ const t1 = await createThreadViaAPI(app, "Thread 1");
+ const t2 = await createThreadViaAPI(app, "Thread 2");
+ const candidate = await createCandidateViaAPI(app, t2.id, {
+ name: "Wrong Thread",
+ categoryId: 1,
+ });
- const res = await app.request(`/api/threads/${t1.id}/resolve`, {
- method: "POST",
- headers: { "Content-Type": "application/json" },
- body: JSON.stringify({ candidateId: candidate.id }),
- });
+ const res = await app.request(`/api/threads/${t1.id}/resolve`, {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ candidateId: candidate.id }),
+ });
- expect(res.status).toBe(400);
- });
- });
+ expect(res.status).toBe(400);
+ });
+ });
});
diff --git a/tests/services/category.service.test.ts b/tests/services/category.service.test.ts
index 87fc435..6e67d80 100644
--- a/tests/services/category.service.test.ts
+++ b/tests/services/category.service.test.ts
@@ -1,98 +1,98 @@
-import { describe, it, expect, beforeEach } from "bun:test";
-import { createTestDb } from "../helpers/db.ts";
+import { beforeEach, describe, expect, it } from "bun:test";
+import { eq } from "drizzle-orm";
+import { items } from "../../src/db/schema.ts";
import {
- getAllCategories,
- createCategory,
- updateCategory,
- deleteCategory,
+ createCategory,
+ deleteCategory,
+ getAllCategories,
+ updateCategory,
} from "../../src/server/services/category.service.ts";
import { createItem } from "../../src/server/services/item.service.ts";
-import { items } from "../../src/db/schema.ts";
-import { eq } from "drizzle-orm";
+import { createTestDb } from "../helpers/db.ts";
describe("Category Service", () => {
- let db: ReturnType;
+ let db: ReturnType;
- beforeEach(() => {
- db = createTestDb();
- });
+ beforeEach(() => {
+ db = createTestDb();
+ });
- describe("createCategory", () => {
- it("creates with name and icon", () => {
- const cat = createCategory(db, { name: "Shelter", icon: "tent" });
+ describe("createCategory", () => {
+ it("creates with name and icon", () => {
+ const cat = createCategory(db, { name: "Shelter", icon: "tent" });
- expect(cat).toBeDefined();
- expect(cat!.id).toBeGreaterThan(0);
- expect(cat!.name).toBe("Shelter");
- expect(cat!.icon).toBe("tent");
- });
+ expect(cat).toBeDefined();
+ expect(cat?.id).toBeGreaterThan(0);
+ expect(cat?.name).toBe("Shelter");
+ expect(cat?.icon).toBe("tent");
+ });
- it("uses default icon if not provided", () => {
- const cat = createCategory(db, { name: "Cooking" });
+ it("uses default icon if not provided", () => {
+ const cat = createCategory(db, { name: "Cooking" });
- expect(cat).toBeDefined();
- expect(cat!.icon).toBe("package");
- });
- });
+ expect(cat).toBeDefined();
+ expect(cat?.icon).toBe("package");
+ });
+ });
- describe("getAllCategories", () => {
- it("returns all categories", () => {
- createCategory(db, { name: "Shelter", icon: "tent" });
- createCategory(db, { name: "Cooking", icon: "cooking-pot" });
+ describe("getAllCategories", () => {
+ it("returns all categories", () => {
+ createCategory(db, { name: "Shelter", icon: "tent" });
+ createCategory(db, { name: "Cooking", icon: "cooking-pot" });
- const all = getAllCategories(db);
- // Includes seeded Uncategorized + 2 new
- expect(all.length).toBeGreaterThanOrEqual(3);
- });
- });
+ const all = getAllCategories(db);
+ // Includes seeded Uncategorized + 2 new
+ expect(all.length).toBeGreaterThanOrEqual(3);
+ });
+ });
- describe("updateCategory", () => {
- it("renames category", () => {
- const cat = createCategory(db, { name: "Shelter", icon: "tent" });
- const updated = updateCategory(db, cat!.id, { name: "Sleep System" });
+ describe("updateCategory", () => {
+ it("renames category", () => {
+ const cat = createCategory(db, { name: "Shelter", icon: "tent" });
+ const updated = updateCategory(db, cat?.id, { name: "Sleep System" });
- expect(updated).toBeDefined();
- expect(updated!.name).toBe("Sleep System");
- expect(updated!.icon).toBe("tent");
- });
+ expect(updated).toBeDefined();
+ expect(updated?.name).toBe("Sleep System");
+ expect(updated?.icon).toBe("tent");
+ });
- it("changes icon", () => {
- const cat = createCategory(db, { name: "Shelter", icon: "tent" });
- const updated = updateCategory(db, cat!.id, { icon: "home" });
+ it("changes icon", () => {
+ const cat = createCategory(db, { name: "Shelter", icon: "tent" });
+ const updated = updateCategory(db, cat?.id, { icon: "home" });
- expect(updated).toBeDefined();
- expect(updated!.icon).toBe("home");
- });
+ expect(updated).toBeDefined();
+ expect(updated?.icon).toBe("home");
+ });
- it("returns null for non-existent id", () => {
- const result = updateCategory(db, 9999, { name: "Ghost" });
- expect(result).toBeNull();
- });
- });
+ it("returns null for non-existent id", () => {
+ const result = updateCategory(db, 9999, { name: "Ghost" });
+ expect(result).toBeNull();
+ });
+ });
- describe("deleteCategory", () => {
- it("reassigns items to Uncategorized (id=1) then deletes", () => {
- const shelter = createCategory(db, { name: "Shelter", icon: "tent" });
- createItem(db, { name: "Tent", categoryId: shelter!.id });
- createItem(db, { name: "Tarp", categoryId: shelter!.id });
+ describe("deleteCategory", () => {
+ it("reassigns items to Uncategorized (id=1) then deletes", () => {
+ const shelter = createCategory(db, { name: "Shelter", icon: "tent" });
+ createItem(db, { name: "Tent", categoryId: shelter?.id });
+ createItem(db, { name: "Tarp", categoryId: shelter?.id });
- const result = deleteCategory(db, shelter!.id);
- expect(result.success).toBe(true);
+ const result = deleteCategory(db, shelter?.id);
+ expect(result.success).toBe(true);
- // Items should now be in Uncategorized (id=1)
- const reassigned = db
- .select()
- .from(items)
- .where(eq(items.categoryId, 1))
- .all();
- expect(reassigned).toHaveLength(2);
- expect(reassigned.map((i) => i.name).sort()).toEqual(["Tarp", "Tent"]);
- });
+ // Items should now be in Uncategorized (id=1)
+ const reassigned = db
+ .select()
+ .from(items)
+ .where(eq(items.categoryId, 1))
+ .all();
+ expect(reassigned).toHaveLength(2);
+ expect(reassigned.map((i) => i.name).sort()).toEqual(["Tarp", "Tent"]);
+ });
- it("cannot delete Uncategorized (id=1)", () => {
- const result = deleteCategory(db, 1);
- expect(result.success).toBe(false);
- expect(result.error).toBeDefined();
- });
- });
+ it("cannot delete Uncategorized (id=1)", () => {
+ const result = deleteCategory(db, 1);
+ expect(result.success).toBe(false);
+ expect(result.error).toBeDefined();
+ });
+ });
});
diff --git a/tests/services/item.service.test.ts b/tests/services/item.service.test.ts
index 673a9c8..2a78edf 100644
--- a/tests/services/item.service.test.ts
+++ b/tests/services/item.service.test.ts
@@ -1,127 +1,124 @@
-import { describe, it, expect, beforeEach } from "bun:test";
-import { createTestDb } from "../helpers/db.ts";
+import { beforeEach, describe, expect, it } from "bun:test";
import {
- getAllItems,
- getItemById,
- createItem,
- updateItem,
- deleteItem,
+ createItem,
+ deleteItem,
+ getAllItems,
+ getItemById,
+ updateItem,
} from "../../src/server/services/item.service.ts";
+import { createTestDb } from "../helpers/db.ts";
describe("Item Service", () => {
- let db: ReturnType;
+ let db: ReturnType;
- beforeEach(() => {
- db = createTestDb();
- });
+ beforeEach(() => {
+ db = createTestDb();
+ });
- describe("createItem", () => {
- it("creates item with all fields, returns item with id and timestamps", () => {
- const item = createItem(
- db,
- {
- name: "Tent",
- weightGrams: 1200,
- priceCents: 35000,
- categoryId: 1,
- notes: "Ultralight 2-person",
- productUrl: "https://example.com/tent",
- },
- );
+ describe("createItem", () => {
+ it("creates item with all fields, returns item with id and timestamps", () => {
+ const item = createItem(db, {
+ name: "Tent",
+ weightGrams: 1200,
+ priceCents: 35000,
+ categoryId: 1,
+ notes: "Ultralight 2-person",
+ productUrl: "https://example.com/tent",
+ });
- expect(item).toBeDefined();
- expect(item!.id).toBeGreaterThan(0);
- expect(item!.name).toBe("Tent");
- expect(item!.weightGrams).toBe(1200);
- expect(item!.priceCents).toBe(35000);
- expect(item!.categoryId).toBe(1);
- expect(item!.notes).toBe("Ultralight 2-person");
- expect(item!.productUrl).toBe("https://example.com/tent");
- expect(item!.createdAt).toBeDefined();
- expect(item!.updatedAt).toBeDefined();
- });
+ expect(item).toBeDefined();
+ expect(item?.id).toBeGreaterThan(0);
+ expect(item?.name).toBe("Tent");
+ expect(item?.weightGrams).toBe(1200);
+ expect(item?.priceCents).toBe(35000);
+ expect(item?.categoryId).toBe(1);
+ expect(item?.notes).toBe("Ultralight 2-person");
+ expect(item?.productUrl).toBe("https://example.com/tent");
+ expect(item?.createdAt).toBeDefined();
+ expect(item?.updatedAt).toBeDefined();
+ });
- it("only name and categoryId are required, other fields optional", () => {
- const item = createItem(db, { name: "Spork", categoryId: 1 });
+ it("only name and categoryId are required, other fields optional", () => {
+ const item = createItem(db, { name: "Spork", categoryId: 1 });
- expect(item).toBeDefined();
- expect(item!.name).toBe("Spork");
- expect(item!.weightGrams).toBeNull();
- expect(item!.priceCents).toBeNull();
- expect(item!.notes).toBeNull();
- expect(item!.productUrl).toBeNull();
- });
- });
+ expect(item).toBeDefined();
+ expect(item?.name).toBe("Spork");
+ expect(item?.weightGrams).toBeNull();
+ expect(item?.priceCents).toBeNull();
+ expect(item?.notes).toBeNull();
+ expect(item?.productUrl).toBeNull();
+ });
+ });
- describe("getAllItems", () => {
- it("returns all items with category info joined", () => {
- createItem(db, { name: "Tent", categoryId: 1 });
- createItem(db, { name: "Sleeping Bag", categoryId: 1 });
+ describe("getAllItems", () => {
+ it("returns all items with category info joined", () => {
+ createItem(db, { name: "Tent", categoryId: 1 });
+ createItem(db, { name: "Sleeping Bag", categoryId: 1 });
- const all = getAllItems(db);
- expect(all).toHaveLength(2);
- expect(all[0].categoryName).toBe("Uncategorized");
- expect(all[0].categoryIcon).toBeDefined();
- });
- });
+ const all = getAllItems(db);
+ expect(all).toHaveLength(2);
+ expect(all[0].categoryName).toBe("Uncategorized");
+ expect(all[0].categoryIcon).toBeDefined();
+ });
+ });
- describe("getItemById", () => {
- it("returns single item or null", () => {
- const created = createItem(db, { name: "Tent", categoryId: 1 });
- const found = getItemById(db, created!.id);
- expect(found).toBeDefined();
- expect(found!.name).toBe("Tent");
+ describe("getItemById", () => {
+ it("returns single item or null", () => {
+ const created = createItem(db, { name: "Tent", categoryId: 1 });
+ const found = getItemById(db, created?.id);
+ expect(found).toBeDefined();
+ expect(found?.name).toBe("Tent");
- const notFound = getItemById(db, 9999);
- expect(notFound).toBeNull();
- });
- });
+ const notFound = getItemById(db, 9999);
+ expect(notFound).toBeNull();
+ });
+ });
- describe("updateItem", () => {
- it("updates specified fields, sets updatedAt", () => {
- const created = createItem(db, {
- name: "Tent",
- weightGrams: 1200,
- categoryId: 1,
- });
+ describe("updateItem", () => {
+ it("updates specified fields, sets updatedAt", () => {
+ const created = createItem(db, {
+ name: "Tent",
+ weightGrams: 1200,
+ categoryId: 1,
+ });
- const updated = updateItem(db, created!.id, {
- name: "Big Agnes Tent",
- weightGrams: 1100,
- });
+ const updated = updateItem(db, created?.id, {
+ name: "Big Agnes Tent",
+ weightGrams: 1100,
+ });
- expect(updated).toBeDefined();
- expect(updated!.name).toBe("Big Agnes Tent");
- expect(updated!.weightGrams).toBe(1100);
- });
+ expect(updated).toBeDefined();
+ expect(updated?.name).toBe("Big Agnes Tent");
+ expect(updated?.weightGrams).toBe(1100);
+ });
- it("returns null for non-existent id", () => {
- const result = updateItem(db, 9999, { name: "Ghost" });
- expect(result).toBeNull();
- });
- });
+ it("returns null for non-existent id", () => {
+ const result = updateItem(db, 9999, { name: "Ghost" });
+ expect(result).toBeNull();
+ });
+ });
- describe("deleteItem", () => {
- it("removes item from DB, returns deleted item", () => {
- const created = createItem(db, {
- name: "Tent",
- categoryId: 1,
- imageFilename: "tent.jpg",
- });
+ describe("deleteItem", () => {
+ it("removes item from DB, returns deleted item", () => {
+ const created = createItem(db, {
+ name: "Tent",
+ categoryId: 1,
+ imageFilename: "tent.jpg",
+ });
- const deleted = deleteItem(db, created!.id);
- expect(deleted).toBeDefined();
- expect(deleted!.name).toBe("Tent");
- expect(deleted!.imageFilename).toBe("tent.jpg");
+ const deleted = deleteItem(db, created?.id);
+ expect(deleted).toBeDefined();
+ expect(deleted?.name).toBe("Tent");
+ expect(deleted?.imageFilename).toBe("tent.jpg");
- // Verify it's gone
- const found = getItemById(db, created!.id);
- expect(found).toBeNull();
- });
+ // Verify it's gone
+ const found = getItemById(db, created?.id);
+ expect(found).toBeNull();
+ });
- it("returns null for non-existent id", () => {
- const result = deleteItem(db, 9999);
- expect(result).toBeNull();
- });
- });
+ it("returns null for non-existent id", () => {
+ const result = deleteItem(db, 9999);
+ expect(result).toBeNull();
+ });
+ });
});
diff --git a/tests/services/setup.service.test.ts b/tests/services/setup.service.test.ts
index d58d3df..f0edecf 100644
--- a/tests/services/setup.service.test.ts
+++ b/tests/services/setup.service.test.ts
@@ -1,192 +1,192 @@
-import { describe, it, expect, beforeEach } from "bun:test";
-import { createTestDb } from "../helpers/db.ts";
-import {
- getAllSetups,
- getSetupWithItems,
- createSetup,
- updateSetup,
- deleteSetup,
- syncSetupItems,
- removeSetupItem,
-} from "../../src/server/services/setup.service.ts";
+import { beforeEach, describe, expect, it } from "bun:test";
import { createItem } from "../../src/server/services/item.service.ts";
+import {
+ createSetup,
+ deleteSetup,
+ getAllSetups,
+ getSetupWithItems,
+ removeSetupItem,
+ syncSetupItems,
+ updateSetup,
+} from "../../src/server/services/setup.service.ts";
+import { createTestDb } from "../helpers/db.ts";
describe("Setup Service", () => {
- let db: ReturnType;
+ let db: ReturnType;
- beforeEach(() => {
- db = createTestDb();
- });
+ beforeEach(() => {
+ db = createTestDb();
+ });
- describe("createSetup", () => {
- it("creates setup with name, returns setup with id/timestamps", () => {
- const setup = createSetup(db, { name: "Day Hike" });
+ describe("createSetup", () => {
+ it("creates setup with name, returns setup with id/timestamps", () => {
+ const setup = createSetup(db, { name: "Day Hike" });
- expect(setup).toBeDefined();
- expect(setup.id).toBeGreaterThan(0);
- expect(setup.name).toBe("Day Hike");
- expect(setup.createdAt).toBeDefined();
- expect(setup.updatedAt).toBeDefined();
- });
- });
+ expect(setup).toBeDefined();
+ expect(setup.id).toBeGreaterThan(0);
+ expect(setup.name).toBe("Day Hike");
+ expect(setup.createdAt).toBeDefined();
+ expect(setup.updatedAt).toBeDefined();
+ });
+ });
- describe("getAllSetups", () => {
- it("returns setups with itemCount, totalWeight, totalCost", () => {
- const setup = createSetup(db, { name: "Backpacking" });
- const item1 = createItem(db, {
- name: "Tent",
- categoryId: 1,
- weightGrams: 1200,
- priceCents: 30000,
- });
- const item2 = createItem(db, {
- name: "Sleeping Bag",
- categoryId: 1,
- weightGrams: 800,
- priceCents: 20000,
- });
- syncSetupItems(db, setup.id, [item1.id, item2.id]);
+ describe("getAllSetups", () => {
+ it("returns setups with itemCount, totalWeight, totalCost", () => {
+ const setup = createSetup(db, { name: "Backpacking" });
+ const item1 = createItem(db, {
+ name: "Tent",
+ categoryId: 1,
+ weightGrams: 1200,
+ priceCents: 30000,
+ });
+ const item2 = createItem(db, {
+ name: "Sleeping Bag",
+ categoryId: 1,
+ weightGrams: 800,
+ priceCents: 20000,
+ });
+ syncSetupItems(db, setup.id, [item1.id, item2.id]);
- const setups = getAllSetups(db);
- expect(setups).toHaveLength(1);
- expect(setups[0].name).toBe("Backpacking");
- expect(setups[0].itemCount).toBe(2);
- expect(setups[0].totalWeight).toBe(2000);
- expect(setups[0].totalCost).toBe(50000);
- });
+ const setups = getAllSetups(db);
+ expect(setups).toHaveLength(1);
+ expect(setups[0].name).toBe("Backpacking");
+ expect(setups[0].itemCount).toBe(2);
+ expect(setups[0].totalWeight).toBe(2000);
+ expect(setups[0].totalCost).toBe(50000);
+ });
- it("returns 0 for weight/cost when setup has no items", () => {
- createSetup(db, { name: "Empty Setup" });
+ it("returns 0 for weight/cost when setup has no items", () => {
+ createSetup(db, { name: "Empty Setup" });
- const setups = getAllSetups(db);
- expect(setups).toHaveLength(1);
- expect(setups[0].itemCount).toBe(0);
- expect(setups[0].totalWeight).toBe(0);
- expect(setups[0].totalCost).toBe(0);
- });
- });
+ const setups = getAllSetups(db);
+ expect(setups).toHaveLength(1);
+ expect(setups[0].itemCount).toBe(0);
+ expect(setups[0].totalWeight).toBe(0);
+ expect(setups[0].totalCost).toBe(0);
+ });
+ });
- describe("getSetupWithItems", () => {
- it("returns setup with full item details and category info", () => {
- const setup = createSetup(db, { name: "Day Hike" });
- const item = createItem(db, {
- name: "Water Bottle",
- categoryId: 1,
- weightGrams: 200,
- priceCents: 2500,
- });
- syncSetupItems(db, setup.id, [item.id]);
+ describe("getSetupWithItems", () => {
+ it("returns setup with full item details and category info", () => {
+ const setup = createSetup(db, { name: "Day Hike" });
+ const item = createItem(db, {
+ name: "Water Bottle",
+ categoryId: 1,
+ weightGrams: 200,
+ priceCents: 2500,
+ });
+ syncSetupItems(db, setup.id, [item.id]);
- const result = getSetupWithItems(db, setup.id);
- expect(result).toBeDefined();
- expect(result!.name).toBe("Day Hike");
- expect(result!.items).toHaveLength(1);
- expect(result!.items[0].name).toBe("Water Bottle");
- expect(result!.items[0].categoryName).toBe("Uncategorized");
- expect(result!.items[0].categoryIcon).toBeDefined();
- });
+ const result = getSetupWithItems(db, setup.id);
+ expect(result).toBeDefined();
+ expect(result?.name).toBe("Day Hike");
+ expect(result?.items).toHaveLength(1);
+ expect(result?.items[0].name).toBe("Water Bottle");
+ expect(result?.items[0].categoryName).toBe("Uncategorized");
+ expect(result?.items[0].categoryIcon).toBeDefined();
+ });
- it("returns null for non-existent setup", () => {
- const result = getSetupWithItems(db, 9999);
- expect(result).toBeNull();
- });
- });
+ it("returns null for non-existent setup", () => {
+ const result = getSetupWithItems(db, 9999);
+ expect(result).toBeNull();
+ });
+ });
- describe("updateSetup", () => {
- it("updates setup name, returns updated setup", () => {
- const setup = createSetup(db, { name: "Original" });
- const updated = updateSetup(db, setup.id, { name: "Renamed" });
+ describe("updateSetup", () => {
+ it("updates setup name, returns updated setup", () => {
+ const setup = createSetup(db, { name: "Original" });
+ const updated = updateSetup(db, setup.id, { name: "Renamed" });
- expect(updated).toBeDefined();
- expect(updated!.name).toBe("Renamed");
- });
+ expect(updated).toBeDefined();
+ expect(updated?.name).toBe("Renamed");
+ });
- it("returns null for non-existent setup", () => {
- const result = updateSetup(db, 9999, { name: "Ghost" });
- expect(result).toBeNull();
- });
- });
+ it("returns null for non-existent setup", () => {
+ const result = updateSetup(db, 9999, { name: "Ghost" });
+ expect(result).toBeNull();
+ });
+ });
- describe("deleteSetup", () => {
- it("removes setup and cascades to setup_items", () => {
- const setup = createSetup(db, { name: "To Delete" });
- const item = createItem(db, { name: "Item", categoryId: 1 });
- syncSetupItems(db, setup.id, [item.id]);
+ describe("deleteSetup", () => {
+ it("removes setup and cascades to setup_items", () => {
+ const setup = createSetup(db, { name: "To Delete" });
+ const item = createItem(db, { name: "Item", categoryId: 1 });
+ syncSetupItems(db, setup.id, [item.id]);
- const deleted = deleteSetup(db, setup.id);
- expect(deleted).toBe(true);
+ const deleted = deleteSetup(db, setup.id);
+ expect(deleted).toBe(true);
- // Setup gone
- const result = getSetupWithItems(db, setup.id);
- expect(result).toBeNull();
- });
+ // Setup gone
+ const result = getSetupWithItems(db, setup.id);
+ expect(result).toBeNull();
+ });
- it("returns false for non-existent setup", () => {
- const result = deleteSetup(db, 9999);
- expect(result).toBe(false);
- });
- });
+ it("returns false for non-existent setup", () => {
+ const result = deleteSetup(db, 9999);
+ expect(result).toBe(false);
+ });
+ });
- describe("syncSetupItems", () => {
- it("sets items for a setup (delete-all + re-insert)", () => {
- const setup = createSetup(db, { name: "Kit" });
- const item1 = createItem(db, { name: "Item 1", categoryId: 1 });
- const item2 = createItem(db, { name: "Item 2", categoryId: 1 });
- const item3 = createItem(db, { name: "Item 3", categoryId: 1 });
+ describe("syncSetupItems", () => {
+ it("sets items for a setup (delete-all + re-insert)", () => {
+ const setup = createSetup(db, { name: "Kit" });
+ const item1 = createItem(db, { name: "Item 1", categoryId: 1 });
+ const item2 = createItem(db, { name: "Item 2", categoryId: 1 });
+ const item3 = createItem(db, { name: "Item 3", categoryId: 1 });
- // Initial sync
- syncSetupItems(db, setup.id, [item1.id, item2.id]);
- let result = getSetupWithItems(db, setup.id);
- expect(result!.items).toHaveLength(2);
+ // Initial sync
+ syncSetupItems(db, setup.id, [item1.id, item2.id]);
+ let result = getSetupWithItems(db, setup.id);
+ expect(result?.items).toHaveLength(2);
- // Re-sync with different items
- syncSetupItems(db, setup.id, [item2.id, item3.id]);
- result = getSetupWithItems(db, setup.id);
- expect(result!.items).toHaveLength(2);
- const names = result!.items.map((i: any) => i.name).sort();
- expect(names).toEqual(["Item 2", "Item 3"]);
- });
+ // Re-sync with different items
+ syncSetupItems(db, setup.id, [item2.id, item3.id]);
+ result = getSetupWithItems(db, setup.id);
+ expect(result?.items).toHaveLength(2);
+ const names = result?.items.map((i: any) => i.name).sort();
+ expect(names).toEqual(["Item 2", "Item 3"]);
+ });
- it("syncing with empty array clears all items", () => {
- const setup = createSetup(db, { name: "Kit" });
- const item = createItem(db, { name: "Item", categoryId: 1 });
- syncSetupItems(db, setup.id, [item.id]);
+ it("syncing with empty array clears all items", () => {
+ const setup = createSetup(db, { name: "Kit" });
+ const item = createItem(db, { name: "Item", categoryId: 1 });
+ syncSetupItems(db, setup.id, [item.id]);
- syncSetupItems(db, setup.id, []);
- const result = getSetupWithItems(db, setup.id);
- expect(result!.items).toHaveLength(0);
- });
- });
+ syncSetupItems(db, setup.id, []);
+ const result = getSetupWithItems(db, setup.id);
+ expect(result?.items).toHaveLength(0);
+ });
+ });
- describe("removeSetupItem", () => {
- it("removes single item from setup", () => {
- const setup = createSetup(db, { name: "Kit" });
- const item1 = createItem(db, { name: "Item 1", categoryId: 1 });
- const item2 = createItem(db, { name: "Item 2", categoryId: 1 });
- syncSetupItems(db, setup.id, [item1.id, item2.id]);
+ describe("removeSetupItem", () => {
+ it("removes single item from setup", () => {
+ const setup = createSetup(db, { name: "Kit" });
+ const item1 = createItem(db, { name: "Item 1", categoryId: 1 });
+ const item2 = createItem(db, { name: "Item 2", categoryId: 1 });
+ syncSetupItems(db, setup.id, [item1.id, item2.id]);
- removeSetupItem(db, setup.id, item1.id);
- const result = getSetupWithItems(db, setup.id);
- expect(result!.items).toHaveLength(1);
- expect(result!.items[0].name).toBe("Item 2");
- });
- });
+ removeSetupItem(db, setup.id, item1.id);
+ const result = getSetupWithItems(db, setup.id);
+ expect(result?.items).toHaveLength(1);
+ expect(result?.items[0].name).toBe("Item 2");
+ });
+ });
- describe("cascade behavior", () => {
- it("deleting a collection item removes it from all setups", () => {
- const setup = createSetup(db, { name: "Kit" });
- const item1 = createItem(db, { name: "Item 1", categoryId: 1 });
- const item2 = createItem(db, { name: "Item 2", categoryId: 1 });
- syncSetupItems(db, setup.id, [item1.id, item2.id]);
+ describe("cascade behavior", () => {
+ it("deleting a collection item removes it from all setups", () => {
+ const setup = createSetup(db, { name: "Kit" });
+ const item1 = createItem(db, { name: "Item 1", categoryId: 1 });
+ const item2 = createItem(db, { name: "Item 2", categoryId: 1 });
+ syncSetupItems(db, setup.id, [item1.id, item2.id]);
- // Delete item1 from collection (need direct DB access)
- const { items: itemsTable } = require("../../src/db/schema.ts");
- const { eq } = require("drizzle-orm");
- db.delete(itemsTable).where(eq(itemsTable.id, item1.id)).run();
+ // Delete item1 from collection (need direct DB access)
+ const { items: itemsTable } = require("../../src/db/schema.ts");
+ const { eq } = require("drizzle-orm");
+ db.delete(itemsTable).where(eq(itemsTable.id, item1.id)).run();
- const result = getSetupWithItems(db, setup.id);
- expect(result!.items).toHaveLength(1);
- expect(result!.items[0].name).toBe("Item 2");
- });
- });
+ const result = getSetupWithItems(db, setup.id);
+ expect(result?.items).toHaveLength(1);
+ expect(result?.items[0].name).toBe("Item 2");
+ });
+ });
});
diff --git a/tests/services/thread.service.test.ts b/tests/services/thread.service.test.ts
index 7c3eaa1..fc6157d 100644
--- a/tests/services/thread.service.test.ts
+++ b/tests/services/thread.service.test.ts
@@ -1,280 +1,285 @@
-import { describe, it, expect, beforeEach } from "bun:test";
-import { createTestDb } from "../helpers/db.ts";
+import { beforeEach, describe, expect, it } from "bun:test";
import {
- createThread,
- getAllThreads,
- getThreadWithCandidates,
- createCandidate,
- updateCandidate,
- deleteCandidate,
- updateThread,
- deleteThread,
- resolveThread,
+ createCandidate,
+ createThread,
+ deleteCandidate,
+ deleteThread,
+ getAllThreads,
+ getThreadWithCandidates,
+ resolveThread,
+ updateCandidate,
+ updateThread,
} from "../../src/server/services/thread.service.ts";
-import { createItem } from "../../src/server/services/item.service.ts";
+import { createTestDb } from "../helpers/db.ts";
describe("Thread Service", () => {
- let db: ReturnType;
+ let db: ReturnType;
- beforeEach(() => {
- db = createTestDb();
- });
+ beforeEach(() => {
+ db = createTestDb();
+ });
- describe("createThread", () => {
- it("creates thread with name, returns thread with id/status/timestamps", () => {
- const thread = createThread(db, { name: "New Tent", categoryId: 1 });
+ describe("createThread", () => {
+ it("creates thread with name, returns thread with id/status/timestamps", () => {
+ const thread = createThread(db, { name: "New Tent", categoryId: 1 });
- expect(thread).toBeDefined();
- expect(thread.id).toBeGreaterThan(0);
- expect(thread.name).toBe("New Tent");
- expect(thread.status).toBe("active");
- expect(thread.resolvedCandidateId).toBeNull();
- expect(thread.createdAt).toBeDefined();
- expect(thread.updatedAt).toBeDefined();
- });
- });
+ expect(thread).toBeDefined();
+ expect(thread.id).toBeGreaterThan(0);
+ expect(thread.name).toBe("New Tent");
+ expect(thread.status).toBe("active");
+ expect(thread.resolvedCandidateId).toBeNull();
+ expect(thread.createdAt).toBeDefined();
+ expect(thread.updatedAt).toBeDefined();
+ });
+ });
- describe("getAllThreads", () => {
- it("returns active threads with candidateCount and price range", () => {
- const thread = createThread(db, { name: "Backpack Options", categoryId: 1 });
- createCandidate(db, thread.id, {
- name: "Pack A",
- categoryId: 1,
- priceCents: 20000,
- });
- createCandidate(db, thread.id, {
- name: "Pack B",
- categoryId: 1,
- priceCents: 35000,
- });
+ describe("getAllThreads", () => {
+ it("returns active threads with candidateCount and price range", () => {
+ const thread = createThread(db, {
+ name: "Backpack Options",
+ categoryId: 1,
+ });
+ createCandidate(db, thread.id, {
+ name: "Pack A",
+ categoryId: 1,
+ priceCents: 20000,
+ });
+ createCandidate(db, thread.id, {
+ name: "Pack B",
+ categoryId: 1,
+ priceCents: 35000,
+ });
- const threads = getAllThreads(db);
- expect(threads).toHaveLength(1);
- expect(threads[0].name).toBe("Backpack Options");
- expect(threads[0].candidateCount).toBe(2);
- expect(threads[0].minPriceCents).toBe(20000);
- expect(threads[0].maxPriceCents).toBe(35000);
- });
+ const threads = getAllThreads(db);
+ expect(threads).toHaveLength(1);
+ expect(threads[0].name).toBe("Backpack Options");
+ expect(threads[0].candidateCount).toBe(2);
+ expect(threads[0].minPriceCents).toBe(20000);
+ expect(threads[0].maxPriceCents).toBe(35000);
+ });
- it("excludes resolved threads by default", () => {
- const t1 = createThread(db, { name: "Active Thread", categoryId: 1 });
- const t2 = createThread(db, { name: "Resolved Thread", categoryId: 1 });
- const candidate = createCandidate(db, t2.id, {
- name: "Winner",
- categoryId: 1,
- });
- resolveThread(db, t2.id, candidate.id);
+ it("excludes resolved threads by default", () => {
+ const _t1 = createThread(db, { name: "Active Thread", categoryId: 1 });
+ const t2 = createThread(db, { name: "Resolved Thread", categoryId: 1 });
+ const candidate = createCandidate(db, t2.id, {
+ name: "Winner",
+ categoryId: 1,
+ });
+ resolveThread(db, t2.id, candidate.id);
- const active = getAllThreads(db);
- expect(active).toHaveLength(1);
- expect(active[0].name).toBe("Active Thread");
- });
+ const active = getAllThreads(db);
+ expect(active).toHaveLength(1);
+ expect(active[0].name).toBe("Active Thread");
+ });
- it("includes resolved threads when includeResolved=true", () => {
- const t1 = createThread(db, { name: "Active Thread", categoryId: 1 });
- const t2 = createThread(db, { name: "Resolved Thread", categoryId: 1 });
- const candidate = createCandidate(db, t2.id, {
- name: "Winner",
- categoryId: 1,
- });
- resolveThread(db, t2.id, candidate.id);
+ it("includes resolved threads when includeResolved=true", () => {
+ const _t1 = createThread(db, { name: "Active Thread", categoryId: 1 });
+ const t2 = createThread(db, { name: "Resolved Thread", categoryId: 1 });
+ const candidate = createCandidate(db, t2.id, {
+ name: "Winner",
+ categoryId: 1,
+ });
+ resolveThread(db, t2.id, candidate.id);
- const all = getAllThreads(db, true);
- expect(all).toHaveLength(2);
- });
- });
+ const all = getAllThreads(db, true);
+ expect(all).toHaveLength(2);
+ });
+ });
- describe("getThreadWithCandidates", () => {
- it("returns thread with nested candidates array including category info", () => {
- const thread = createThread(db, { name: "Tent Options", categoryId: 1 });
- createCandidate(db, thread.id, {
- name: "Tent A",
- categoryId: 1,
- weightGrams: 1200,
- priceCents: 30000,
- });
+ describe("getThreadWithCandidates", () => {
+ it("returns thread with nested candidates array including category info", () => {
+ const thread = createThread(db, { name: "Tent Options", categoryId: 1 });
+ createCandidate(db, thread.id, {
+ name: "Tent A",
+ categoryId: 1,
+ weightGrams: 1200,
+ priceCents: 30000,
+ });
- const result = getThreadWithCandidates(db, thread.id);
- expect(result).toBeDefined();
- expect(result!.name).toBe("Tent Options");
- expect(result!.candidates).toHaveLength(1);
- expect(result!.candidates[0].name).toBe("Tent A");
- expect(result!.candidates[0].categoryName).toBe("Uncategorized");
- expect(result!.candidates[0].categoryIcon).toBeDefined();
- });
+ const result = getThreadWithCandidates(db, thread.id);
+ expect(result).toBeDefined();
+ expect(result?.name).toBe("Tent Options");
+ expect(result?.candidates).toHaveLength(1);
+ expect(result?.candidates[0].name).toBe("Tent A");
+ expect(result?.candidates[0].categoryName).toBe("Uncategorized");
+ expect(result?.candidates[0].categoryIcon).toBeDefined();
+ });
- it("returns null for non-existent thread", () => {
- const result = getThreadWithCandidates(db, 9999);
- expect(result).toBeNull();
- });
- });
+ it("returns null for non-existent thread", () => {
+ const result = getThreadWithCandidates(db, 9999);
+ expect(result).toBeNull();
+ });
+ });
- describe("createCandidate", () => {
- it("adds candidate to thread with all item-compatible fields", () => {
- const thread = createThread(db, { name: "Tent Options", categoryId: 1 });
- const candidate = createCandidate(db, thread.id, {
- name: "Tent A",
- weightGrams: 1200,
- priceCents: 30000,
- categoryId: 1,
- notes: "Ultralight 2-person",
- productUrl: "https://example.com/tent",
- });
+ describe("createCandidate", () => {
+ it("adds candidate to thread with all item-compatible fields", () => {
+ const thread = createThread(db, { name: "Tent Options", categoryId: 1 });
+ const candidate = createCandidate(db, thread.id, {
+ name: "Tent A",
+ weightGrams: 1200,
+ priceCents: 30000,
+ categoryId: 1,
+ notes: "Ultralight 2-person",
+ productUrl: "https://example.com/tent",
+ });
- expect(candidate).toBeDefined();
- expect(candidate.id).toBeGreaterThan(0);
- expect(candidate.threadId).toBe(thread.id);
- expect(candidate.name).toBe("Tent A");
- expect(candidate.weightGrams).toBe(1200);
- expect(candidate.priceCents).toBe(30000);
- expect(candidate.categoryId).toBe(1);
- expect(candidate.notes).toBe("Ultralight 2-person");
- expect(candidate.productUrl).toBe("https://example.com/tent");
- });
- });
+ expect(candidate).toBeDefined();
+ expect(candidate.id).toBeGreaterThan(0);
+ expect(candidate.threadId).toBe(thread.id);
+ expect(candidate.name).toBe("Tent A");
+ expect(candidate.weightGrams).toBe(1200);
+ expect(candidate.priceCents).toBe(30000);
+ expect(candidate.categoryId).toBe(1);
+ expect(candidate.notes).toBe("Ultralight 2-person");
+ expect(candidate.productUrl).toBe("https://example.com/tent");
+ });
+ });
- describe("updateCandidate", () => {
- it("updates candidate fields, returns updated candidate", () => {
- const thread = createThread(db, { name: "Test", categoryId: 1 });
- const candidate = createCandidate(db, thread.id, {
- name: "Original",
- categoryId: 1,
- });
+ describe("updateCandidate", () => {
+ it("updates candidate fields, returns updated candidate", () => {
+ const thread = createThread(db, { name: "Test", categoryId: 1 });
+ const candidate = createCandidate(db, thread.id, {
+ name: "Original",
+ categoryId: 1,
+ });
- const updated = updateCandidate(db, candidate.id, {
- name: "Updated Name",
- priceCents: 15000,
- });
+ const updated = updateCandidate(db, candidate.id, {
+ name: "Updated Name",
+ priceCents: 15000,
+ });
- expect(updated).toBeDefined();
- expect(updated!.name).toBe("Updated Name");
- expect(updated!.priceCents).toBe(15000);
- });
+ expect(updated).toBeDefined();
+ expect(updated?.name).toBe("Updated Name");
+ expect(updated?.priceCents).toBe(15000);
+ });
- it("returns null for non-existent candidate", () => {
- const result = updateCandidate(db, 9999, { name: "Ghost" });
- expect(result).toBeNull();
- });
- });
+ it("returns null for non-existent candidate", () => {
+ const result = updateCandidate(db, 9999, { name: "Ghost" });
+ expect(result).toBeNull();
+ });
+ });
- describe("deleteCandidate", () => {
- it("removes candidate, returns deleted candidate", () => {
- const thread = createThread(db, { name: "Test", categoryId: 1 });
- const candidate = createCandidate(db, thread.id, {
- name: "To Delete",
- categoryId: 1,
- });
+ describe("deleteCandidate", () => {
+ it("removes candidate, returns deleted candidate", () => {
+ const thread = createThread(db, { name: "Test", categoryId: 1 });
+ const candidate = createCandidate(db, thread.id, {
+ name: "To Delete",
+ categoryId: 1,
+ });
- const deleted = deleteCandidate(db, candidate.id);
- expect(deleted).toBeDefined();
- expect(deleted!.name).toBe("To Delete");
+ const deleted = deleteCandidate(db, candidate.id);
+ expect(deleted).toBeDefined();
+ expect(deleted?.name).toBe("To Delete");
- // Verify it's gone
- const result = getThreadWithCandidates(db, thread.id);
- expect(result!.candidates).toHaveLength(0);
- });
+ // Verify it's gone
+ const result = getThreadWithCandidates(db, thread.id);
+ expect(result?.candidates).toHaveLength(0);
+ });
- it("returns null for non-existent candidate", () => {
- const result = deleteCandidate(db, 9999);
- expect(result).toBeNull();
- });
- });
+ it("returns null for non-existent candidate", () => {
+ const result = deleteCandidate(db, 9999);
+ expect(result).toBeNull();
+ });
+ });
- describe("updateThread", () => {
- it("updates thread name", () => {
- const thread = createThread(db, { name: "Original", categoryId: 1 });
- const updated = updateThread(db, thread.id, { name: "Renamed" });
+ describe("updateThread", () => {
+ it("updates thread name", () => {
+ const thread = createThread(db, { name: "Original", categoryId: 1 });
+ const updated = updateThread(db, thread.id, { name: "Renamed" });
- expect(updated).toBeDefined();
- expect(updated!.name).toBe("Renamed");
- });
+ expect(updated).toBeDefined();
+ expect(updated?.name).toBe("Renamed");
+ });
- it("returns null for non-existent thread", () => {
- const result = updateThread(db, 9999, { name: "Ghost" });
- expect(result).toBeNull();
- });
- });
+ it("returns null for non-existent thread", () => {
+ const result = updateThread(db, 9999, { name: "Ghost" });
+ expect(result).toBeNull();
+ });
+ });
- describe("deleteThread", () => {
- it("removes thread and cascading candidates", () => {
- const thread = createThread(db, { name: "To Delete", categoryId: 1 });
- createCandidate(db, thread.id, { name: "Candidate", categoryId: 1 });
+ describe("deleteThread", () => {
+ it("removes thread and cascading candidates", () => {
+ const thread = createThread(db, { name: "To Delete", categoryId: 1 });
+ createCandidate(db, thread.id, { name: "Candidate", categoryId: 1 });
- const deleted = deleteThread(db, thread.id);
- expect(deleted).toBeDefined();
- expect(deleted!.name).toBe("To Delete");
+ const deleted = deleteThread(db, thread.id);
+ expect(deleted).toBeDefined();
+ expect(deleted?.name).toBe("To Delete");
- // Thread and candidates gone
- const result = getThreadWithCandidates(db, thread.id);
- expect(result).toBeNull();
- });
+ // Thread and candidates gone
+ const result = getThreadWithCandidates(db, thread.id);
+ expect(result).toBeNull();
+ });
- it("returns null for non-existent thread", () => {
- const result = deleteThread(db, 9999);
- expect(result).toBeNull();
- });
- });
+ it("returns null for non-existent thread", () => {
+ const result = deleteThread(db, 9999);
+ expect(result).toBeNull();
+ });
+ });
- describe("resolveThread", () => {
- it("atomically creates collection item from candidate data and archives thread", () => {
- const thread = createThread(db, { name: "Tent Decision", categoryId: 1 });
- const candidate = createCandidate(db, thread.id, {
- name: "Winner Tent",
- weightGrams: 1200,
- priceCents: 30000,
- categoryId: 1,
- notes: "Best choice",
- productUrl: "https://example.com/tent",
- });
+ describe("resolveThread", () => {
+ it("atomically creates collection item from candidate data and archives thread", () => {
+ const thread = createThread(db, { name: "Tent Decision", categoryId: 1 });
+ const candidate = createCandidate(db, thread.id, {
+ name: "Winner Tent",
+ weightGrams: 1200,
+ priceCents: 30000,
+ categoryId: 1,
+ notes: "Best choice",
+ productUrl: "https://example.com/tent",
+ });
- const result = resolveThread(db, thread.id, candidate.id);
- expect(result.success).toBe(true);
- expect(result.item).toBeDefined();
- expect(result.item!.name).toBe("Winner Tent");
- expect(result.item!.weightGrams).toBe(1200);
- expect(result.item!.priceCents).toBe(30000);
- expect(result.item!.categoryId).toBe(1);
- expect(result.item!.notes).toBe("Best choice");
- expect(result.item!.productUrl).toBe("https://example.com/tent");
+ const result = resolveThread(db, thread.id, candidate.id);
+ expect(result.success).toBe(true);
+ expect(result.item).toBeDefined();
+ expect(result.item?.name).toBe("Winner Tent");
+ expect(result.item?.weightGrams).toBe(1200);
+ expect(result.item?.priceCents).toBe(30000);
+ expect(result.item?.categoryId).toBe(1);
+ expect(result.item?.notes).toBe("Best choice");
+ expect(result.item?.productUrl).toBe("https://example.com/tent");
- // Thread should be resolved
- const resolved = getThreadWithCandidates(db, thread.id);
- expect(resolved!.status).toBe("resolved");
- expect(resolved!.resolvedCandidateId).toBe(candidate.id);
- });
+ // Thread should be resolved
+ const resolved = getThreadWithCandidates(db, thread.id);
+ expect(resolved?.status).toBe("resolved");
+ expect(resolved?.resolvedCandidateId).toBe(candidate.id);
+ });
- it("fails if thread is not active", () => {
- const thread = createThread(db, { name: "Already Resolved", categoryId: 1 });
- const candidate = createCandidate(db, thread.id, {
- name: "Winner",
- categoryId: 1,
- });
- resolveThread(db, thread.id, candidate.id);
+ it("fails if thread is not active", () => {
+ const thread = createThread(db, {
+ name: "Already Resolved",
+ categoryId: 1,
+ });
+ const candidate = createCandidate(db, thread.id, {
+ name: "Winner",
+ categoryId: 1,
+ });
+ resolveThread(db, thread.id, candidate.id);
- // Try to resolve again
- const result = resolveThread(db, thread.id, candidate.id);
- expect(result.success).toBe(false);
- expect(result.error).toBeDefined();
- });
+ // Try to resolve again
+ const result = resolveThread(db, thread.id, candidate.id);
+ expect(result.success).toBe(false);
+ expect(result.error).toBeDefined();
+ });
- it("fails if candidate is not in thread", () => {
- const thread1 = createThread(db, { name: "Thread 1", categoryId: 1 });
- const thread2 = createThread(db, { name: "Thread 2", categoryId: 1 });
- const candidate = createCandidate(db, thread2.id, {
- name: "Wrong Thread",
- categoryId: 1,
- });
+ it("fails if candidate is not in thread", () => {
+ const thread1 = createThread(db, { name: "Thread 1", categoryId: 1 });
+ const thread2 = createThread(db, { name: "Thread 2", categoryId: 1 });
+ const candidate = createCandidate(db, thread2.id, {
+ name: "Wrong Thread",
+ categoryId: 1,
+ });
- const result = resolveThread(db, thread1.id, candidate.id);
- expect(result.success).toBe(false);
- expect(result.error).toBeDefined();
- });
+ const result = resolveThread(db, thread1.id, candidate.id);
+ expect(result.success).toBe(false);
+ expect(result.error).toBeDefined();
+ });
- it("fails if candidate not found", () => {
- const thread = createThread(db, { name: "Test", categoryId: 1 });
- const result = resolveThread(db, thread.id, 9999);
- expect(result.success).toBe(false);
- expect(result.error).toBeDefined();
- });
- });
+ it("fails if candidate not found", () => {
+ const thread = createThread(db, { name: "Test", categoryId: 1 });
+ const result = resolveThread(db, thread.id, 9999);
+ expect(result.success).toBe(false);
+ expect(result.error).toBeDefined();
+ });
+ });
});
diff --git a/tests/services/totals.test.ts b/tests/services/totals.test.ts
index 947a441..1d9279f 100644
--- a/tests/services/totals.test.ts
+++ b/tests/services/totals.test.ts
@@ -1,79 +1,79 @@
-import { describe, it, expect, beforeEach } from "bun:test";
-import { createTestDb } from "../helpers/db.ts";
-import { createItem } from "../../src/server/services/item.service.ts";
+import { beforeEach, describe, expect, it } from "bun:test";
import { createCategory } from "../../src/server/services/category.service.ts";
+import { createItem } from "../../src/server/services/item.service.ts";
import {
- getCategoryTotals,
- getGlobalTotals,
+ getCategoryTotals,
+ getGlobalTotals,
} from "../../src/server/services/totals.service.ts";
+import { createTestDb } from "../helpers/db.ts";
describe("Totals Service", () => {
- let db: ReturnType;
+ let db: ReturnType;
- beforeEach(() => {
- db = createTestDb();
- });
+ beforeEach(() => {
+ db = createTestDb();
+ });
- describe("getCategoryTotals", () => {
- it("returns weight sum, cost sum, item count per category", () => {
- const shelter = createCategory(db, { name: "Shelter", icon: "tent" });
- createItem(db, {
- name: "Tent",
- weightGrams: 1200,
- priceCents: 35000,
- categoryId: shelter!.id,
- });
- createItem(db, {
- name: "Tarp",
- weightGrams: 300,
- priceCents: 8000,
- categoryId: shelter!.id,
- });
+ describe("getCategoryTotals", () => {
+ it("returns weight sum, cost sum, item count per category", () => {
+ const shelter = createCategory(db, { name: "Shelter", icon: "tent" });
+ createItem(db, {
+ name: "Tent",
+ weightGrams: 1200,
+ priceCents: 35000,
+ categoryId: shelter?.id,
+ });
+ createItem(db, {
+ name: "Tarp",
+ weightGrams: 300,
+ priceCents: 8000,
+ categoryId: shelter?.id,
+ });
- const totals = getCategoryTotals(db);
- expect(totals).toHaveLength(1); // Only Shelter has items
- expect(totals[0].categoryName).toBe("Shelter");
- expect(totals[0].totalWeight).toBe(1500);
- expect(totals[0].totalCost).toBe(43000);
- expect(totals[0].itemCount).toBe(2);
- });
+ const totals = getCategoryTotals(db);
+ expect(totals).toHaveLength(1); // Only Shelter has items
+ expect(totals[0].categoryName).toBe("Shelter");
+ expect(totals[0].totalWeight).toBe(1500);
+ expect(totals[0].totalCost).toBe(43000);
+ expect(totals[0].itemCount).toBe(2);
+ });
- it("excludes empty categories (no items)", () => {
- createCategory(db, { name: "Shelter", icon: "tent" });
- // No items added
- const totals = getCategoryTotals(db);
- expect(totals).toHaveLength(0);
- });
- });
+ it("excludes empty categories (no items)", () => {
+ createCategory(db, { name: "Shelter", icon: "tent" });
+ // No items added
+ const totals = getCategoryTotals(db);
+ expect(totals).toHaveLength(0);
+ });
+ });
- describe("getGlobalTotals", () => {
- it("returns overall weight, cost, count", () => {
- createItem(db, {
- name: "Tent",
- weightGrams: 1200,
- priceCents: 35000,
- categoryId: 1,
- });
- createItem(db, {
- name: "Spork",
- weightGrams: 20,
- priceCents: 500,
- categoryId: 1,
- });
+ describe("getGlobalTotals", () => {
+ it("returns overall weight, cost, count", () => {
+ createItem(db, {
+ name: "Tent",
+ weightGrams: 1200,
+ priceCents: 35000,
+ categoryId: 1,
+ });
+ createItem(db, {
+ name: "Spork",
+ weightGrams: 20,
+ priceCents: 500,
+ categoryId: 1,
+ });
- const totals = getGlobalTotals(db);
- expect(totals).toBeDefined();
- expect(totals!.totalWeight).toBe(1220);
- expect(totals!.totalCost).toBe(35500);
- expect(totals!.itemCount).toBe(2);
- });
+ const totals = getGlobalTotals(db);
+ expect(totals).toBeDefined();
+ expect(totals?.totalWeight).toBe(1220);
+ expect(totals?.totalCost).toBe(35500);
+ expect(totals?.itemCount).toBe(2);
+ });
- it("returns zeros when no items exist", () => {
- const totals = getGlobalTotals(db);
- expect(totals).toBeDefined();
- expect(totals!.totalWeight).toBe(0);
- expect(totals!.totalCost).toBe(0);
- expect(totals!.itemCount).toBe(0);
- });
- });
+ it("returns zeros when no items exist", () => {
+ const totals = getGlobalTotals(db);
+ expect(totals).toBeDefined();
+ expect(totals?.totalWeight).toBe(0);
+ expect(totals?.totalCost).toBe(0);
+ expect(totals?.itemCount).toBe(0);
+ });
+ });
});
diff --git a/tsconfig.json b/tsconfig.json
index 529d440..fc8d5c9 100644
--- a/tsconfig.json
+++ b/tsconfig.json
@@ -1,31 +1,31 @@
{
- "compilerOptions": {
- "lib": ["ESNext", "DOM", "DOM.Iterable"],
- "target": "ESNext",
- "module": "ESNext",
- "moduleDetection": "force",
- "jsx": "react-jsx",
- "allowJs": true,
+ "compilerOptions": {
+ "lib": ["ESNext", "DOM", "DOM.Iterable"],
+ "target": "ESNext",
+ "module": "ESNext",
+ "moduleDetection": "force",
+ "jsx": "react-jsx",
+ "allowJs": true,
- "moduleResolution": "bundler",
- "allowImportingTsExtensions": true,
- "verbatimModuleSyntax": true,
- "noEmit": true,
+ "moduleResolution": "bundler",
+ "allowImportingTsExtensions": true,
+ "verbatimModuleSyntax": true,
+ "noEmit": true,
- "strict": true,
- "skipLibCheck": true,
- "noFallthroughCasesInSwitch": true,
- "noUncheckedIndexedAccess": true,
- "noImplicitOverride": true,
+ "strict": true,
+ "skipLibCheck": true,
+ "noFallthroughCasesInSwitch": true,
+ "noUncheckedIndexedAccess": true,
+ "noImplicitOverride": true,
- "noUnusedLocals": false,
- "noUnusedParameters": false,
- "noPropertyAccessFromIndexSignature": false,
+ "noUnusedLocals": false,
+ "noUnusedParameters": false,
+ "noPropertyAccessFromIndexSignature": false,
- "paths": {
- "@/*": ["./src/*"]
- },
- "types": ["bun-types"]
- },
- "include": ["src", "tests"]
+ "paths": {
+ "@/*": ["./src/*"]
+ },
+ "types": ["bun-types"]
+ },
+ "include": ["src", "tests"]
}
diff --git a/vite.config.ts b/vite.config.ts
index 72980f3..042aac9 100644
--- a/vite.config.ts
+++ b/vite.config.ts
@@ -1,26 +1,26 @@
-import { defineConfig } from "vite";
-import react from "@vitejs/plugin-react";
import tailwindcss from "@tailwindcss/vite";
import { TanStackRouterVite } from "@tanstack/router-plugin/vite";
+import react from "@vitejs/plugin-react";
+import { defineConfig } from "vite";
export default defineConfig({
- plugins: [
- TanStackRouterVite({
- target: "react",
- autoCodeSplitting: true,
- routesDirectory: "./src/client/routes",
- generatedRouteTree: "./src/client/routeTree.gen.ts",
- }),
- react(),
- tailwindcss(),
- ],
- server: {
- proxy: {
- "/api": "http://localhost:3000",
- "/uploads": "http://localhost:3000",
- },
- },
- build: {
- outDir: "dist/client",
- },
+ plugins: [
+ TanStackRouterVite({
+ target: "react",
+ autoCodeSplitting: true,
+ routesDirectory: "./src/client/routes",
+ generatedRouteTree: "./src/client/routeTree.gen.ts",
+ }),
+ react(),
+ tailwindcss(),
+ ],
+ server: {
+ proxy: {
+ "/api": "http://localhost:3000",
+ "/uploads": "http://localhost:3000",
+ },
+ },
+ build: {
+ outDir: "dist/client",
+ },
});