Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e9093d0fad | ||
|
|
e52557b4bf | ||
|
|
0d08df0bff | ||
|
|
91fb66b13b | ||
|
|
87131b8e70 | ||
|
|
c23b754515 | ||
|
|
7265c03548 |
@@ -32,3 +32,16 @@ TZ=Europe/Berlin
|
|||||||
# und Bild werden uebernommen. Ohne Key funktioniert alles manuell weiter.
|
# und Bild werden uebernommen. Ohne Key funktioniert alles manuell weiter.
|
||||||
# Kostenlos anlegen: https://rebrickable.com/api/ (Konto -> Settings -> API)
|
# Kostenlos anlegen: https://rebrickable.com/api/ (Konto -> Settings -> API)
|
||||||
# REBRICKABLE_API_KEY=
|
# 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
|
||||||
|
|||||||
@@ -7,6 +7,53 @@ die Versionierung [Semantic Versioning](https://semver.org/lang/de/).
|
|||||||
|
|
||||||
## [Unreleased]
|
## [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
|
## [0.2.0] - 2026-09-07
|
||||||
|
|
||||||
Phase 2: die Handgriffe, die den Alltag schneller machen.
|
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
|
- Unter Windows scheitert `pnpm build` an fehlenden Symlink-Rechten. Der
|
||||||
Docker-Build unter Linux ist davon nicht betroffen.
|
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.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
|
[0.1.0]: https://git.wilde-server.de/florian.wilde/bricktrack/releases/tag/v0.1.0
|
||||||
|
|||||||
@@ -57,6 +57,10 @@ Bild-URLs, die der Nutzer selbst hinterlegt hat.
|
|||||||
| `src/lib/rebrickable.ts` | Lego-Import, ausschliesslich serverseitig |
|
| `src/lib/rebrickable.ts` | Lego-Import, ausschliesslich serverseitig |
|
||||||
| `src/lib/set-number.ts`, `src/lib/ean.ts` | Setnummern und Barcodes (rein, testbar) |
|
| `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/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/export.ts` | Export und Import des gesamten Bestands |
|
||||||
| `src/lib/money.ts`, `src/lib/format.ts` | Cent-Umrechnung, dd.MM.yyyy |
|
| `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
|
### 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 |
|
| 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` |
|
| `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` |
|
| `location` | Aufbewahrungsort, hierarchisch | `parent_location_id` |
|
||||||
| `missing_part` | Fehlteile je Exemplar | `part_number`, `color_name`, `quantity`, `resolved` |
|
| `missing_part` | Fehlteile je Exemplar | `part_number`, `color_name`, `quantity`, `resolved` |
|
||||||
@@ -190,7 +202,7 @@ Session.
|
|||||||
- **Phase 2 (Komfort)** - Rebrickable-Import, Barcode-Scanner, Fehlteile,
|
- **Phase 2 (Komfort)** - Rebrickable-Import, Barcode-Scanner, Fehlteile,
|
||||||
Einladungen, Export/Import, PWA. *Fertig in 0.2.0.*
|
Einladungen, Export/Import, PWA. *Fertig in 0.2.0.*
|
||||||
- **Phase 3 (Nice to have)** - mehrere Sammler, Preisbeobachtung,
|
- **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`.
|
Details und Stand: `docs/ROADMAP.md`. Zeitaufwaende: `docs/DEVLOG.md`.
|
||||||
|
|
||||||
|
|||||||
@@ -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 |
|
| **Fotos** | Eigene Bilder je Set – direkt aus der Handykamera |
|
||||||
| **Wunschlisten** | Mehrfachauswahl aus den fehlenden Sets, Priorität, Reihenfolge, Termin |
|
| **Wunschlisten** | Mehrfachauswahl aus den fehlenden Sets, Priorität, Reihenfolge, Termin |
|
||||||
| **Teilen** | Öffentlicher Link ohne Anmeldung, Reservierung mit Namen, Storno-Link, Überraschungsmodus |
|
| **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 |
|
| **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
|
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. |
|
| `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. |
|
| `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. |
|
| `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
|
### 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
|
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`.
|
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).
|
Das vollständige Modell inklusive aller Tabellen: [CLAUDE.md](CLAUDE.md#datenmodell).
|
||||||
|
|
||||||
## Sicherheit
|
## Sicherheit
|
||||||
|
|||||||
@@ -27,7 +27,11 @@ Kinderzimmer, nicht für den Schreibtisch.
|
|||||||
13. [Noch ein Konto für die Familie](#13-einladungen)
|
13. [Noch ein Konto für die Familie](#13-einladungen)
|
||||||
14. [Daten sichern, umziehen, auswerten](#14-daten)
|
14. [Daten sichern, umziehen, auswerten](#14-daten)
|
||||||
15. [Auf den Startbildschirm legen](#15-startbildschirm)
|
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
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
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.
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
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
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
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
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
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:
|
Dafür ist die ganze App gebaut:
|
||||||
|
|
||||||
@@ -428,7 +540,8 @@ Dafür ist die ganze App gebaut:
|
|||||||
|
|
||||||
**„Welche Sets hat er noch nicht?"**
|
**„Welche Sets hat er noch nicht?"**
|
||||||
→ Katalog, Filter „Fehlt noch". Von dort in zwei Handgriffen auf eine
|
→ 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
|
Im Laden vor dem Regal: Scanner öffnen, Strichcode einlesen. Steht ein grüner
|
||||||
Haken dran, habt ihr es schon.
|
Haken dran, habt ihr es schon.
|
||||||
|
|||||||
@@ -45,6 +45,20 @@ Commit-Spalte verweist auf den Stand am Ende des Schritts.
|
|||||||
|
|
||||||
**Summe Phase 2: 0:55**
|
**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
|
## 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.
|
Konfliktregeln für jede Tabelle und wäre im Ergebnis nicht vorhersagbar.
|
||||||
Der Import ersetzt deshalb – dafür mit getipptem `ERSETZEN` als Bremse.
|
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
|
### Beim Testen gefunden und behoben
|
||||||
|
|
||||||
- Eine spontan aus dem Katalog angelegte Wunschliste war **nicht teilbar**:
|
- 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.
|
`required` des Eingabefeldes.
|
||||||
- In der Mehrfachauswahl verdeckte die Aktionsleiste die letzte Kachelreihe;
|
- In der Mehrfachauswahl verdeckte die Aktionsleiste die letzte Kachelreihe;
|
||||||
das Raster bekommt im Auswahlmodus zusätzliches Polster nach unten.
|
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`.
|
||||||
|
|||||||
@@ -46,12 +46,25 @@ Laeuft vollstaendig im Docker-Stack.
|
|||||||
Sammlung zusaetzlich als CSV
|
Sammlung zusaetzlich als CSV
|
||||||
- [x] PWA: Manifest, Service Worker, Katalog offline lesbar
|
- [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
|
gemeinsamer Katalog
|
||||||
- [ ] Preisbeobachtung und Wertentwicklung (manuell gepflegt,
|
- [x] Preisbeobachtung und Wertentwicklung (manuell gepflegt).
|
||||||
optional BrickLink-Preisfuehrer)
|
Der BrickLink-Preisfuehrer war als optional vermerkt und wurde
|
||||||
- [ ] E-Mail-Erinnerung (SMTP), wenn ein Wunschlisten-Termin naeher
|
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
|
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
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 99 KiB After Width: | Height: | Size: 99 KiB |
|
Before Width: | Height: | Size: 288 KiB After Width: | Height: | Size: 294 KiB |
|
Before Width: | Height: | Size: 201 KiB After Width: | Height: | Size: 204 KiB |
|
Before Width: | Height: | Size: 209 KiB After Width: | Height: | Size: 211 KiB |
|
Before Width: | Height: | Size: 236 KiB After Width: | Height: | Size: 212 KiB |
|
Before Width: | Height: | Size: 185 KiB After Width: | Height: | Size: 224 KiB |
|
Before Width: | Height: | Size: 184 KiB After Width: | Height: | Size: 198 KiB |
|
Before Width: | Height: | Size: 76 KiB After Width: | Height: | Size: 79 KiB |
|
Before Width: | Height: | Size: 293 KiB After Width: | Height: | Size: 326 KiB |
|
Before Width: | Height: | Size: 244 KiB After Width: | Height: | Size: 244 KiB |
|
Before Width: | Height: | Size: 301 KiB After Width: | Height: | Size: 343 KiB |
|
Before Width: | Height: | Size: 294 KiB After Width: | Height: | Size: 328 KiB |
|
Before Width: | Height: | Size: 124 KiB After Width: | Height: | Size: 122 KiB |
|
Before Width: | Height: | Size: 124 KiB After Width: | Height: | Size: 124 KiB |
|
Before Width: | Height: | Size: 257 KiB After Width: | Height: | Size: 257 KiB |
|
After Width: | Height: | Size: 129 KiB |
|
After Width: | Height: | Size: 238 KiB |
|
After Width: | Height: | Size: 224 KiB |
|
After Width: | Height: | Size: 185 KiB |
@@ -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;
|
||||||
@@ -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`);
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
ALTER TABLE `wishlist` ADD `last_reminder_at` integer;
|
||||||
@@ -8,6 +8,27 @@
|
|||||||
"when": 1788726503038,
|
"when": 1788726503038,
|
||||||
"tag": "0000_rapid_gargoyle",
|
"tag": "0000_rapid_gargoyle",
|
||||||
"breakpoints": true
|
"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
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@@ -66,7 +66,8 @@ test('Screenshots fuer die Anleitung', async ({ page, browser }) => {
|
|||||||
|
|
||||||
await page.getByRole('button', { name: 'Auswählen' }).click();
|
await page.getByRole('button', { name: 'Auswählen' }).click();
|
||||||
await expect(page.getByText('Tippe die Sets an')).toBeVisible();
|
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(0).click();
|
||||||
await cards.nth(1).click();
|
await cards.nth(1).click();
|
||||||
await expect(page.getByText('2 ausgewählt')).toBeVisible();
|
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 visit(page, '/verwaltung/daten');
|
||||||
await expect(page.getByText('Alles als ZIP')).toBeVisible();
|
await expect(page.getByText('Alles als ZIP')).toBeVisible();
|
||||||
await shoot(page, '20-daten');
|
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');
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -81,6 +81,30 @@ test('Registrieren, Set anlegen, Wunschliste teilen und reservieren', async ({ p
|
|||||||
await visitorContext.close();
|
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 }) => {
|
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.
|
// The admin from the first test is still the only account, so log in.
|
||||||
await page.goto('/anmelden');
|
await page.goto('/anmelden');
|
||||||
|
|||||||
@@ -36,6 +36,7 @@
|
|||||||
"lucide-react": "^1.41.0",
|
"lucide-react": "^1.41.0",
|
||||||
"next": "15.5.25",
|
"next": "15.5.25",
|
||||||
"next-themes": "^0.4.6",
|
"next-themes": "^0.4.6",
|
||||||
|
"nodemailer": "^10.0.0",
|
||||||
"pdfjs-dist": "^6.3.289",
|
"pdfjs-dist": "^6.3.289",
|
||||||
"radix-ui": "^1.6.7",
|
"radix-ui": "^1.6.7",
|
||||||
"react": "19.1.0",
|
"react": "19.1.0",
|
||||||
@@ -52,6 +53,7 @@
|
|||||||
"@tailwindcss/postcss": "^4",
|
"@tailwindcss/postcss": "^4",
|
||||||
"@types/better-sqlite3": "^9.6.0",
|
"@types/better-sqlite3": "^9.6.0",
|
||||||
"@types/node": "^20",
|
"@types/node": "^20",
|
||||||
|
"@types/nodemailer": "^8.0.1",
|
||||||
"@types/react": "^19",
|
"@types/react": "^19",
|
||||||
"@types/react-dom": "^19",
|
"@types/react-dom": "^19",
|
||||||
"dotenv": "^17.4.2",
|
"dotenv": "^17.4.2",
|
||||||
|
|||||||
@@ -47,6 +47,9 @@ importers:
|
|||||||
next-themes:
|
next-themes:
|
||||||
specifier: ^0.4.6
|
specifier: ^0.4.6
|
||||||
version: 0.4.6(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
|
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:
|
pdfjs-dist:
|
||||||
specifier: ^6.3.289
|
specifier: ^6.3.289
|
||||||
version: 6.3.289
|
version: 6.3.289
|
||||||
@@ -90,6 +93,9 @@ importers:
|
|||||||
'@types/node':
|
'@types/node':
|
||||||
specifier: ^20
|
specifier: ^20
|
||||||
version: 20.19.43
|
version: 20.19.43
|
||||||
|
'@types/nodemailer':
|
||||||
|
specifier: ^8.0.1
|
||||||
|
version: 8.0.1
|
||||||
'@types/react':
|
'@types/react':
|
||||||
specifier: ^19
|
specifier: ^19
|
||||||
version: 19.2.18
|
version: 19.2.18
|
||||||
@@ -2262,6 +2268,9 @@ packages:
|
|||||||
'@types/node@20.19.43':
|
'@types/node@20.19.43':
|
||||||
resolution: {integrity: sha512-6oYBAi5ikg4Pl+kGsoYtawUMBT2zZMCvPNF7pVLnHZfd1zf38DRiWn/gT01RYCdUqkv7Fhr+C9ot4/tb+2sVvA==}
|
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':
|
'@types/react-dom@19.2.7':
|
||||||
resolution: {integrity: sha512-I8bPpDLcHBv1qiIiXDCy71Rt8eQDKJP0sMSWJphDdAcdqiJ1sGpZamavoEIRZmYzjia9LuEb2HlYdDpmoENpvQ==}
|
resolution: {integrity: sha512-I8bPpDLcHBv1qiIiXDCy71Rt8eQDKJP0sMSWJphDdAcdqiJ1sGpZamavoEIRZmYzjia9LuEb2HlYdDpmoENpvQ==}
|
||||||
peerDependencies:
|
peerDependencies:
|
||||||
@@ -4135,6 +4144,10 @@ packages:
|
|||||||
resolution: {integrity: sha512-YHs7BmmcsdAI5Ozuf8JZo6PT0mv2GIWC9vMfvUC3dp65M8hn7Ux8CPL+2oBI7juNuj9d0ndhTcznq2ODBps9cQ==}
|
resolution: {integrity: sha512-YHs7BmmcsdAI5Ozuf8JZo6PT0mv2GIWC9vMfvUC3dp65M8hn7Ux8CPL+2oBI7juNuj9d0ndhTcznq2ODBps9cQ==}
|
||||||
engines: {node: '>=18'}
|
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:
|
npm-run-path@4.0.1:
|
||||||
resolution: {integrity: sha512-S48WzZW777zhNIrn7gxOlISNAqi9ZC/uQFnRdbeIHhZhCA6UqpkOT8T1G7BvfdgP4Er8gF4sUbaS0i7QvIfCWw==}
|
resolution: {integrity: sha512-S48WzZW777zhNIrn7gxOlISNAqi9ZC/uQFnRdbeIHhZhCA6UqpkOT8T1G7BvfdgP4Er8gF4sUbaS0i7QvIfCWw==}
|
||||||
engines: {node: '>=8'}
|
engines: {node: '>=8'}
|
||||||
@@ -6950,6 +6963,10 @@ snapshots:
|
|||||||
dependencies:
|
dependencies:
|
||||||
undici-types: 6.21.0
|
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)':
|
'@types/react-dom@19.2.7(@types/react@19.2.18)':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@types/react': 19.2.18
|
'@types/react': 19.2.18
|
||||||
@@ -8805,6 +8822,8 @@ snapshots:
|
|||||||
|
|
||||||
node-releases@2.0.54: {}
|
node-releases@2.0.54: {}
|
||||||
|
|
||||||
|
nodemailer@10.0.0: {}
|
||||||
|
|
||||||
npm-run-path@4.0.1:
|
npm-run-path@4.0.1:
|
||||||
dependencies:
|
dependencies:
|
||||||
path-key: 3.1.1
|
path-key: 3.1.1
|
||||||
|
|||||||
@@ -17,6 +17,13 @@ fs.mkdirSync(path.join(dataDir, 'db'), { recursive: true });
|
|||||||
fs.mkdirSync(path.join(dataDir, 'files'), { recursive: true });
|
fs.mkdirSync(path.join(dataDir, 'files'), { recursive: true });
|
||||||
console.log(`[e2e] ${dataDir} zurueckgesetzt.`);
|
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) …');
|
console.log('[e2e] Image wird gebaut (bricktrack:e2e) …');
|
||||||
execFileSync('docker', ['build', '-t', 'bricktrack:e2e', '.'], { stdio: 'inherit' });
|
execFileSync('docker', ['build', '-t', 'bricktrack:e2e', '.'], { stdio: 'inherit' });
|
||||||
console.log('[e2e] Image ist bereit.');
|
console.log('[e2e] Image ist bereit.');
|
||||||
|
|||||||
@@ -16,6 +16,13 @@ fs.mkdirSync(path.join(dataDir, 'db'), { recursive: true });
|
|||||||
fs.mkdirSync(path.join(dataDir, 'files'), { recursive: true });
|
fs.mkdirSync(path.join(dataDir, 'files'), { recursive: true });
|
||||||
console.log(`[screenshots] ${dataDir} zurueckgesetzt.`);
|
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) …');
|
console.log('[screenshots] Image wird gebaut (bricktrack:shots) …');
|
||||||
execFileSync('docker', ['build', '-t', 'bricktrack:shots', '.'], { stdio: 'inherit' });
|
execFileSync('docker', ['build', '-t', 'bricktrack:shots', '.'], { stdio: 'inherit' });
|
||||||
console.log('[screenshots] Image ist bereit.');
|
console.log('[screenshots] Image ist bereit.');
|
||||||
|
|||||||
@@ -33,11 +33,12 @@ type SeedSet = {
|
|||||||
minifigCount: number | null;
|
minifigCount: number | null;
|
||||||
retailPriceCents: number;
|
retailPriceCents: number;
|
||||||
ageRange: string;
|
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?: {
|
owned?: {
|
||||||
status: 'owned' | 'built' | 'disassembled' | 'lent_out' | 'sold';
|
status: 'owned' | 'built' | 'disassembled' | 'lent_out' | 'sold';
|
||||||
completeness: 'complete' | 'missing_parts' | 'unknown';
|
completeness: 'complete' | 'missing_parts' | 'unknown';
|
||||||
location: string;
|
location: string;
|
||||||
|
collector: string;
|
||||||
giftedBy?: string;
|
giftedBy?: string;
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
@@ -59,6 +60,13 @@ const THEMES: Array<{ manufacturer: string; name: string }> = [
|
|||||||
|
|
||||||
const LOCATIONS = ['Regal Kinderzimmer', 'Kiste Keller 3', 'Vitrine Wohnzimmer'];
|
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[] = [
|
const SETS: SeedSet[] = [
|
||||||
{
|
{
|
||||||
manufacturer: 'Lego',
|
manufacturer: 'Lego',
|
||||||
@@ -70,7 +78,12 @@ const SETS: SeedSet[] = [
|
|||||||
minifigCount: 4,
|
minifigCount: 4,
|
||||||
retailPriceCents: 4999,
|
retailPriceCents: 4999,
|
||||||
ageRange: '6-12',
|
ageRange: '6-12',
|
||||||
owned: { status: 'built', completeness: 'complete', location: 'Regal Kinderzimmer' },
|
owned: {
|
||||||
|
status: 'built',
|
||||||
|
completeness: 'complete',
|
||||||
|
location: 'Regal Kinderzimmer',
|
||||||
|
collector: 'Max',
|
||||||
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
manufacturer: 'Lego',
|
manufacturer: 'Lego',
|
||||||
@@ -86,6 +99,7 @@ const SETS: SeedSet[] = [
|
|||||||
status: 'owned',
|
status: 'owned',
|
||||||
completeness: 'missing_parts',
|
completeness: 'missing_parts',
|
||||||
location: 'Kiste Keller 3',
|
location: 'Kiste Keller 3',
|
||||||
|
collector: 'Max',
|
||||||
giftedBy: 'Oma Helga',
|
giftedBy: 'Oma Helga',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -99,7 +113,12 @@ const SETS: SeedSet[] = [
|
|||||||
minifigCount: null,
|
minifigCount: null,
|
||||||
retailPriceCents: 999,
|
retailPriceCents: 999,
|
||||||
ageRange: '7-14',
|
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',
|
manufacturer: 'Lego',
|
||||||
@@ -144,7 +163,12 @@ const SETS: SeedSet[] = [
|
|||||||
minifigCount: 5,
|
minifigCount: 5,
|
||||||
retailPriceCents: 12999,
|
retailPriceCents: 12999,
|
||||||
ageRange: '4-10',
|
ageRange: '4-10',
|
||||||
owned: { status: 'built', completeness: 'complete', location: 'Regal Kinderzimmer' },
|
owned: {
|
||||||
|
status: 'built',
|
||||||
|
completeness: 'complete',
|
||||||
|
location: 'Regal Kinderzimmer',
|
||||||
|
collector: 'Mia',
|
||||||
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
manufacturer: 'Playmobil',
|
manufacturer: 'Playmobil',
|
||||||
@@ -241,6 +265,59 @@ function seedThemes(manufacturers: Map<string, string>) {
|
|||||||
return byKey;
|
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() {
|
function seedLocations() {
|
||||||
const byName = new Map<string, string>();
|
const byName = new Map<string, string>();
|
||||||
|
|
||||||
@@ -270,6 +347,7 @@ function seedSets(
|
|||||||
manufacturers: Map<string, string>,
|
manufacturers: Map<string, string>,
|
||||||
themes: Map<string, string>,
|
themes: Map<string, string>,
|
||||||
locations: Map<string, string>,
|
locations: Map<string, string>,
|
||||||
|
collectors: Map<string, string>,
|
||||||
) {
|
) {
|
||||||
const setIds = new Map<string, string>();
|
const setIds = new Map<string, string>();
|
||||||
|
|
||||||
@@ -317,6 +395,7 @@ function seedSets(
|
|||||||
completeness: entry.owned.completeness,
|
completeness: entry.owned.completeness,
|
||||||
condition: 'good',
|
condition: 'good',
|
||||||
locationId: locations.get(entry.owned.location) ?? null,
|
locationId: locations.get(entry.owned.location) ?? null,
|
||||||
|
collectorId: collectors.get(entry.owned.collector) ?? null,
|
||||||
acquiredAt: new Date(entry.year, 11, 24),
|
acquiredAt: new Date(entry.year, 11, 24),
|
||||||
acquiredPriceCents: entry.retailPriceCents,
|
acquiredPriceCents: entry.retailPriceCents,
|
||||||
giftedBy: entry.owned.giftedBy ?? null,
|
giftedBy: entry.owned.giftedBy ?? null,
|
||||||
@@ -330,7 +409,7 @@ function seedSets(
|
|||||||
return setIds;
|
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();
|
const owner = db.select({ id: schema.user.id }).from(schema.user).get();
|
||||||
if (!owner) {
|
if (!owner) {
|
||||||
console.log('[seed] Kein Nutzer vorhanden - die Beispiel-Wunschliste wird uebersprungen.');
|
console.log('[seed] Kein Nutzer vorhanden - die Beispiel-Wunschliste wird uebersprungen.');
|
||||||
@@ -353,6 +432,7 @@ function seedWishlist(setIds: Map<string, string>) {
|
|||||||
.values({
|
.values({
|
||||||
id: wishlistId,
|
id: wishlistId,
|
||||||
ownerUserId: owner.id,
|
ownerUserId: owner.id,
|
||||||
|
collectorId: collectors.get('Max') ?? null,
|
||||||
title: 'Weihnachten 2026',
|
title: 'Weihnachten 2026',
|
||||||
occasion: 'christmas',
|
occasion: 'christmas',
|
||||||
eventDate: new Date(2026, 11, 24),
|
eventDate: new Date(2026, 11, 24),
|
||||||
@@ -389,10 +469,12 @@ console.log('[seed] Beispieldaten werden eingespielt …');
|
|||||||
const manufacturers = seedManufacturers();
|
const manufacturers = seedManufacturers();
|
||||||
const themes = seedThemes(manufacturers);
|
const themes = seedThemes(manufacturers);
|
||||||
const locations = seedLocations();
|
const locations = seedLocations();
|
||||||
const setIds = seedSets(manufacturers, themes, locations);
|
const collectors = seedCollectors();
|
||||||
seedWishlist(setIds);
|
const setIds = seedSets(manufacturers, themes, locations, collectors);
|
||||||
|
seedWishlist(setIds, collectors);
|
||||||
|
|
||||||
console.log(
|
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();
|
sqlite.close();
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { notFound } from 'next/navigation';
|
|||||||
import { Check, ExternalLink, Gift, ImageOff, Pencil } from 'lucide-react';
|
import { Check, ExternalLink, Gift, ImageOff, Pencil } from 'lucide-react';
|
||||||
import { InstructionsSection } from '@/components/katalog/instructions-section';
|
import { InstructionsSection } from '@/components/katalog/instructions-section';
|
||||||
import { PhotosSection } from '@/components/katalog/photos-section';
|
import { PhotosSection } from '@/components/katalog/photos-section';
|
||||||
|
import { PriceSection } from '@/components/katalog/price-section';
|
||||||
import { SetActions } from '@/components/katalog/set-actions';
|
import { SetActions } from '@/components/katalog/set-actions';
|
||||||
import { CollectionItemsSection } from '@/components/sammlung/collection-items-section';
|
import { CollectionItemsSection } from '@/components/sammlung/collection-items-section';
|
||||||
import { Badge } from '@/components/ui/badge';
|
import { Badge } from '@/components/ui/badge';
|
||||||
@@ -17,25 +18,37 @@ import {
|
|||||||
listCollectionItemsForSet,
|
listCollectionItemsForSet,
|
||||||
listInstructionsForSet,
|
listInstructionsForSet,
|
||||||
listPhotosForSet,
|
listPhotosForSet,
|
||||||
|
listPricePoints,
|
||||||
} from '@/lib/queries/catalog';
|
} from '@/lib/queries/catalog';
|
||||||
import { listMissingParts } from '@/lib/queries/collection';
|
import { listMissingParts } from '@/lib/queries/collection';
|
||||||
|
import { listCollectors, resolveCollectorId } from '@/lib/queries/collectors';
|
||||||
import { listLocations } from '@/lib/queries/master-data';
|
import { listLocations } from '@/lib/queries/master-data';
|
||||||
import { listWishlistsForSet, listWishlistTargets } from '@/lib/queries/wishlists';
|
import { listWishlistsForSet, listWishlistTargets } from '@/lib/queries/wishlists';
|
||||||
import { requireUser } from '@/lib/session';
|
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 { id } = await params;
|
||||||
const set = getSetById(id);
|
const set = getSetById(id);
|
||||||
return { title: set ? `${set.setNumber} · ${set.name}` : 'Set' };
|
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();
|
await requireUser();
|
||||||
const { id } = await params;
|
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();
|
if (!set) notFound();
|
||||||
|
|
||||||
const image = setImageSrc(set);
|
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 locations = listLocations().map((row) => ({ id: row.id, name: row.name }));
|
||||||
const locationNames = new Map(locations.map((row) => [row.id, 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) => ({
|
const collectionItems = listCollectionItemsForSet(set.id).map((item) => ({
|
||||||
...item,
|
...item,
|
||||||
locationName: item.locationId ? (locationNames.get(item.locationId) ?? null) : null,
|
locationName: item.locationId ? (locationNames.get(item.locationId) ?? null) : null,
|
||||||
setName: `${set.setNumber} · ${set.name}`,
|
setName: `${set.setNumber} · ${set.name}`,
|
||||||
missingParts: listMissingParts(item.id),
|
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]> = [
|
const facts: Array<[string, string]> = [
|
||||||
@@ -113,6 +131,7 @@ export default async function SetDetailPage({ params }: Props) {
|
|||||||
setName={`${set.setNumber} · ${set.name}`}
|
setName={`${set.setNumber} · ${set.name}`}
|
||||||
owned={owned}
|
owned={owned}
|
||||||
wishlists={listWishlistTargets()}
|
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">
|
<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>
|
<CardTitle>In der Sammlung</CardTitle>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<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>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
import type { Metadata } from 'next';
|
import type { Metadata } from 'next';
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import { Plus } from 'lucide-react';
|
import { Plus } from 'lucide-react';
|
||||||
|
import { CollectorFilter } from '@/components/layout/collector-filter';
|
||||||
import { BarcodeScannerButton } from '@/components/katalog/barcode-scanner';
|
import { BarcodeScannerButton } from '@/components/katalog/barcode-scanner';
|
||||||
import { CatalogFilters } from '@/components/katalog/catalog-filters';
|
import { CatalogFilters } from '@/components/katalog/catalog-filters';
|
||||||
import { CatalogGrid } from '@/components/katalog/catalog-grid';
|
import { CatalogGrid } from '@/components/katalog/catalog-grid';
|
||||||
@@ -9,6 +10,7 @@ import { Button } from '@/components/ui/button';
|
|||||||
import { pluralize } from '@/lib/format';
|
import { pluralize } from '@/lib/format';
|
||||||
import { OWNERSHIP_FILTER_LABELS, parseOwnershipFilter } from '@/lib/ownership';
|
import { OWNERSHIP_FILTER_LABELS, parseOwnershipFilter } from '@/lib/ownership';
|
||||||
import { CATALOG_PAGE_SIZE, listSets, listSetYears } from '@/lib/queries/catalog';
|
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 { listManufacturers, listThemes } from '@/lib/queries/master-data';
|
||||||
import { listWishlistTargets } from '@/lib/queries/wishlists';
|
import { listWishlistTargets } from '@/lib/queries/wishlists';
|
||||||
import { requireUser } from '@/lib/session';
|
import { requireUser } from '@/lib/session';
|
||||||
@@ -27,6 +29,9 @@ export default async function KatalogPage({ searchParams }: { searchParams: Sear
|
|||||||
const params = await searchParams;
|
const params = await searchParams;
|
||||||
|
|
||||||
const ownership = parseOwnershipFilter(single(params.besitz));
|
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 yearParam = single(params.jahr);
|
||||||
const page = Number(single(params.seite) ?? '1') || 1;
|
const page = Number(single(params.seite) ?? '1') || 1;
|
||||||
|
|
||||||
@@ -36,6 +41,7 @@ export default async function KatalogPage({ searchParams }: { searchParams: Sear
|
|||||||
themeId: single(params.thema),
|
themeId: single(params.thema),
|
||||||
year: yearParam ? Number(yearParam) : undefined,
|
year: yearParam ? Number(yearParam) : undefined,
|
||||||
ownership,
|
ownership,
|
||||||
|
collectorId,
|
||||||
page,
|
page,
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -57,7 +63,9 @@ export default async function KatalogPage({ searchParams }: { searchParams: Sear
|
|||||||
</h1>
|
</h1>
|
||||||
<p className="text-sm text-muted-foreground">
|
<p className="text-sm text-muted-foreground">
|
||||||
{ownership === 'fehlt'
|
{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.'}
|
: 'Alle bekannten Sets – unabhängig davon, ob sie schon da sind.'}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
@@ -72,6 +80,11 @@ export default async function KatalogPage({ searchParams }: { searchParams: Sear
|
|||||||
</div>
|
</div>
|
||||||
</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() }} />
|
<CatalogFilters options={{ manufacturers, themes, years: listSetYears() }} />
|
||||||
|
|
||||||
<p className="text-sm text-muted-foreground">
|
<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]}` : ''}
|
{ownership !== 'alle' ? ` · ${OWNERSHIP_FILTER_LABELS[ownership]}` : ''}
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<CatalogGrid sets={rows} wishlists={wishlists} />
|
<CatalogGrid sets={rows} wishlists={wishlists} collectorId={collectorId} />
|
||||||
|
|
||||||
<Pagination page={page} total={total} pageSize={CATALOG_PAGE_SIZE} />
|
<Pagination page={page} total={total} pageSize={CATALOG_PAGE_SIZE} />
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -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();
|
||||||
|
}
|
||||||
@@ -1,8 +1,13 @@
|
|||||||
import { AppHeader } from '@/components/layout/app-header';
|
import { AppHeader } from '@/components/layout/app-header';
|
||||||
import { BottomNav } from '@/components/layout/bottom-nav';
|
import { BottomNav } from '@/components/layout/bottom-nav';
|
||||||
|
import { ensureReminderScheduler } from '@/lib/reminder-job';
|
||||||
import { requireUser } from '@/lib/session';
|
import { requireUser } from '@/lib/session';
|
||||||
|
|
||||||
export default async function AppLayout({ children }: { children: React.ReactNode }) {
|
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();
|
const user = await requireUser();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import {
|
|||||||
parseForm,
|
parseForm,
|
||||||
type ActionResult,
|
type ActionResult,
|
||||||
} from '@/lib/action-result';
|
} from '@/lib/action-result';
|
||||||
|
import { defaultCollectorId } from '@/lib/queries/collectors';
|
||||||
import { reconcileFulfilledWishlistItems } from '@/lib/queries/wishlists';
|
import { reconcileFulfilledWishlistItems } from '@/lib/queries/wishlists';
|
||||||
import { requireUser } from '@/lib/session';
|
import { requireUser } from '@/lib/session';
|
||||||
import { collectionItemSchema, missingPartSchema } from '@/lib/validation';
|
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
|
* Quick add from the set detail page: one tap, sensible defaults, details can
|
||||||
* be filled in later.
|
* 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();
|
await requireUser();
|
||||||
|
|
||||||
const exists = db.select({ id: set.id }).from(set).where(eq(set.id, setId)).get();
|
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.');
|
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();
|
const id = crypto.randomUUID();
|
||||||
db.insert(collectionItem)
|
db.insert(collectionItem)
|
||||||
.values({
|
.values({
|
||||||
id,
|
id,
|
||||||
setId,
|
setId,
|
||||||
|
collectorId: owner,
|
||||||
status: 'owned',
|
status: 'owned',
|
||||||
completeness: 'unknown',
|
completeness: 'unknown',
|
||||||
createdAt: now(),
|
createdAt: now(),
|
||||||
@@ -68,6 +76,7 @@ export async function updateCollectionItem(id: string, input: unknown): Promise<
|
|||||||
|
|
||||||
db.update(collectionItem)
|
db.update(collectionItem)
|
||||||
.set({
|
.set({
|
||||||
|
collectorId: parsed.data.collectorId ?? existing.collectorId,
|
||||||
status: parsed.data.status,
|
status: parsed.data.status,
|
||||||
completeness: parsed.data.completeness,
|
completeness: parsed.data.completeness,
|
||||||
condition: parsed.data.condition as Condition | null,
|
condition: parsed.data.condition as Condition | null,
|
||||||
|
|||||||
@@ -1,10 +1,12 @@
|
|||||||
import type { Metadata } from 'next';
|
import type { Metadata } from 'next';
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import { Boxes, Euro, ImageOff, Layers, PackageSearch } from 'lucide-react';
|
import { Boxes, Euro, ImageOff, Layers, PackageSearch } from 'lucide-react';
|
||||||
|
import { CollectorFilter } from '@/components/layout/collector-filter';
|
||||||
import { CollectionFilters } from '@/components/sammlung/collection-filters';
|
import { CollectionFilters } from '@/components/sammlung/collection-filters';
|
||||||
import { Badge } from '@/components/ui/badge';
|
import { Badge } from '@/components/ui/badge';
|
||||||
import { Card, CardContent } from '@/components/ui/card';
|
import { Card, CardContent } from '@/components/ui/card';
|
||||||
import { formatDate, formatNumber, pluralize } from '@/lib/format';
|
import { formatDate, formatNumber, pluralize } from '@/lib/format';
|
||||||
|
import { collectorBadgeClass } from '@/lib/collector-colors';
|
||||||
import { setImageSrc } from '@/lib/images';
|
import { setImageSrc } from '@/lib/images';
|
||||||
import { completenessLabel, statusLabel } from '@/lib/labels';
|
import { completenessLabel, statusLabel } from '@/lib/labels';
|
||||||
import { formatEuro } from '@/lib/money';
|
import { formatEuro } from '@/lib/money';
|
||||||
@@ -15,6 +17,7 @@ import {
|
|||||||
listCollectionItems,
|
listCollectionItems,
|
||||||
type CollectionFilters as Filters,
|
type CollectionFilters as Filters,
|
||||||
} from '@/lib/queries/collection';
|
} from '@/lib/queries/collection';
|
||||||
|
import { listCollectors, resolveCollectorId } from '@/lib/queries/collectors';
|
||||||
import { listLocations, listManufacturers } from '@/lib/queries/master-data';
|
import { listLocations, listManufacturers } from '@/lib/queries/master-data';
|
||||||
import { requireUser } from '@/lib/session';
|
import { requireUser } from '@/lib/session';
|
||||||
import type { CollectionStatus, Completeness } from '@/db/types';
|
import type { CollectionStatus, Completeness } from '@/db/types';
|
||||||
@@ -32,23 +35,33 @@ export default async function SammlungPage({ searchParams }: { searchParams: Sea
|
|||||||
await requireUser();
|
await requireUser();
|
||||||
const params = await searchParams;
|
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 = {
|
const filters: Filters = {
|
||||||
q: single(params.q),
|
q: single(params.q),
|
||||||
status: single(params.status) as CollectionStatus | undefined,
|
status: single(params.status) as CollectionStatus | undefined,
|
||||||
completeness: single(params.zustand) as Completeness | undefined,
|
completeness: single(params.zustand) as Completeness | undefined,
|
||||||
locationId: single(params.standort),
|
locationId: single(params.standort),
|
||||||
manufacturerId: single(params.hersteller),
|
manufacturerId: single(params.hersteller),
|
||||||
|
collectorId,
|
||||||
};
|
};
|
||||||
|
|
||||||
const items = listCollectionItems(filters);
|
const items = listCollectionItems(filters);
|
||||||
const openParts = countOpenMissingParts(items.map((item) => item.id));
|
const openParts = countOpenMissingParts(items.map((item) => item.id));
|
||||||
const stats = getCollectionStats();
|
const stats = getCollectionStats(collectorId);
|
||||||
const missing = countMissingSets();
|
const missing = countMissingSets(collectorId);
|
||||||
|
|
||||||
const tiles = [
|
const tiles = [
|
||||||
{ icon: Boxes, label: 'Sets', value: formatNumber(stats.setCount) },
|
{ icon: Boxes, label: 'Sets', value: formatNumber(stats.setCount) },
|
||||||
{ icon: Layers, label: 'Teile gesamt', value: formatNumber(stats.pieceCount) },
|
{ 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) },
|
{ icon: PackageSearch, label: 'Fehlt noch', value: formatNumber(missing) },
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
@@ -56,9 +69,18 @@ export default async function SammlungPage({ searchParams }: { searchParams: Sea
|
|||||||
<div className="space-y-5">
|
<div className="space-y-5">
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-2xl font-semibold tracking-tight">Sammlung</h1>
|
<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>
|
</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">
|
<div className="grid grid-cols-2 gap-3 sm:grid-cols-4">
|
||||||
{tiles.map((tile) => (
|
{tiles.map((tile) => (
|
||||||
<Card key={tile.label}>
|
<Card key={tile.label}>
|
||||||
@@ -134,6 +156,11 @@ export default async function SammlungPage({ searchParams }: { searchParams: Sea
|
|||||||
{item.completeness !== 'unknown' ? (
|
{item.completeness !== 'unknown' ? (
|
||||||
<Badge variant="secondary">{completenessLabel(item.completeness)}</Badge>
|
<Badge variant="secondary">{completenessLabel(item.completeness)}</Badge>
|
||||||
) : null}
|
) : null}
|
||||||
|
{collectors.length > 1 && item.collectorName ? (
|
||||||
|
<Badge className={collectorBadgeClass(item.collectorColor)}>
|
||||||
|
{item.collectorName}
|
||||||
|
</Badge>
|
||||||
|
) : null}
|
||||||
{openParts.get(item.id) ? (
|
{openParts.get(item.id) ? (
|
||||||
<Badge variant="outline">{openParts.get(item.id)} Fehlteile</Badge>
|
<Badge variant="outline">{openParts.get(item.id)} Fehlteile</Badge>
|
||||||
) : null}
|
) : 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}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -8,10 +8,12 @@ import { WishlistHeaderActions } from '@/components/wunschlisten/wishlist-header
|
|||||||
import { WishlistItems } from '@/components/wunschlisten/wishlist-items';
|
import { WishlistItems } from '@/components/wunschlisten/wishlist-items';
|
||||||
import { Badge } from '@/components/ui/badge';
|
import { Badge } from '@/components/ui/badge';
|
||||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||||
|
import { collectorBadgeClass } from '@/lib/collector-colors';
|
||||||
import { formatDate, pluralize } from '@/lib/format';
|
import { formatDate, pluralize } from '@/lib/format';
|
||||||
import { occasionLabel } from '@/lib/labels';
|
import { occasionLabel } from '@/lib/labels';
|
||||||
import { CATALOG_PAGE_SIZE, listSets, listSetYears } from '@/lib/queries/catalog';
|
import { CATALOG_PAGE_SIZE, listSets, listSetYears } from '@/lib/queries/catalog';
|
||||||
import { listManufacturers, listThemes } from '@/lib/queries/master-data';
|
import { listManufacturers, listThemes } from '@/lib/queries/master-data';
|
||||||
|
import { listCollectors } from '@/lib/queries/collectors';
|
||||||
import { getWishlist, listWishlistItems } from '@/lib/queries/wishlists';
|
import { getWishlist, listWishlistItems } from '@/lib/queries/wishlists';
|
||||||
import { requireUser } from '@/lib/session';
|
import { requireUser } from '@/lib/session';
|
||||||
|
|
||||||
@@ -39,7 +41,9 @@ export default async function WunschlisteDetailPage({ params, searchParams }: Pr
|
|||||||
const list = getWishlist(id);
|
const list = getWishlist(id);
|
||||||
if (!list || list.ownerUserId !== user.id) notFound();
|
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 embedded picker: missing sets that are not on this list yet. It shares
|
||||||
// the URL parameters with the catalog, so the filters behave identically.
|
// 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,
|
year: yearParam ? Number(yearParam) : undefined,
|
||||||
ownership: 'fehlt',
|
ownership: 'fehlt',
|
||||||
excludeWishlistId: list.id,
|
excludeWishlistId: list.id,
|
||||||
|
collectorId: list.collectorId ?? undefined,
|
||||||
page,
|
page,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -63,6 +68,11 @@ export default async function WunschlisteDetailPage({ params, searchParams }: Pr
|
|||||||
<Badge variant="secondary">{occasionLabel(list.occasion)}</Badge>
|
<Badge variant="secondary">{occasionLabel(list.occasion)}</Badge>
|
||||||
{list.eventDate ? <Badge variant="outline">{formatDate(list.eventDate)}</Badge> : null}
|
{list.eventDate ? <Badge variant="outline">{formatDate(list.eventDate)}</Badge> : null}
|
||||||
{list.surpriseMode ? <Badge variant="outline">Überraschungsmodus</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>
|
</div>
|
||||||
<h1 className="mt-2 truncate text-2xl font-semibold tracking-tight">{list.title}</h1>
|
<h1 className="mt-2 truncate text-2xl font-semibold tracking-tight">{list.title}</h1>
|
||||||
<p className="text-sm text-muted-foreground">
|
<p className="text-sm text-muted-foreground">
|
||||||
@@ -70,8 +80,10 @@ export default async function WunschlisteDetailPage({ params, searchParams }: Pr
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<WishlistHeaderActions
|
<WishlistHeaderActions
|
||||||
|
collectors={collectors.map((row) => ({ id: row.id, name: row.name }))}
|
||||||
wishlist={{
|
wishlist={{
|
||||||
id: list.id,
|
id: list.id,
|
||||||
|
collectorId: list.collectorId,
|
||||||
title: list.title,
|
title: list.title,
|
||||||
occasion: list.occasion,
|
occasion: list.occasion,
|
||||||
eventDate: list.eventDate,
|
eventDate: list.eventDate,
|
||||||
@@ -99,7 +111,9 @@ export default async function WunschlisteDetailPage({ params, searchParams }: Pr
|
|||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle>Aus fehlenden Sets hinzufügen</CardTitle>
|
<CardTitle>Aus fehlenden Sets hinzufügen</CardTitle>
|
||||||
<CardDescription>
|
<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.
|
lange drücken oder „Auswählen“ tippen, dann mehrere auf einmal übernehmen.
|
||||||
</CardDescription>
|
</CardDescription>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
@@ -124,6 +138,7 @@ export default async function WunschlisteDetailPage({ params, searchParams }: Pr
|
|||||||
sets={missing.rows}
|
sets={missing.rows}
|
||||||
wishlists={[{ id: list.id, title: list.title }]}
|
wishlists={[{ id: list.id, title: list.title }]}
|
||||||
defaultWishlistId={list.id}
|
defaultWishlistId={list.id}
|
||||||
|
collectorId={list.collectorId ?? undefined}
|
||||||
emptyState="Es fehlt gerade nichts, was noch nicht auf dieser Liste steht."
|
emptyState="Es fehlt gerade nichts, was noch nicht auf dieser Liste steht."
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import {
|
|||||||
type ActionResult,
|
type ActionResult,
|
||||||
} from '@/lib/action-result';
|
} from '@/lib/action-result';
|
||||||
import { ownedCopiesSql } from '@/lib/ownership';
|
import { ownedCopiesSql } from '@/lib/ownership';
|
||||||
|
import { defaultCollectorId } from '@/lib/queries/collectors';
|
||||||
import {
|
import {
|
||||||
findExistingWishlistSetIds,
|
findExistingWishlistSetIds,
|
||||||
getWishlist,
|
getWishlist,
|
||||||
@@ -58,6 +59,7 @@ export async function createWishlist(input: unknown): Promise<ActionResult<{ id:
|
|||||||
.values({
|
.values({
|
||||||
id,
|
id,
|
||||||
ownerUserId: user.id,
|
ownerUserId: user.id,
|
||||||
|
collectorId: parsed.data.collectorId ?? defaultCollectorId(),
|
||||||
title: parsed.data.title,
|
title: parsed.data.title,
|
||||||
occasion: parsed.data.occasion,
|
occasion: parsed.data.occasion,
|
||||||
eventDate: parsed.data.eventDate,
|
eventDate: parsed.data.eventDate,
|
||||||
@@ -83,6 +85,7 @@ export async function updateWishlist(id: string, input: unknown): Promise<Action
|
|||||||
|
|
||||||
db.update(wishlist)
|
db.update(wishlist)
|
||||||
.set({
|
.set({
|
||||||
|
collectorId: parsed.data.collectorId,
|
||||||
title: parsed.data.title,
|
title: parsed.data.title,
|
||||||
occasion: parsed.data.occasion,
|
occasion: parsed.data.occasion,
|
||||||
eventDate: parsed.data.eventDate,
|
eventDate: parsed.data.eventDate,
|
||||||
@@ -153,7 +156,7 @@ export async function addSetsToWishlist(
|
|||||||
id: set.id,
|
id: set.id,
|
||||||
name: set.name,
|
name: set.name,
|
||||||
setNumber: set.setNumber,
|
setNumber: set.setNumber,
|
||||||
ownedCopies: ownedCopiesSql(set.id).mapWith(Number),
|
ownedCopies: ownedCopiesSql(set.id, list.collectorId).mapWith(Number),
|
||||||
})
|
})
|
||||||
.from(set)
|
.from(set)
|
||||||
.where(inArray(set.id, setIds))
|
.where(inArray(set.id, setIds))
|
||||||
|
|||||||
@@ -4,8 +4,10 @@ import { CalendarDays, Gift, Link2Off } from 'lucide-react';
|
|||||||
import { NewWishlistButton } from '@/components/wunschlisten/wishlist-form-dialog';
|
import { NewWishlistButton } from '@/components/wunschlisten/wishlist-form-dialog';
|
||||||
import { Badge } from '@/components/ui/badge';
|
import { Badge } from '@/components/ui/badge';
|
||||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||||
|
import { collectorBadgeClass } from '@/lib/collector-colors';
|
||||||
import { formatDate, pluralize } from '@/lib/format';
|
import { formatDate, pluralize } from '@/lib/format';
|
||||||
import { occasionLabel } from '@/lib/labels';
|
import { occasionLabel } from '@/lib/labels';
|
||||||
|
import { listCollectors } from '@/lib/queries/collectors';
|
||||||
import { listWishlists } from '@/lib/queries/wishlists';
|
import { listWishlists } from '@/lib/queries/wishlists';
|
||||||
import { requireUser } from '@/lib/session';
|
import { requireUser } from '@/lib/session';
|
||||||
|
|
||||||
@@ -14,6 +16,7 @@ export const metadata: Metadata = { title: 'Wunschlisten' };
|
|||||||
export default async function WunschlistenPage() {
|
export default async function WunschlistenPage() {
|
||||||
const user = await requireUser();
|
const user = await requireUser();
|
||||||
const lists = listWishlists(user.id);
|
const lists = listWishlists(user.id);
|
||||||
|
const collectors = listCollectors();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-5">
|
<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.
|
Zum Teilen mit Familie und Freunden – ganz ohne Konto für die anderen.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<NewWishlistButton />
|
<NewWishlistButton
|
||||||
|
collectors={collectors.map((row) => ({ id: row.id, name: row.name }))}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{lists.length === 0 ? (
|
{lists.length === 0 ? (
|
||||||
@@ -54,6 +59,11 @@ export default async function WunschlistenPage() {
|
|||||||
</CardDescription>
|
</CardDescription>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="flex flex-wrap items-center gap-2 text-xs">
|
<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>
|
<Badge variant="secondary">{pluralize(list.itemCount, 'Wunsch', 'Wünsche')}</Badge>
|
||||||
{list.openCount > 0 ? (
|
{list.openCount > 0 ? (
|
||||||
<Badge variant="outline">{list.openCount} noch offen</Badge>
|
<Badge variant="outline">{list.openCount} noch offen</Badge>
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { sql } from 'drizzle-orm';
|
import { sql } from 'drizzle-orm';
|
||||||
import { NextResponse } from 'next/server';
|
import { NextResponse } from 'next/server';
|
||||||
import { db } from '@/db';
|
import { db } from '@/db';
|
||||||
|
import { ensureReminderScheduler } from '@/lib/reminder-job';
|
||||||
|
|
||||||
export const dynamic = 'force-dynamic';
|
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.
|
* file is readable - a healthy Node process on a broken volume is not healthy.
|
||||||
*/
|
*/
|
||||||
export function GET() {
|
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 {
|
try {
|
||||||
db.get(sql`select 1`);
|
db.get(sql`select 1`);
|
||||||
return NextResponse.json({ status: 'ok', time: new Date().toISOString() });
|
return NextResponse.json({ status: 'ok', time: new Date().toISOString() });
|
||||||
|
|||||||
@@ -27,7 +27,7 @@ export default async function PublicWishlistPage({ params }: Props) {
|
|||||||
const list = getWishlistByShareToken(shareToken);
|
const list = getWishlistByShareToken(shareToken);
|
||||||
if (!list || !list.isPublic) notFound();
|
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
|
// Fulfilled wishes are gone from the public view: the present is already
|
||||||
// under the tree, no reason to let someone buy it twice.
|
// under the tree, no reason to let someone buy it twice.
|
||||||
|
|||||||
@@ -33,6 +33,8 @@ type Props = {
|
|||||||
wishlists: WishlistTarget[];
|
wishlists: WishlistTarget[];
|
||||||
/** Preselected target, used by the embedded view on a wishlist page. */
|
/** Preselected target, used by the embedded view on a wishlist page. */
|
||||||
defaultWishlistId?: string;
|
defaultWishlistId?: string;
|
||||||
|
/** A list created from a filtered view belongs to that collector. */
|
||||||
|
collectorId?: string;
|
||||||
emptyState?: React.ReactNode;
|
emptyState?: React.ReactNode;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -43,7 +45,13 @@ type Props = {
|
|||||||
* On a desktop the "Auswählen" button does the same job. Sets that are already
|
* 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.
|
* 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 router = useRouter();
|
||||||
const [selecting, setSelecting] = useState(false);
|
const [selecting, setSelecting] = useState(false);
|
||||||
const [selected, setSelected] = useState<Set<string>>(new Set());
|
const [selected, setSelected] = useState<Set<string>>(new Set());
|
||||||
@@ -102,6 +110,7 @@ export function CatalogGrid({ sets, wishlists, defaultWishlistId, emptyState }:
|
|||||||
title: newTitle,
|
title: newTitle,
|
||||||
occasion: 'other',
|
occasion: 'other',
|
||||||
isPublic: 'on',
|
isPublic: 'on',
|
||||||
|
collectorId: collectorId ?? '',
|
||||||
});
|
});
|
||||||
if (!created.ok) {
|
if (!created.ok) {
|
||||||
toast.error(created.error);
|
toast.error(created.error);
|
||||||
@@ -199,6 +208,7 @@ export function CatalogGrid({ sets, wishlists, defaultWishlistId, emptyState }:
|
|||||||
<button
|
<button
|
||||||
key={item.id}
|
key={item.id}
|
||||||
type="button"
|
type="button"
|
||||||
|
data-set-card
|
||||||
disabled={owned}
|
disabled={owned}
|
||||||
aria-pressed={isSelected}
|
aria-pressed={isSelected}
|
||||||
onClick={() => toggle(item.id)}
|
onClick={() => toggle(item.id)}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -38,32 +38,47 @@ export function SetActions({
|
|||||||
setName,
|
setName,
|
||||||
owned,
|
owned,
|
||||||
wishlists,
|
wishlists,
|
||||||
|
collectors,
|
||||||
}: {
|
}: {
|
||||||
setId: string;
|
setId: string;
|
||||||
setName: string;
|
setName: string;
|
||||||
owned: boolean;
|
owned: boolean;
|
||||||
wishlists: Array<{ id: string; title: string }>;
|
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 router = useRouter();
|
||||||
const [pending, startTransition] = useTransition();
|
const [pending, startTransition] = useTransition();
|
||||||
const [wishlistOpen, setWishlistOpen] = useState(false);
|
const [wishlistOpen, setWishlistOpen] = useState(false);
|
||||||
const [deleteOpen, setDeleteOpen] = useState(false);
|
const [deleteOpen, setDeleteOpen] = useState(false);
|
||||||
const [targetId, setTargetId] = useState(wishlists[0]?.id ?? NEW_LIST);
|
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 [newTitle, setNewTitle] = useState('');
|
||||||
const [priority, setPriority] = useState('2');
|
const [priority, setPriority] = useState('2');
|
||||||
|
|
||||||
function addToCollection() {
|
function addToCollection(forCollectorId?: string) {
|
||||||
startTransition(async () => {
|
startTransition(async () => {
|
||||||
const result = await addSetToCollection(setId);
|
const result = await addSetToCollection(setId, forCollectorId);
|
||||||
if (!result.ok) {
|
if (!result.ok) {
|
||||||
toast.error(result.error);
|
toast.error(result.error);
|
||||||
return;
|
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();
|
router.refresh();
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// With a single collector there is nothing to ask.
|
||||||
|
function onAddClick() {
|
||||||
|
if (collectors.length > 1) setCollectorOpen(true);
|
||||||
|
else addToCollection();
|
||||||
|
}
|
||||||
|
|
||||||
function addToWishlist() {
|
function addToWishlist() {
|
||||||
startTransition(async () => {
|
startTransition(async () => {
|
||||||
let wishlistId = targetId;
|
let wishlistId = targetId;
|
||||||
@@ -111,7 +126,7 @@ export function SetActions({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-wrap gap-2">
|
<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 />}
|
{pending ? <Loader2 className="animate-spin" /> : <Plus />}
|
||||||
Zur Sammlung hinzufügen
|
Zur Sammlung hinzufügen
|
||||||
</Button>
|
</Button>
|
||||||
@@ -138,6 +153,39 @@ export function SetActions({
|
|||||||
</p>
|
</p>
|
||||||
) : null}
|
) : 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}>
|
<Dialog open={wishlistOpen} onOpenChange={setWishlistOpen}>
|
||||||
<DialogContent>
|
<DialogContent>
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
|
|||||||
@@ -1,13 +1,28 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useRouter } from 'next/navigation';
|
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 { Loader2, Save } from 'lucide-react';
|
||||||
import { toast } from 'sonner';
|
import { toast } from 'sonner';
|
||||||
import { createSet, updateSet } from '@/app/(app)/katalog/actions';
|
import { createSet, updateSet } from '@/app/(app)/katalog/actions';
|
||||||
|
import {
|
||||||
|
findDuplicateCandidates,
|
||||||
|
type DuplicateCandidate,
|
||||||
|
} from '@/app/(app)/katalog/duplicate-actions';
|
||||||
import { centsToInput } from '@/lib/money';
|
import { centsToInput } from '@/lib/money';
|
||||||
import { setImageSrc } from '@/lib/images';
|
import { setImageSrc } from '@/lib/images';
|
||||||
import { Button } from '@/components/ui/button';
|
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 { Checkbox } from '@/components/ui/checkbox';
|
||||||
import { Field } from '@/components/ui/field';
|
import { Field } from '@/components/ui/field';
|
||||||
import { Input } from '@/components/ui/input';
|
import { Input } from '@/components/ui/input';
|
||||||
@@ -46,6 +61,8 @@ export function SetForm({ manufacturers, themes, set, defaultEan }: Props) {
|
|||||||
const [manufacturerId, setManufacturerId] = useState(
|
const [manufacturerId, setManufacturerId] = useState(
|
||||||
set?.manufacturerId ?? manufacturers[0]?.id ?? '',
|
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 existingImage = set ? setImageSrc(set) : null;
|
||||||
const availableThemes = themes.filter((theme) => theme.manufacturerId === manufacturerId);
|
const availableThemes = themes.filter((theme) => theme.manufacturerId === manufacturerId);
|
||||||
@@ -54,31 +71,69 @@ export function SetForm({ manufacturers, themes, set, defaultEan }: Props) {
|
|||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
const formData = new FormData(event.currentTarget);
|
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 () => {
|
startTransition(async () => {
|
||||||
if (set) {
|
if (set) {
|
||||||
const result = await updateSet(set.id, formData);
|
const result = await updateSet(set.id, formData);
|
||||||
if (!result.ok) {
|
if (!result.ok) {
|
||||||
toast.error(result.error);
|
toast.error(result.error);
|
||||||
|
resolve();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
toast.success('Set gespeichert.');
|
toast.success('Set gespeichert.');
|
||||||
router.push(`/katalog/${set.id}`);
|
router.push(`/katalog/${set.id}`);
|
||||||
router.refresh();
|
router.refresh();
|
||||||
|
resolve();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const result = await createSet(formData);
|
const result = await createSet(formData);
|
||||||
if (!result.ok) {
|
if (!result.ok) {
|
||||||
toast.error(result.error);
|
toast.error(result.error);
|
||||||
|
resolve();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
toast.success('Set angelegt.');
|
toast.success('Set angelegt.');
|
||||||
router.push(`/katalog/${result.data.id}`);
|
router.push(`/katalog/${result.data.id}`);
|
||||||
router.refresh();
|
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 (
|
return (
|
||||||
|
<>
|
||||||
<form onSubmit={onSubmit} className="space-y-6">
|
<form onSubmit={onSubmit} className="space-y-6">
|
||||||
<div className="grid gap-4 sm:grid-cols-2">
|
<div className="grid gap-4 sm:grid-cols-2">
|
||||||
<Field label="Hersteller" htmlFor="manufacturerId" required>
|
<Field label="Hersteller" htmlFor="manufacturerId" required>
|
||||||
@@ -244,5 +299,56 @@ export function SetForm({ manufacturers, themes, set, defaultEan }: Props) {
|
|||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</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 type { MissingPartRow } from '@/lib/queries/collection';
|
||||||
import { MissingPartsDialog } from './missing-parts-dialog';
|
import { MissingPartsDialog } from './missing-parts-dialog';
|
||||||
import type { CollectionStatus, Completeness, Condition } from '@/db/types';
|
import type { CollectionStatus, Completeness, Condition } from '@/db/types';
|
||||||
|
import { collectorBadgeClass } from '@/lib/collector-colors';
|
||||||
import { formatDate, toDateInputValue } from '@/lib/format';
|
import { formatDate, toDateInputValue } from '@/lib/format';
|
||||||
import {
|
import {
|
||||||
COMPLETENESS_LABELS,
|
COMPLETENESS_LABELS,
|
||||||
@@ -58,14 +59,19 @@ export type CollectionItemRow = {
|
|||||||
notes: string | null;
|
notes: string | null;
|
||||||
setName: string;
|
setName: string;
|
||||||
missingParts: MissingPartRow[];
|
missingParts: MissingPartRow[];
|
||||||
|
collectorId: string | null;
|
||||||
|
collectorName: string | null;
|
||||||
|
collectorColor: string | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
export function CollectionItemsSection({
|
export function CollectionItemsSection({
|
||||||
items,
|
items,
|
||||||
locations,
|
locations,
|
||||||
|
collectors,
|
||||||
}: {
|
}: {
|
||||||
items: CollectionItemRow[];
|
items: CollectionItemRow[];
|
||||||
locations: Array<{ id: string; name: string }>;
|
locations: Array<{ id: string; name: string }>;
|
||||||
|
collectors: Array<{ id: string; name: string }>;
|
||||||
}) {
|
}) {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const [pending, startTransition] = useTransition();
|
const [pending, startTransition] = useTransition();
|
||||||
@@ -126,6 +132,11 @@ export function CollectionItemsSection({
|
|||||||
<li key={item.id} className="flex items-start gap-3 p-3">
|
<li key={item.id} className="flex items-start gap-3 p-3">
|
||||||
<div className="min-w-0 flex-1 space-y-1">
|
<div className="min-w-0 flex-1 space-y-1">
|
||||||
<div className="flex flex-wrap items-center gap-2">
|
<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'}>
|
<Badge variant={item.status === 'sold' ? 'outline' : 'default'}>
|
||||||
{statusLabel(item.status)}
|
{statusLabel(item.status)}
|
||||||
</Badge>
|
</Badge>
|
||||||
@@ -194,6 +205,22 @@ export function CollectionItemsSection({
|
|||||||
|
|
||||||
<form id="collection-item-form" onSubmit={submit} className="space-y-4">
|
<form id="collection-item-form" onSubmit={submit} className="space-y-4">
|
||||||
<div className="grid gap-4 sm:grid-cols-2">
|
<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">
|
<Field label="Status" htmlFor="status">
|
||||||
<NativeSelect id="status" name="status" defaultValue={editing?.status ?? 'owned'}>
|
<NativeSelect id="status" name="status" defaultValue={editing?.status ?? 'owned'}>
|
||||||
{Object.entries(STATUS_LABELS).map(([value, label]) => (
|
{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';
|
import { cn } from 'cn';
|
||||||
|
|
||||||
const tabs = [
|
const tabs = [
|
||||||
|
{ href: '/verwaltung/sammler', label: 'Sammler', adminOnly: false },
|
||||||
{ href: '/verwaltung/hersteller', label: 'Hersteller', adminOnly: false },
|
{ href: '/verwaltung/hersteller', label: 'Hersteller', adminOnly: false },
|
||||||
{ href: '/verwaltung/themen', label: 'Themen', adminOnly: false },
|
{ href: '/verwaltung/themen', label: 'Themen', adminOnly: false },
|
||||||
{ href: '/verwaltung/standorte', label: 'Standorte', adminOnly: false },
|
{ href: '/verwaltung/standorte', label: 'Standorte', adminOnly: false },
|
||||||
{ href: '/verwaltung/einladungen', label: 'Einladungen', adminOnly: true },
|
{ href: '/verwaltung/einladungen', label: 'Einladungen', adminOnly: true },
|
||||||
|
{ href: '/verwaltung/erinnerungen', label: 'Erinnerungen', adminOnly: true },
|
||||||
{ href: '/verwaltung/daten', label: 'Daten', adminOnly: true },
|
{ href: '/verwaltung/daten', label: 'Daten', adminOnly: true },
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ import { Textarea } from '@/components/ui/textarea';
|
|||||||
|
|
||||||
export type WishlistFormValues = {
|
export type WishlistFormValues = {
|
||||||
id: string;
|
id: string;
|
||||||
|
collectorId: string | null;
|
||||||
title: string;
|
title: string;
|
||||||
occasion: Occasion;
|
occasion: Occasion;
|
||||||
eventDate: Date | null;
|
eventDate: Date | null;
|
||||||
@@ -36,10 +37,13 @@ export type WishlistFormValues = {
|
|||||||
|
|
||||||
export function WishlistFormDialog({
|
export function WishlistFormDialog({
|
||||||
wishlist,
|
wishlist,
|
||||||
|
collectors = [],
|
||||||
open,
|
open,
|
||||||
onOpenChange,
|
onOpenChange,
|
||||||
}: {
|
}: {
|
||||||
wishlist?: WishlistFormValues;
|
wishlist?: WishlistFormValues;
|
||||||
|
/** Only asked about when there is more than one. */
|
||||||
|
collectors?: Array<{ id: string; name: string }>;
|
||||||
open: boolean;
|
open: boolean;
|
||||||
onOpenChange: (open: boolean) => void;
|
onOpenChange: (open: boolean) => void;
|
||||||
}) {
|
}) {
|
||||||
@@ -99,6 +103,27 @@ export function WishlistFormDialog({
|
|||||||
/>
|
/>
|
||||||
</Field>
|
</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">
|
<div className="grid gap-4 sm:grid-cols-2">
|
||||||
<Field label="Anlass" htmlFor="occasion">
|
<Field label="Anlass" htmlFor="occasion">
|
||||||
<NativeSelect
|
<NativeSelect
|
||||||
@@ -180,7 +205,11 @@ export function WishlistFormDialog({
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** Button plus dialog, for the wishlist overview page. */
|
/** 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);
|
const [open, setOpen] = useState(false);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -189,7 +218,7 @@ export function NewWishlistButton() {
|
|||||||
<Plus />
|
<Plus />
|
||||||
Neue Wunschliste
|
Neue Wunschliste
|
||||||
</Button>
|
</Button>
|
||||||
<WishlistFormDialog open={open} onOpenChange={setOpen} />
|
<WishlistFormDialog collectors={collectors} open={open} onOpenChange={setOpen} />
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -18,7 +18,13 @@ import {
|
|||||||
AlertDialogTitle,
|
AlertDialogTitle,
|
||||||
} from '@/components/ui/alert-dialog';
|
} 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 router = useRouter();
|
||||||
const [editOpen, setEditOpen] = useState(false);
|
const [editOpen, setEditOpen] = useState(false);
|
||||||
const [deleteOpen, setDeleteOpen] = useState(false);
|
const [deleteOpen, setDeleteOpen] = useState(false);
|
||||||
@@ -52,7 +58,12 @@ export function WishlistHeaderActions({ wishlist }: { wishlist: WishlistFormValu
|
|||||||
<Trash2 className="text-destructive" />
|
<Trash2 className="text-destructive" />
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
<WishlistFormDialog wishlist={wishlist} open={editOpen} onOpenChange={setEditOpen} />
|
<WishlistFormDialog
|
||||||
|
wishlist={wishlist}
|
||||||
|
collectors={collectors}
|
||||||
|
open={editOpen}
|
||||||
|
onOpenChange={setEditOpen}
|
||||||
|
/>
|
||||||
|
|
||||||
<AlertDialog open={deleteOpen} onOpenChange={setDeleteOpen}>
|
<AlertDialog open={deleteOpen} onOpenChange={setDeleteOpen}>
|
||||||
<AlertDialogContent>
|
<AlertDialogContent>
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import {
|
|||||||
text,
|
text,
|
||||||
} from 'drizzle-orm/sqlite-core';
|
} from 'drizzle-orm/sqlite-core';
|
||||||
import { set } from './catalog';
|
import { set } from './catalog';
|
||||||
|
import { collector } from './collector';
|
||||||
|
|
||||||
export const location = sqliteTable('location', {
|
export const location = sqliteTable('location', {
|
||||||
id: text('id').primaryKey(),
|
id: text('id').primaryKey(),
|
||||||
@@ -31,6 +32,12 @@ export const collectionItem = sqliteTable(
|
|||||||
setId: text('set_id')
|
setId: text('set_id')
|
||||||
.notNull()
|
.notNull()
|
||||||
.references(() => set.id, { onDelete: 'cascade' }),
|
.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', {
|
status: text('status', {
|
||||||
enum: ['owned', 'built', 'disassembled', 'lent_out', 'sold'],
|
enum: ['owned', 'built', 'disassembled', 'lent_out', 'sold'],
|
||||||
})
|
})
|
||||||
@@ -54,6 +61,7 @@ export const collectionItem = sqliteTable(
|
|||||||
},
|
},
|
||||||
(t) => [
|
(t) => [
|
||||||
index('collection_item_set_idx').on(t.setId),
|
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_status_idx').on(t.status),
|
||||||
index('collection_item_location_idx').on(t.locationId),
|
index('collection_item_location_idx').on(t.locationId),
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -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)],
|
||||||
|
);
|
||||||
@@ -1,5 +1,7 @@
|
|||||||
export * from './auth';
|
export * from './auth';
|
||||||
export * from './catalog';
|
export * from './catalog';
|
||||||
|
export * from './collector';
|
||||||
|
export * from './prices';
|
||||||
export * from './collection';
|
export * from './collection';
|
||||||
export * from './wishlist';
|
export * from './wishlist';
|
||||||
export * from './relations';
|
export * from './relations';
|
||||||
|
|||||||
@@ -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`),
|
||||||
|
],
|
||||||
|
);
|
||||||
@@ -2,6 +2,8 @@ import { relations } from 'drizzle-orm';
|
|||||||
import { passkey, session, user } from './auth';
|
import { passkey, session, user } from './auth';
|
||||||
import { instruction, manufacturer, set, setTag, tag, theme } from './catalog';
|
import { instruction, manufacturer, set, setTag, tag, theme } from './catalog';
|
||||||
import { collectionItem, location, missingPart, photo } from './collection';
|
import { collectionItem, location, missingPart, photo } from './collection';
|
||||||
|
import { collector } from './collector';
|
||||||
|
import { pricePoint } from './prices';
|
||||||
import { wishlist, wishlistItem } from './wishlist';
|
import { wishlist, wishlistItem } from './wishlist';
|
||||||
|
|
||||||
export const userRelations = relations(user, ({ many }) => ({
|
export const userRelations = relations(user, ({ many }) => ({
|
||||||
@@ -48,6 +50,11 @@ export const setRelations = relations(set, ({ one, many }) => ({
|
|||||||
photos: many(photo),
|
photos: many(photo),
|
||||||
setTags: many(setTag),
|
setTags: many(setTag),
|
||||||
wishlistItems: many(wishlistItem),
|
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 }) => ({
|
export const tagRelations = relations(tag, ({ many }) => ({
|
||||||
@@ -73,8 +80,17 @@ export const locationRelations = relations(location, ({ one, many }) => ({
|
|||||||
collectionItems: many(collectionItem),
|
collectionItems: many(collectionItem),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
export const collectorRelations = relations(collector, ({ many }) => ({
|
||||||
|
collectionItems: many(collectionItem),
|
||||||
|
wishlists: many(wishlist),
|
||||||
|
}));
|
||||||
|
|
||||||
export const collectionItemRelations = relations(collectionItem, ({ one, many }) => ({
|
export const collectionItemRelations = relations(collectionItem, ({ one, many }) => ({
|
||||||
set: one(set, { fields: [collectionItem.setId], references: [set.id] }),
|
set: one(set, { fields: [collectionItem.setId], references: [set.id] }),
|
||||||
|
collector: one(collector, {
|
||||||
|
fields: [collectionItem.collectorId],
|
||||||
|
references: [collector.id],
|
||||||
|
}),
|
||||||
location: one(location, {
|
location: one(location, {
|
||||||
fields: [collectionItem.locationId],
|
fields: [collectionItem.locationId],
|
||||||
references: [location.id],
|
references: [location.id],
|
||||||
@@ -100,6 +116,10 @@ export const photoRelations = relations(photo, ({ one }) => ({
|
|||||||
|
|
||||||
export const wishlistRelations = relations(wishlist, ({ one, many }) => ({
|
export const wishlistRelations = relations(wishlist, ({ one, many }) => ({
|
||||||
owner: one(user, { fields: [wishlist.ownerUserId], references: [user.id] }),
|
owner: one(user, { fields: [wishlist.ownerUserId], references: [user.id] }),
|
||||||
|
collector: one(collector, {
|
||||||
|
fields: [wishlist.collectorId],
|
||||||
|
references: [collector.id],
|
||||||
|
}),
|
||||||
items: many(wishlistItem),
|
items: many(wishlistItem),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { sql } from 'drizzle-orm';
|
|||||||
import { check, index, integer, sqliteTable, text, uniqueIndex } from 'drizzle-orm/sqlite-core';
|
import { check, index, integer, sqliteTable, text, uniqueIndex } from 'drizzle-orm/sqlite-core';
|
||||||
import { user } from './auth';
|
import { user } from './auth';
|
||||||
import { set } from './catalog';
|
import { set } from './catalog';
|
||||||
|
import { collector } from './collector';
|
||||||
|
|
||||||
export const wishlist = sqliteTable(
|
export const wishlist = sqliteTable(
|
||||||
'wishlist',
|
'wishlist',
|
||||||
@@ -10,6 +11,11 @@ export const wishlist = sqliteTable(
|
|||||||
ownerUserId: text('owner_user_id')
|
ownerUserId: text('owner_user_id')
|
||||||
.notNull()
|
.notNull()
|
||||||
.references(() => user.id, { onDelete: 'cascade' }),
|
.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". */
|
/** e.g. "Weihnachten 2026". */
|
||||||
title: text('title').notNull(),
|
title: text('title').notNull(),
|
||||||
occasion: text('occasion', { enum: ['christmas', 'birthday', 'easter', 'other'] })
|
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),
|
surpriseMode: integer('surprise_mode', { mode: 'boolean' }).notNull().default(false),
|
||||||
/** Message shown to the gift givers, e.g. "Bitte keine Doppelungen". */
|
/** Message shown to the gift givers, e.g. "Bitte keine Doppelungen". */
|
||||||
note: text('note'),
|
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(),
|
createdAt: integer('created_at', { mode: 'timestamp' }).notNull(),
|
||||||
updatedAt: integer('updated_at', { mode: 'timestamp' }).notNull(),
|
updatedAt: integer('updated_at', { mode: 'timestamp' }).notNull(),
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -27,8 +27,26 @@ const schema = z.object({
|
|||||||
/** Upload size limit per file, in megabytes. */
|
/** Upload size limit per file, in megabytes. */
|
||||||
MAX_UPLOAD_MB: z.coerce.number().int().positive().max(200).default(25),
|
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(),
|
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,
|
MAX_UPLOAD_BYTES: raw.MAX_UPLOAD_MB * 1024 * 1024,
|
||||||
|
|
||||||
isProduction: raw.NODE_ENV === 'production',
|
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;
|
} as const;
|
||||||
|
|
||||||
export type Env = typeof env;
|
export type Env = typeof env;
|
||||||
|
|||||||
@@ -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!;
|
||||||
|
}
|
||||||
@@ -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.',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -8,8 +8,9 @@ import type { CollectionStatus } from '@/db/types';
|
|||||||
* status is anything but `sold`. A sold copy is history, not ownership - so a
|
* 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.
|
* 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
|
* With more than one collector the question gains a "for whom": the catalog is
|
||||||
* ever changes, it changes here.
|
* 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;
|
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];
|
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? */
|
/** Does a single collection item count towards ownership? */
|
||||||
export function isOwningStatus(status: CollectionStatus): status is OwnedStatus {
|
export function isOwningStatus(status: CollectionStatus): status is OwnedStatus {
|
||||||
return status !== SOLD_STATUS;
|
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
|
* A subquery instead of a join, because a set owned twice must not appear
|
||||||
* twice in the catalog list.
|
* 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>`(
|
return sql<number>`(
|
||||||
select count(*) from collection_item ci
|
select count(*) from collection_item ci
|
||||||
where ci.set_id = ${setIdColumn} and ci.status <> ${SOLD_STATUS}
|
where ci.set_id = ${setIdColumn} and ci.status <> ${SOLD_STATUS}
|
||||||
)`;
|
)`;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** How many wishlists this set is currently on. */
|
return sql<number>`(
|
||||||
export function wishlistCountSql(setIdColumn: SQL | unknown): 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>`(
|
return sql<number>`(
|
||||||
select count(*) from wishlist_item wi where wi.set_id = ${setIdColumn}
|
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. */
|
/** The four states the catalog filter offers, as they appear in the URL. */
|
||||||
export const OWNERSHIP_FILTERS = ['alle', 'sammlung', 'fehlt', 'wunschliste'] as const;
|
export const OWNERSHIP_FILTERS = ['alle', 'sammlung', 'fehlt', 'wunschliste'] as const;
|
||||||
|
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -1,7 +1,15 @@
|
|||||||
import 'server-only';
|
import 'server-only';
|
||||||
import { and, asc, desc, eq, like, not, or, sql, type SQL } from 'drizzle-orm';
|
import { and, asc, desc, eq, like, not, or, sql, type SQL } from 'drizzle-orm';
|
||||||
import { db } from '@/db';
|
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';
|
import { ownedCopiesSql, wishlistCountSql, type OwnershipFilter } from '@/lib/ownership';
|
||||||
|
|
||||||
export const CATALOG_PAGE_SIZE = 48;
|
export const CATALOG_PAGE_SIZE = 48;
|
||||||
@@ -14,6 +22,8 @@ export type CatalogFilters = {
|
|||||||
ownership?: OwnershipFilter;
|
ownership?: OwnershipFilter;
|
||||||
/** Hides sets that are already on this wishlist (for "aus fehlenden Sets hinzufügen"). */
|
/** Hides sets that are already on this wishlist (for "aus fehlenden Sets hinzufügen"). */
|
||||||
excludeWishlistId?: string;
|
excludeWishlistId?: string;
|
||||||
|
/** Scopes "owned" and "on a wishlist" to one collector. */
|
||||||
|
collectorId?: string;
|
||||||
page?: number;
|
page?: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -52,7 +62,7 @@ function buildWhere(filters: CatalogFilters): SQL | undefined {
|
|||||||
if (filters.themeId) conditions.push(eq(set.themeId, filters.themeId));
|
if (filters.themeId) conditions.push(eq(set.themeId, filters.themeId));
|
||||||
if (filters.year !== undefined) conditions.push(eq(set.year, filters.year));
|
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) {
|
switch (filters.ownership) {
|
||||||
case 'sammlung':
|
case 'sammlung':
|
||||||
conditions.push(sql`${owned} > 0`);
|
conditions.push(sql`${owned} > 0`);
|
||||||
@@ -61,7 +71,7 @@ function buildWhere(filters: CatalogFilters): SQL | undefined {
|
|||||||
conditions.push(sql`${owned} = 0`);
|
conditions.push(sql`${owned} = 0`);
|
||||||
break;
|
break;
|
||||||
case 'wunschliste':
|
case 'wunschliste':
|
||||||
conditions.push(sql`${wishlistCountSql(set.id)} > 0`);
|
conditions.push(sql`${wishlistCountSql(set.id, filters.collectorId)} > 0`);
|
||||||
break;
|
break;
|
||||||
default:
|
default:
|
||||||
break;
|
break;
|
||||||
@@ -95,8 +105,8 @@ export function listSets(filters: CatalogFilters): { rows: CatalogRow[]; total:
|
|||||||
retailPriceCents: set.retailPriceCents,
|
retailPriceCents: set.retailPriceCents,
|
||||||
manufacturerName: manufacturer.name,
|
manufacturerName: manufacturer.name,
|
||||||
themeName: theme.name,
|
themeName: theme.name,
|
||||||
ownedCopies: ownedCopiesSql(set.id).mapWith(Number),
|
ownedCopies: ownedCopiesSql(set.id, filters.collectorId).mapWith(Number),
|
||||||
wishlistCount: wishlistCountSql(set.id).mapWith(Number),
|
wishlistCount: wishlistCountSql(set.id, filters.collectorId).mapWith(Number),
|
||||||
})
|
})
|
||||||
.from(set)
|
.from(set)
|
||||||
.innerJoin(manufacturer, eq(manufacturer.id, set.manufacturerId))
|
.innerJoin(manufacturer, eq(manufacturer.id, set.manufacturerId))
|
||||||
@@ -126,7 +136,7 @@ export function listSetIds(filters: CatalogFilters): string[] {
|
|||||||
.map((row) => row.id);
|
.map((row) => row.id);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getSetById(id: string) {
|
export function getSetById(id: string, collectorId?: string) {
|
||||||
const row = db
|
const row = db
|
||||||
.select({
|
.select({
|
||||||
id: set.id,
|
id: set.id,
|
||||||
@@ -148,8 +158,8 @@ export function getSetById(id: string) {
|
|||||||
notes: set.notes,
|
notes: set.notes,
|
||||||
createdAt: set.createdAt,
|
createdAt: set.createdAt,
|
||||||
updatedAt: set.updatedAt,
|
updatedAt: set.updatedAt,
|
||||||
ownedCopies: ownedCopiesSql(set.id).mapWith(Number),
|
ownedCopies: ownedCopiesSql(set.id, collectorId).mapWith(Number),
|
||||||
wishlistCount: wishlistCountSql(set.id).mapWith(Number),
|
wishlistCount: wishlistCountSql(set.id, collectorId).mapWith(Number),
|
||||||
})
|
})
|
||||||
.from(set)
|
.from(set)
|
||||||
.innerJoin(manufacturer, eq(manufacturer.id, set.manufacturerId))
|
.innerJoin(manufacturer, eq(manufacturer.id, set.manufacturerId))
|
||||||
@@ -225,3 +235,28 @@ export function listSetYears(): number[] {
|
|||||||
.all()
|
.all()
|
||||||
.flatMap((row) => (row.year === null ? [] : [row.year]));
|
.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();
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,8 +1,16 @@
|
|||||||
import 'server-only';
|
import 'server-only';
|
||||||
import { and, asc, countDistinct, eq, inArray, sql, type SQL } from 'drizzle-orm';
|
import { and, asc, countDistinct, eq, inArray, sql, type SQL } from 'drizzle-orm';
|
||||||
import { db } from '@/db';
|
import { db } from '@/db';
|
||||||
import { collectionItem, location, manufacturer, missingPart, set, theme } from '@/db/schema';
|
import {
|
||||||
import { SOLD_STATUS } from '@/lib/ownership';
|
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';
|
import type { CollectionStatus, Completeness, Condition } from '@/db/types';
|
||||||
|
|
||||||
export type CollectionFilters = {
|
export type CollectionFilters = {
|
||||||
@@ -11,6 +19,7 @@ export type CollectionFilters = {
|
|||||||
completeness?: Completeness;
|
completeness?: Completeness;
|
||||||
locationId?: string;
|
locationId?: string;
|
||||||
manufacturerId?: string;
|
manufacturerId?: string;
|
||||||
|
collectorId?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type CollectionRow = {
|
export type CollectionRow = {
|
||||||
@@ -21,6 +30,9 @@ export type CollectionRow = {
|
|||||||
condition: Condition | null;
|
condition: Condition | null;
|
||||||
locationId: string | null;
|
locationId: string | null;
|
||||||
locationName: string | null;
|
locationName: string | null;
|
||||||
|
collectorId: string | null;
|
||||||
|
collectorName: string | null;
|
||||||
|
collectorColor: string | null;
|
||||||
acquiredAt: Date | null;
|
acquiredAt: Date | null;
|
||||||
acquiredPriceCents: number | null;
|
acquiredPriceCents: number | null;
|
||||||
acquiredFrom: string | 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.completeness) conditions.push(eq(collectionItem.completeness, filters.completeness));
|
||||||
if (filters.locationId) conditions.push(eq(collectionItem.locationId, filters.locationId));
|
if (filters.locationId) conditions.push(eq(collectionItem.locationId, filters.locationId));
|
||||||
if (filters.manufacturerId) conditions.push(eq(set.manufacturerId, filters.manufacturerId));
|
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;
|
return conditions.length > 0 ? and(...conditions) : undefined;
|
||||||
}
|
}
|
||||||
@@ -65,6 +78,9 @@ export function listCollectionItems(filters: CollectionFilters): CollectionRow[]
|
|||||||
condition: collectionItem.condition,
|
condition: collectionItem.condition,
|
||||||
locationId: collectionItem.locationId,
|
locationId: collectionItem.locationId,
|
||||||
locationName: location.name,
|
locationName: location.name,
|
||||||
|
collectorId: collectionItem.collectorId,
|
||||||
|
collectorName: collector.name,
|
||||||
|
collectorColor: collector.color,
|
||||||
acquiredAt: collectionItem.acquiredAt,
|
acquiredAt: collectionItem.acquiredAt,
|
||||||
acquiredPriceCents: collectionItem.acquiredPriceCents,
|
acquiredPriceCents: collectionItem.acquiredPriceCents,
|
||||||
acquiredFrom: collectionItem.acquiredFrom,
|
acquiredFrom: collectionItem.acquiredFrom,
|
||||||
@@ -86,6 +102,7 @@ export function listCollectionItems(filters: CollectionFilters): CollectionRow[]
|
|||||||
.innerJoin(manufacturer, eq(manufacturer.id, set.manufacturerId))
|
.innerJoin(manufacturer, eq(manufacturer.id, set.manufacturerId))
|
||||||
.leftJoin(theme, eq(theme.id, set.themeId))
|
.leftJoin(theme, eq(theme.id, set.themeId))
|
||||||
.leftJoin(location, eq(location.id, collectionItem.locationId))
|
.leftJoin(location, eq(location.id, collectionItem.locationId))
|
||||||
|
.leftJoin(collector, eq(collector.id, collectionItem.collectorId))
|
||||||
.where(buildWhere(filters))
|
.where(buildWhere(filters))
|
||||||
.orderBy(asc(manufacturer.name), asc(set.setNumber))
|
.orderBy(asc(manufacturer.name), asc(set.setNumber))
|
||||||
.all();
|
.all();
|
||||||
@@ -96,6 +113,8 @@ export type CollectionStats = {
|
|||||||
itemCount: number;
|
itemCount: number;
|
||||||
pieceCount: number;
|
pieceCount: number;
|
||||||
retailValueCents: number;
|
retailValueCents: number;
|
||||||
|
/** Latest observed price where there is one, UVP otherwise. */
|
||||||
|
currentValueCents: number;
|
||||||
paidCents: number;
|
paidCents: number;
|
||||||
byManufacturer: Array<{ name: string; count: 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
|
* 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.
|
* not count anywhere - same rule as everywhere else in the app.
|
||||||
*/
|
*/
|
||||||
export function getCollectionStats(): CollectionStats {
|
export function getCollectionStats(collectorId?: string): CollectionStats {
|
||||||
const active = sql`${collectionItem.status} <> ${SOLD_STATUS}`;
|
const active = collectorId
|
||||||
|
? and(
|
||||||
|
sql`${collectionItem.status} <> ${SOLD_STATUS}`,
|
||||||
|
eq(collectionItem.collectorId, collectorId),
|
||||||
|
)
|
||||||
|
: sql`${collectionItem.status} <> ${SOLD_STATUS}`;
|
||||||
|
|
||||||
const [totals] = db
|
const [totals] = db
|
||||||
.select({
|
.select({
|
||||||
@@ -133,27 +157,39 @@ export function getCollectionStats(): CollectionStats {
|
|||||||
.orderBy(asc(manufacturer.name))
|
.orderBy(asc(manufacturer.name))
|
||||||
.all();
|
.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 {
|
return {
|
||||||
setCount: totals?.setCount ?? 0,
|
setCount: totals?.setCount ?? 0,
|
||||||
itemCount: totals?.itemCount ?? 0,
|
itemCount: totals?.itemCount ?? 0,
|
||||||
pieceCount: totals?.pieceCount ?? 0,
|
pieceCount: totals?.pieceCount ?? 0,
|
||||||
retailValueCents: totals?.retailValueCents ?? 0,
|
retailValueCents: totals?.retailValueCents ?? 0,
|
||||||
|
currentValueCents: current?.total ?? 0,
|
||||||
paidCents: totals?.paidCents ?? 0,
|
paidCents: totals?.paidCents ?? 0,
|
||||||
byManufacturer,
|
byManufacturer,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Number of catalog sets that are not in the collection - the "fehlt" count. */
|
/** 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
|
const [row] = db
|
||||||
.select({ total: sql<number>`count(*)`.mapWith(Number) })
|
.select({ total: sql<number>`count(*)`.mapWith(Number) })
|
||||||
.from(set)
|
.from(set)
|
||||||
.where(
|
.where(sql`${ownedCopiesSql(set.id, collectorId)} = 0`)
|
||||||
sql`not exists (
|
|
||||||
select 1 from collection_item ci
|
|
||||||
where ci.set_id = ${set.id} and ci.status <> ${SOLD_STATUS}
|
|
||||||
)`,
|
|
||||||
)
|
|
||||||
.all();
|
.all();
|
||||||
|
|
||||||
return row?.total ?? 0;
|
return row?.total ?? 0;
|
||||||
|
|||||||
@@ -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] : [])),
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,8 +1,8 @@
|
|||||||
import 'server-only';
|
import 'server-only';
|
||||||
import { and, asc, count, eq, inArray, isNotNull, isNull, sql } from 'drizzle-orm';
|
import { and, asc, count, eq, inArray, isNotNull, isNull, sql } from 'drizzle-orm';
|
||||||
import { db } from '@/db';
|
import { db } from '@/db';
|
||||||
import { manufacturer, set, theme, wishlist, wishlistItem } from '@/db/schema';
|
import { collector, manufacturer, set, theme, wishlist, wishlistItem } from '@/db/schema';
|
||||||
import { ownedCopiesSql } from '@/lib/ownership';
|
import { ownedCopiesSql, SOLD_STATUS } from '@/lib/ownership';
|
||||||
|
|
||||||
/** Short list for the "auf welche Wunschliste?" dialogs. */
|
/** Short list for the "auf welche Wunschliste?" dialogs. */
|
||||||
export function listWishlistTargets() {
|
export function listWishlistTargets() {
|
||||||
@@ -34,12 +34,16 @@ export function listWishlists(ownerUserId: string) {
|
|||||||
isPublic: wishlist.isPublic,
|
isPublic: wishlist.isPublic,
|
||||||
surpriseMode: wishlist.surpriseMode,
|
surpriseMode: wishlist.surpriseMode,
|
||||||
shareToken: wishlist.shareToken,
|
shareToken: wishlist.shareToken,
|
||||||
|
collectorId: wishlist.collectorId,
|
||||||
|
collectorName: collector.name,
|
||||||
|
collectorColor: collector.color,
|
||||||
createdAt: wishlist.createdAt,
|
createdAt: wishlist.createdAt,
|
||||||
itemCount: count(wishlistItem.id),
|
itemCount: count(wishlistItem.id),
|
||||||
openCount: openItems,
|
openCount: openItems,
|
||||||
})
|
})
|
||||||
.from(wishlist)
|
.from(wishlist)
|
||||||
.leftJoin(wishlistItem, eq(wishlistItem.wishlistId, wishlist.id))
|
.leftJoin(wishlistItem, eq(wishlistItem.wishlistId, wishlist.id))
|
||||||
|
.leftJoin(collector, eq(collector.id, wishlist.collectorId))
|
||||||
.where(eq(wishlist.ownerUserId, ownerUserId))
|
.where(eq(wishlist.ownerUserId, ownerUserId))
|
||||||
.groupBy(wishlist.id)
|
.groupBy(wishlist.id)
|
||||||
.orderBy(asc(wishlist.title))
|
.orderBy(asc(wishlist.title))
|
||||||
@@ -76,7 +80,10 @@ export type WishlistItemRow = {
|
|||||||
ownedCopies: number;
|
ownedCopies: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
export function listWishlistItems(wishlistId: string): WishlistItemRow[] {
|
export function listWishlistItems(
|
||||||
|
wishlistId: string,
|
||||||
|
collectorId?: string | null,
|
||||||
|
): WishlistItemRow[] {
|
||||||
return db
|
return db
|
||||||
.select({
|
.select({
|
||||||
id: wishlistItem.id,
|
id: wishlistItem.id,
|
||||||
@@ -97,7 +104,7 @@ export function listWishlistItems(wishlistId: string): WishlistItemRow[] {
|
|||||||
manufacturerName: manufacturer.name,
|
manufacturerName: manufacturer.name,
|
||||||
manufacturerWebsite: manufacturer.website,
|
manufacturerWebsite: manufacturer.website,
|
||||||
themeName: theme.name,
|
themeName: theme.name,
|
||||||
ownedCopies: ownedCopiesSql(set.id).mapWith(Number),
|
ownedCopies: ownedCopiesSql(set.id, collectorId).mapWith(Number),
|
||||||
})
|
})
|
||||||
.from(wishlistItem)
|
.from(wishlistItem)
|
||||||
.innerJoin(set, eq(set.id, wishlistItem.setId))
|
.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
|
* 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
|
* 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.
|
* 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 {
|
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)
|
db.update(wishlistItem)
|
||||||
.set({ fulfilledAt: new Date() })
|
.set({ fulfilledAt: new Date() })
|
||||||
.where(and(isNull(wishlistItem.fulfilledAt), sql`${owned} > 0`))
|
.where(and(isNull(wishlistItem.fulfilledAt), ownedForThisList))
|
||||||
.run();
|
.run();
|
||||||
|
|
||||||
db.update(wishlistItem)
|
db.update(wishlistItem)
|
||||||
.set({ fulfilledAt: null })
|
.set({ fulfilledAt: null })
|
||||||
.where(and(isNotNull(wishlistItem.fulfilledAt), sql`${owned} = 0`))
|
.where(and(isNotNull(wishlistItem.fulfilledAt), sql`not ${ownedForThisList}`))
|
||||||
.run();
|
.run();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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?.();
|
||||||
|
}
|
||||||
@@ -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');
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -88,6 +88,22 @@ export const locationSchema = z.object({
|
|||||||
parentLocationId: optionalId,
|
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
|
// Catalog
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -127,6 +143,7 @@ export const conditionEnum = z.enum(['new_sealed', 'like_new', 'good', 'used', '
|
|||||||
|
|
||||||
export const collectionItemSchema = z.object({
|
export const collectionItemSchema = z.object({
|
||||||
setId: z.string().trim().min(1),
|
setId: z.string().trim().min(1),
|
||||||
|
collectorId: optionalId,
|
||||||
status: collectionStatusEnum.default('owned'),
|
status: collectionStatusEnum.default('owned'),
|
||||||
completeness: completenessEnum.default('unknown'),
|
completeness: completenessEnum.default('unknown'),
|
||||||
condition: z
|
condition: z
|
||||||
@@ -171,6 +188,7 @@ export const occasionEnum = z.enum(['christmas', 'birthday', 'easter', 'other'])
|
|||||||
|
|
||||||
export const wishlistSchema = z.object({
|
export const wishlistSchema = z.object({
|
||||||
title: z.string().trim().min(1, 'Bitte einen Titel angeben.').max(80),
|
title: z.string().trim().min(1, 'Bitte einen Titel angeben.').max(80),
|
||||||
|
collectorId: optionalId,
|
||||||
occasion: occasionEnum.default('other'),
|
occasion: occasionEnum.default('other'),
|
||||||
eventDate: optionalDate,
|
eventDate: optionalDate,
|
||||||
note: optionalText(1000),
|
note: optionalText(1000),
|
||||||
@@ -205,3 +223,4 @@ export type LocationInput = z.infer<typeof locationSchema>;
|
|||||||
export type SetInput = z.infer<typeof setSchema>;
|
export type SetInput = z.infer<typeof setSchema>;
|
||||||
export type CollectionItemInput = z.infer<typeof collectionItemSchema>;
|
export type CollectionItemInput = z.infer<typeof collectionItemSchema>;
|
||||||
export type WishlistInput = z.infer<typeof wishlistSchema>;
|
export type WishlistInput = z.infer<typeof wishlistSchema>;
|
||||||
|
export type CollectorInput = z.infer<typeof collectorSchema>;
|
||||||
|
|||||||
@@ -2,8 +2,10 @@ import { describe, expect, it } from 'vitest';
|
|||||||
import type { CollectionStatus } from '@/db/types';
|
import type { CollectionStatus } from '@/db/types';
|
||||||
import {
|
import {
|
||||||
countOwnedCopies,
|
countOwnedCopies,
|
||||||
|
countOwnedCopiesFor,
|
||||||
isOwningStatus,
|
isOwningStatus,
|
||||||
isSetOwned,
|
isSetOwned,
|
||||||
|
isSetOwnedBy,
|
||||||
OWNED_STATUSES,
|
OWNED_STATUSES,
|
||||||
parseOwnershipFilter,
|
parseOwnershipFilter,
|
||||||
} from '@/lib/ownership';
|
} from '@/lib/ownership';
|
||||||
@@ -59,3 +61,47 @@ describe('Besitz-Filter aus der URL', () => {
|
|||||||
expect(parseOwnershipFilter('')).toBe('alle');
|
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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||