Merge: Phase 3 - Nice to have (v0.3.0)

Mehrere Sammler mit gemeinsamem Katalog und getrennter Sammlung,
Preisbeobachtung mit Wertentwicklung, E-Mail-Erinnerungen vor
Wunschlisten-Terminen und die Dublettenpruefung beim Anlegen von Sets.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
unknown
2026-09-07 14:50:43 +02:00
co-authored by Claude Opus 5
90 changed files with 9367 additions and 103 deletions
+13
View File
@@ -32,3 +32,16 @@ TZ=Europe/Berlin
# und Bild werden uebernommen. Ohne Key funktioniert alles manuell weiter.
# Kostenlos anlegen: https://rebrickable.com/api/ (Konto -> Settings -> API)
# REBRICKABLE_API_KEY=
# Optional: Mailversand fuer Erinnerungen an Wunschlisten-Termine. Ohne
# SMTP_HOST und SMTP_FROM sind Erinnerungen komplett aus - alles andere
# funktioniert unveraendert.
# SMTP_HOST=mail.example.de
# SMTP_PORT=587
# SMTP_SECURE=false
# SMTP_USER=bricktrack@example.de
# SMTP_PASSWORD=
# SMTP_FROM=Bricktrack <bricktrack@example.de>
# Wie viele Tage vor dem Termin erinnert wird (Standard: 14).
# REMINDER_DAYS_BEFORE=14
+49 -1
View File
@@ -7,6 +7,53 @@ die Versionierung [Semantic Versioning](https://semver.org/lang/de/).
## [Unreleased]
## [0.3.0] - 2026-09-07
Phase 3: mehrere Kinder, Wertentwicklung, Erinnerungen und ein Schutz vor
doppelten Katalogeintraegen.
### Hinzugefuegt
**Mehrere Sammler**
- Der Katalog bleibt gemeinsam, Sammlung und Wunschlisten gehoeren je einem
Sammler. "Hat er das schon?" beantwortet damit die Frage fuer *ihn*: ein Set,
das nur die Schwester besitzt, faellt fuer den Bruder unter "fehlt noch".
- Umschalter in Katalog und Sammlung, Farbmarkierung je Sammler, Zuordnung im
Sammlungseintrag und je Wunschliste.
- Solange es nur einen Sammler gibt, ist davon nichts zu sehen - die App
bleibt genauso einfach wie vorher.
**Preisbeobachtung**
- Notierte Preise je Set mit Betrag, Zustand, Datum, Quelle und Notiz.
- Wertentwicklung gegen die UVP, in der Sammlung als Kachel "Aktueller Wert".
**Erinnerungen**
- E-Mail an den Besitzer, wenn der Termin einer Wunschliste naeher rueckt und
noch Wuensche offen sind - standardmaessig 14 Tage vorher, hoechstens einmal
pro Woche je Liste.
- Verwaltungsseite mit Verbindungstest, Testnachricht und sofortiger Pruefung.
- Ohne SMTP-Konfiguration bleibt die Funktion vollstaendig aus.
**Dublettenpruefung**
- Vor dem Anlegen eines Sets warnt die App vor gleicher Setnummer beim
gleichen Hersteller, sehr aehnlichem Namen und gleicher Nummer bei einem
anderen Hersteller - mit Link zum Verdaechtigen und der Moeglichkeit,
trotzdem anzulegen.
### Geaendert
- Die Migration legt einen Sammler "Sammlung" an und ordnet ihm alle
vorhandenen Sammlungseintraege und Wunschlisten zu. Umbenennen unter
Verwaltung -> Sammler.
- Der Smoke-Test laeuft gegen das Docker-Image statt gegen den Dev-Server.
### Nicht umgesetzt
- Der BrickLink-Preisfuehrer war im Plan als optional vermerkt und wurde
bewusst weggelassen: OAuth1 mit einer pro Nutzer registrierten Anwendung ist
viel Apparat fuer eine Zahl, die sich in zehn Sekunden eintippen laesst. Das
Feld "Wo" nimmt jede Quelle auf, die Datenstruktur bleibt offen.
## [0.2.0] - 2026-09-07
Phase 2: die Handgriffe, die den Alltag schneller machen.
@@ -147,6 +194,7 @@ Erste lauffaehige Fassung: Phase 1 vollstaendig, im Docker-Stack verifiziert.
- Unter Windows scheitert `pnpm build` an fehlenden Symlink-Rechten. Der
Docker-Build unter Linux ist davon nicht betroffen.
[Unreleased]: https://git.wilde-server.de/florian.wilde/bricktrack/compare/v0.2.0...HEAD
[Unreleased]: https://git.wilde-server.de/florian.wilde/bricktrack/compare/v0.3.0...HEAD
[0.3.0]: https://git.wilde-server.de/florian.wilde/bricktrack/releases/tag/v0.3.0
[0.2.0]: https://git.wilde-server.de/florian.wilde/bricktrack/releases/tag/v0.2.0
[0.1.0]: https://git.wilde-server.de/florian.wilde/bricktrack/releases/tag/v0.1.0
+13 -1
View File
@@ -57,6 +57,10 @@ Bild-URLs, die der Nutzer selbst hinterlegt hat.
| `src/lib/rebrickable.ts` | Lego-Import, ausschliesslich serverseitig |
| `src/lib/set-number.ts`, `src/lib/ean.ts` | Setnummern und Barcodes (rein, testbar) |
| `src/lib/uploads.ts` | Datei-Uploads, Magic Bytes, Thumbnails |
| `src/lib/similarity.ts` | Namensvergleich fuer die Dublettenpruefung (rein) |
| `src/lib/prices.ts` | Wertentwicklung aus notierten Preisen (rein) |
| `src/lib/reminders.ts` | Regeln, wann eine Erinnerung faellig ist (rein) |
| `src/lib/reminder-job.ts`, `src/lib/mailer.ts` | Versand und Zeitplan |
| `src/lib/export.ts` | Export und Import des gesamten Bestands |
| `src/lib/money.ts`, `src/lib/format.ts` | Cent-Umrechnung, dd.MM.yyyy |
@@ -114,8 +118,16 @@ Diese Bedingung steht an genau einer Stelle: `src/lib/ownership.ts`.
### Besitz
Mehrere **Sammler** teilen sich den Katalog, nicht den Besitz: `collector`
haelt Name und Farbe, `collection_item.collector_id` und
`wishlist.collector_id` ordnen zu. Solange nur ein Sammler existiert, blendet
die Oberflaeche das Thema vollstaendig aus - Umschalter, Abfragen und Badges
erscheinen erst ab dem zweiten.
| Tabelle | Zweck | Wichtige Felder |
|---|---|---|
| `collector` | ein Kind | `name`, `color`, `birthday`, `sort_order` |
| `price_point` | beobachteter Preis je Set | `price_cents`, `condition`, `observed_at`, `source` |
| `collection_item` | ein besessenes Exemplar | `status` = `owned` / `built` / `disassembled` / `lent_out` / `sold`, `completeness`, `condition`, `location_id`, `acquired_*`, `gifted_by` |
| `location` | Aufbewahrungsort, hierarchisch | `parent_location_id` |
| `missing_part` | Fehlteile je Exemplar | `part_number`, `color_name`, `quantity`, `resolved` |
@@ -190,7 +202,7 @@ Session.
- **Phase 2 (Komfort)** - Rebrickable-Import, Barcode-Scanner, Fehlteile,
Einladungen, Export/Import, PWA. *Fertig in 0.2.0.*
- **Phase 3 (Nice to have)** - mehrere Sammler, Preisbeobachtung,
E-Mail-Erinnerungen, Dublettenpruefung.
E-Mail-Erinnerungen, Dublettenpruefung. *Fertig in 0.3.0.*
Details und Stand: `docs/ROADMAP.md`. Zeitaufwaende: `docs/DEVLOG.md`.
+10 -1
View File
@@ -29,6 +29,9 @@ du per Link mit Familie und Freunden teilst. Die reservieren dort ihr Geschenk
| **Fotos** | Eigene Bilder je Set – direkt aus der Handykamera |
| **Wunschlisten** | Mehrfachauswahl aus den fehlenden Sets, Priorität, Reihenfolge, Termin |
| **Teilen** | Öffentlicher Link ohne Anmeldung, Reservierung mit Namen, Storno-Link, Überraschungsmodus |
| **Mehrere Kinder** | Gemeinsamer Katalog, getrennte Sammlungen und Wunschlisten – sichtbar erst ab dem zweiten Sammler |
| **Preise** | Notierte Preise je Set, Wertentwicklung gegen die UVP |
| **Erinnerungen** | E-Mail, wenn ein Termin näher rückt und noch Wünsche offen sind (SMTP optional) |
| **Anmeldung** | E-Mail und Passwort oder Passkey (WebAuthn), mehrere Geräte je Konto |
Mobil zuerst gebaut: Die App wird im Laden und im Kinderzimmer benutzt, nicht
@@ -81,7 +84,9 @@ Alle Werte kommen aus `.env` (Vorlage: `.env.example`).
| `TZ` | nein | `Europe/Berlin` | Zeitzone des Containers. |
| `BIND_ADDRESS` | nein | `127.0.0.1` | Auf welcher Host-Adresse der Container lauscht. `0.0.0.0`, wenn der Proxy auf einem anderen Host läuft. |
| `HOST_PORT` | nein | `3000` | Port auf dem Host, falls 3000 belegt ist. |
| `REBRICKABLE_API_KEY` | nein | – | Ab Version 0.2.0: Lego-Import. Ohne Key läuft alles manuell. |
| `REBRICKABLE_API_KEY` | nein | – | Lego-Import. Ohne Key läuft alles manuell. |
| `SMTP_HOST`, `SMTP_PORT`, `SMTP_USER`, `SMTP_PASSWORD`, `SMTP_FROM` | nein | – | Mailversand für Wunschlisten-Erinnerungen. Ohne `SMTP_HOST` und `SMTP_FROM` ist die Funktion aus. |
| `REMINDER_DAYS_BEFORE` | nein | `14` | Wie viele Tage vor dem Termin erinnert wird. |
### Reverse Proxy
@@ -208,6 +213,10 @@ Ein verkauftes Exemplar zählt also nicht mehr als Besitz – das Set taucht
wieder unter „fehlt noch" auf und darf zurück auf eine Wunschliste. Diese
Regel steht an genau einer Stelle im Code: `src/lib/ownership.ts`.
Gibt es mehrere **Sammler**, bekommt die Frage ein „für wen": Der Katalog ist
gemeinsam, der Besitz nicht. Ein Set, das nur das Geschwisterkind hat, fehlt
dem anderen weiterhin.
Das vollständige Modell inklusive aller Tabellen: [CLAUDE.md](CLAUDE.md#datenmodell).
## Sicherheit
+116 -3
View File
@@ -27,7 +27,11 @@ Kinderzimmer, nicht für den Schreibtisch.
13. [Noch ein Konto für die Familie](#13-einladungen)
14. [Daten sichern, umziehen, auswerten](#14-daten)
15. [Auf den Startbildschirm legen](#15-startbildschirm)
16. [Antworten auf die zwei wichtigen Fragen](#16-die-zwei-fragen)
16. [Zwei Kinder, ein Katalog](#16-mehrere-sammler)
17. [Was ein Set heute wert ist](#17-preise)
18. [Erinnerung vor dem Termin](#18-erinnerungen)
19. [Schutz vor doppelten Einträgen](#19-dubletten)
20. [Antworten auf die zwei wichtigen Fragen](#20-die-zwei-fragen)
---
@@ -419,7 +423,115 @@ den Zwischenspeicher, damit auf einem geteilten Gerät nichts lesbar bleibt.
---
## 16. Die zwei Fragen
## 16. Mehrere Sammler
![Sammler verwalten](bilder/21-sammler.png)
Kommt ein zweites Kind dazu, wird aus „hat er das schon?" die Frage „hat
**er** das schon?". Genau dafür gibt es Sammler.
Der **Katalog bleibt gemeinsam**: Ein Set existiert einmal, unabhängig davon,
wer es besitzt. **Sammlung und Wunschlisten** gehören dagegen je einem
Sammler. Ein Set, das nur die Schwester hat, fällt für den Bruder wieder unter
„fehlt noch" – und darf auf seine Wunschliste.
> **Solange es nur einen Sammler gibt, siehst du davon nichts.** Kein
> Umschalter, keine Abfrage, keine Farben. Die App bleibt genauso einfach wie
> vorher. Erst der zweite Sammler schaltet all das frei – und dann willst du
> es auch.
Anlegen unter **Verwaltung → Sammler**. Jeder bekommt einen Namen und eine
Farbe zur Unterscheidung.
![Nach Sammler filtern](bilder/22-sammler-filter.png)
Danach steht in Katalog und Sammlung oben ein Umschalter. „Alle zusammen"
zeigt den Haushalt als Ganzes, ein Name zeigt genau dessen Sicht – inklusive
der Frage, was **diesem** Kind noch fehlt.
Beim Hinzufügen zur Sammlung fragt die App jetzt, wer das Set bekommt. Und
beim Anlegen einer Wunschliste, für wen sie ist: Das bestimmt, wessen Sammlung
als „hat er schon" zählt und wann ein Wunsch automatisch als erfüllt gilt.
> Die Umstellung einer bestehenden Instanz macht nichts kaputt: Beim Update
> entsteht ein Sammler namens „Sammlung", dem alles Vorhandene gehört.
> Umbenennen unter **Verwaltung → Sammler**, fertig.
---
## 17. Preise
![Preis und Wertentwicklung](bilder/23-preise.png)
Auf jeder Set-Seite gibt es **„Preis notieren"**: Betrag, Zustand (neu oder
gebraucht), wann und wo gesehen. Daraus wird eine Reihe – und damit eine
Antwort auf die eigentlich interessante Frage: Was hat das Set seit dem Kauf
gemacht?
- **Ursprünglich** ist die UVP des Sets, sonst der älteste notierte Preis.
- **Aktuell** ist der neueste notierte Preis.
- **Entwicklung** ist die Differenz in Prozent.
In der Sammlung wird die Kachel „Wert nach UVP" automatisch zu **„Aktueller
Wert"**, sobald irgendwo ein Preis notiert ist.
> Es wird **kein** Preisdienst abgefragt. Was hier steht, hast du selbst
> eingetragen – das ist ehrlicher als eine Zahl, deren Herkunft niemand kennt,
> und es geht nichts nach draußen.
---
## 18. Erinnerungen
![Erinnerungen](bilder/24-erinnerungen.png)
Wenn ein Wunschlisten-Termin näher rückt und **noch Wünsche offen sind**,
schickt Bricktrack dir eine E-Mail – standardmäßig 14 Tage vorher, höchstens
einmal pro Woche je Liste. Ist alles reserviert, bleibt es still: eine
Erinnerung ohne Handlungsbedarf ist nur Lärm.
Dafür braucht es einen SMTP-Zugang in der `.env`:
```bash
SMTP_HOST=mail.example.de
SMTP_PORT=587
SMTP_USER=bricktrack@example.de
SMTP_PASSWORD=…
SMTP_FROM=Bricktrack <bricktrack@example.de>
# Optional, Standard ist 14:
REMINDER_DAYS_BEFORE=14
```
Unter **Verwaltung → Erinnerungen** kannst du die Verbindung prüfen, dir eine
Testnachricht schicken und die Prüfung sofort auslösen, statt auf die nächste
Stunde zu warten. Dort steht auch, welche Listen einen Termin haben und wie
viel dort noch offen ist.
Ohne SMTP-Zugang ist die Funktion vollständig aus – alles andere funktioniert
unverändert.
---
## 19. Dubletten
Beim Anlegen eines neuen Sets schaut Bricktrack nach, ob es das schon gibt:
- **gleiche Setnummer beim gleichen Hersteller** – dann ist es dasselbe Set,
- **sehr ähnlicher Name** – meist ein Tippfehler oder eine andere Schreibweise,
- **gleiche Nummer bei einem anderen Hersteller** – meist der falsche
Hersteller im Formular.
Passt etwas, erscheint ein Hinweis mit Link zu den Verdächtigen. Von dort
kommst du direkt zum vorhandenen Eintrag – oder legst mit „Trotzdem anlegen"
weiter an, wenn es wirklich ein anderes Set ist.
Die Erkennung ist bewusst zurückhaltend eingestellt: Tippfehler schlagen an,
zwei echte Sets desselben Themas nicht.
---
## 20. Die zwei Fragen
Dafür ist die ganze App gebaut:
@@ -428,7 +540,8 @@ Dafür ist die ganze App gebaut:
**„Welche Sets hat er noch nicht?"**
→ Katalog, Filter „Fehlt noch". Von dort in zwei Handgriffen auf eine
Wunschliste und per Link zu Oma.
Wunschliste und per Link zu Oma. Bei mehreren Kindern beantwortet der
Umschalter oben die Frage für jedes einzeln.
Im Laden vor dem Regal: Scanner öffnen, Strichcode einlesen. Steht ein grüner
Haken dran, habt ihr es schon.
+34
View File
@@ -45,6 +45,20 @@ Commit-Spalte verweist auf den Stand am Ende des Schritts.
**Summe Phase 2: 0:55**
## Phase 3 – Nice to have (v0.3.0)
### 07.09.2026
| Von | Bis | Dauer | Schritt | Ergebnis | Commit |
|---|---|---|---|---|---|
| 08:35 | 09:00 | 0:25 | Mehrere Sammler | Neue Tabelle, Migration mit Nachziehen der Bestandsdaten, Besitzlogik um eine Dimension erweitert, Umschalter in Katalog und Sammlung, Zuordnung in Sammlungseintrag und Wunschliste | `c23b754` |
| 09:00 | 09:13 | 0:13 | Dublettenprüfung | Ähnlichkeitsvergleich mit konservativer Schwelle, Warndialog mit Links, E2E-Test | `87131b8` |
| 09:13 | 09:17 | 0:04 | Preisbeobachtung | Preisreihe je Set, Wertentwicklung gegen die UVP, Kachel „Aktueller Wert" | `91fb66b` |
| 09:17 | 09:22 | 0:05 | Erinnerungen | SMTP optional, stündliche Prüfung, Sperrfrist je Liste, Verwaltungsseite mit Testfunktionen | `0d08df0` |
| 09:22 | 09:50 | 0:28 | Verifikation und Dokumentation | Docker-Build, Smoke-Test, Screenshots, Changelog 0.3.0, Anleitung | folgt |
**Summe Phase 3: 1:15**
---
## Notizen und Entscheidungen
@@ -120,6 +134,21 @@ Commit-Spalte verweist auf den Stand am Ende des Schritts.
Konfliktregeln für jede Tabelle und wäre im Ergebnis nicht vorhersagbar.
Der Import ersetzt deshalb – dafür mit getipptem `ERSETZEN` als Bremse.
### Phase 3
- **Migration mit Bestandsdaten**: Die generierte SQL-Datei wurde von Hand um
drei Anweisungen ergänzt, die einen Sammler anlegen und alles Vorhandene
zuordnen. Ohne das wären nach dem Update alle Sammlungseinträge ohne
Besitzer gewesen.
- **Instrumentation und native Module**: Der Zeitplan für Erinnerungen liess
sich nicht aus Next' `instrumentation.ts` starten – die Datei wird auch für
die Edge-Runtime übersetzt, und das native SQLite-Modul bricht dort den
Build. Der Zeitplan startet jetzt aus dem Healthcheck heraus, den der
Container ohnehin alle 30 Sekunden aufruft.
- **BrickLink bewusst weggelassen**: OAuth1 mit einer pro Nutzer registrierten
Anwendung ist viel Apparat für eine Zahl, die man in zehn Sekunden eintippt.
Im Plan war der Punkt als optional vermerkt.
### Beim Testen gefunden und behoben
- Eine spontan aus dem Katalog angelegte Wunschliste war **nicht teilbar**:
@@ -130,3 +159,8 @@ Commit-Spalte verweist auf den Stand am Ende des Schritts.
`required` des Eingabefeldes.
- In der Mehrfachauswahl verdeckte die Aktionsleiste die letzte Kachelreihe;
das Raster bekommt im Auswahlmodus zusätzliches Polster nach unten.
- Die Sperrfrist der Erinnerungen rechnete mit umgedrehtem Vorzeichen und
hätte jede Erinnerung dauerhaft unterdrückt. Der Test dafür war zuerst da.
- Der Screenshot-Lauf wählte plötzlich die falschen Kacheln aus: Der neue
Sammler-Umschalter benutzt ebenfalls `aria-pressed`. Die Auswahlkacheln
tragen jetzt ein eigenes `data-set-card`.
+19 -6
View File
@@ -46,12 +46,25 @@ Laeuft vollstaendig im Docker-Stack.
Sammlung zusaetzlich als CSV
- [x] PWA: Manifest, Service Worker, Katalog offline lesbar
## Phase 3 - Nice to have (Ziel: v0.3.0)
## Phase 3 - Nice to have (v0.3.0, abgeschlossen am 07.09.2026)
- [ ] Mehrere Sammler mit eigener Sammlung und eigenen Wunschlisten,
- [x] Mehrere Sammler mit eigener Sammlung und eigenen Wunschlisten,
gemeinsamer Katalog
- [ ] Preisbeobachtung und Wertentwicklung (manuell gepflegt,
optional BrickLink-Preisfuehrer)
- [ ] E-Mail-Erinnerung (SMTP), wenn ein Wunschlisten-Termin naeher
- [x] Preisbeobachtung und Wertentwicklung (manuell gepflegt).
Der BrickLink-Preisfuehrer war als optional vermerkt und wurde
bewusst weggelassen: er verlangt OAuth1 mit einer pro Nutzer
registrierten Anwendung - viel Apparat fuer eine Zahl, die sich in
zehn Sekunden eintippen laesst. Die Datenstruktur ist offen dafuer.
- [x] E-Mail-Erinnerung (SMTP), wenn ein Wunschlisten-Termin naeher
rueckt und Eintraege unreserviert sind
- [ ] Dublettenpruefung beim Anlegen von Sets
- [x] Dublettenpruefung beim Anlegen von Sets
## Danach
Phase 1 bis 3 sind umgesetzt. Was noch offen ist, steht hier - ohne Termin
und ohne Anspruch auf Vollstaendigkeit:
- [ ] BrickLink-Preisfuehrer anbinden, falls der Aufwand sich doch lohnt
- [ ] Mehrere Fotos je Sammlungseintrag (bisher nur je Set)
- [ ] Anleitungen im PDF-Viewer durchsuchen
- [ ] Statistik ueber die Zeit: was kam wann dazu
Binary file not shown.

Before

Width:  |  Height:  |  Size: 99 KiB

After

Width:  |  Height:  |  Size: 99 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 288 KiB

After

Width:  |  Height:  |  Size: 294 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 201 KiB

After

Width:  |  Height:  |  Size: 204 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 209 KiB

After

Width:  |  Height:  |  Size: 211 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 236 KiB

After

Width:  |  Height:  |  Size: 212 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 185 KiB

After

Width:  |  Height:  |  Size: 224 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 184 KiB

After

Width:  |  Height:  |  Size: 198 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 76 KiB

After

Width:  |  Height:  |  Size: 79 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 293 KiB

After

Width:  |  Height:  |  Size: 326 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 244 KiB

After

Width:  |  Height:  |  Size: 244 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 301 KiB

After

Width:  |  Height:  |  Size: 343 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 294 KiB

After

Width:  |  Height:  |  Size: 328 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 124 KiB

After

Width:  |  Height:  |  Size: 122 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 124 KiB

After

Width:  |  Height:  |  Size: 124 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 257 KiB

After

Width:  |  Height:  |  Size: 257 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 129 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 238 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 224 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 185 KiB

+28
View File
@@ -0,0 +1,28 @@
CREATE TABLE `collector` (
`id` text PRIMARY KEY NOT NULL,
`name` text NOT NULL,
`color` text DEFAULT 'blue' NOT NULL,
`birthday` integer,
`notes` text,
`sort_order` integer DEFAULT 0 NOT NULL,
`created_at` integer NOT NULL,
`updated_at` integer NOT NULL
);
--> statement-breakpoint
CREATE INDEX `collector_sort_idx` ON `collector` (`sort_order`,`name`);--> statement-breakpoint
ALTER TABLE `collection_item` ADD `collector_id` text REFERENCES collector(id);--> statement-breakpoint
CREATE INDEX `collection_item_collector_idx` ON `collection_item` (`collector_id`);--> statement-breakpoint
ALTER TABLE `wishlist` ADD `collector_id` text REFERENCES collector(id);--> statement-breakpoint
-- Von Hand ergaenzt: Bestandsdaten nachziehen.
-- Vor dieser Migration gab es genau eine Sammlung. Sie bekommt einen Sammler,
-- damit alles Vorhandene zugeordnet bleibt; umbenennen laesst er sich unter
-- Verwaltung -> Sammler.
INSERT INTO `collector` (`id`, `name`, `color`, `sort_order`, `created_at`, `updated_at`)
SELECT 'c0000000-0000-4000-8000-000000000001', 'Sammlung', 'blue', 0, unixepoch(), unixepoch()
WHERE NOT EXISTS (SELECT 1 FROM `collector`);--> statement-breakpoint
UPDATE `collection_item`
SET `collector_id` = (SELECT `id` FROM `collector` ORDER BY `sort_order`, `name` LIMIT 1)
WHERE `collector_id` IS NULL;--> statement-breakpoint
UPDATE `wishlist`
SET `collector_id` = (SELECT `id` FROM `collector` ORDER BY `sort_order`, `name` LIMIT 1)
WHERE `collector_id` IS NULL;
+14
View File
@@ -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`);
+1
View File
@@ -0,0 +1 @@
ALTER TABLE `wishlist` ADD `last_reminder_at` integer;
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+21
View File
@@ -8,6 +8,27 @@
"when": 1788726503038,
"tag": "0000_rapid_gargoyle",
"breakpoints": true
},
{
"idx": 1,
"version": "6",
"when": 1788763704015,
"tag": "0001_lumpy_bill_hollister",
"breakpoints": true
},
{
"idx": 2,
"version": "6",
"when": 1788765229376,
"tag": "0002_aspiring_whiplash",
"breakpoints": true
},
{
"idx": 3,
"version": "6",
"when": 1788765478965,
"tag": "0003_powerful_sally_floyd",
"breakpoints": true
}
]
}
+31 -1
View File
@@ -66,7 +66,8 @@ test('Screenshots fuer die Anleitung', async ({ page, browser }) => {
await page.getByRole('button', { name: 'Auswählen' }).click();
await expect(page.getByText('Tippe die Sets an')).toBeVisible();
const cards = page.locator('button[aria-pressed]');
// data-set-card, not aria-pressed: the collector switcher uses that too.
const cards = page.locator('button[data-set-card]');
await cards.nth(0).click();
await cards.nth(1).click();
await expect(page.getByText('2 ausgewählt')).toBeVisible();
@@ -160,4 +161,33 @@ test('Screenshots fuer die Anleitung', async ({ page, browser }) => {
await visit(page, '/verwaltung/daten');
await expect(page.getByText('Alles als ZIP')).toBeVisible();
await shoot(page, '20-daten');
// --- 16. collectors -------------------------------------------------------
await visit(page, '/verwaltung/sammler');
await expect(page.getByText('Der Katalog ist gemeinsam')).toBeVisible();
await shoot(page, '21-sammler');
// The switcher only exists because the seed created two collectors.
await visit(page, '/katalog?besitz=fehlt');
await expect(page.getByRole('button', { name: /Max/ })).toBeVisible();
await page.getByRole('button', { name: /Max/ }).click();
await expect(page.getByText('Sets, die Max noch nicht hat')).toBeVisible();
await shoot(page, '22-sammler-filter');
// --- 17. prices -----------------------------------------------------------
await visit(page, '/katalog?q=60398');
await page.getByText('Familienhaus und Elektroauto').first().click();
await expect(page.getByText('Preis und Wertentwicklung')).toBeVisible();
await page.getByRole('button', { name: 'Preis notieren' }).click();
// Role, not label: the label text carries the required marker.
await page.getByRole('textbox', { name: 'Preis', exact: true }).fill('79,99');
await page.getByLabel('Wo').fill('Amazon');
await page.getByRole('button', { name: 'Speichern' }).click();
await expect(page.getByText('Amazon')).toBeVisible();
await shoot(page, '23-preise');
// --- 18. reminders --------------------------------------------------------
await visit(page, '/verwaltung/erinnerungen');
await expect(page.getByText('Erinnerungen').first()).toBeVisible();
await shoot(page, '24-erinnerungen');
});
+24
View File
@@ -81,6 +81,30 @@ test('Registrieren, Set anlegen, Wunschliste teilen und reservieren', async ({ p
await visitorContext.close();
});
test('Dublettenpruefung warnt vor einem fast gleichen Set', async ({ page }) => {
await page.goto('/anmelden');
await page.getByLabel('E-Mail').fill('test@bricktrack.local');
await page.getByLabel('Passwort').fill('smoketest-passwort');
await page.getByRole('button', { name: 'Anmelden', exact: true }).click();
await expect(page.getByRole('heading', { name: 'Hallo Florian' })).toBeVisible();
// Dasselbe Set noch einmal, mit Tippfehler im Namen und anderer Nummer.
await page.goto('/katalog/neu');
await page.getByLabel('Setnummer').fill('60399');
await page
.getByRole('textbox', { name: 'Name', exact: true })
.fill('Familienhaus und Elektroauo');
await page.getByRole('button', { name: 'Set anlegen' }).click();
await expect(page.getByText('Gibt es das vielleicht schon?')).toBeVisible();
await expect(page.getByText('Sehr ähnlicher Name')).toBeVisible();
// Zurueck ins Formular: nichts wurde angelegt.
await page.getByRole('button', { name: 'Zurück zum Formular' }).click();
await page.goto('/katalog?q=60399');
await expect(page.getByText('0 Sets')).toBeVisible();
});
test('Einladung: zweiter Elternteil bekommt ein eigenes Konto', async ({ page, browser }) => {
// The admin from the first test is still the only account, so log in.
await page.goto('/anmelden');
+2
View File
@@ -36,6 +36,7 @@
"lucide-react": "^1.41.0",
"next": "15.5.25",
"next-themes": "^0.4.6",
"nodemailer": "^10.0.0",
"pdfjs-dist": "^6.3.289",
"radix-ui": "^1.6.7",
"react": "19.1.0",
@@ -52,6 +53,7 @@
"@tailwindcss/postcss": "^4",
"@types/better-sqlite3": "^9.6.0",
"@types/node": "^20",
"@types/nodemailer": "^8.0.1",
"@types/react": "^19",
"@types/react-dom": "^19",
"dotenv": "^17.4.2",
+19
View File
@@ -47,6 +47,9 @@ importers:
next-themes:
specifier: ^0.4.6
version: 0.4.6(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
nodemailer:
specifier: ^10.0.0
version: 10.0.0
pdfjs-dist:
specifier: ^6.3.289
version: 6.3.289
@@ -90,6 +93,9 @@ importers:
'@types/node':
specifier: ^20
version: 20.19.43
'@types/nodemailer':
specifier: ^8.0.1
version: 8.0.1
'@types/react':
specifier: ^19
version: 19.2.18
@@ -2262,6 +2268,9 @@ packages:
'@types/node@20.19.43':
resolution: {integrity: sha512-6oYBAi5ikg4Pl+kGsoYtawUMBT2zZMCvPNF7pVLnHZfd1zf38DRiWn/gT01RYCdUqkv7Fhr+C9ot4/tb+2sVvA==}
'@types/nodemailer@8.0.1':
resolution: {integrity: sha512-PxpaInm8V1JQDd4j0ds5HfvWQk8JupS1C0Picb96QJsrrRDjBH+DlK7L4ZdNSqNULhiZRQHc40nLVShaGxXAMw==}
'@types/react-dom@19.2.7':
resolution: {integrity: sha512-I8bPpDLcHBv1qiIiXDCy71Rt8eQDKJP0sMSWJphDdAcdqiJ1sGpZamavoEIRZmYzjia9LuEb2HlYdDpmoENpvQ==}
peerDependencies:
@@ -4135,6 +4144,10 @@ packages:
resolution: {integrity: sha512-YHs7BmmcsdAI5Ozuf8JZo6PT0mv2GIWC9vMfvUC3dp65M8hn7Ux8CPL+2oBI7juNuj9d0ndhTcznq2ODBps9cQ==}
engines: {node: '>=18'}
nodemailer@10.0.0:
resolution: {integrity: sha512-wdv+hXBg0iIPIOf108FaZyTxqjQpCvQeAJt9pHHTooPy/y3vQtI9eiImM9AIVKR3e/0FeMA1oueNfBjFJcaK+Q==}
engines: {node: '>=20.0.0'}
npm-run-path@4.0.1:
resolution: {integrity: sha512-S48WzZW777zhNIrn7gxOlISNAqi9ZC/uQFnRdbeIHhZhCA6UqpkOT8T1G7BvfdgP4Er8gF4sUbaS0i7QvIfCWw==}
engines: {node: '>=8'}
@@ -6950,6 +6963,10 @@ snapshots:
dependencies:
undici-types: 6.21.0
'@types/nodemailer@8.0.1':
dependencies:
'@types/node': 20.19.43
'@types/react-dom@19.2.7(@types/react@19.2.18)':
dependencies:
'@types/react': 19.2.18
@@ -8805,6 +8822,8 @@ snapshots:
node-releases@2.0.54: {}
nodemailer@10.0.0: {}
npm-run-path@4.0.1:
dependencies:
path-key: 3.1.1
+7
View File
@@ -17,6 +17,13 @@ fs.mkdirSync(path.join(dataDir, 'db'), { recursive: true });
fs.mkdirSync(path.join(dataDir, 'files'), { recursive: true });
console.log(`[e2e] ${dataDir} zurueckgesetzt.`);
// A container left behind by an aborted run would hold the port.
try {
execFileSync('docker', ['rm', '-f', 'bricktrack-e2e'], { stdio: 'ignore' });
} catch {
// Nothing to remove - the normal case.
}
console.log('[e2e] Image wird gebaut (bricktrack:e2e) …');
execFileSync('docker', ['build', '-t', 'bricktrack:e2e', '.'], { stdio: 'inherit' });
console.log('[e2e] Image ist bereit.');
+7
View File
@@ -16,6 +16,13 @@ fs.mkdirSync(path.join(dataDir, 'db'), { recursive: true });
fs.mkdirSync(path.join(dataDir, 'files'), { recursive: true });
console.log(`[screenshots] ${dataDir} zurueckgesetzt.`);
// A container left behind by an aborted run would hold the port.
try {
execFileSync('docker', ['rm', '-f', 'bricktrack-shots'], { stdio: 'ignore' });
} catch {
// Nothing to remove - the normal case.
}
console.log('[screenshots] Image wird gebaut (bricktrack:shots) …');
execFileSync('docker', ['build', '-t', 'bricktrack:shots', '.'], { stdio: 'inherit' });
console.log('[screenshots] Image ist bereit.');
+90 -8
View File
@@ -33,11 +33,12 @@ type SeedSet = {
minifigCount: number | null;
retailPriceCents: number;
ageRange: string;
/** Copies owned - 0 means the set is only in the catalog. */
/** Copies owned - absent means the set is only in the catalog. */
owned?: {
status: 'owned' | 'built' | 'disassembled' | 'lent_out' | 'sold';
completeness: 'complete' | 'missing_parts' | 'unknown';
location: string;
collector: string;
giftedBy?: string;
};
};
@@ -59,6 +60,13 @@ const THEMES: Array<{ manufacturer: string; name: string }> = [
const LOCATIONS = ['Regal Kinderzimmer', 'Kiste Keller 3', 'Vitrine Wohnzimmer'];
// Two collectors, so the shared catalog and the separate collections are
// visible right away. With only one, the whole concept stays hidden in the UI.
const COLLECTORS = [
{ name: 'Max', color: 'blue' as const },
{ name: 'Mia', color: 'rose' as const },
];
const SETS: SeedSet[] = [
{
manufacturer: 'Lego',
@@ -70,7 +78,12 @@ const SETS: SeedSet[] = [
minifigCount: 4,
retailPriceCents: 4999,
ageRange: '6-12',
owned: { status: 'built', completeness: 'complete', location: 'Regal Kinderzimmer' },
owned: {
status: 'built',
completeness: 'complete',
location: 'Regal Kinderzimmer',
collector: 'Max',
},
},
{
manufacturer: 'Lego',
@@ -86,6 +99,7 @@ const SETS: SeedSet[] = [
status: 'owned',
completeness: 'missing_parts',
location: 'Kiste Keller 3',
collector: 'Max',
giftedBy: 'Oma Helga',
},
},
@@ -99,7 +113,12 @@ const SETS: SeedSet[] = [
minifigCount: null,
retailPriceCents: 999,
ageRange: '7-14',
owned: { status: 'disassembled', completeness: 'unknown', location: 'Kiste Keller 3' },
owned: {
status: 'disassembled',
completeness: 'unknown',
location: 'Kiste Keller 3',
collector: 'Mia',
},
},
{
manufacturer: 'Lego',
@@ -144,7 +163,12 @@ const SETS: SeedSet[] = [
minifigCount: 5,
retailPriceCents: 12999,
ageRange: '4-10',
owned: { status: 'built', completeness: 'complete', location: 'Regal Kinderzimmer' },
owned: {
status: 'built',
completeness: 'complete',
location: 'Regal Kinderzimmer',
collector: 'Mia',
},
},
{
manufacturer: 'Playmobil',
@@ -241,6 +265,59 @@ function seedThemes(manufacturers: Map<string, string>) {
return byKey;
}
function seedCollectors() {
const byName = new Map<string, string>();
COLLECTORS.forEach((entry, index) => {
const all = db
.select({ id: schema.collector.id, name: schema.collector.name })
.from(schema.collector)
.all();
const existing = all.find((row) => row.name === entry.name);
if (existing) {
byName.set(entry.name, existing.id);
return;
}
// A fresh install already has the collector the migration created. Rather
// than adding a third one next to it, the seed takes it over.
const untouchedDefault = all.find(
(row) =>
row.name === 'Sammlung' &&
!db
.select({ id: schema.collectionItem.id })
.from(schema.collectionItem)
.where(eq(schema.collectionItem.collectorId, row.id))
.get(),
);
if (untouchedDefault) {
db.update(schema.collector)
.set({ name: entry.name, color: entry.color, sortOrder: index, updatedAt: now })
.where(eq(schema.collector.id, untouchedDefault.id))
.run();
byName.set(entry.name, untouchedDefault.id);
return;
}
const newId = id();
db.insert(schema.collector)
.values({
id: newId,
name: entry.name,
color: entry.color,
sortOrder: index,
createdAt: now,
updatedAt: now,
})
.run();
byName.set(entry.name, newId);
});
return byName;
}
function seedLocations() {
const byName = new Map<string, string>();
@@ -270,6 +347,7 @@ function seedSets(
manufacturers: Map<string, string>,
themes: Map<string, string>,
locations: Map<string, string>,
collectors: Map<string, string>,
) {
const setIds = new Map<string, string>();
@@ -317,6 +395,7 @@ function seedSets(
completeness: entry.owned.completeness,
condition: 'good',
locationId: locations.get(entry.owned.location) ?? null,
collectorId: collectors.get(entry.owned.collector) ?? null,
acquiredAt: new Date(entry.year, 11, 24),
acquiredPriceCents: entry.retailPriceCents,
giftedBy: entry.owned.giftedBy ?? null,
@@ -330,7 +409,7 @@ function seedSets(
return setIds;
}
function seedWishlist(setIds: Map<string, string>) {
function seedWishlist(setIds: Map<string, string>, collectors: Map<string, string>) {
const owner = db.select({ id: schema.user.id }).from(schema.user).get();
if (!owner) {
console.log('[seed] Kein Nutzer vorhanden - die Beispiel-Wunschliste wird uebersprungen.');
@@ -353,6 +432,7 @@ function seedWishlist(setIds: Map<string, string>) {
.values({
id: wishlistId,
ownerUserId: owner.id,
collectorId: collectors.get('Max') ?? null,
title: 'Weihnachten 2026',
occasion: 'christmas',
eventDate: new Date(2026, 11, 24),
@@ -389,10 +469,12 @@ console.log('[seed] Beispieldaten werden eingespielt …');
const manufacturers = seedManufacturers();
const themes = seedThemes(manufacturers);
const locations = seedLocations();
const setIds = seedSets(manufacturers, themes, locations);
seedWishlist(setIds);
const collectors = seedCollectors();
const setIds = seedSets(manufacturers, themes, locations, collectors);
seedWishlist(setIds, collectors);
console.log(
`[seed] Fertig: ${manufacturers.size} Hersteller, ${themes.size} Themen, ${setIds.size} Sets.`,
`[seed] Fertig: ${collectors.size} Sammler, ${manufacturers.size} Hersteller, ` +
`${themes.size} Themen, ${setIds.size} Sets.`,
);
sqlite.close();
+41 -5
View File
@@ -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,25 +18,37 @@ import {
listCollectionItemsForSet,
listInstructionsForSet,
listPhotosForSet,
listPricePoints,
} from '@/lib/queries/catalog';
import { listMissingParts } from '@/lib/queries/collection';
import { listCollectors, resolveCollectorId } from '@/lib/queries/collectors';
import { listLocations } from '@/lib/queries/master-data';
import { listWishlistsForSet, listWishlistTargets } from '@/lib/queries/wishlists';
import { requireUser } from '@/lib/session';
type Props = { params: Promise<{ id: string }> };
type Props = {
params: Promise<{ id: string }>;
searchParams: Promise<Record<string, string | string[] | undefined>>;
};
export async function generateMetadata({ params }: Props): Promise<Metadata> {
export async function generateMetadata({ params }: Pick<Props, 'params'>): Promise<Metadata> {
const { id } = await params;
const set = getSetById(id);
return { title: set ? `${set.setNumber} · ${set.name}` : 'Set' };
}
export default async function SetDetailPage({ params }: Props) {
export default async function SetDetailPage({ params, searchParams }: Props) {
await requireUser();
const { id } = await params;
const query = await searchParams;
const set = getSetById(id);
// Keeps the "vorhanden / fehlt" badge in step with the collector the user
// was looking at in the catalog.
const rawCollector = Array.isArray(query.sammler) ? query.sammler[0] : query.sammler;
const collectorId = resolveCollectorId(rawCollector);
const collectors = listCollectors();
const set = getSetById(id, collectorId);
if (!set) notFound();
const image = setImageSrc(set);
@@ -43,11 +56,16 @@ export default async function SetDetailPage({ params }: Props) {
const locations = listLocations().map((row) => ({ id: row.id, name: row.name }));
const locationNames = new Map(locations.map((row) => [row.id, row.name]));
const collectorsById = new Map(collectors.map((row) => [row.id, row]));
const collectionItems = listCollectionItemsForSet(set.id).map((item) => ({
...item,
locationName: item.locationId ? (locationNames.get(item.locationId) ?? null) : null,
setName: `${set.setNumber} · ${set.name}`,
missingParts: listMissingParts(item.id),
collectorName: item.collectorId ? (collectorsById.get(item.collectorId)?.name ?? null) : null,
collectorColor: item.collectorId
? (collectorsById.get(item.collectorId)?.color ?? null)
: null,
}));
const facts: Array<[string, string]> = [
@@ -113,6 +131,7 @@ export default async function SetDetailPage({ params }: Props) {
setName={`${set.setNumber} · ${set.name}`}
owned={owned}
wishlists={listWishlistTargets()}
collectors={collectors.map((row) => ({ id: row.id, name: row.name }))}
/>
<dl className="grid grid-cols-2 gap-x-4 gap-y-2 text-sm sm:grid-cols-3">
@@ -144,7 +163,24 @@ export default async function SetDetailPage({ params }: Props) {
<CardTitle>In der Sammlung</CardTitle>
</CardHeader>
<CardContent>
<CollectionItemsSection items={collectionItems} locations={locations} />
<CollectionItemsSection
items={collectionItems}
locations={locations}
collectors={collectors.map((row) => ({ id: row.id, name: row.name }))}
/>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>Preis und Wertentwicklung</CardTitle>
</CardHeader>
<CardContent>
<PriceSection
setId={set.id}
prices={listPricePoints(set.id)}
retailPriceCents={set.retailPriceCents}
/>
</CardContent>
</Card>
+100
View File
@@ -0,0 +1,100 @@
'use server';
import { and, eq, ne } from 'drizzle-orm';
import { db } from '@/db';
import { manufacturer, set } from '@/db/schema';
import { actionData, type ActionResult } from '@/lib/action-result';
import { requireUser } from '@/lib/session';
import { isSimilarName } from '@/lib/similarity';
export type DuplicateCandidate = {
id: string;
setNumber: string;
name: string;
manufacturerName: string;
/** Why we are showing it. */
reason: 'same-number' | 'similar-name' | 'number-elsewhere';
};
/**
* Looks for sets that the one being created might be a duplicate of.
*
* Three cases, in descending order of certainty:
* - the same number at the same manufacturer (that is a real duplicate and is
* rejected on save anyway),
* - a suspiciously similar name at the same manufacturer,
* - the same number at a different manufacturer, which usually means the
* wrong manufacturer was selected.
*/
export async function findDuplicateCandidates(input: {
manufacturerId: string;
setNumber: string;
name: string;
excludeSetId?: string;
}): Promise<ActionResult<{ candidates: DuplicateCandidate[] }>> {
await requireUser();
const setNumber = input.setNumber?.trim() ?? '';
const name = input.name?.trim() ?? '';
if (!input.manufacturerId || (setNumber === '' && name === '')) {
return actionData({ candidates: [] });
}
const rows = db
.select({
id: set.id,
setNumber: set.setNumber,
name: set.name,
manufacturerId: set.manufacturerId,
manufacturerName: manufacturer.name,
})
.from(set)
.innerJoin(manufacturer, eq(manufacturer.id, set.manufacturerId))
.where(input.excludeSetId ? ne(set.id, input.excludeSetId) : undefined)
.all();
const candidates: DuplicateCandidate[] = [];
for (const row of rows) {
const sameManufacturer = row.manufacturerId === input.manufacturerId;
const sameNumber = setNumber !== '' && row.setNumber.toLowerCase() === setNumber.toLowerCase();
if (sameManufacturer && sameNumber) {
candidates.push({ ...row, reason: 'same-number' });
continue;
}
if (sameManufacturer && name !== '' && isSimilarName(row.name, name)) {
candidates.push({ ...row, reason: 'similar-name' });
continue;
}
if (!sameManufacturer && sameNumber) {
candidates.push({ ...row, reason: 'number-elsewhere' });
}
}
// Certain matches first, and never more than a handful.
const order: Record<DuplicateCandidate['reason'], number> = {
'same-number': 0,
'similar-name': 1,
'number-elsewhere': 2,
};
candidates.sort((a, b) => order[a.reason] - order[b.reason]);
return actionData({ candidates: candidates.slice(0, 5) });
}
/** Used by the import to point at an existing entry instead of failing blindly. */
export async function findSetByNumber(
manufacturerId: string,
setNumber: string,
): Promise<ActionResult<{ id: string } | null>> {
await requireUser();
const row = db
.select({ id: set.id })
.from(set)
.where(and(eq(set.manufacturerId, manufacturerId), eq(set.setNumber, setNumber)))
.get();
return actionData(row ?? null);
}
+15 -2
View File
@@ -1,6 +1,7 @@
import type { Metadata } from 'next';
import Link from 'next/link';
import { Plus } from 'lucide-react';
import { CollectorFilter } from '@/components/layout/collector-filter';
import { BarcodeScannerButton } from '@/components/katalog/barcode-scanner';
import { CatalogFilters } from '@/components/katalog/catalog-filters';
import { CatalogGrid } from '@/components/katalog/catalog-grid';
@@ -9,6 +10,7 @@ import { Button } from '@/components/ui/button';
import { pluralize } from '@/lib/format';
import { OWNERSHIP_FILTER_LABELS, parseOwnershipFilter } from '@/lib/ownership';
import { CATALOG_PAGE_SIZE, listSets, listSetYears } from '@/lib/queries/catalog';
import { listCollectors, resolveCollectorId } from '@/lib/queries/collectors';
import { listManufacturers, listThemes } from '@/lib/queries/master-data';
import { listWishlistTargets } from '@/lib/queries/wishlists';
import { requireUser } from '@/lib/session';
@@ -27,6 +29,9 @@ export default async function KatalogPage({ searchParams }: { searchParams: Sear
const params = await searchParams;
const ownership = parseOwnershipFilter(single(params.besitz));
const collectors = listCollectors();
const collectorId = resolveCollectorId(single(params.sammler));
const activeCollector = collectors.find((row) => row.id === collectorId) ?? null;
const yearParam = single(params.jahr);
const page = Number(single(params.seite) ?? '1') || 1;
@@ -36,6 +41,7 @@ export default async function KatalogPage({ searchParams }: { searchParams: Sear
themeId: single(params.thema),
year: yearParam ? Number(yearParam) : undefined,
ownership,
collectorId,
page,
};
@@ -57,7 +63,9 @@ export default async function KatalogPage({ searchParams }: { searchParams: Sear
</h1>
<p className="text-sm text-muted-foreground">
{ownership === 'fehlt'
? 'Sets, die noch nicht in der Sammlung sind.'
? activeCollector
? `Sets, die ${activeCollector.name} noch nicht hat.`
: 'Sets, die noch nicht in der Sammlung sind.'
: 'Alle bekannten Sets – unabhängig davon, ob sie schon da sind.'}
</p>
</div>
@@ -72,6 +80,11 @@ export default async function KatalogPage({ searchParams }: { searchParams: Sear
</div>
</div>
<CollectorFilter
collectors={collectors.map((row) => ({ id: row.id, name: row.name, color: row.color }))}
allLabel="Alle zusammen"
/>
<CatalogFilters options={{ manufacturers, themes, years: listSetYears() }} />
<p className="text-sm text-muted-foreground">
@@ -79,7 +92,7 @@ export default async function KatalogPage({ searchParams }: { searchParams: Sear
{ownership !== 'alle' ? ` · ${OWNERSHIP_FILTER_LABELS[ownership]}` : ''}
</p>
<CatalogGrid sets={rows} wishlists={wishlists} />
<CatalogGrid sets={rows} wishlists={wishlists} collectorId={collectorId} />
<Pagination page={page} total={total} pageSize={CATALOG_PAGE_SIZE} />
</div>
+61
View File
@@ -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<ActionResult> {
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<ActionResult> {
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();
}
+5
View File
@@ -1,8 +1,13 @@
import { AppHeader } from '@/components/layout/app-header';
import { BottomNav } from '@/components/layout/bottom-nav';
import { ensureReminderScheduler } from '@/lib/reminder-job';
import { requireUser } from '@/lib/session';
export default async function AppLayout({ children }: { children: React.ReactNode }) {
// Second entry point besides the healthcheck, in case someone turns that
// off. The call is idempotent.
ensureReminderScheduler();
const user = await requireUser();
return (
+10 -1
View File
@@ -11,6 +11,7 @@ import {
parseForm,
type ActionResult,
} from '@/lib/action-result';
import { defaultCollectorId } from '@/lib/queries/collectors';
import { reconcileFulfilledWishlistItems } from '@/lib/queries/wishlists';
import { requireUser } from '@/lib/session';
import { collectionItemSchema, missingPartSchema } from '@/lib/validation';
@@ -32,17 +33,24 @@ function revalidateCollection(setId?: string) {
* Quick add from the set detail page: one tap, sensible defaults, details can
* be filled in later.
*/
export async function addSetToCollection(setId: string): Promise<ActionResult<{ id: string }>> {
export async function addSetToCollection(
setId: string,
collectorId?: string,
): Promise<ActionResult<{ id: string }>> {
await requireUser();
const exists = db.select({ id: set.id }).from(set).where(eq(set.id, setId)).get();
if (!exists) return actionError('Das Set gibt es nicht mehr.');
// With a single collector nobody is asked - the copy simply belongs to them.
const owner = collectorId ?? defaultCollectorId();
const id = crypto.randomUUID();
db.insert(collectionItem)
.values({
id,
setId,
collectorId: owner,
status: 'owned',
completeness: 'unknown',
createdAt: now(),
@@ -68,6 +76,7 @@ export async function updateCollectionItem(id: string, input: unknown): Promise<
db.update(collectionItem)
.set({
collectorId: parsed.data.collectorId ?? existing.collectorId,
status: parsed.data.status,
completeness: parsed.data.completeness,
condition: parsed.data.condition as Condition | null,
+31 -4
View File
@@ -1,10 +1,12 @@
import type { Metadata } from 'next';
import Link from 'next/link';
import { Boxes, Euro, ImageOff, Layers, PackageSearch } from 'lucide-react';
import { CollectorFilter } from '@/components/layout/collector-filter';
import { CollectionFilters } from '@/components/sammlung/collection-filters';
import { Badge } from '@/components/ui/badge';
import { Card, CardContent } from '@/components/ui/card';
import { formatDate, formatNumber, pluralize } from '@/lib/format';
import { collectorBadgeClass } from '@/lib/collector-colors';
import { setImageSrc } from '@/lib/images';
import { completenessLabel, statusLabel } from '@/lib/labels';
import { formatEuro } from '@/lib/money';
@@ -15,6 +17,7 @@ import {
listCollectionItems,
type CollectionFilters as Filters,
} from '@/lib/queries/collection';
import { listCollectors, resolveCollectorId } from '@/lib/queries/collectors';
import { listLocations, listManufacturers } from '@/lib/queries/master-data';
import { requireUser } from '@/lib/session';
import type { CollectionStatus, Completeness } from '@/db/types';
@@ -32,23 +35,33 @@ export default async function SammlungPage({ searchParams }: { searchParams: Sea
await requireUser();
const params = await searchParams;
const collectors = listCollectors();
const collectorId = resolveCollectorId(single(params.sammler));
const activeCollector = collectors.find((row) => row.id === collectorId) ?? null;
const filters: Filters = {
q: single(params.q),
status: single(params.status) as CollectionStatus | undefined,
completeness: single(params.zustand) as Completeness | undefined,
locationId: single(params.standort),
manufacturerId: single(params.hersteller),
collectorId,
};
const items = listCollectionItems(filters);
const openParts = countOpenMissingParts(items.map((item) => item.id));
const stats = getCollectionStats();
const missing = countMissingSets();
const stats = getCollectionStats(collectorId);
const missing = countMissingSets(collectorId);
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;
@@ -56,9 +69,18 @@ export default async function SammlungPage({ searchParams }: { searchParams: Sea
<div className="space-y-5">
<div>
<h1 className="text-2xl font-semibold tracking-tight">Sammlung</h1>
<p className="text-sm text-muted-foreground">Alles, was tatsächlich da ist.</p>
<p className="text-sm text-muted-foreground">
{activeCollector
? `Alles, was ${activeCollector.name} tatsächlich hat.`
: 'Alles, was tatsächlich da ist.'}
</p>
</div>
<CollectorFilter
collectors={collectors.map((row) => ({ id: row.id, name: row.name, color: row.color }))}
allLabel="Alle zusammen"
/>
<div className="grid grid-cols-2 gap-3 sm:grid-cols-4">
{tiles.map((tile) => (
<Card key={tile.label}>
@@ -134,6 +156,11 @@ export default async function SammlungPage({ searchParams }: { searchParams: Sea
{item.completeness !== 'unknown' ? (
<Badge variant="secondary">{completenessLabel(item.completeness)}</Badge>
) : null}
{collectors.length > 1 && item.collectorName ? (
<Badge className={collectorBadgeClass(item.collectorColor)}>
{item.collectorName}
</Badge>
) : null}
{openParts.get(item.id) ? (
<Badge variant="outline">{openParts.get(item.id)} Fehlteile</Badge>
) : null}
@@ -0,0 +1,51 @@
'use server';
import { revalidatePath } from 'next/cache';
import { actionData, actionError, type ActionResult } from '@/lib/action-result';
import { env } from '@/env';
import { sendMail, verifyMailConnection } from '@/lib/mailer';
import { runReminderJob, type ReminderRun } from '@/lib/reminder-job';
import { requireAdmin } from '@/lib/session';
export async function testMailConnection(): Promise<ActionResult> {
await requireAdmin();
const result = await verifyMailConnection();
return result.ok ? { ok: true } : actionError(result.error);
}
export async function sendTestMail(): Promise<ActionResult> {
const admin = await requireAdmin();
if (!env.isMailConfigured) {
return actionError('Es ist kein SMTP-Server konfiguriert.');
}
const result = await sendMail({
to: admin.email,
subject: 'Bricktrack: Testnachricht',
text: [
'Das hat geklappt.',
'',
'Wenn diese Nachricht ankommt, funktionieren auch die Erinnerungen zu',
'Wunschlisten-Terminen.',
'',
env.BASE_URL,
].join('\n'),
});
return result.ok ? { ok: true } : actionError(result.error);
}
/** Runs the check immediately instead of waiting for the next hour. */
export async function runRemindersNow(): Promise<ActionResult<ReminderRun>> {
await requireAdmin();
if (!env.isMailConfigured) {
return actionError('Es ist kein SMTP-Server konfiguriert.');
}
const run = await runReminderJob();
revalidatePath('/verwaltung/erinnerungen');
return actionData(run);
}
@@ -0,0 +1,51 @@
import type { Metadata } from 'next';
import { eq, sql } from 'drizzle-orm';
import { db } from '@/db';
import { wishlist } from '@/db/schema';
import { env } from '@/env';
import {
ReminderSettings,
type ReminderListRow,
} from '@/components/verwaltung/reminder-settings';
import { daysUntil } from '@/lib/reminders';
import { requireAdmin } from '@/lib/session';
export const metadata: Metadata = { title: 'Erinnerungen' };
export default async function ErinnerungenPage() {
const admin = await requireAdmin();
const openItems = sql<number>`(
select count(*) from wishlist_item wi
where wi.wishlist_id = ${wishlist.id}
and wi.reserved_at is null
and wi.fulfilled_at is null
)`.mapWith(Number);
const now = new Date();
const lists: ReminderListRow[] = db
.select({
id: wishlist.id,
title: wishlist.title,
eventDate: wishlist.eventDate,
lastReminderAt: wishlist.lastReminderAt,
openItemCount: openItems,
})
.from(wishlist)
.where(eq(wishlist.ownerUserId, admin.id))
.all()
.filter((row) => row.eventDate !== null)
.map((row) => ({
...row,
daysLeft: row.eventDate ? daysUntil(row.eventDate, now) : null,
}))
.sort((a, b) => (a.daysLeft ?? 0) - (b.daysLeft ?? 0));
return (
<ReminderSettings
configured={env.isMailConfigured}
daysBefore={env.REMINDER_DAYS_BEFORE}
lists={lists}
/>
);
}
+160
View File
@@ -0,0 +1,160 @@
'use server';
import { revalidatePath } from 'next/cache';
import { asc, eq, sql } from 'drizzle-orm';
import { db } from '@/db';
import { collectionItem, collector, wishlist } from '@/db/schema';
import { actionError, actionOk, parseForm, type ActionResult } from '@/lib/action-result';
import { requireUser } from '@/lib/session';
import { collectorSchema } from '@/lib/validation';
function now() {
return new Date();
}
function revalidateEverything() {
// A collector shows up in the catalog, the collection and the wishlists.
revalidatePath('/', 'layout');
}
export async function createCollector(input: unknown): Promise<ActionResult> {
await requireUser();
const parsed = parseForm(collectorSchema, input);
if (!parsed.ok) return parsed;
const [row] = db
.select({ next: sql<number>`coalesce(max(${collector.sortOrder}), -1) + 1`.mapWith(Number) })
.from(collector)
.all();
db.insert(collector)
.values({
id: crypto.randomUUID(),
name: parsed.data.name,
color: parsed.data.color,
birthday: parsed.data.birthday,
notes: parsed.data.notes,
sortOrder: row?.next ?? 0,
createdAt: now(),
updatedAt: now(),
})
.run();
revalidateEverything();
return actionOk();
}
export async function updateCollector(id: string, input: unknown): Promise<ActionResult> {
await requireUser();
const parsed = parseForm(collectorSchema, input);
if (!parsed.ok) return parsed;
db.update(collector)
.set({
name: parsed.data.name,
color: parsed.data.color,
birthday: parsed.data.birthday,
notes: parsed.data.notes,
updatedAt: now(),
})
.where(eq(collector.id, id))
.run();
revalidateEverything();
return actionOk();
}
/**
* Deleting a collector would take their whole collection with it, so it is
* only allowed while nothing is attached - and never for the last one.
*/
export async function deleteCollector(id: string): Promise<ActionResult> {
await requireUser();
const remaining = db.select({ id: collector.id }).from(collector).all();
if (remaining.length <= 1) {
return actionError('Der letzte Sammler kann nicht gelöscht werden.');
}
const owned = db
.select({ id: collectionItem.id })
.from(collectionItem)
.where(eq(collectionItem.collectorId, id))
.get();
if (owned) {
return actionError(
'Diesem Sammler sind noch Sets zugeordnet. Ordne sie erst jemand anderem zu, dann lässt er sich löschen.',
);
}
const lists = db
.select({ id: wishlist.id })
.from(wishlist)
.where(eq(wishlist.collectorId, id))
.get();
if (lists) {
return actionError(
'Zu diesem Sammler gibt es noch Wunschlisten. Ordne sie erst um oder lösch sie.',
);
}
db.delete(collector).where(eq(collector.id, id)).run();
revalidateEverything();
return actionOk();
}
/** Moves every owned copy of one collector over to another. */
export async function reassignCollectionItems(
fromCollectorId: string,
toCollectorId: string,
): Promise<ActionResult> {
await requireUser();
if (fromCollectorId === toCollectorId) return actionOk();
const target = db
.select({ id: collector.id })
.from(collector)
.where(eq(collector.id, toCollectorId))
.get();
if (!target) return actionError('Diesen Sammler gibt es nicht.');
db.update(collectionItem)
.set({ collectorId: toCollectorId, updatedAt: now() })
.where(eq(collectionItem.collectorId, fromCollectorId))
.run();
revalidateEverything();
return actionOk();
}
/** Reorders the collectors; the first one is the default for new entries. */
export async function reorderCollectors(orderedIds: string[]): Promise<ActionResult> {
await requireUser();
db.transaction((tx) => {
orderedIds.forEach((id, index) => {
tx.update(collector)
.set({ sortOrder: index, updatedAt: now() })
.where(eq(collector.id, id))
.run();
});
});
revalidateEverything();
return actionOk();
}
/** Every collector, ordered - for the selects in the forms. */
export async function listCollectorOptions(): Promise<Array<{ id: string; name: string }>> {
await requireUser();
return db
.select({ id: collector.id, name: collector.name })
.from(collector)
.orderBy(asc(collector.sortOrder), asc(collector.name))
.all();
}
+40
View File
@@ -0,0 +1,40 @@
import type { Metadata } from 'next';
import { CollectorList, type CollectorRowItem } from '@/components/verwaltung/collector-list';
import { formatDate, pluralize } from '@/lib/format';
import { COLLECTOR_COLOR_LABELS } from '@/lib/collector-colors';
import { listCollectors } from '@/lib/queries/collectors';
import { requireUser } from '@/lib/session';
export const metadata: Metadata = { title: 'Sammler' };
export default async function SammlerPage() {
await requireUser();
const collectors = listCollectors();
const rows: CollectorRowItem[] = collectors.map((row) => ({
id: row.id,
title: row.name,
subtitle: [
pluralize(row.itemCount, 'Set', 'Sets'),
COLLECTOR_COLOR_LABELS[row.color],
row.birthday ? `geboren am ${formatDate(row.birthday)}` : null,
]
.filter(Boolean)
.join(' · '),
color: row.color,
birthday: row.birthday,
notes: row.notes,
itemCount: row.itemCount,
}));
return (
<div className="space-y-4">
<p className="rounded-lg border border-dashed border-border p-4 text-sm text-muted-foreground">
Der Katalog ist gemeinsam: Ein Set existiert einmal, unabhängig davon, wer es hat.
Sammlung und Wunschlisten gehören dagegen je einem Sammler. Solange es nur einen gibt,
taucht die Unterscheidung nirgends in der App auf – sie erscheint erst mit dem zweiten.
</p>
<CollectorList collectors={rows} />
</div>
);
}
+17 -2
View File
@@ -8,10 +8,12 @@ import { WishlistHeaderActions } from '@/components/wunschlisten/wishlist-header
import { WishlistItems } from '@/components/wunschlisten/wishlist-items';
import { Badge } from '@/components/ui/badge';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { collectorBadgeClass } from '@/lib/collector-colors';
import { formatDate, pluralize } from '@/lib/format';
import { occasionLabel } from '@/lib/labels';
import { CATALOG_PAGE_SIZE, listSets, listSetYears } from '@/lib/queries/catalog';
import { listManufacturers, listThemes } from '@/lib/queries/master-data';
import { listCollectors } from '@/lib/queries/collectors';
import { getWishlist, listWishlistItems } from '@/lib/queries/wishlists';
import { requireUser } from '@/lib/session';
@@ -39,7 +41,9 @@ export default async function WunschlisteDetailPage({ params, searchParams }: Pr
const list = getWishlist(id);
if (!list || list.ownerUserId !== user.id) notFound();
const items = listWishlistItems(list.id);
const collectors = listCollectors();
const activeCollector = collectors.find((row) => row.id === list.collectorId) ?? null;
const items = listWishlistItems(list.id, list.collectorId);
// The embedded picker: missing sets that are not on this list yet. It shares
// the URL parameters with the catalog, so the filters behave identically.
@@ -52,6 +56,7 @@ export default async function WunschlisteDetailPage({ params, searchParams }: Pr
year: yearParam ? Number(yearParam) : undefined,
ownership: 'fehlt',
excludeWishlistId: list.id,
collectorId: list.collectorId ?? undefined,
page,
});
@@ -63,6 +68,11 @@ export default async function WunschlisteDetailPage({ params, searchParams }: Pr
<Badge variant="secondary">{occasionLabel(list.occasion)}</Badge>
{list.eventDate ? <Badge variant="outline">{formatDate(list.eventDate)}</Badge> : null}
{list.surpriseMode ? <Badge variant="outline">Überraschungsmodus</Badge> : null}
{collectors.length > 1 && activeCollector ? (
<Badge className={collectorBadgeClass(activeCollector.color)}>
für {activeCollector.name}
</Badge>
) : null}
</div>
<h1 className="mt-2 truncate text-2xl font-semibold tracking-tight">{list.title}</h1>
<p className="text-sm text-muted-foreground">
@@ -70,8 +80,10 @@ export default async function WunschlisteDetailPage({ params, searchParams }: Pr
</p>
</div>
<WishlistHeaderActions
collectors={collectors.map((row) => ({ id: row.id, name: row.name }))}
wishlist={{
id: list.id,
collectorId: list.collectorId,
title: list.title,
occasion: list.occasion,
eventDate: list.eventDate,
@@ -99,7 +111,9 @@ export default async function WunschlisteDetailPage({ params, searchParams }: Pr
<CardHeader>
<CardTitle>Aus fehlenden Sets hinzufügen</CardTitle>
<CardDescription>
Nur Sets, die noch nicht in der Sammlung und noch nicht auf dieser Liste sind. Kachel
{activeCollector
? `Nur Sets, die ${activeCollector.name} noch nicht hat und die noch nicht auf dieser Liste stehen.`
: 'Nur Sets, die noch nicht in der Sammlung und noch nicht auf dieser Liste sind.'}{' '} Kachel
lange drücken oder „Auswählen“ tippen, dann mehrere auf einmal übernehmen.
</CardDescription>
</CardHeader>
@@ -124,6 +138,7 @@ export default async function WunschlisteDetailPage({ params, searchParams }: Pr
sets={missing.rows}
wishlists={[{ id: list.id, title: list.title }]}
defaultWishlistId={list.id}
collectorId={list.collectorId ?? undefined}
emptyState="Es fehlt gerade nichts, was noch nicht auf dieser Liste steht."
/>
+4 -1
View File
@@ -12,6 +12,7 @@ import {
type ActionResult,
} from '@/lib/action-result';
import { ownedCopiesSql } from '@/lib/ownership';
import { defaultCollectorId } from '@/lib/queries/collectors';
import {
findExistingWishlistSetIds,
getWishlist,
@@ -58,6 +59,7 @@ export async function createWishlist(input: unknown): Promise<ActionResult<{ id:
.values({
id,
ownerUserId: user.id,
collectorId: parsed.data.collectorId ?? defaultCollectorId(),
title: parsed.data.title,
occasion: parsed.data.occasion,
eventDate: parsed.data.eventDate,
@@ -83,6 +85,7 @@ export async function updateWishlist(id: string, input: unknown): Promise<Action
db.update(wishlist)
.set({
collectorId: parsed.data.collectorId,
title: parsed.data.title,
occasion: parsed.data.occasion,
eventDate: parsed.data.eventDate,
@@ -153,7 +156,7 @@ export async function addSetsToWishlist(
id: set.id,
name: set.name,
setNumber: set.setNumber,
ownedCopies: ownedCopiesSql(set.id).mapWith(Number),
ownedCopies: ownedCopiesSql(set.id, list.collectorId).mapWith(Number),
})
.from(set)
.where(inArray(set.id, setIds))
+11 -1
View File
@@ -4,8 +4,10 @@ import { CalendarDays, Gift, Link2Off } from 'lucide-react';
import { NewWishlistButton } from '@/components/wunschlisten/wishlist-form-dialog';
import { Badge } from '@/components/ui/badge';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { collectorBadgeClass } from '@/lib/collector-colors';
import { formatDate, pluralize } from '@/lib/format';
import { occasionLabel } from '@/lib/labels';
import { listCollectors } from '@/lib/queries/collectors';
import { listWishlists } from '@/lib/queries/wishlists';
import { requireUser } from '@/lib/session';
@@ -14,6 +16,7 @@ export const metadata: Metadata = { title: 'Wunschlisten' };
export default async function WunschlistenPage() {
const user = await requireUser();
const lists = listWishlists(user.id);
const collectors = listCollectors();
return (
<div className="space-y-5">
@@ -24,7 +27,9 @@ export default async function WunschlistenPage() {
Zum Teilen mit Familie und Freunden – ganz ohne Konto für die anderen.
</p>
</div>
<NewWishlistButton />
<NewWishlistButton
collectors={collectors.map((row) => ({ id: row.id, name: row.name }))}
/>
</div>
{lists.length === 0 ? (
@@ -54,6 +59,11 @@ export default async function WunschlistenPage() {
</CardDescription>
</CardHeader>
<CardContent className="flex flex-wrap items-center gap-2 text-xs">
{collectors.length > 1 && list.collectorName ? (
<Badge className={collectorBadgeClass(list.collectorColor)}>
{list.collectorName}
</Badge>
) : null}
<Badge variant="secondary">{pluralize(list.itemCount, 'Wunsch', 'Wünsche')}</Badge>
{list.openCount > 0 ? (
<Badge variant="outline">{list.openCount} noch offen</Badge>
+5
View File
@@ -1,6 +1,7 @@
import { sql } from 'drizzle-orm';
import { NextResponse } from 'next/server';
import { db } from '@/db';
import { ensureReminderScheduler } from '@/lib/reminder-job';
export const dynamic = 'force-dynamic';
@@ -9,6 +10,10 @@ export const dynamic = 'force-dynamic';
* file is readable - a healthy Node process on a broken volume is not healthy.
*/
export function GET() {
// The container polls this every 30s, which makes it the earliest reliable
// moment to have the reminder scheduler running. Repeat calls are free.
ensureReminderScheduler();
try {
db.get(sql`select 1`);
return NextResponse.json({ status: 'ok', time: new Date().toISOString() });
+1 -1
View File
@@ -27,7 +27,7 @@ export default async function PublicWishlistPage({ params }: Props) {
const list = getWishlistByShareToken(shareToken);
if (!list || !list.isPublic) notFound();
const rows = listWishlistItems(list.id);
const rows = listWishlistItems(list.id, list.collectorId);
// Fulfilled wishes are gone from the public view: the present is already
// under the tree, no reason to let someone buy it twice.
+11 -1
View File
@@ -33,6 +33,8 @@ type Props = {
wishlists: WishlistTarget[];
/** Preselected target, used by the embedded view on a wishlist page. */
defaultWishlistId?: string;
/** A list created from a filtered view belongs to that collector. */
collectorId?: string;
emptyState?: React.ReactNode;
};
@@ -43,7 +45,13 @@ type Props = {
* On a desktop the "Auswählen" button does the same job. Sets that are already
* in the collection cannot be selected - they do not belong on a wishlist.
*/
export function CatalogGrid({ sets, wishlists, defaultWishlistId, emptyState }: Props) {
export function CatalogGrid({
sets,
wishlists,
defaultWishlistId,
collectorId,
emptyState,
}: Props) {
const router = useRouter();
const [selecting, setSelecting] = useState(false);
const [selected, setSelected] = useState<Set<string>>(new Set());
@@ -102,6 +110,7 @@ export function CatalogGrid({ sets, wishlists, defaultWishlistId, emptyState }:
title: newTitle,
occasion: 'other',
isPublic: 'on',
collectorId: collectorId ?? '',
});
if (!created.ok) {
toast.error(created.error);
@@ -199,6 +208,7 @@ export function CatalogGrid({ sets, wishlists, defaultWishlistId, emptyState }:
<button
key={item.id}
type="button"
data-set-card
disabled={owned}
aria-pressed={isSelected}
onClick={() => toggle(item.id)}
+206
View File
@@ -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<string, string> = {
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<HTMLFormElement>) {
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 (
<div className="space-y-4">
<dl className="grid grid-cols-3 gap-3 text-sm">
<div>
<dt className="text-xs text-muted-foreground">Ursprünglich</dt>
<dd className="font-medium">{formatEuro(trend.baseCents)}</dd>
</div>
<div>
<dt className="text-xs text-muted-foreground">Aktuell</dt>
<dd className="font-medium">{formatEuro(trend.currentCents)}</dd>
</div>
<div>
<dt className="text-xs text-muted-foreground">Entwicklung</dt>
<dd>
{trend.changePercent === null ? (
<span className="text-muted-foreground">–</span>
) : (
<span
className={`flex items-center gap-1 font-medium ${
gained ? 'text-emerald-600' : 'text-muted-foreground'
}`}
>
{gained ? (
<TrendingUp className="size-4" aria-hidden />
) : (
<TrendingDown className="size-4" aria-hidden />
)}
{trend.changePercent > 0 ? '+' : ''}
{trend.changePercent.toLocaleString('de-DE')} %
</span>
)}
</dd>
</div>
</dl>
{prices.length > 0 ? (
<ul className="divide-y divide-border rounded-lg border border-border">
{prices.map((price) => (
<li key={price.id} className="flex items-center gap-3 p-3">
<div className="min-w-0 flex-1">
<p className="text-sm font-medium">
{formatEuro(price.priceCents)}{' '}
<Badge variant="secondary">{CONDITION_LABELS[price.condition]}</Badge>
</p>
<p className="truncate text-xs text-muted-foreground">
{[formatDate(price.observedAt), price.source, price.note]
.filter(Boolean)
.join(' · ')}
</p>
</div>
<Button
variant="ghost"
size="icon-sm"
aria-label="Preis löschen"
onClick={() => remove(price.id)}
disabled={pending}
>
<Trash2 className="text-destructive" />
</Button>
</li>
))}
</ul>
) : (
<p className="text-sm text-muted-foreground">
Noch kein Preis notiert. Der Ausgangswert ist die UVP des Sets.
</p>
)}
{open ? (
<form onSubmit={submit} className="space-y-3 rounded-lg border border-border p-3">
<p className="text-sm font-medium">Preis notieren</p>
<div className="grid gap-3 sm:grid-cols-3">
<Field label="Preis" htmlFor="priceCents" required hint="In Euro.">
<Input
id="priceCents"
name="priceCents"
inputMode="decimal"
required
placeholder="79,99"
/>
</Field>
<Field label="Zustand" htmlFor="condition">
<NativeSelect id="condition" name="condition" defaultValue="new">
<option value="new">Neu</option>
<option value="used">Gebraucht</option>
</NativeSelect>
</Field>
<Field label="Gesehen am" htmlFor="observedAt" hint="Leer = heute.">
<Input
id="observedAt"
name="observedAt"
type="date"
defaultValue={toDateInputValue(new Date())}
/>
</Field>
</div>
<div className="grid gap-3 sm:grid-cols-2">
<Field label="Wo" htmlFor="source">
<Input id="source" name="source" maxLength={80} placeholder="Amazon, Flohmarkt …" />
</Field>
<Field label="Notiz" htmlFor="price-note">
<Input id="price-note" name="note" maxLength={200} />
</Field>
</div>
<div className="flex gap-2">
<Button type="submit" disabled={pending}>
{pending ? <Loader2 className="animate-spin" /> : <Plus />}
Speichern
</Button>
<Button type="button" variant="outline" onClick={() => setOpen(false)}>
Abbrechen
</Button>
</div>
</form>
) : (
<Button variant="outline" onClick={() => setOpen(true)}>
<Plus />
Preis notieren
</Button>
)}
</div>
);
}
+52 -4
View File
@@ -38,32 +38,47 @@ export function SetActions({
setName,
owned,
wishlists,
collectors,
}: {
setId: string;
setName: string;
owned: boolean;
wishlists: Array<{ id: string; title: string }>;
/** More than one turns "Zur Sammlung" into a question of "for whom". */
collectors: Array<{ id: string; name: string }>;
}) {
const router = useRouter();
const [pending, startTransition] = useTransition();
const [wishlistOpen, setWishlistOpen] = useState(false);
const [deleteOpen, setDeleteOpen] = useState(false);
const [targetId, setTargetId] = useState(wishlists[0]?.id ?? NEW_LIST);
const [collectorOpen, setCollectorOpen] = useState(false);
const [collectorId, setCollectorId] = useState(collectors[0]?.id ?? '');
const [newTitle, setNewTitle] = useState('');
const [priority, setPriority] = useState('2');
function addToCollection() {
function addToCollection(forCollectorId?: string) {
startTransition(async () => {
const result = await addSetToCollection(setId);
const result = await addSetToCollection(setId, forCollectorId);
if (!result.ok) {
toast.error(result.error);
return;
}
toast.success('Zur Sammlung hinzugefügt.');
const owner = collectors.find((entry) => entry.id === forCollectorId);
toast.success(
owner ? `Zur Sammlung von ${owner.name} hinzugefügt.` : 'Zur Sammlung hinzugefügt.',
);
setCollectorOpen(false);
router.refresh();
});
}
// With a single collector there is nothing to ask.
function onAddClick() {
if (collectors.length > 1) setCollectorOpen(true);
else addToCollection();
}
function addToWishlist() {
startTransition(async () => {
let wishlistId = targetId;
@@ -111,7 +126,7 @@ export function SetActions({
return (
<div className="flex flex-wrap gap-2">
<Button size="lg" onClick={addToCollection} disabled={pending}>
<Button size="lg" onClick={onAddClick} disabled={pending}>
{pending ? <Loader2 className="animate-spin" /> : <Plus />}
Zur Sammlung hinzufügen
</Button>
@@ -138,6 +153,39 @@ export function SetActions({
</p>
) : null}
<Dialog open={collectorOpen} onOpenChange={setCollectorOpen}>
<DialogContent>
<DialogHeader>
<DialogTitle>Wer bekommt das Set?</DialogTitle>
<DialogDescription>{setName}</DialogDescription>
</DialogHeader>
<Field label="Sammler" htmlFor="add-collector" required>
<NativeSelect
id="add-collector"
value={collectorId}
onChange={(event) => setCollectorId(event.target.value)}
>
{collectors.map((entry) => (
<option key={entry.id} value={entry.id}>
{entry.name}
</option>
))}
</NativeSelect>
</Field>
<DialogFooter>
<Button variant="outline" onClick={() => setCollectorOpen(false)} disabled={pending}>
Abbrechen
</Button>
<Button onClick={() => addToCollection(collectorId)} disabled={pending}>
{pending ? <Loader2 className="animate-spin" /> : <Plus />}
Hinzufügen
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
<Dialog open={wishlistOpen} onOpenChange={setWishlistOpen}>
<DialogContent>
<DialogHeader>
+107 -1
View File
@@ -1,13 +1,28 @@
'use client';
import { useRouter } from 'next/navigation';
import { useState, useTransition } from 'react';
import { useRef, useState, useTransition } from 'react';
import Link from 'next/link';
import { Loader2, Save } from 'lucide-react';
import { toast } from 'sonner';
import { createSet, updateSet } from '@/app/(app)/katalog/actions';
import {
findDuplicateCandidates,
type DuplicateCandidate,
} from '@/app/(app)/katalog/duplicate-actions';
import { centsToInput } from '@/lib/money';
import { setImageSrc } from '@/lib/images';
import { Button } from '@/components/ui/button';
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from '@/components/ui/alert-dialog';
import { Checkbox } from '@/components/ui/checkbox';
import { Field } from '@/components/ui/field';
import { Input } from '@/components/ui/input';
@@ -46,6 +61,8 @@ export function SetForm({ manufacturers, themes, set, defaultEan }: Props) {
const [manufacturerId, setManufacturerId] = useState(
set?.manufacturerId ?? manufacturers[0]?.id ?? '',
);
const [duplicates, setDuplicates] = useState<DuplicateCandidate[] | null>(null);
const pendingForm = useRef<FormData | null>(null);
const existingImage = set ? setImageSrc(set) : null;
const availableThemes = themes.filter((theme) => theme.manufacturerId === manufacturerId);
@@ -54,31 +71,69 @@ export function SetForm({ manufacturers, themes, set, defaultEan }: Props) {
event.preventDefault();
const formData = new FormData(event.currentTarget);
// Ask before creating something that already exists. Editing skips this:
// there the entry is the one we would be warning about.
if (!set) {
startTransition(async () => {
const check = await findDuplicateCandidates({
manufacturerId: String(formData.get('manufacturerId') ?? ''),
setNumber: String(formData.get('setNumber') ?? ''),
name: String(formData.get('name') ?? ''),
});
if (check.ok && check.data.candidates.length > 0) {
pendingForm.current = formData;
setDuplicates(check.data.candidates);
return;
}
await save(formData);
});
return;
}
startTransition(() => save(formData));
}
async function save(formData: FormData) {
return new Promise<void>((resolve) => {
startTransition(async () => {
if (set) {
const result = await updateSet(set.id, formData);
if (!result.ok) {
toast.error(result.error);
resolve();
return;
}
toast.success('Set gespeichert.');
router.push(`/katalog/${set.id}`);
router.refresh();
resolve();
return;
}
const result = await createSet(formData);
if (!result.ok) {
toast.error(result.error);
resolve();
return;
}
toast.success('Set angelegt.');
router.push(`/katalog/${result.data.id}`);
router.refresh();
resolve();
});
});
}
const REASON_LABELS: Record<DuplicateCandidate['reason'], string> = {
'same-number': 'Gleiche Setnummer beim gleichen Hersteller',
'similar-name': 'Sehr ähnlicher Name',
'number-elsewhere': 'Gleiche Setnummer bei einem anderen Hersteller',
};
return (
<>
<form onSubmit={onSubmit} className="space-y-6">
<div className="grid gap-4 sm:grid-cols-2">
<Field label="Hersteller" htmlFor="manufacturerId" required>
@@ -244,5 +299,56 @@ export function SetForm({ manufacturers, themes, set, defaultEan }: Props) {
</Button>
</div>
</form>
<AlertDialog
open={duplicates !== null}
onOpenChange={(open) => {
if (!open) {
setDuplicates(null);
pendingForm.current = null;
}
}}
>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Gibt es das vielleicht schon?</AlertDialogTitle>
<AlertDialogDescription>
Diese Einträge sehen ähnlich aus. Schau kurz nach, bevor der Katalog zwei Mal
dasselbe Set enthält.
</AlertDialogDescription>
</AlertDialogHeader>
<ul className="divide-y divide-border rounded-lg border border-border">
{(duplicates ?? []).map((candidate) => (
<li key={candidate.id} className="p-3">
<Link
href={`/katalog/${candidate.id}`}
className="text-sm font-medium hover:underline"
>
{candidate.setNumber} · {candidate.name}
</Link>
<p className="text-xs text-muted-foreground">
{candidate.manufacturerName} — {REASON_LABELS[candidate.reason]}
</p>
</li>
))}
</ul>
<AlertDialogFooter>
<AlertDialogCancel>Zurück zum Formular</AlertDialogCancel>
<AlertDialogAction
onClick={() => {
const formData = pendingForm.current;
setDuplicates(null);
pendingForm.current = null;
if (formData) void save(formData);
}}
>
Trotzdem anlegen
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</>
);
}
@@ -0,0 +1,78 @@
'use client';
import { usePathname, useRouter, useSearchParams } from 'next/navigation';
import { useTransition } from 'react';
import { cn } from 'cn';
import { collectorDotClass } from '@/lib/collector-colors';
export type CollectorOption = { id: string; name: string; color: string };
/**
* Switches the view between collectors.
*
* Rendered only when there is more than one - with a single collector the
* whole question does not exist, and the app should not ask it. The choice
* lives in the URL (`?sammler=`), so a filtered view stays shareable and the
* back button behaves.
*/
export function CollectorFilter({
collectors,
allLabel = 'Alle',
}: {
collectors: CollectorOption[];
allLabel?: string;
}) {
const router = useRouter();
const pathname = usePathname();
const searchParams = useSearchParams();
const [, startTransition] = useTransition();
if (collectors.length < 2) return null;
const current = searchParams.get('sammler') ?? '';
function select(id: string) {
const params = new URLSearchParams(searchParams.toString());
if (id) params.set('sammler', id);
else params.delete('sammler');
params.delete('seite');
const query = params.toString();
startTransition(() => router.push(query ? `${pathname}?${query}` : pathname));
}
const options = [{ id: '', name: allLabel, color: 'slate' }, ...collectors];
return (
<nav
aria-label="Sammler"
className="flex gap-1 overflow-x-auto rounded-lg bg-muted p-1"
>
{options.map((option) => {
const active = current === option.id;
return (
<button
key={option.id || 'alle'}
type="button"
aria-pressed={active}
onClick={() => select(option.id)}
className={cn(
'flex flex-1 items-center justify-center gap-2 rounded-md px-3 py-2 text-sm font-medium whitespace-nowrap transition-colors',
active
? 'bg-background text-foreground shadow-sm'
: 'text-muted-foreground hover:text-foreground',
)}
>
{option.id ? (
<span
className={cn('size-2 rounded-full', collectorDotClass(option.color))}
aria-hidden
/>
) : null}
{option.name}
</button>
);
})}
</nav>
);
}
@@ -8,6 +8,7 @@ import { deleteCollectionItem, updateCollectionItem } from '@/app/(app)/sammlung
import type { MissingPartRow } from '@/lib/queries/collection';
import { MissingPartsDialog } from './missing-parts-dialog';
import type { CollectionStatus, Completeness, Condition } from '@/db/types';
import { collectorBadgeClass } from '@/lib/collector-colors';
import { formatDate, toDateInputValue } from '@/lib/format';
import {
COMPLETENESS_LABELS,
@@ -58,14 +59,19 @@ export type CollectionItemRow = {
notes: string | null;
setName: string;
missingParts: MissingPartRow[];
collectorId: string | null;
collectorName: string | null;
collectorColor: string | null;
};
export function CollectionItemsSection({
items,
locations,
collectors,
}: {
items: CollectionItemRow[];
locations: Array<{ id: string; name: string }>;
collectors: Array<{ id: string; name: string }>;
}) {
const router = useRouter();
const [pending, startTransition] = useTransition();
@@ -126,6 +132,11 @@ export function CollectionItemsSection({
<li key={item.id} className="flex items-start gap-3 p-3">
<div className="min-w-0 flex-1 space-y-1">
<div className="flex flex-wrap items-center gap-2">
{collectors.length > 1 && item.collectorName ? (
<Badge className={collectorBadgeClass(item.collectorColor)}>
{item.collectorName}
</Badge>
) : null}
<Badge variant={item.status === 'sold' ? 'outline' : 'default'}>
{statusLabel(item.status)}
</Badge>
@@ -194,6 +205,22 @@ export function CollectionItemsSection({
<form id="collection-item-form" onSubmit={submit} className="space-y-4">
<div className="grid gap-4 sm:grid-cols-2">
{collectors.length > 1 ? (
<Field label="Sammler" htmlFor="collectorId" className="sm:col-span-2">
<NativeSelect
id="collectorId"
name="collectorId"
defaultValue={editing?.collectorId ?? collectors[0]?.id ?? 'none'}
>
{collectors.map((entry) => (
<option key={entry.id} value={entry.id}>
{entry.name}
</option>
))}
</NativeSelect>
</Field>
) : null}
<Field label="Status" htmlFor="status">
<NativeSelect id="status" name="status" defaultValue={editing?.status ?? 'owned'}>
{Object.entries(STATUS_LABELS).map(([value, label]) => (
@@ -0,0 +1,87 @@
'use client';
import {
createCollector,
deleteCollector,
updateCollector,
} from '@/app/(app)/verwaltung/sammler/actions';
import { COLLECTOR_COLOR_LABELS } from '@/lib/collector-colors';
import { toDateInputValue } from '@/lib/format';
import { CrudSection, type CrudItem } from './crud-section';
import { Field } from '@/components/ui/field';
import { Input } from '@/components/ui/input';
import { NativeSelect } from '@/components/ui/native-select';
import { Textarea } from '@/components/ui/textarea';
export type CollectorRowItem = CrudItem & {
color: string;
birthday: Date | null;
notes: string | null;
itemCount: number;
};
export function CollectorList({ collectors }: { collectors: CollectorRowItem[] }) {
return (
<CrudSection<CollectorRowItem>
items={collectors}
emptyText="Noch kein Sammler angelegt."
createLabel="Sammler anlegen"
dialogTitle={(item) => (item ? 'Sammler bearbeiten' : 'Neuer Sammler')}
dialogDescription="Der Katalog bleibt gemeinsam – Sammlung und Wunschlisten gehören je einem Sammler."
onCreate={createCollector}
onUpdate={updateCollector}
onDelete={deleteCollector}
deleteWarning={(item) =>
item.itemCount > 0
? `„${item.title}“ hat ${item.itemCount} Sets in der Sammlung. Ordne sie erst jemand anderem zu – erst dann lässt sich der Sammler löschen.`
: `„${item.title}“ wird gelöscht. Der Katalog bleibt unverändert.`
}
renderFields={(item) => (
<>
<Field label="Name" htmlFor="name" required>
<Input
id="name"
name="name"
defaultValue={item?.title ?? ''}
required
maxLength={60}
placeholder="Max"
/>
</Field>
<Field
label="Farbe"
htmlFor="color"
hint="Nur zur Unterscheidung auf einen Blick."
>
<NativeSelect id="color" name="color" defaultValue={item?.color ?? 'blue'}>
{Object.entries(COLLECTOR_COLOR_LABELS).map(([value, label]) => (
<option key={value} value={value}>
{label}
</option>
))}
</NativeSelect>
</Field>
<Field label="Geburtstag" htmlFor="birthday" hint="Optional.">
<Input
id="birthday"
name="birthday"
type="date"
defaultValue={toDateInputValue(item?.birthday)}
/>
</Field>
<Field label="Notizen" htmlFor="collector-notes">
<Textarea
id="collector-notes"
name="notes"
defaultValue={item?.notes ?? ''}
rows={3}
/>
</Field>
</>
)}
/>
);
}
@@ -0,0 +1,166 @@
'use client';
import { useState, useTransition } from 'react';
import { BellRing, Loader2, Mail, PlugZap } from 'lucide-react';
import { toast } from 'sonner';
import {
runRemindersNow,
sendTestMail,
testMailConnection,
} from '@/app/(app)/verwaltung/erinnerungen/actions';
import { formatDate } from '@/lib/format';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
export type ReminderListRow = {
id: string;
title: string;
eventDate: Date | null;
openItemCount: number;
lastReminderAt: Date | null;
daysLeft: number | null;
};
export function ReminderSettings({
configured,
daysBefore,
lists,
}: {
configured: boolean;
daysBefore: number;
lists: ReminderListRow[];
}) {
const [pending, startTransition] = useTransition();
const [busy, setBusy] = useState<'verify' | 'test' | 'run' | null>(null);
function run(kind: 'verify' | 'test' | 'run') {
setBusy(kind);
startTransition(async () => {
if (kind === 'verify') {
const result = await testMailConnection();
if (result.ok) toast.success('Der SMTP-Server antwortet.');
else toast.error(result.error);
}
if (kind === 'test') {
const result = await sendTestMail();
if (result.ok) toast.success('Testnachricht verschickt. Schau in dein Postfach.');
else toast.error(result.error);
}
if (kind === 'run') {
const result = await runRemindersNow();
if (!result.ok) {
toast.error(result.error);
} else if (result.data.sent === 0 && result.data.failures.length === 0) {
toast.info('Nichts zu tun – gerade steht keine Erinnerung an.');
} else {
toast.success(
`${result.data.sent} Erinnerung(en) verschickt, ${result.data.failures.length} fehlgeschlagen.`,
);
}
}
setBusy(null);
});
}
return (
<div className="space-y-6">
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<BellRing className="size-5 text-primary" aria-hidden />
Erinnerungen
</CardTitle>
<CardDescription>
Rückt der Termin einer Wunschliste näher und stehen noch Wünsche offen, geht eine
Nachricht an dich raus – {daysBefore} Tage vorher, höchstens einmal pro Woche je Liste.
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
{configured ? (
<div className="flex flex-wrap gap-2">
<Button variant="outline" onClick={() => run('verify')} disabled={pending}>
{busy === 'verify' ? <Loader2 className="animate-spin" /> : <PlugZap />}
Verbindung prüfen
</Button>
<Button variant="outline" onClick={() => run('test')} disabled={pending}>
{busy === 'test' ? <Loader2 className="animate-spin" /> : <Mail />}
Testnachricht an mich
</Button>
<Button onClick={() => run('run')} disabled={pending}>
{busy === 'run' ? <Loader2 className="animate-spin" /> : <BellRing />}
Jetzt prüfen
</Button>
</div>
) : (
<div className="space-y-2 rounded-lg border border-dashed border-border p-4 text-sm text-muted-foreground">
<p>
Es ist kein Mailversand eingerichtet – Erinnerungen sind damit aus, alles andere
funktioniert unverändert.
</p>
<p>Zum Aktivieren in die .env eintragen und den Container neu starten:</p>
<pre className="overflow-x-auto rounded-md bg-muted p-3 font-mono text-xs">
{`SMTP_HOST=mail.example.de
SMTP_PORT=587
SMTP_USER=bricktrack@example.de
SMTP_PASSWORD=…
SMTP_FROM=Bricktrack <bricktrack@example.de>`}
</pre>
</div>
)}
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>Wunschlisten mit Termin</CardTitle>
<CardDescription>
Nur Listen mit Termin können erinnert werden. Ist alles reserviert, bleibt es still.
</CardDescription>
</CardHeader>
<CardContent>
{lists.length === 0 ? (
<p className="text-sm text-muted-foreground">
Keine Wunschliste hat bisher einen Termin.
</p>
) : (
<ul className="divide-y divide-border rounded-lg border border-border">
{lists.map((list) => (
<li key={list.id} className="flex items-center gap-3 p-3">
<div className="min-w-0 flex-1">
<p className="truncate text-sm font-medium">{list.title}</p>
<p className="text-xs text-muted-foreground">
{list.eventDate ? formatDate(list.eventDate) : 'ohne Termin'}
{list.lastReminderAt
? ` · zuletzt erinnert am ${formatDate(list.lastReminderAt)}`
: ''}
</p>
</div>
{list.openItemCount > 0 ? (
<Badge variant="outline">{list.openItemCount} offen</Badge>
) : (
<Badge className="bg-emerald-600 text-white hover:bg-emerald-600">
Alles vergeben
</Badge>
)}
{list.daysLeft !== null && list.daysLeft >= 0 ? (
<Badge variant="secondary">
{list.daysLeft === 0 ? 'heute' : `in ${list.daysLeft} Tagen`}
</Badge>
) : (
<Badge variant="outline">vorbei</Badge>
)}
</li>
))}
</ul>
)}
</CardContent>
</Card>
</div>
);
}
@@ -5,10 +5,12 @@ import { usePathname } from 'next/navigation';
import { cn } from 'cn';
const tabs = [
{ href: '/verwaltung/sammler', label: 'Sammler', adminOnly: false },
{ href: '/verwaltung/hersteller', label: 'Hersteller', adminOnly: false },
{ href: '/verwaltung/themen', label: 'Themen', adminOnly: false },
{ href: '/verwaltung/standorte', label: 'Standorte', adminOnly: false },
{ href: '/verwaltung/einladungen', label: 'Einladungen', adminOnly: true },
{ href: '/verwaltung/erinnerungen', label: 'Erinnerungen', adminOnly: true },
{ href: '/verwaltung/daten', label: 'Daten', adminOnly: true },
] as const;
@@ -26,6 +26,7 @@ import { Textarea } from '@/components/ui/textarea';
export type WishlistFormValues = {
id: string;
collectorId: string | null;
title: string;
occasion: Occasion;
eventDate: Date | null;
@@ -36,10 +37,13 @@ export type WishlistFormValues = {
export function WishlistFormDialog({
wishlist,
collectors = [],
open,
onOpenChange,
}: {
wishlist?: WishlistFormValues;
/** Only asked about when there is more than one. */
collectors?: Array<{ id: string; name: string }>;
open: boolean;
onOpenChange: (open: boolean) => void;
}) {
@@ -99,6 +103,27 @@ export function WishlistFormDialog({
/>
</Field>
{collectors.length > 1 ? (
<Field
label="Für wen"
htmlFor="collectorId"
hint="Bestimmt, wessen Sammlung als „hat er schon“ zählt."
>
<NativeSelect
id="collectorId"
name="collectorId"
defaultValue={wishlist?.collectorId ?? 'none'}
>
<option value="none">– alle zusammen –</option>
{collectors.map((entry) => (
<option key={entry.id} value={entry.id}>
{entry.name}
</option>
))}
</NativeSelect>
</Field>
) : null}
<div className="grid gap-4 sm:grid-cols-2">
<Field label="Anlass" htmlFor="occasion">
<NativeSelect
@@ -180,7 +205,11 @@ export function WishlistFormDialog({
}
/** Button plus dialog, for the wishlist overview page. */
export function NewWishlistButton() {
export function NewWishlistButton({
collectors = [],
}: {
collectors?: Array<{ id: string; name: string }>;
}) {
const [open, setOpen] = useState(false);
return (
@@ -189,7 +218,7 @@ export function NewWishlistButton() {
<Plus />
Neue Wunschliste
</Button>
<WishlistFormDialog open={open} onOpenChange={setOpen} />
<WishlistFormDialog collectors={collectors} open={open} onOpenChange={setOpen} />
</>
);
}
@@ -18,7 +18,13 @@ import {
AlertDialogTitle,
} from '@/components/ui/alert-dialog';
export function WishlistHeaderActions({ wishlist }: { wishlist: WishlistFormValues }) {
export function WishlistHeaderActions({
wishlist,
collectors = [],
}: {
wishlist: WishlistFormValues;
collectors?: Array<{ id: string; name: string }>;
}) {
const router = useRouter();
const [editOpen, setEditOpen] = useState(false);
const [deleteOpen, setDeleteOpen] = useState(false);
@@ -52,7 +58,12 @@ export function WishlistHeaderActions({ wishlist }: { wishlist: WishlistFormValu
<Trash2 className="text-destructive" />
</Button>
<WishlistFormDialog wishlist={wishlist} open={editOpen} onOpenChange={setEditOpen} />
<WishlistFormDialog
wishlist={wishlist}
collectors={collectors}
open={editOpen}
onOpenChange={setEditOpen}
/>
<AlertDialog open={deleteOpen} onOpenChange={setDeleteOpen}>
<AlertDialogContent>
+8
View File
@@ -8,6 +8,7 @@ import {
text,
} from 'drizzle-orm/sqlite-core';
import { set } from './catalog';
import { collector } from './collector';
export const location = sqliteTable('location', {
id: text('id').primaryKey(),
@@ -31,6 +32,12 @@ export const collectionItem = sqliteTable(
setId: text('set_id')
.notNull()
.references(() => set.id, { onDelete: 'cascade' }),
/**
* Who owns this copy. Nullable only for the sake of the migration path -
* the app always sets it, and the migration assigned every existing row to
* the first collector.
*/
collectorId: text('collector_id').references(() => collector.id, { onDelete: 'cascade' }),
status: text('status', {
enum: ['owned', 'built', 'disassembled', 'lent_out', 'sold'],
})
@@ -54,6 +61,7 @@ export const collectionItem = sqliteTable(
},
(t) => [
index('collection_item_set_idx').on(t.setId),
index('collection_item_collector_idx').on(t.collectorId),
index('collection_item_status_idx').on(t.status),
index('collection_item_location_idx').on(t.locationId),
],
+29
View File
@@ -0,0 +1,29 @@
import { index, integer, sqliteTable, text } from 'drizzle-orm/sqlite-core';
/**
* A collector - in practice: a child.
*
* The catalog is shared (a set exists once, no matter who owns it), while
* ownership and wishlists belong to one collector. With a single collector the
* whole concept stays invisible in the UI; it only appears once a second one
* exists, so a one-child household never pays for the feature.
*/
export const collector = sqliteTable(
'collector',
{
id: text('id').primaryKey(),
name: text('name').notNull(),
/** For telling two collectors apart at a glance. */
color: text('color', {
enum: ['blue', 'green', 'amber', 'rose', 'violet', 'slate'],
})
.notNull()
.default('blue'),
birthday: integer('birthday', { mode: 'timestamp' }),
notes: text('notes'),
sortOrder: integer('sort_order').notNull().default(0),
createdAt: integer('created_at', { mode: 'timestamp' }).notNull(),
updatedAt: integer('updated_at', { mode: 'timestamp' }).notNull(),
},
(t) => [index('collector_sort_idx').on(t.sortOrder, t.name)],
);
+2
View File
@@ -1,5 +1,7 @@
export * from './auth';
export * from './catalog';
export * from './collector';
export * from './prices';
export * from './collection';
export * from './wishlist';
export * from './relations';
+35
View File
@@ -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`),
],
);
+20
View File
@@ -2,6 +2,8 @@ import { relations } from 'drizzle-orm';
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 }) => ({
@@ -48,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 }) => ({
@@ -73,8 +80,17 @@ export const locationRelations = relations(location, ({ one, many }) => ({
collectionItems: many(collectionItem),
}));
export const collectorRelations = relations(collector, ({ many }) => ({
collectionItems: many(collectionItem),
wishlists: many(wishlist),
}));
export const collectionItemRelations = relations(collectionItem, ({ one, many }) => ({
set: one(set, { fields: [collectionItem.setId], references: [set.id] }),
collector: one(collector, {
fields: [collectionItem.collectorId],
references: [collector.id],
}),
location: one(location, {
fields: [collectionItem.locationId],
references: [location.id],
@@ -100,6 +116,10 @@ export const photoRelations = relations(photo, ({ one }) => ({
export const wishlistRelations = relations(wishlist, ({ one, many }) => ({
owner: one(user, { fields: [wishlist.ownerUserId], references: [user.id] }),
collector: one(collector, {
fields: [wishlist.collectorId],
references: [collector.id],
}),
items: many(wishlistItem),
}));
+8
View File
@@ -2,6 +2,7 @@ import { sql } from 'drizzle-orm';
import { check, index, integer, sqliteTable, text, uniqueIndex } from 'drizzle-orm/sqlite-core';
import { user } from './auth';
import { set } from './catalog';
import { collector } from './collector';
export const wishlist = sqliteTable(
'wishlist',
@@ -10,6 +11,11 @@ export const wishlist = sqliteTable(
ownerUserId: text('owner_user_id')
.notNull()
.references(() => user.id, { onDelete: 'cascade' }),
/**
* Whose list this is. Null means "not tied to one collector" - useful
* while there is only one, and for a shared list.
*/
collectorId: text('collector_id').references(() => collector.id, { onDelete: 'set null' }),
/** e.g. "Weihnachten 2026". */
title: text('title').notNull(),
occasion: text('occasion', { enum: ['christmas', 'birthday', 'easter', 'other'] })
@@ -23,6 +29,8 @@ export const wishlist = sqliteTable(
surpriseMode: integer('surprise_mode', { mode: 'boolean' }).notNull().default(false),
/** Message shown to the gift givers, e.g. "Bitte keine Doppelungen". */
note: text('note'),
/** When the owner was last reminded - keeps a nag from becoming a flood. */
lastReminderAt: integer('last_reminder_at', { mode: 'timestamp' }),
createdAt: integer('created_at', { mode: 'timestamp' }).notNull(),
updatedAt: integer('updated_at', { mode: 'timestamp' }).notNull(),
},
+22 -1
View File
@@ -27,8 +27,26 @@ const schema = z.object({
/** Upload size limit per file, in megabytes. */
MAX_UPLOAD_MB: z.coerce.number().int().positive().max(200).default(25),
/** Optional, phase 2: Rebrickable set import. Import stays disabled without it. */
/** Optional: Rebrickable set import. Import stays disabled without it. */
REBRICKABLE_API_KEY: z.string().min(1).optional(),
/**
* Optional: outgoing mail for wishlist reminders. Without SMTP_HOST the
* whole reminder feature stays switched off - nothing is scheduled, nothing
* is sent.
*/
SMTP_HOST: z.string().min(1).optional(),
SMTP_PORT: z.coerce.number().int().min(1).max(65535).default(587),
SMTP_SECURE: z
.enum(['true', 'false'])
.default('false')
.transform((value) => value === 'true'),
SMTP_USER: z.string().min(1).optional(),
SMTP_PASSWORD: z.string().min(1).optional(),
SMTP_FROM: z.string().min(1).optional(),
/** How many days before the event a reminder goes out. */
REMINDER_DAYS_BEFORE: z.coerce.number().int().min(1).max(90).default(14),
});
/**
@@ -82,6 +100,9 @@ export const env = {
MAX_UPLOAD_BYTES: raw.MAX_UPLOAD_MB * 1024 * 1024,
isProduction: raw.NODE_ENV === 'production',
/** Reminders need a host and a sender; everything else has a default. */
isMailConfigured: Boolean(raw.SMTP_HOST && raw.SMTP_FROM),
} as const;
export type Env = typeof env;
+40
View File
@@ -0,0 +1,40 @@
/**
* The small colour palette that tells collectors apart.
*
* Fixed classes rather than computed ones, because Tailwind only ships the
* classes it can see in the source.
*/
export const COLLECTOR_COLOR_LABELS: Record<string, string> = {
blue: 'Blau',
green: 'Grün',
amber: 'Gelb',
rose: 'Rot',
violet: 'Violett',
slate: 'Grau',
};
const BADGE_CLASSES: Record<string, string> = {
blue: 'bg-blue-100 text-blue-900 dark:bg-blue-950 dark:text-blue-200',
green: 'bg-emerald-100 text-emerald-900 dark:bg-emerald-950 dark:text-emerald-200',
amber: 'bg-amber-100 text-amber-900 dark:bg-amber-950 dark:text-amber-200',
rose: 'bg-rose-100 text-rose-900 dark:bg-rose-950 dark:text-rose-200',
violet: 'bg-violet-100 text-violet-900 dark:bg-violet-950 dark:text-violet-200',
slate: 'bg-slate-200 text-slate-900 dark:bg-slate-800 dark:text-slate-100',
};
export function collectorBadgeClass(color: string | null | undefined): string {
return BADGE_CLASSES[color ?? 'blue'] ?? BADGE_CLASSES.blue!;
}
const DOT_CLASSES: Record<string, string> = {
blue: 'bg-blue-500',
green: 'bg-emerald-500',
amber: 'bg-amber-500',
rose: 'bg-rose-500',
violet: 'bg-violet-500',
slate: 'bg-slate-500',
};
export function collectorDotClass(color: string | null | undefined): string {
return DOT_CLASSES[color ?? 'blue'] ?? DOT_CLASSES.blue!;
}
+77
View File
@@ -0,0 +1,77 @@
import 'server-only';
import nodemailer, { type Transporter } from 'nodemailer';
import { env } from '@/env';
/**
* Outgoing mail.
*
* Entirely optional: without SMTP_HOST and SMTP_FROM nothing is scheduled and
* nothing is sent. The app never depends on mail working.
*/
let transporter: Transporter | null = null;
function getTransporter(): Transporter | null {
if (!env.isMailConfigured) return null;
if (transporter) return transporter;
transporter = nodemailer.createTransport({
host: env.SMTP_HOST,
port: env.SMTP_PORT,
// Implicit TLS on 465, STARTTLS on 587 - the usual pair.
secure: env.SMTP_SECURE || env.SMTP_PORT === 465,
auth:
env.SMTP_USER && env.SMTP_PASSWORD
? { user: env.SMTP_USER, pass: env.SMTP_PASSWORD }
: undefined,
});
return transporter;
}
export type MailResult = { ok: true } | { ok: false; error: string };
export async function sendMail(input: {
to: string;
subject: string;
text: string;
}): Promise<MailResult> {
const transport = getTransporter();
if (!transport) {
return { ok: false, error: 'Es ist kein SMTP-Server konfiguriert.' };
}
try {
await transport.sendMail({
from: env.SMTP_FROM,
to: input.to,
subject: input.subject,
text: input.text,
});
return { ok: true };
} catch (error) {
console.error('[mail] Versand fehlgeschlagen', error);
return {
ok: false,
error: error instanceof Error ? error.message : 'Der Versand ist fehlgeschlagen.',
};
}
}
/** Checks the connection without sending anything. */
export async function verifyMailConnection(): Promise<MailResult> {
const transport = getTransporter();
if (!transport) {
return { ok: false, error: 'Es ist kein SMTP-Server konfiguriert.' };
}
try {
await transport.verify();
return { ok: true };
} catch (error) {
return {
ok: false,
error: error instanceof Error ? error.message : 'Der Server antwortet nicht.',
};
}
}
+63 -6
View File
@@ -8,8 +8,9 @@ import type { CollectionStatus } from '@/db/types';
* status is anything but `sold`. A sold copy is history, not ownership - so a
* set that was sold shows up under "fehlt" again and can go back on a wishlist.
*
* Every query that asks about ownership goes through this module. If the rule
* ever changes, it changes here.
* With more than one collector the question gains a "for whom": the catalog is
* shared, ownership is not. Every query that asks about ownership goes through
* this module, optionally scoped to one collector.
*/
export const OWNED_STATUSES = ['owned', 'built', 'disassembled', 'lent_out'] as const;
@@ -18,6 +19,12 @@ export const SOLD_STATUS: CollectionStatus = 'sold';
export type OwnedStatus = (typeof OWNED_STATUSES)[number];
/** One owned copy, as far as ownership is concerned. */
export type OwnableCopy = {
status: CollectionStatus;
collectorId?: string | null;
};
/** Does a single collection item count towards ownership? */
export function isOwningStatus(status: CollectionStatus): status is OwnedStatus {
return status !== SOLD_STATUS;
@@ -37,25 +44,75 @@ export function countOwnedCopies(statuses: readonly CollectionStatus[]): number
}
/**
* SQL counterpart of `countOwnedCopies`, as a correlated subquery.
* Ownership from the point of view of one collector.
*
* Without a collector the question is "does anyone here have it", which is
* what a single-collector household asks anyway.
*/
export function countOwnedCopiesFor(
copies: readonly OwnableCopy[],
collectorId?: string | null,
): number {
return copies.filter(
(copy) =>
isOwningStatus(copy.status) &&
(collectorId === undefined || collectorId === null || copy.collectorId === collectorId),
).length;
}
export function isSetOwnedBy(
copies: readonly OwnableCopy[],
collectorId?: string | null,
): boolean {
return countOwnedCopiesFor(copies, collectorId) > 0;
}
/**
* SQL counterpart of `countOwnedCopiesFor`, as a correlated subquery.
*
* A subquery instead of a join, because a set owned twice must not appear
* twice in the catalog list.
*/
export function ownedCopiesSql(setIdColumn: SQL | unknown): SQL<number> {
export function ownedCopiesSql(
setIdColumn: SQL | unknown,
collectorId?: string | null,
): SQL<number> {
if (!collectorId) {
return sql<number>`(
select count(*) from collection_item ci
where ci.set_id = ${setIdColumn} and ci.status <> ${SOLD_STATUS}
)`;
}
/** How many wishlists this set is currently on. */
export function wishlistCountSql(setIdColumn: SQL | unknown): SQL<number> {
return sql<number>`(
select count(*) from collection_item ci
where ci.set_id = ${setIdColumn}
and ci.status <> ${SOLD_STATUS}
and ci.collector_id = ${collectorId}
)`;
}
/**
* How many wishlists this set is on. Scoped to a collector where one is
* selected - a set on the other child's list is not "already wished for" here.
*/
export function wishlistCountSql(
setIdColumn: SQL | unknown,
collectorId?: string | null,
): SQL<number> {
if (!collectorId) {
return sql<number>`(
select count(*) from wishlist_item wi where wi.set_id = ${setIdColumn}
)`;
}
return sql<number>`(
select count(*) from wishlist_item wi
join wishlist w on w.id = wi.wishlist_id
where wi.set_id = ${setIdColumn} and w.collector_id = ${collectorId}
)`;
}
/** The four states the catalog filter offers, as they appear in the URL. */
export const OWNERSHIP_FILTERS = ['alle', 'sammlung', 'fehlt', 'wunschliste'] as const;
+77
View File
@@ -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);
}
+43 -8
View File
@@ -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;
@@ -14,6 +22,8 @@ export type CatalogFilters = {
ownership?: OwnershipFilter;
/** Hides sets that are already on this wishlist (for "aus fehlenden Sets hinzufügen"). */
excludeWishlistId?: string;
/** Scopes "owned" and "on a wishlist" to one collector. */
collectorId?: string;
page?: number;
};
@@ -52,7 +62,7 @@ function buildWhere(filters: CatalogFilters): SQL | undefined {
if (filters.themeId) conditions.push(eq(set.themeId, filters.themeId));
if (filters.year !== undefined) conditions.push(eq(set.year, filters.year));
const owned = ownedCopiesSql(set.id);
const owned = ownedCopiesSql(set.id, filters.collectorId);
switch (filters.ownership) {
case 'sammlung':
conditions.push(sql`${owned} > 0`);
@@ -61,7 +71,7 @@ function buildWhere(filters: CatalogFilters): SQL | undefined {
conditions.push(sql`${owned} = 0`);
break;
case 'wunschliste':
conditions.push(sql`${wishlistCountSql(set.id)} > 0`);
conditions.push(sql`${wishlistCountSql(set.id, filters.collectorId)} > 0`);
break;
default:
break;
@@ -95,8 +105,8 @@ export function listSets(filters: CatalogFilters): { rows: CatalogRow[]; total:
retailPriceCents: set.retailPriceCents,
manufacturerName: manufacturer.name,
themeName: theme.name,
ownedCopies: ownedCopiesSql(set.id).mapWith(Number),
wishlistCount: wishlistCountSql(set.id).mapWith(Number),
ownedCopies: ownedCopiesSql(set.id, filters.collectorId).mapWith(Number),
wishlistCount: wishlistCountSql(set.id, filters.collectorId).mapWith(Number),
})
.from(set)
.innerJoin(manufacturer, eq(manufacturer.id, set.manufacturerId))
@@ -126,7 +136,7 @@ export function listSetIds(filters: CatalogFilters): string[] {
.map((row) => row.id);
}
export function getSetById(id: string) {
export function getSetById(id: string, collectorId?: string) {
const row = db
.select({
id: set.id,
@@ -148,8 +158,8 @@ export function getSetById(id: string) {
notes: set.notes,
createdAt: set.createdAt,
updatedAt: set.updatedAt,
ownedCopies: ownedCopiesSql(set.id).mapWith(Number),
wishlistCount: wishlistCountSql(set.id).mapWith(Number),
ownedCopies: ownedCopiesSql(set.id, collectorId).mapWith(Number),
wishlistCount: wishlistCountSql(set.id, collectorId).mapWith(Number),
})
.from(set)
.innerJoin(manufacturer, eq(manufacturer.id, set.manufacturerId))
@@ -225,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();
}
+47 -11
View File
@@ -1,8 +1,16 @@
import 'server-only';
import { and, asc, countDistinct, eq, inArray, sql, type SQL } from 'drizzle-orm';
import { db } from '@/db';
import { collectionItem, location, manufacturer, missingPart, set, theme } from '@/db/schema';
import { SOLD_STATUS } from '@/lib/ownership';
import {
collectionItem,
collector,
location,
manufacturer,
missingPart,
set,
theme,
} from '@/db/schema';
import { ownedCopiesSql, SOLD_STATUS } from '@/lib/ownership';
import type { CollectionStatus, Completeness, Condition } from '@/db/types';
export type CollectionFilters = {
@@ -11,6 +19,7 @@ export type CollectionFilters = {
completeness?: Completeness;
locationId?: string;
manufacturerId?: string;
collectorId?: string;
};
export type CollectionRow = {
@@ -21,6 +30,9 @@ export type CollectionRow = {
condition: Condition | null;
locationId: string | null;
locationName: string | null;
collectorId: string | null;
collectorName: string | null;
collectorColor: string | null;
acquiredAt: Date | null;
acquiredPriceCents: number | null;
acquiredFrom: string | null;
@@ -51,6 +63,7 @@ function buildWhere(filters: CollectionFilters): SQL | undefined {
if (filters.completeness) conditions.push(eq(collectionItem.completeness, filters.completeness));
if (filters.locationId) conditions.push(eq(collectionItem.locationId, filters.locationId));
if (filters.manufacturerId) conditions.push(eq(set.manufacturerId, filters.manufacturerId));
if (filters.collectorId) conditions.push(eq(collectionItem.collectorId, filters.collectorId));
return conditions.length > 0 ? and(...conditions) : undefined;
}
@@ -65,6 +78,9 @@ export function listCollectionItems(filters: CollectionFilters): CollectionRow[]
condition: collectionItem.condition,
locationId: collectionItem.locationId,
locationName: location.name,
collectorId: collectionItem.collectorId,
collectorName: collector.name,
collectorColor: collector.color,
acquiredAt: collectionItem.acquiredAt,
acquiredPriceCents: collectionItem.acquiredPriceCents,
acquiredFrom: collectionItem.acquiredFrom,
@@ -86,6 +102,7 @@ export function listCollectionItems(filters: CollectionFilters): CollectionRow[]
.innerJoin(manufacturer, eq(manufacturer.id, set.manufacturerId))
.leftJoin(theme, eq(theme.id, set.themeId))
.leftJoin(location, eq(location.id, collectionItem.locationId))
.leftJoin(collector, eq(collector.id, collectionItem.collectorId))
.where(buildWhere(filters))
.orderBy(asc(manufacturer.name), asc(set.setNumber))
.all();
@@ -96,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 }>;
};
@@ -104,8 +123,13 @@ export type CollectionStats = {
* The numbers on top of the collection page. Sold copies are history and do
* not count anywhere - same rule as everywhere else in the app.
*/
export function getCollectionStats(): CollectionStats {
const active = sql`${collectionItem.status} <> ${SOLD_STATUS}`;
export function getCollectionStats(collectorId?: string): CollectionStats {
const active = collectorId
? and(
sql`${collectionItem.status} <> ${SOLD_STATUS}`,
eq(collectionItem.collectorId, collectorId),
)
: sql`${collectionItem.status} <> ${SOLD_STATUS}`;
const [totals] = db
.select({
@@ -133,27 +157,39 @@ export function getCollectionStats(): 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<number>`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,
};
}
/** Number of catalog sets that are not in the collection - the "fehlt" count. */
export function countMissingSets(): number {
export function countMissingSets(collectorId?: string): number {
const [row] = db
.select({ total: sql<number>`count(*)`.mapWith(Number) })
.from(set)
.where(
sql`not exists (
select 1 from collection_item ci
where ci.set_id = ${set.id} and ci.status <> ${SOLD_STATUS}
)`,
)
.where(sql`${ownedCopiesSql(set.id, collectorId)} = 0`)
.all();
return row?.total ?? 0;
+89
View File
@@ -0,0 +1,89 @@
import 'server-only';
import { asc, eq, sql } from 'drizzle-orm';
import { db } from '@/db';
import { collectionItem, collector } from '@/db/schema';
import { SOLD_STATUS } from '@/lib/ownership';
export type CollectorRow = {
id: string;
name: string;
color: string;
birthday: Date | null;
notes: string | null;
sortOrder: number;
itemCount: number;
};
export function listCollectors(): CollectorRow[] {
const itemCount = sql<number>`(
select count(*) from collection_item ci
where ci.collector_id = ${collector.id} and ci.status <> ${SOLD_STATUS}
)`.mapWith(Number);
return db
.select({
id: collector.id,
name: collector.name,
color: collector.color,
birthday: collector.birthday,
notes: collector.notes,
sortOrder: collector.sortOrder,
itemCount,
})
.from(collector)
.orderBy(asc(collector.sortOrder), asc(collector.name))
.all();
}
export function getCollector(id: string) {
return db.select().from(collector).where(eq(collector.id, id)).get() ?? null;
}
/**
* The collector a new collection item belongs to when the user did not pick
* one - which is every time there is only a single collector.
*/
export function defaultCollectorId(): string | null {
const row = db
.select({ id: collector.id })
.from(collector)
.orderBy(asc(collector.sortOrder), asc(collector.name))
.get();
return row?.id ?? null;
}
/**
* The whole multi-collector idea only becomes visible once a second collector
* exists. Until then the UI stays exactly as simple as it was.
*/
export function hasMultipleCollectors(): boolean {
const [row] = db
.select({ total: sql<number>`count(*)`.mapWith(Number) })
.from(collector)
.all();
return (row?.total ?? 0) > 1;
}
/** Reads the collector from a URL parameter, ignoring anything unknown. */
export function resolveCollectorId(value: string | undefined | null): string | undefined {
if (!value) return undefined;
const row = db.select({ id: collector.id }).from(collector).where(eq(collector.id, value)).get();
return row?.id;
}
/** Owned copies per collector - used by the collector management page. */
export function countItemsPerCollector(): Map<string, number> {
const rows = db
.select({
collectorId: collectionItem.collectorId,
total: sql<number>`count(*)`.mapWith(Number),
})
.from(collectionItem)
.where(sql`${collectionItem.status} <> ${SOLD_STATUS}`)
.groupBy(collectionItem.collectorId)
.all();
return new Map(
rows.flatMap((row) => (row.collectorId ? [[row.collectorId, row.total] as const] : [])),
);
}
+25 -7
View File
@@ -1,8 +1,8 @@
import 'server-only';
import { and, asc, count, eq, inArray, isNotNull, isNull, sql } from 'drizzle-orm';
import { db } from '@/db';
import { manufacturer, set, theme, wishlist, wishlistItem } from '@/db/schema';
import { ownedCopiesSql } from '@/lib/ownership';
import { collector, manufacturer, set, theme, wishlist, wishlistItem } from '@/db/schema';
import { ownedCopiesSql, SOLD_STATUS } from '@/lib/ownership';
/** Short list for the "auf welche Wunschliste?" dialogs. */
export function listWishlistTargets() {
@@ -34,12 +34,16 @@ export function listWishlists(ownerUserId: string) {
isPublic: wishlist.isPublic,
surpriseMode: wishlist.surpriseMode,
shareToken: wishlist.shareToken,
collectorId: wishlist.collectorId,
collectorName: collector.name,
collectorColor: collector.color,
createdAt: wishlist.createdAt,
itemCount: count(wishlistItem.id),
openCount: openItems,
})
.from(wishlist)
.leftJoin(wishlistItem, eq(wishlistItem.wishlistId, wishlist.id))
.leftJoin(collector, eq(collector.id, wishlist.collectorId))
.where(eq(wishlist.ownerUserId, ownerUserId))
.groupBy(wishlist.id)
.orderBy(asc(wishlist.title))
@@ -76,7 +80,10 @@ export type WishlistItemRow = {
ownedCopies: number;
};
export function listWishlistItems(wishlistId: string): WishlistItemRow[] {
export function listWishlistItems(
wishlistId: string,
collectorId?: string | null,
): WishlistItemRow[] {
return db
.select({
id: wishlistItem.id,
@@ -97,7 +104,7 @@ export function listWishlistItems(wishlistId: string): WishlistItemRow[] {
manufacturerName: manufacturer.name,
manufacturerWebsite: manufacturer.website,
themeName: theme.name,
ownedCopies: ownedCopiesSql(set.id).mapWith(Number),
ownedCopies: ownedCopiesSql(set.id, collectorId).mapWith(Number),
})
.from(wishlistItem)
.innerJoin(set, eq(set.id, wishlistItem.setId))
@@ -145,17 +152,28 @@ export function listWishlistsForSet(setId: string) {
* Marks wishlist entries as fulfilled once the set is in the collection, and
* clears the flag again if the copy was sold. Called after every change to the
* collection, so the lists stay honest without a background job.
*
* With several collectors the question is per list: a wish on Max's list is
* fulfilled when *Max* owns the set, not when his sister does. A list without
* a collector falls back to "anyone here owns it".
*/
export function reconcileFulfilledWishlistItems(): void {
const owned = ownedCopiesSql(wishlistItem.setId);
const ownedForThisList = sql`exists (
select 1
from collection_item ci
join wishlist w on w.id = ${wishlistItem.wishlistId}
where ci.set_id = ${wishlistItem.setId}
and ci.status <> ${SOLD_STATUS}
and (w.collector_id is null or ci.collector_id = w.collector_id)
)`;
db.update(wishlistItem)
.set({ fulfilledAt: new Date() })
.where(and(isNull(wishlistItem.fulfilledAt), sql`${owned} > 0`))
.where(and(isNull(wishlistItem.fulfilledAt), ownedForThisList))
.run();
db.update(wishlistItem)
.set({ fulfilledAt: null })
.where(and(isNotNull(wishlistItem.fulfilledAt), sql`${owned} = 0`))
.where(and(isNotNull(wishlistItem.fulfilledAt), sql`not ${ownedForThisList}`))
.run();
}
+156
View File
@@ -0,0 +1,156 @@
import 'server-only';
import { eq, sql } from 'drizzle-orm';
import { db } from '@/db';
import { user, wishlist } from '@/db/schema';
import { env } from '@/env';
import { sendMail } from './mailer';
import {
reminderSubject,
reminderText,
shouldRemind,
type RemindableWishlist,
} from './reminders';
/**
* Sends the due wishlist reminders.
*
* Run from the hourly scheduler and from the "jetzt prüfen" button. Sending is
* recorded on the list, so a second run within the week stays quiet.
*/
export type ReminderRun = {
checked: number;
sent: number;
skipped: number;
failures: Array<{ title: string; error: string }>;
};
type Candidate = RemindableWishlist & {
ownerEmail: string;
ownerName: string;
shareToken: string;
isPublic: boolean;
};
function loadCandidates(): Candidate[] {
const openItems = sql<number>`(
select count(*) from wishlist_item wi
where wi.wishlist_id = ${wishlist.id}
and wi.reserved_at is null
and wi.fulfilled_at is null
)`.mapWith(Number);
return db
.select({
id: wishlist.id,
title: wishlist.title,
eventDate: wishlist.eventDate,
lastReminderAt: wishlist.lastReminderAt,
shareToken: wishlist.shareToken,
isPublic: wishlist.isPublic,
ownerEmail: user.email,
ownerName: user.name,
openItemCount: openItems,
})
.from(wishlist)
.innerJoin(user, eq(user.id, wishlist.ownerUserId))
.all();
}
export async function runReminderJob(now = new Date()): Promise<ReminderRun> {
const result: ReminderRun = { checked: 0, sent: 0, skipped: 0, failures: [] };
if (!env.isMailConfigured) return result;
const candidates = loadCandidates();
result.checked = candidates.length;
for (const candidate of candidates) {
const decision = shouldRemind(candidate, {
now,
daysBefore: env.REMINDER_DAYS_BEFORE,
});
if (!decision.remind) {
result.skipped += 1;
continue;
}
const mail = await sendMail({
to: candidate.ownerEmail,
subject: reminderSubject(candidate, decision.daysLeft),
text: reminderText({
title: candidate.title,
daysLeft: decision.daysLeft,
openItemCount: candidate.openItemCount,
shareUrl: candidate.isPublic
? `${env.BASE_URL}/w/${candidate.shareToken}`
: '(Teilen ist für diese Liste ausgeschaltet)',
appUrl: `${env.BASE_URL}/wunschlisten/${candidate.id}`,
}),
});
if (!mail.ok) {
result.failures.push({ title: candidate.title, error: mail.error });
continue;
}
// Only after a successful send - a failed attempt must be retried.
db.update(wishlist)
.set({ lastReminderAt: now })
.where(eq(wishlist.id, candidate.id))
.run();
result.sent += 1;
}
return result;
}
const HOUR_MS = 60 * 60 * 1000;
/**
* Started lazily from the first server render or healthcheck rather than from
* Next's instrumentation hook: that hook is compiled for the edge runtime too,
* and pulling the native SQLite driver into that bundle breaks the build.
* Cached on globalThis so the dev server's module reloading cannot stack up
* several schedulers.
*/
const globalForScheduler = globalThis as unknown as { bricktrackReminders?: boolean };
export function ensureReminderScheduler(): void {
if (globalForScheduler.bricktrackReminders) return;
globalForScheduler.bricktrackReminders = true;
startReminderScheduler();
}
/**
* Starts the hourly check.
*
* Hourly rather than daily so a restart cannot skip a day, and the per-list
* cooldown keeps that from turning into repeated mail.
*/
export function startReminderScheduler(): void {
if (!env.isMailConfigured) {
console.log('[reminders] Kein SMTP konfiguriert - Erinnerungen sind aus.');
return;
}
console.log(
`[reminders] Aktiv: Erinnerung ${env.REMINDER_DAYS_BEFORE} Tage vor dem Termin, stuendliche Pruefung.`,
);
const tick = () => {
void runReminderJob()
.then((run) => {
if (run.sent > 0 || run.failures.length > 0) {
console.log(
`[reminders] ${run.sent} verschickt, ${run.failures.length} fehlgeschlagen.`,
);
}
})
.catch((error) => console.error('[reminders] Durchlauf fehlgeschlagen', error));
};
// A short delay so the first check does not compete with the app's start-up.
setTimeout(tick, 60_000).unref?.();
setInterval(tick, HOUR_MS).unref?.();
}
+91
View File
@@ -0,0 +1,91 @@
/**
* When to remind someone about a wishlist.
*
* Pure rules, no database and no mail: a reminder that arrives at the wrong
* moment is worse than none, so this is the part that gets tested.
*/
export type RemindableWishlist = {
id: string;
title: string;
eventDate: Date | null;
openItemCount: number;
lastReminderAt: Date | null;
};
export type ReminderDecision =
| { remind: true; daysLeft: number }
| { remind: false; reason: 'no-date' | 'too-early' | 'past' | 'nothing-open' | 'already-sent' };
/** Whole days between two points, ignoring the time of day. */
export function daysUntil(target: Date, now: Date): number {
const startOfTarget = Date.UTC(target.getFullYear(), target.getMonth(), target.getDate());
const startOfNow = Date.UTC(now.getFullYear(), now.getMonth(), now.getDate());
return Math.round((startOfTarget - startOfNow) / 86_400_000);
}
/**
* A reminder goes out when the date is close, something is still unclaimed,
* and we have not just written about it.
*/
export function shouldRemind(
list: RemindableWishlist,
options: { now: Date; daysBefore: number; minDaysBetween?: number },
): ReminderDecision {
const minDaysBetween = options.minDaysBetween ?? 7;
if (!list.eventDate) return { remind: false, reason: 'no-date' };
const daysLeft = daysUntil(list.eventDate, options.now);
// The day itself still counts - a morning reminder can save the afternoon.
if (daysLeft < 0) return { remind: false, reason: 'past' };
if (daysLeft > options.daysBefore) return { remind: false, reason: 'too-early' };
// Nothing open means everything is taken care of; silence is the right answer.
if (list.openItemCount <= 0) return { remind: false, reason: 'nothing-open' };
if (list.lastReminderAt) {
// daysUntil(now, lastReminderAt) reads as "days from the last reminder
// until now", which is exactly the age of that reminder.
const daysSinceLast = daysUntil(options.now, list.lastReminderAt);
if (daysSinceLast < minDaysBetween) return { remind: false, reason: 'already-sent' };
}
return { remind: true, daysLeft };
}
/** German subject line, because that is what lands in the inbox. */
export function reminderSubject(list: RemindableWishlist, daysLeft: number): string {
if (daysLeft === 0) return `Heute: ${list.title}`;
if (daysLeft === 1) return `Morgen: ${list.title}`;
return `In ${daysLeft} Tagen: ${list.title}`;
}
export function reminderText(input: {
title: string;
daysLeft: number;
openItemCount: number;
shareUrl: string;
appUrl: string;
}): string {
const when =
input.daysLeft === 0
? 'ist heute'
: input.daysLeft === 1
? 'ist morgen'
: `ist in ${input.daysLeft} Tagen`;
const open =
input.openItemCount === 1
? 'steht noch 1 Wunsch offen'
: `stehen noch ${input.openItemCount} Wünsche offen`;
return [
`Der Termin für „${input.title}" ${when}, und auf der Liste ${open}.`,
'',
`Liste ansehen: ${input.appUrl}`,
`Zum Teilen: ${input.shareUrl}`,
'',
'Diese Erinnerung kommt von deinem eigenen Bricktrack.',
].join('\n');
}
+77
View File
@@ -0,0 +1,77 @@
/**
* Name comparison for the duplicate check when a set is created.
*
* Pure and free of server imports so it can be unit tested - and it needs to
* be, because "similar enough" is a judgement call that is easy to get wrong
* in both directions.
*/
/**
* Lowercases, strips punctuation and collapses whitespace, so that
* "Feuerwehr-Auto" and "feuerwehr auto" are the same string.
*/
export function normalizeName(value: string): string {
return value
.toLowerCase()
.replace(/ä/g, 'ae')
.replace(/ö/g, 'oe')
.replace(/ü/g, 'ue')
.replace(/ß/g, 'ss')
.replace(/[^a-z0-9]+/g, ' ')
.trim();
}
/** Classic Levenshtein distance, iterative with a single row. */
export function levenshtein(a: string, b: string): number {
if (a === b) return 0;
if (a.length === 0) return b.length;
if (b.length === 0) return a.length;
let previous = Array.from({ length: b.length + 1 }, (_, index) => index);
for (let i = 1; i <= a.length; i += 1) {
const current = [i];
for (let j = 1; j <= b.length; j += 1) {
const cost = a[i - 1] === b[j - 1] ? 0 : 1;
current[j] = Math.min(
current[j - 1]! + 1, // insertion
previous[j]! + 1, // deletion
previous[j - 1]! + cost, // substitution
);
}
previous = current;
}
return previous[b.length]!;
}
/** 1 means identical, 0 means nothing in common. */
export function similarity(a: string, b: string): number {
const left = normalizeName(a);
const right = normalizeName(b);
if (left === right) return 1;
if (left.length === 0 || right.length === 0) return 0;
const longest = Math.max(left.length, right.length);
return 1 - levenshtein(left, right) / longest;
}
/**
* Above this, two names are close enough to be worth asking about. Chosen so
* that a typo or a different spelling triggers, while two genuinely different
* sets of the same theme do not.
*/
export const SIMILAR_NAME_THRESHOLD = 0.82;
export function isSimilarName(a: string, b: string): boolean {
const left = normalizeName(a);
const right = normalizeName(b);
// One name fully contained in the other ("Feuerwehrauto" vs "Feuerwehrauto
// mit Leiter") is a strong hint even when the length differs a lot.
if (left.length >= 6 && right.length >= 6 && (left.includes(right) || right.includes(left))) {
return true;
}
return similarity(a, b) >= SIMILAR_NAME_THRESHOLD;
}
+19
View File
@@ -88,6 +88,22 @@ export const locationSchema = z.object({
parentLocationId: optionalId,
});
export const collectorColorEnum = z.enum([
'blue',
'green',
'amber',
'rose',
'violet',
'slate',
]);
export const collectorSchema = z.object({
name: z.string().trim().min(1, 'Bitte einen Namen angeben.').max(60),
color: collectorColorEnum.default('blue'),
birthday: optionalDate,
notes: optionalText(500),
});
// ---------------------------------------------------------------------------
// Catalog
// ---------------------------------------------------------------------------
@@ -127,6 +143,7 @@ export const conditionEnum = z.enum(['new_sealed', 'like_new', 'good', 'used', '
export const collectionItemSchema = z.object({
setId: z.string().trim().min(1),
collectorId: optionalId,
status: collectionStatusEnum.default('owned'),
completeness: completenessEnum.default('unknown'),
condition: z
@@ -171,6 +188,7 @@ export const occasionEnum = z.enum(['christmas', 'birthday', 'easter', 'other'])
export const wishlistSchema = z.object({
title: z.string().trim().min(1, 'Bitte einen Titel angeben.').max(80),
collectorId: optionalId,
occasion: occasionEnum.default('other'),
eventDate: optionalDate,
note: optionalText(1000),
@@ -205,3 +223,4 @@ export type LocationInput = z.infer<typeof locationSchema>;
export type SetInput = z.infer<typeof setSchema>;
export type CollectionItemInput = z.infer<typeof collectionItemSchema>;
export type WishlistInput = z.infer<typeof wishlistSchema>;
export type CollectorInput = z.infer<typeof collectorSchema>;
+46
View File
@@ -2,8 +2,10 @@ import { describe, expect, it } from 'vitest';
import type { CollectionStatus } from '@/db/types';
import {
countOwnedCopies,
countOwnedCopiesFor,
isOwningStatus,
isSetOwned,
isSetOwnedBy,
OWNED_STATUSES,
parseOwnershipFilter,
} from '@/lib/ownership';
@@ -59,3 +61,47 @@ describe('Besitz-Filter aus der URL', () => {
expect(parseOwnershipFilter('')).toBe('alle');
});
});
describe('Besitz je Sammler', () => {
const copies = [
{ status: 'built' as const, collectorId: 'max' },
{ status: 'owned' as const, collectorId: 'mia' },
{ status: 'sold' as const, collectorId: 'max' },
];
it('zaehlt ohne Sammler alles, was irgendjemand hier hat', () => {
expect(countOwnedCopiesFor(copies)).toBe(2);
expect(isSetOwnedBy(copies)).toBe(true);
});
it('zaehlt mit Sammler nur dessen Exemplare', () => {
expect(countOwnedCopiesFor(copies, 'max')).toBe(1);
expect(countOwnedCopiesFor(copies, 'mia')).toBe(1);
});
it('sieht ein Set als fehlend, wenn nur das Geschwisterkind es hat', () => {
const onlyMia = [{ status: 'built' as const, collectorId: 'mia' }];
expect(isSetOwnedBy(onlyMia, 'mia')).toBe(true);
// Genau der Fall, fuer den es mehrere Sammler gibt.
expect(isSetOwnedBy(onlyMia, 'max')).toBe(false);
});
it('ignoriert verkaufte Exemplare auch je Sammler', () => {
const sold = [{ status: 'sold' as const, collectorId: 'max' }];
expect(isSetOwnedBy(sold, 'max')).toBe(false);
expect(isSetOwnedBy(sold)).toBe(false);
});
it('behandelt einen unbekannten Sammler als "hat nichts"', () => {
expect(countOwnedCopiesFor(copies, 'niemand')).toBe(0);
});
it('kommt mit Exemplaren ohne Sammler klar', () => {
const unassigned = [{ status: 'owned' as const, collectorId: null }];
expect(isSetOwnedBy(unassigned)).toBe(true);
expect(isSetOwnedBy(unassigned, 'max')).toBe(false);
});
});
+127
View File
@@ -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);
});
});
+131
View File
@@ -0,0 +1,131 @@
import { describe, expect, it } from 'vitest';
import {
daysUntil,
reminderSubject,
reminderText,
shouldRemind,
type RemindableWishlist,
} from '@/lib/reminders';
const now = new Date('2026-12-10T09:00:00');
const on = (iso: string) => new Date(`${iso}T00:00:00`);
function list(overrides: Partial<RemindableWishlist> = {}): RemindableWishlist {
return {
id: 'w-1',
title: 'Weihnachten 2026',
eventDate: on('2026-12-24'),
openItemCount: 3,
lastReminderAt: null,
...overrides,
};
}
describe('Tage bis zum Termin', () => {
it('zaehlt ganze Tage, unabhaengig von der Uhrzeit', () => {
expect(daysUntil(on('2026-12-24'), now)).toBe(14);
expect(daysUntil(on('2026-12-10'), now)).toBe(0);
expect(daysUntil(on('2026-12-09'), now)).toBe(-1);
});
});
describe('Wann erinnert wird', () => {
it('erinnert, wenn der Termin im Fenster liegt und etwas offen ist', () => {
const decision = shouldRemind(list(), { now, daysBefore: 14 });
expect(decision).toEqual({ remind: true, daysLeft: 14 });
});
it('schweigt, solange der Termin weit weg ist', () => {
const decision = shouldRemind(list({ eventDate: on('2027-06-01') }), {
now,
daysBefore: 14,
});
expect(decision).toEqual({ remind: false, reason: 'too-early' });
});
it('erinnert am Tag selbst noch', () => {
const decision = shouldRemind(list({ eventDate: on('2026-12-10') }), {
now,
daysBefore: 14,
});
expect(decision).toEqual({ remind: true, daysLeft: 0 });
});
it('schweigt, wenn der Termin vorbei ist', () => {
const decision = shouldRemind(list({ eventDate: on('2026-12-01') }), {
now,
daysBefore: 14,
});
expect(decision).toEqual({ remind: false, reason: 'past' });
});
it('schweigt ohne Termin', () => {
const decision = shouldRemind(list({ eventDate: null }), { now, daysBefore: 14 });
expect(decision).toEqual({ remind: false, reason: 'no-date' });
});
it('schweigt, wenn alles reserviert ist', () => {
const decision = shouldRemind(list({ openItemCount: 0 }), { now, daysBefore: 14 });
expect(decision).toEqual({ remind: false, reason: 'nothing-open' });
});
it('wiederholt sich nicht innerhalb einer Woche', () => {
const decision = shouldRemind(list({ lastReminderAt: on('2026-12-08') }), {
now,
daysBefore: 14,
});
expect(decision).toEqual({ remind: false, reason: 'already-sent' });
});
it('erinnert wieder, wenn die Sperrfrist vorbei ist', () => {
const decision = shouldRemind(list({ lastReminderAt: on('2026-12-01') }), {
now,
daysBefore: 14,
});
expect(decision).toMatchObject({ remind: true });
});
});
describe('Betreff und Text', () => {
it('formuliert die Naehe im Betreff', () => {
expect(reminderSubject(list(), 0)).toBe('Heute: Weihnachten 2026');
expect(reminderSubject(list(), 1)).toBe('Morgen: Weihnachten 2026');
expect(reminderSubject(list(), 5)).toBe('In 5 Tagen: Weihnachten 2026');
});
it('nennt Termin, offene Wuensche und beide Links', () => {
const text = reminderText({
title: 'Weihnachten 2026',
daysLeft: 3,
openItemCount: 2,
shareUrl: 'https://bricktrack.example.de/w/abc',
appUrl: 'https://bricktrack.example.de/wunschlisten/w-1',
});
expect(text).toContain('ist in 3 Tagen');
expect(text).toContain('stehen noch 2 Wünsche offen');
expect(text).toContain('https://bricktrack.example.de/w/abc');
expect(text).toContain('https://bricktrack.example.de/wunschlisten/w-1');
});
it('bleibt im Singular, wenn nur ein Wunsch offen ist', () => {
const text = reminderText({
title: 'Geburtstag',
daysLeft: 1,
openItemCount: 1,
shareUrl: 'x',
appUrl: 'y',
});
expect(text).toContain('ist morgen');
expect(text).toContain('steht noch 1 Wunsch offen');
});
});
+59
View File
@@ -0,0 +1,59 @@
import { describe, expect, it } from 'vitest';
import { isSimilarName, levenshtein, normalizeName, similarity } from '@/lib/similarity';
describe('Namen normalisieren', () => {
it('schreibt Umlaute aus und wirft Satzzeichen weg', () => {
expect(normalizeName('Feuerwehr-Auto')).toBe('feuerwehr auto');
expect(normalizeName('Grüne Kiste!')).toBe('gruene kiste');
expect(normalizeName('Straßenbahn')).toBe('strassenbahn');
});
it('macht aus mehreren Leerzeichen eines', () => {
expect(normalizeName(' Lego City ')).toBe('lego city');
});
});
describe('Levenshtein', () => {
it('ist null bei identischen Strings', () => {
expect(levenshtein('haus', 'haus')).toBe(0);
});
it('zaehlt einzelne Aenderungen', () => {
expect(levenshtein('haus', 'maus')).toBe(1);
expect(levenshtein('haus', 'hausen')).toBe(2);
expect(levenshtein('', 'haus')).toBe(4);
});
});
describe('Aehnlichkeit', () => {
it('erkennt identische Namen', () => {
expect(similarity('Feuerwehrauto', 'feuerwehrauto')).toBe(1);
});
it('sinkt mit dem Unterschied', () => {
expect(similarity('Feuerwehrauto', 'Polizeiauto')).toBeLessThan(0.7);
});
});
describe('Dublettenverdacht', () => {
it('schlaegt bei Tippfehlern an', () => {
expect(isSimilarName('Familienhaus und Elektroauto', 'Familienhaus und Elektroauo')).toBe(true);
expect(isSimilarName('Feuerwehreinsatz', 'Feuerwehr-Einsatz')).toBe(true);
});
it('schlaegt an, wenn ein Name im anderen steckt', () => {
expect(isSimilarName('Feuerwehrauto', 'Feuerwehrauto mit Leiter')).toBe(true);
});
it('schlaegt nicht bei verschiedenen Sets desselben Themas an', () => {
expect(isSimilarName('Feuerwehreinsatz', 'Polizeistation')).toBe(false);
expect(isSimilarName('Kipplaster', 'McLaren P1')).toBe(false);
// Zwei echte, unterschiedliche City-Sets.
expect(isSimilarName('Familienhaus und Elektroauto', 'Feuerwehreinsatz')).toBe(false);
});
it('laesst sich von kurzen Namen nicht zu Unsinn verleiten', () => {
// Ohne die Mindestlaenge wuerde "Bus" in "Autobus" jede Dublette melden.
expect(isSimilarName('Bus', 'Autobus')).toBe(false);
});
});