From 91fb66b13b443c397e8cc767bf40eee84905f3c9 Mon Sep 17 00:00:00 2001 From: unknown Date: Mon, 7 Sep 2026 09:17:03 +0200 Subject: [PATCH] feat(preise): beobachtete preise und wertentwicklung je set Auf der Set-Detailseite lassen sich Preise notieren: Betrag, Zustand, Datum, wo gesehen und eine Notiz. Daraus entsteht eine Reihe statt eines einzelnen Feldes, denn die interessante Frage ist nicht "was kostet es", sondern "was hat es seit dem Kauf gemacht". Als Ausgangswert dient die UVP, sonst die aelteste Beobachtung; der aktuelle Wert ist die neueste. Fallen am selben Tag zwei Beobachtungen an, gewinnt der Neupreis - das ist die Zahl, die man zum Ersetzen zahlen wuerde. Die Statistik-Kachel in der Sammlung zeigt entsprechend "Aktueller Wert", sobald irgendwo ein Preis notiert ist, sonst unveraendert "Wert nach UVP". Bewusst nicht dabei: die Anbindung des BrickLink-Preisfuehrers. Sie war im Plan als optional vermerkt und braucht OAuth1 mit einer pro Nutzer registrierten Anwendung - viel Apparat fuer eine Zahl, die sich in zehn Sekunden eintippen laesst. Die Datenstruktur ist dafuer offen: das Feld "Wo" nimmt jede Quelle auf. 13 Tests fuer die Rechenwege, inklusive Division durch null, fehlender UVP und Rundung. Refs Phase 3 Co-Authored-By: Claude Opus 5 (1M context) --- drizzle/0002_aspiring_whiplash.sql | 14 + drizzle/meta/0002_snapshot.json | 2087 ++++++++++++++++++++++ drizzle/meta/_journal.json | 7 + src/app/(app)/katalog/[id]/page.tsx | 15 + src/app/(app)/katalog/price-actions.ts | 61 + src/app/(app)/sammlung/page.tsx | 7 +- src/components/katalog/price-section.tsx | 206 +++ src/db/schema/index.ts | 1 + src/db/schema/prices.ts | 35 + src/db/schema/relations.ts | 6 + src/lib/prices.ts | 77 + src/lib/queries/catalog.ts | 35 +- src/lib/queries/collection.ts | 19 + tests/unit/prices.test.ts | 127 ++ 14 files changed, 2695 insertions(+), 2 deletions(-) create mode 100644 drizzle/0002_aspiring_whiplash.sql create mode 100644 drizzle/meta/0002_snapshot.json create mode 100644 src/app/(app)/katalog/price-actions.ts create mode 100644 src/components/katalog/price-section.tsx create mode 100644 src/db/schema/prices.ts create mode 100644 src/lib/prices.ts create mode 100644 tests/unit/prices.test.ts diff --git a/drizzle/0002_aspiring_whiplash.sql b/drizzle/0002_aspiring_whiplash.sql new file mode 100644 index 0000000..f5ad919 --- /dev/null +++ b/drizzle/0002_aspiring_whiplash.sql @@ -0,0 +1,14 @@ +CREATE TABLE `price_point` ( + `id` text PRIMARY KEY NOT NULL, + `set_id` text NOT NULL, + `price_cents` integer NOT NULL, + `condition` text DEFAULT 'new' NOT NULL, + `source` text, + `observed_at` integer NOT NULL, + `note` text, + `created_at` integer NOT NULL, + FOREIGN KEY (`set_id`) REFERENCES `set`(`id`) ON UPDATE no action ON DELETE cascade, + CONSTRAINT "price_point_positive" CHECK("price_point"."price_cents" >= 0) +); +--> statement-breakpoint +CREATE INDEX `price_point_set_idx` ON `price_point` (`set_id`,`observed_at`); \ No newline at end of file diff --git a/drizzle/meta/0002_snapshot.json b/drizzle/meta/0002_snapshot.json new file mode 100644 index 0000000..cbc9ee4 --- /dev/null +++ b/drizzle/meta/0002_snapshot.json @@ -0,0 +1,2087 @@ +{ + "version": "6", + "dialect": "sqlite", + "id": "36e88ca3-098f-40f0-9253-abe30ff27c47", + "prevId": "db83b244-734e-48d4-9656-f335650ba9c7", + "tables": { + "account": { + "name": "account", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "account_id": { + "name": "account_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "access_token": { + "name": "access_token", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "refresh_token": { + "name": "refresh_token", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "id_token": { + "name": "id_token", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "access_token_expires_at": { + "name": "access_token_expires_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "refresh_token_expires_at": { + "name": "refresh_token_expires_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "scope": { + "name": "scope", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "password": { + "name": "password", + "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": { + "account_user_idx": { + "name": "account_user_idx", + "columns": [ + "user_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "account_user_id_user_id_fk": { + "name": "account_user_id_user_id_fk", + "tableFrom": "account", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "invite": { + "name": "invite", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "token_hash": { + "name": "token_hash", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'member'" + }, + "created_by_user_id": { + "name": "created_by_user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "expires_at": { + "name": "expires_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "used_at": { + "name": "used_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "used_by_user_id": { + "name": "used_by_user_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "invite_token_hash_unique": { + "name": "invite_token_hash_unique", + "columns": [ + "token_hash" + ], + "isUnique": true + } + }, + "foreignKeys": { + "invite_created_by_user_id_user_id_fk": { + "name": "invite_created_by_user_id_user_id_fk", + "tableFrom": "invite", + "tableTo": "user", + "columnsFrom": [ + "created_by_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "invite_used_by_user_id_user_id_fk": { + "name": "invite_used_by_user_id_user_id_fk", + "tableFrom": "invite", + "tableTo": "user", + "columnsFrom": [ + "used_by_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "passkey": { + "name": "passkey", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "public_key": { + "name": "public_key", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "credential_id": { + "name": "credential_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "counter": { + "name": "counter", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "device_type": { + "name": "device_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "backed_up": { + "name": "backed_up", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "transports": { + "name": "transports", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "aaguid": { + "name": "aaguid", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "passkey_credential_id_unique": { + "name": "passkey_credential_id_unique", + "columns": [ + "credential_id" + ], + "isUnique": true + }, + "passkey_user_idx": { + "name": "passkey_user_idx", + "columns": [ + "user_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "passkey_user_id_user_id_fk": { + "name": "passkey_user_id_user_id_fk", + "tableFrom": "passkey", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "rate_limit": { + "name": "rate_limit", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "key": { + "name": "key", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "count": { + "name": "count", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "last_request": { + "name": "last_request", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "session": { + "name": "session", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "expires_at": { + "name": "expires_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "ip_address": { + "name": "ip_address", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "user_agent": { + "name": "user_agent", + "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": { + "session_token_unique": { + "name": "session_token_unique", + "columns": [ + "token" + ], + "isUnique": true + }, + "session_user_idx": { + "name": "session_user_idx", + "columns": [ + "user_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "session_user_id_user_id_fk": { + "name": "session_user_id_user_id_fk", + "tableFrom": "session", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "user": { + "name": "user", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "email_verified": { + "name": "email_verified", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "image": { + "name": "image", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'member'" + }, + "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": { + "user_email_unique": { + "name": "user_email_unique", + "columns": [ + "email" + ], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "verification": { + "name": "verification", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "identifier": { + "name": "identifier", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "expires_at": { + "name": "expires_at", + "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": { + "verification_identifier_idx": { + "name": "verification_identifier_idx", + "columns": [ + "identifier" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "instruction": { + "name": "instruction", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "set_id": { + "name": "set_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "file_path": { + "name": "file_path", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "url": { + "name": "url", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "label": { + "name": "label", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "page_count": { + "name": "page_count", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "file_size_bytes": { + "name": "file_size_bytes", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "sort_order": { + "name": "sort_order", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "instruction_set_idx": { + "name": "instruction_set_idx", + "columns": [ + "set_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "instruction_set_id_set_id_fk": { + "name": "instruction_set_id_set_id_fk", + "tableFrom": "instruction", + "tableTo": "set", + "columnsFrom": [ + "set_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": { + "instruction_source": { + "name": "instruction_source", + "value": "(\"instruction\".\"kind\" = 'pdf_upload' AND \"instruction\".\"file_path\" IS NOT NULL AND \"instruction\".\"url\" IS NULL)\n OR (\"instruction\".\"kind\" = 'external_url' AND \"instruction\".\"url\" IS NOT NULL AND \"instruction\".\"file_path\" IS NULL)" + } + } + }, + "manufacturer": { + "name": "manufacturer", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "slug": { + "name": "slug", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "website": { + "name": "website", + "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": { + "manufacturer_slug_unique": { + "name": "manufacturer_slug_unique", + "columns": [ + "slug" + ], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "set": { + "name": "set", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "manufacturer_id": { + "name": "manufacturer_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "theme_id": { + "name": "theme_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "set_number": { + "name": "set_number", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "year": { + "name": "year", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "piece_count": { + "name": "piece_count", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "minifig_count": { + "name": "minifig_count", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "ean": { + "name": "ean", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "age_range": { + "name": "age_range", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "image_url": { + "name": "image_url", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "image_path": { + "name": "image_path", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "retail_price_cents": { + "name": "retail_price_cents", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "external_ids": { + "name": "external_ids", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "notes": { + "name": "notes", + "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": { + "set_manufacturer_number_unique": { + "name": "set_manufacturer_number_unique", + "columns": [ + "manufacturer_id", + "set_number" + ], + "isUnique": true + }, + "set_name_idx": { + "name": "set_name_idx", + "columns": [ + "name" + ], + "isUnique": false + }, + "set_ean_idx": { + "name": "set_ean_idx", + "columns": [ + "ean" + ], + "isUnique": false + }, + "set_theme_idx": { + "name": "set_theme_idx", + "columns": [ + "theme_id" + ], + "isUnique": false + }, + "set_year_idx": { + "name": "set_year_idx", + "columns": [ + "year" + ], + "isUnique": false + } + }, + "foreignKeys": { + "set_manufacturer_id_manufacturer_id_fk": { + "name": "set_manufacturer_id_manufacturer_id_fk", + "tableFrom": "set", + "tableTo": "manufacturer", + "columnsFrom": [ + "manufacturer_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "onUpdate": "no action" + }, + "set_theme_id_theme_id_fk": { + "name": "set_theme_id_theme_id_fk", + "tableFrom": "set", + "tableTo": "theme", + "columnsFrom": [ + "theme_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "set_tag": { + "name": "set_tag", + "columns": { + "set_id": { + "name": "set_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "tag_id": { + "name": "tag_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "set_tag_set_id_set_id_fk": { + "name": "set_tag_set_id_set_id_fk", + "tableFrom": "set_tag", + "tableTo": "set", + "columnsFrom": [ + "set_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "set_tag_tag_id_tag_id_fk": { + "name": "set_tag_tag_id_tag_id_fk", + "tableFrom": "set_tag", + "tableTo": "tag", + "columnsFrom": [ + "tag_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "set_tag_set_id_tag_id_pk": { + "columns": [ + "set_id", + "tag_id" + ], + "name": "set_tag_set_id_tag_id_pk" + } + }, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "tag": { + "name": "tag", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "slug": { + "name": "slug", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "tag_slug_unique": { + "name": "tag_slug_unique", + "columns": [ + "slug" + ], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "theme": { + "name": "theme", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "manufacturer_id": { + "name": "manufacturer_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "parent_theme_id": { + "name": "parent_theme_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "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": { + "theme_manufacturer_idx": { + "name": "theme_manufacturer_idx", + "columns": [ + "manufacturer_id" + ], + "isUnique": false + }, + "theme_unique_name": { + "name": "theme_unique_name", + "columns": [ + "manufacturer_id", + "name" + ], + "isUnique": true + } + }, + "foreignKeys": { + "theme_manufacturer_id_manufacturer_id_fk": { + "name": "theme_manufacturer_id_manufacturer_id_fk", + "tableFrom": "theme", + "tableTo": "manufacturer", + "columnsFrom": [ + "manufacturer_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "theme_parent_theme_id_theme_id_fk": { + "name": "theme_parent_theme_id_theme_id_fk", + "tableFrom": "theme", + "tableTo": "theme", + "columnsFrom": [ + "parent_theme_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "collector": { + "name": "collector", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "color": { + "name": "color", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'blue'" + }, + "birthday": { + "name": "birthday", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "notes": { + "name": "notes", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "sort_order": { + "name": "sort_order", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "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": { + "collector_sort_idx": { + "name": "collector_sort_idx", + "columns": [ + "sort_order", + "name" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "price_point": { + "name": "price_point", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "set_id": { + "name": "set_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "price_cents": { + "name": "price_cents", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "condition": { + "name": "condition", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'new'" + }, + "source": { + "name": "source", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "observed_at": { + "name": "observed_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "note": { + "name": "note", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "price_point_set_idx": { + "name": "price_point_set_idx", + "columns": [ + "set_id", + "observed_at" + ], + "isUnique": false + } + }, + "foreignKeys": { + "price_point_set_id_set_id_fk": { + "name": "price_point_set_id_set_id_fk", + "tableFrom": "price_point", + "tableTo": "set", + "columnsFrom": [ + "set_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": { + "price_point_positive": { + "name": "price_point_positive", + "value": "\"price_point\".\"price_cents\" >= 0" + } + } + }, + "collection_item": { + "name": "collection_item", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "set_id": { + "name": "set_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "collector_id": { + "name": "collector_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'owned'" + }, + "completeness": { + "name": "completeness", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'unknown'" + }, + "condition": { + "name": "condition", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "location_id": { + "name": "location_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "acquired_at": { + "name": "acquired_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "acquired_price_cents": { + "name": "acquired_price_cents", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "acquired_from": { + "name": "acquired_from", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "gifted_by": { + "name": "gifted_by", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "lent_out_to": { + "name": "lent_out_to", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "notes": { + "name": "notes", + "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": { + "collection_item_set_idx": { + "name": "collection_item_set_idx", + "columns": [ + "set_id" + ], + "isUnique": false + }, + "collection_item_collector_idx": { + "name": "collection_item_collector_idx", + "columns": [ + "collector_id" + ], + "isUnique": false + }, + "collection_item_status_idx": { + "name": "collection_item_status_idx", + "columns": [ + "status" + ], + "isUnique": false + }, + "collection_item_location_idx": { + "name": "collection_item_location_idx", + "columns": [ + "location_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "collection_item_set_id_set_id_fk": { + "name": "collection_item_set_id_set_id_fk", + "tableFrom": "collection_item", + "tableTo": "set", + "columnsFrom": [ + "set_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "collection_item_collector_id_collector_id_fk": { + "name": "collection_item_collector_id_collector_id_fk", + "tableFrom": "collection_item", + "tableTo": "collector", + "columnsFrom": [ + "collector_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "collection_item_location_id_location_id_fk": { + "name": "collection_item_location_id_location_id_fk", + "tableFrom": "collection_item", + "tableTo": "location", + "columnsFrom": [ + "location_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "location": { + "name": "location", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "parent_location_id": { + "name": "parent_location_id", + "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": { + "location_parent_location_id_location_id_fk": { + "name": "location_parent_location_id_location_id_fk", + "tableFrom": "location", + "tableTo": "location", + "columnsFrom": [ + "parent_location_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "missing_part": { + "name": "missing_part", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "collection_item_id": { + "name": "collection_item_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "part_number": { + "name": "part_number", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "color_name": { + "name": "color_name", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "quantity": { + "name": "quantity", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 1 + }, + "resolved": { + "name": "resolved", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "notes": { + "name": "notes", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "missing_part_item_idx": { + "name": "missing_part_item_idx", + "columns": [ + "collection_item_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "missing_part_collection_item_id_collection_item_id_fk": { + "name": "missing_part_collection_item_id_collection_item_id_fk", + "tableFrom": "missing_part", + "tableTo": "collection_item", + "columnsFrom": [ + "collection_item_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "photo": { + "name": "photo", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "set_id": { + "name": "set_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "collection_item_id": { + "name": "collection_item_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "file_path": { + "name": "file_path", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "thumbnail_path": { + "name": "thumbnail_path", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "caption": { + "name": "caption", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "sort_order": { + "name": "sort_order", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "photo_set_idx": { + "name": "photo_set_idx", + "columns": [ + "set_id" + ], + "isUnique": false + }, + "photo_item_idx": { + "name": "photo_item_idx", + "columns": [ + "collection_item_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "photo_set_id_set_id_fk": { + "name": "photo_set_id_set_id_fk", + "tableFrom": "photo", + "tableTo": "set", + "columnsFrom": [ + "set_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "photo_collection_item_id_collection_item_id_fk": { + "name": "photo_collection_item_id_collection_item_id_fk", + "tableFrom": "photo", + "tableTo": "collection_item", + "columnsFrom": [ + "collection_item_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": { + "photo_single_owner": { + "name": "photo_single_owner", + "value": "(\"photo\".\"set_id\" IS NOT NULL) <> (\"photo\".\"collection_item_id\" IS NOT NULL)" + } + } + }, + "wishlist": { + "name": "wishlist", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "owner_user_id": { + "name": "owner_user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "collector_id": { + "name": "collector_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "occasion": { + "name": "occasion", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'other'" + }, + "event_date": { + "name": "event_date", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "share_token": { + "name": "share_token", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "is_public": { + "name": "is_public", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "surprise_mode": { + "name": "surprise_mode", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "note": { + "name": "note", + "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": { + "wishlist_share_token_unique": { + "name": "wishlist_share_token_unique", + "columns": [ + "share_token" + ], + "isUnique": true + }, + "wishlist_owner_idx": { + "name": "wishlist_owner_idx", + "columns": [ + "owner_user_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "wishlist_owner_user_id_user_id_fk": { + "name": "wishlist_owner_user_id_user_id_fk", + "tableFrom": "wishlist", + "tableTo": "user", + "columnsFrom": [ + "owner_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "wishlist_collector_id_collector_id_fk": { + "name": "wishlist_collector_id_collector_id_fk", + "tableFrom": "wishlist", + "tableTo": "collector", + "columnsFrom": [ + "collector_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "wishlist_item": { + "name": "wishlist_item", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "wishlist_id": { + "name": "wishlist_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "set_id": { + "name": "set_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "priority": { + "name": "priority", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 2 + }, + "note": { + "name": "note", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "sort_order": { + "name": "sort_order", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "reserved_by_name": { + "name": "reserved_by_name", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "reserved_at": { + "name": "reserved_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "reservation_token_hash": { + "name": "reservation_token_hash", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "fulfilled_at": { + "name": "fulfilled_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "wishlist_item_unique": { + "name": "wishlist_item_unique", + "columns": [ + "wishlist_id", + "set_id" + ], + "isUnique": true + }, + "wishlist_item_list_idx": { + "name": "wishlist_item_list_idx", + "columns": [ + "wishlist_id", + "sort_order" + ], + "isUnique": false + } + }, + "foreignKeys": { + "wishlist_item_wishlist_id_wishlist_id_fk": { + "name": "wishlist_item_wishlist_id_wishlist_id_fk", + "tableFrom": "wishlist_item", + "tableTo": "wishlist", + "columnsFrom": [ + "wishlist_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "wishlist_item_set_id_set_id_fk": { + "name": "wishlist_item_set_id_set_id_fk", + "tableFrom": "wishlist_item", + "tableTo": "set", + "columnsFrom": [ + "set_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": { + "wishlist_item_priority": { + "name": "wishlist_item_priority", + "value": "\"wishlist_item\".\"priority\" BETWEEN 1 AND 3" + } + } + } + }, + "views": {}, + "enums": {}, + "_meta": { + "schemas": {}, + "tables": {}, + "columns": {} + }, + "internal": { + "indexes": {} + } +} \ No newline at end of file diff --git a/drizzle/meta/_journal.json b/drizzle/meta/_journal.json index 12651b7..00d2600 100644 --- a/drizzle/meta/_journal.json +++ b/drizzle/meta/_journal.json @@ -15,6 +15,13 @@ "when": 1788763704015, "tag": "0001_lumpy_bill_hollister", "breakpoints": true + }, + { + "idx": 2, + "version": "6", + "when": 1788765229376, + "tag": "0002_aspiring_whiplash", + "breakpoints": true } ] } \ No newline at end of file diff --git a/src/app/(app)/katalog/[id]/page.tsx b/src/app/(app)/katalog/[id]/page.tsx index 3215a56..8650504 100644 --- a/src/app/(app)/katalog/[id]/page.tsx +++ b/src/app/(app)/katalog/[id]/page.tsx @@ -4,6 +4,7 @@ import { notFound } from 'next/navigation'; import { Check, ExternalLink, Gift, ImageOff, Pencil } from 'lucide-react'; import { InstructionsSection } from '@/components/katalog/instructions-section'; import { PhotosSection } from '@/components/katalog/photos-section'; +import { PriceSection } from '@/components/katalog/price-section'; import { SetActions } from '@/components/katalog/set-actions'; import { CollectionItemsSection } from '@/components/sammlung/collection-items-section'; import { Badge } from '@/components/ui/badge'; @@ -17,6 +18,7 @@ import { listCollectionItemsForSet, listInstructionsForSet, listPhotosForSet, + listPricePoints, } from '@/lib/queries/catalog'; import { listMissingParts } from '@/lib/queries/collection'; import { listCollectors, resolveCollectorId } from '@/lib/queries/collectors'; @@ -169,6 +171,19 @@ export default async function SetDetailPage({ params, searchParams }: Props) { + + + Preis und Wertentwicklung + + + + + + Anleitungen diff --git a/src/app/(app)/katalog/price-actions.ts b/src/app/(app)/katalog/price-actions.ts new file mode 100644 index 0000000..8e73f76 --- /dev/null +++ b/src/app/(app)/katalog/price-actions.ts @@ -0,0 +1,61 @@ +'use server'; + +import { revalidatePath } from 'next/cache'; +import { eq } from 'drizzle-orm'; +import { z } from 'zod'; +import { db } from '@/db'; +import { pricePoint } from '@/db/schema'; +import { actionError, actionOk, parseForm, type ActionResult } from '@/lib/action-result'; +import { requireUser } from '@/lib/session'; +import { optionalDate, optionalText, optionalPriceCents } from '@/lib/validation'; + +const schema = z.object({ + priceCents: optionalPriceCents, + condition: z.enum(['new', 'used']).default('new'), + source: optionalText(80), + observedAt: optionalDate, + note: optionalText(200), +}); + +export async function addPricePoint(setId: string, input: unknown): Promise { + await requireUser(); + + const parsed = parseForm(schema, input); + if (!parsed.ok) return parsed; + if (parsed.data.priceCents === null) return actionError('Bitte gib einen Preis an.'); + + db.insert(pricePoint) + .values({ + id: crypto.randomUUID(), + setId, + priceCents: parsed.data.priceCents, + condition: parsed.data.condition, + source: parsed.data.source, + // No date given means "seen today". + observedAt: parsed.data.observedAt ?? new Date(), + note: parsed.data.note, + createdAt: new Date(), + }) + .run(); + + revalidatePath(`/katalog/${setId}`); + revalidatePath('/sammlung'); + return actionOk(); +} + +export async function deletePricePoint(id: string): Promise { + await requireUser(); + + const existing = db + .select({ setId: pricePoint.setId }) + .from(pricePoint) + .where(eq(pricePoint.id, id)) + .get(); + if (!existing) return actionOk(); + + db.delete(pricePoint).where(eq(pricePoint.id, id)).run(); + + revalidatePath(`/katalog/${existing.setId}`); + revalidatePath('/sammlung'); + return actionOk(); +} diff --git a/src/app/(app)/sammlung/page.tsx b/src/app/(app)/sammlung/page.tsx index e55f12c..d9d0bfd 100644 --- a/src/app/(app)/sammlung/page.tsx +++ b/src/app/(app)/sammlung/page.tsx @@ -56,7 +56,12 @@ export default async function SammlungPage({ searchParams }: { searchParams: Sea const tiles = [ { icon: Boxes, label: 'Sets', value: formatNumber(stats.setCount) }, { icon: Layers, label: 'Teile gesamt', value: formatNumber(stats.pieceCount) }, - { icon: Euro, label: 'Wert nach UVP', value: formatEuro(stats.retailValueCents) }, + { + icon: Euro, + // Notierte Preise gehen vor, sonst die UVP. + label: stats.currentValueCents === stats.retailValueCents ? 'Wert nach UVP' : 'Aktueller Wert', + value: formatEuro(stats.currentValueCents), + }, { icon: PackageSearch, label: 'Fehlt noch', value: formatNumber(missing) }, ] as const; diff --git a/src/components/katalog/price-section.tsx b/src/components/katalog/price-section.tsx new file mode 100644 index 0000000..2f40c85 --- /dev/null +++ b/src/components/katalog/price-section.tsx @@ -0,0 +1,206 @@ +'use client'; + +import { useRouter } from 'next/navigation'; +import { useState, useTransition } from 'react'; +import { Loader2, Plus, TrendingDown, TrendingUp, Trash2 } from 'lucide-react'; +import { toast } from 'sonner'; +import { addPricePoint, deletePricePoint } from '@/app/(app)/katalog/price-actions'; +import { formatDate, toDateInputValue } from '@/lib/format'; +import { formatEuro } from '@/lib/money'; +import { priceTrend, type PriceObservation } from '@/lib/prices'; +import type { PricePointRow } from '@/lib/queries/catalog'; +import { Badge } from '@/components/ui/badge'; +import { Button } from '@/components/ui/button'; +import { Field } from '@/components/ui/field'; +import { Input } from '@/components/ui/input'; +import { NativeSelect } from '@/components/ui/native-select'; + +const CONDITION_LABELS: Record = { + new: 'Neu', + used: 'Gebraucht', +}; + +/** + * Observed prices for one set. + * + * The point is not the single current price but the development: what a set + * did since it was bought. Everything here is maintained by hand - no price + * service is queried, and nothing leaves the server. + */ +export function PriceSection({ + setId, + prices, + retailPriceCents, +}: { + setId: string; + prices: PricePointRow[]; + retailPriceCents: number | null; +}) { + const router = useRouter(); + const [pending, startTransition] = useTransition(); + const [open, setOpen] = useState(false); + + const observations: PriceObservation[] = prices.map((price) => ({ + priceCents: price.priceCents, + condition: price.condition, + observedAt: price.observedAt, + })); + const trend = priceTrend(observations, retailPriceCents); + const gained = (trend.changeCents ?? 0) > 0; + + function submit(event: React.FormEvent) { + event.preventDefault(); + const form = event.currentTarget; + const input = Object.fromEntries(new FormData(form).entries()); + + startTransition(async () => { + const result = await addPricePoint(setId, input); + if (!result.ok) { + toast.error(result.error); + return; + } + form.reset(); + setOpen(false); + toast.success('Preis notiert.'); + router.refresh(); + }); + } + + function remove(id: string) { + startTransition(async () => { + const result = await deletePricePoint(id); + if (!result.ok) { + toast.error(result.error); + return; + } + toast.success('Eintrag gelöscht.'); + router.refresh(); + }); + } + + return ( +
+
+
+
Ursprünglich
+
{formatEuro(trend.baseCents)}
+
+
+
Aktuell
+
{formatEuro(trend.currentCents)}
+
+
+
Entwicklung
+
+ {trend.changePercent === null ? ( + – + ) : ( + + {gained ? ( + + ) : ( + + )} + {trend.changePercent > 0 ? '+' : ''} + {trend.changePercent.toLocaleString('de-DE')} % + + )} +
+
+
+ + {prices.length > 0 ? ( +
    + {prices.map((price) => ( +
  • +
    +

    + {formatEuro(price.priceCents)}{' '} + {CONDITION_LABELS[price.condition]} +

    +

    + {[formatDate(price.observedAt), price.source, price.note] + .filter(Boolean) + .join(' · ')} +

    +
    + +
  • + ))} +
+ ) : ( +

+ Noch kein Preis notiert. Der Ausgangswert ist die UVP des Sets. +

+ )} + + {open ? ( +
+

Preis notieren

+ +
+ + + + + + + + + + + + +
+ +
+ + + + + + +
+ +
+ + +
+
+ ) : ( + + )} +
+ ); +} diff --git a/src/db/schema/index.ts b/src/db/schema/index.ts index 50acd42..554833b 100644 --- a/src/db/schema/index.ts +++ b/src/db/schema/index.ts @@ -1,6 +1,7 @@ export * from './auth'; export * from './catalog'; export * from './collector'; +export * from './prices'; export * from './collection'; export * from './wishlist'; export * from './relations'; diff --git a/src/db/schema/prices.ts b/src/db/schema/prices.ts new file mode 100644 index 0000000..ae3e224 --- /dev/null +++ b/src/db/schema/prices.ts @@ -0,0 +1,35 @@ +import { check, index, integer, sqliteTable, text } from 'drizzle-orm/sqlite-core'; +import { sql } from 'drizzle-orm'; +import { set } from './catalog'; + +/** + * An observed price for a set at a point in time. + * + * Kept as a series rather than a single field, because the interesting + * question is not "what does it cost" but "what has it done since we bought + * it". Entries are maintained by hand - the UVP from the set itself is the + * starting point, everything after that is what you actually saw somewhere. + */ +export const pricePoint = sqliteTable( + 'price_point', + { + id: text('id').primaryKey(), + setId: text('set_id') + .notNull() + .references(() => set.id, { onDelete: 'cascade' }), + /** EUR in cents, like every other amount in this app. */ + priceCents: integer('price_cents').notNull(), + condition: text('condition', { enum: ['new', 'used'] }) + .notNull() + .default('new'), + /** Where the price was seen, e.g. "Amazon", "Flohmarkt", "BrickLink". */ + source: text('source'), + observedAt: integer('observed_at', { mode: 'timestamp' }).notNull(), + note: text('note'), + createdAt: integer('created_at', { mode: 'timestamp' }).notNull(), + }, + (t) => [ + index('price_point_set_idx').on(t.setId, t.observedAt), + check('price_point_positive', sql`${t.priceCents} >= 0`), + ], +); diff --git a/src/db/schema/relations.ts b/src/db/schema/relations.ts index d8a930e..68e8d5b 100644 --- a/src/db/schema/relations.ts +++ b/src/db/schema/relations.ts @@ -3,6 +3,7 @@ import { passkey, session, user } from './auth'; import { instruction, manufacturer, set, setTag, tag, theme } from './catalog'; import { collectionItem, location, missingPart, photo } from './collection'; import { collector } from './collector'; +import { pricePoint } from './prices'; import { wishlist, wishlistItem } from './wishlist'; export const userRelations = relations(user, ({ many }) => ({ @@ -49,6 +50,11 @@ export const setRelations = relations(set, ({ one, many }) => ({ photos: many(photo), setTags: many(setTag), wishlistItems: many(wishlistItem), + pricePoints: many(pricePoint), +})); + +export const pricePointRelations = relations(pricePoint, ({ one }) => ({ + set: one(set, { fields: [pricePoint.setId], references: [set.id] }), })); export const tagRelations = relations(tag, ({ many }) => ({ diff --git a/src/lib/prices.ts b/src/lib/prices.ts new file mode 100644 index 0000000..b75e953 --- /dev/null +++ b/src/lib/prices.ts @@ -0,0 +1,77 @@ +/** + * Value development of a set. + * + * Pure functions: the interesting logic is "which number counts as the current + * value" and "what has changed since", and that should be testable without a + * database. + */ + +export type PriceObservation = { + priceCents: number; + condition: 'new' | 'used'; + observedAt: Date; +}; + +/** + * The current value: the most recent observation. Where two land on the same + * day, the one for a new set wins - that is the number you would pay to + * replace it. + */ +export function currentPrice( + observations: readonly PriceObservation[], + retailPriceCents?: number | null, +): number | null { + if (observations.length === 0) return retailPriceCents ?? null; + + const sorted = [...observations].sort((a, b) => { + const byDate = b.observedAt.getTime() - a.observedAt.getTime(); + if (byDate !== 0) return byDate; + return a.condition === 'new' ? -1 : 1; + }); + + return sorted[0]!.priceCents; +} + +export type PriceTrend = { + /** What it cost originally - the UVP, or the oldest observation. */ + baseCents: number | null; + currentCents: number | null; + changeCents: number | null; + /** Rounded to one decimal, positive means it gained value. */ + changePercent: number | null; +}; + +export function priceTrend( + observations: readonly PriceObservation[], + retailPriceCents?: number | null, +): PriceTrend { + const current = currentPrice(observations, retailPriceCents); + + const oldest = [...observations].sort( + (a, b) => a.observedAt.getTime() - b.observedAt.getTime(), + )[0]; + // The recommended retail price is the honest baseline; only without one do + // we fall back to the earliest thing we saw. + const base = retailPriceCents ?? oldest?.priceCents ?? null; + + if (base === null || current === null || base === 0) { + return { baseCents: base, currentCents: current, changeCents: null, changePercent: null }; + } + + const changeCents = current - base; + return { + baseCents: base, + currentCents: current, + changeCents, + changePercent: Math.round((changeCents / base) * 1000) / 10, + }; +} + +/** Sum of the current value of many sets, ignoring the ones without a price. */ +export function totalValueCents( + entries: readonly { observations: readonly PriceObservation[]; retailPriceCents?: number | null }[], +): number { + return entries.reduce((total, entry) => { + return total + (currentPrice(entry.observations, entry.retailPriceCents) ?? 0); + }, 0); +} diff --git a/src/lib/queries/catalog.ts b/src/lib/queries/catalog.ts index 1348a97..23b618d 100644 --- a/src/lib/queries/catalog.ts +++ b/src/lib/queries/catalog.ts @@ -1,7 +1,15 @@ import 'server-only'; import { and, asc, desc, eq, like, not, or, sql, type SQL } from 'drizzle-orm'; import { db } from '@/db'; -import { collectionItem, instruction, manufacturer, photo, set, theme } from '@/db/schema'; +import { + collectionItem, + instruction, + manufacturer, + photo, + pricePoint, + set, + theme, +} from '@/db/schema'; import { ownedCopiesSql, wishlistCountSql, type OwnershipFilter } from '@/lib/ownership'; export const CATALOG_PAGE_SIZE = 48; @@ -227,3 +235,28 @@ export function listSetYears(): number[] { .all() .flatMap((row) => (row.year === null ? [] : [row.year])); } + +export type PricePointRow = { + id: string; + priceCents: number; + condition: 'new' | 'used'; + source: string | null; + observedAt: Date; + note: string | null; +}; + +export function listPricePoints(setId: string): PricePointRow[] { + return db + .select({ + id: pricePoint.id, + priceCents: pricePoint.priceCents, + condition: pricePoint.condition, + source: pricePoint.source, + observedAt: pricePoint.observedAt, + note: pricePoint.note, + }) + .from(pricePoint) + .where(eq(pricePoint.setId, setId)) + .orderBy(desc(pricePoint.observedAt)) + .all(); +} diff --git a/src/lib/queries/collection.ts b/src/lib/queries/collection.ts index e47fea6..f68c96e 100644 --- a/src/lib/queries/collection.ts +++ b/src/lib/queries/collection.ts @@ -113,6 +113,8 @@ export type CollectionStats = { itemCount: number; pieceCount: number; retailValueCents: number; + /** Latest observed price where there is one, UVP otherwise. */ + currentValueCents: number; paidCents: number; byManufacturer: Array<{ name: string; count: number }>; }; @@ -155,11 +157,28 @@ export function getCollectionStats(collectorId?: string): CollectionStats { .orderBy(asc(manufacturer.name)) .all(); + // Current value per copy: the newest observed price, falling back to the + // recommended retail price of the set. + const [current] = db + .select({ + total: sql`coalesce(sum(coalesce(( + select p.price_cents from price_point p + where p.set_id = ${set.id} + order by p.observed_at desc, p.created_at desc + limit 1 + ), ${set.retailPriceCents}, 0)), 0)`.mapWith(Number), + }) + .from(collectionItem) + .innerJoin(set, eq(set.id, collectionItem.setId)) + .where(active) + .all(); + return { setCount: totals?.setCount ?? 0, itemCount: totals?.itemCount ?? 0, pieceCount: totals?.pieceCount ?? 0, retailValueCents: totals?.retailValueCents ?? 0, + currentValueCents: current?.total ?? 0, paidCents: totals?.paidCents ?? 0, byManufacturer, }; diff --git a/tests/unit/prices.test.ts b/tests/unit/prices.test.ts new file mode 100644 index 0000000..13b0cbe --- /dev/null +++ b/tests/unit/prices.test.ts @@ -0,0 +1,127 @@ +import { describe, expect, it } from 'vitest'; +import { currentPrice, priceTrend, totalValueCents } from '@/lib/prices'; + +const on = (iso: string) => new Date(`${iso}T12:00:00.000Z`); + +describe('Aktueller Wert', () => { + it('faellt ohne Beobachtung auf die UVP zurueck', () => { + expect(currentPrice([], 4999)).toBe(4999); + }); + + it('ist null, wenn es weder Beobachtung noch UVP gibt', () => { + expect(currentPrice([], null)).toBeNull(); + expect(currentPrice([])).toBeNull(); + }); + + it('nimmt die neueste Beobachtung, nicht die hoechste', () => { + const value = currentPrice( + [ + { priceCents: 9900, condition: 'new', observedAt: on('2026-01-01') }, + { priceCents: 6900, condition: 'new', observedAt: on('2026-06-01') }, + ], + 4999, + ); + + expect(value).toBe(6900); + }); + + it('bevorzugt am selben Tag den Neupreis', () => { + const value = currentPrice([ + { priceCents: 4000, condition: 'used', observedAt: on('2026-06-01') }, + { priceCents: 8000, condition: 'new', observedAt: on('2026-06-01') }, + ]); + + expect(value).toBe(8000); + }); + + it('schlaegt die UVP, sobald es eine Beobachtung gibt', () => { + const value = currentPrice( + [{ priceCents: 12000, condition: 'new', observedAt: on('2026-06-01') }], + 4999, + ); + + expect(value).toBe(12000); + }); +}); + +describe('Wertentwicklung', () => { + it('misst gegen die UVP', () => { + const trend = priceTrend( + [{ priceCents: 9998, condition: 'new', observedAt: on('2026-06-01') }], + 4999, + ); + + expect(trend.baseCents).toBe(4999); + expect(trend.currentCents).toBe(9998); + expect(trend.changeCents).toBe(4999); + expect(trend.changePercent).toBe(100); + }); + + it('zeigt auch Verluste', () => { + const trend = priceTrend( + [{ priceCents: 2500, condition: 'used', observedAt: on('2026-06-01') }], + 5000, + ); + + expect(trend.changeCents).toBe(-2500); + expect(trend.changePercent).toBe(-50); + }); + + it('nimmt ohne UVP die aelteste Beobachtung als Basis', () => { + const trend = priceTrend([ + { priceCents: 1000, condition: 'new', observedAt: on('2026-01-01') }, + { priceCents: 1500, condition: 'new', observedAt: on('2026-06-01') }, + ]); + + expect(trend.baseCents).toBe(1000); + expect(trend.changePercent).toBe(50); + }); + + it('rechnet nicht, wenn es nichts zu vergleichen gibt', () => { + const trend = priceTrend([], null); + + expect(trend.changeCents).toBeNull(); + expect(trend.changePercent).toBeNull(); + }); + + it('teilt nicht durch null', () => { + const trend = priceTrend( + [{ priceCents: 1000, condition: 'new', observedAt: on('2026-06-01') }], + 0, + ); + + expect(trend.changePercent).toBeNull(); + }); + + it('rundet den Prozentwert auf eine Nachkommastelle', () => { + const trend = priceTrend( + [{ priceCents: 3333, condition: 'new', observedAt: on('2026-06-01') }], + 10000, + ); + + expect(trend.changePercent).toBe(-66.7); + }); +}); + +describe('Gesamtwert', () => { + it('summiert die aktuellen Werte', () => { + const total = totalValueCents([ + { + observations: [{ priceCents: 6900, condition: 'new', observedAt: on('2026-06-01') }], + retailPriceCents: 4999, + }, + { observations: [], retailPriceCents: 2999 }, + ]); + + expect(total).toBe(9899); + }); + + it('ignoriert Sets ohne jeden Preis', () => { + const total = totalValueCents([ + { observations: [], retailPriceCents: null }, + { observations: [], retailPriceCents: 1000 }, + ]); + + expect(total).toBe(1000); + }); +});