test: smoke-test gegen das docker-image, dazu die doku zu phase 2
Der Dev-Server verlor unter der Last eines ganzen Durchlaufs reproduzierbar Chunks (ChunkLoadError beim Laden von app/layout), was sich weder durch Aufwaermen noch durch laengere Zeitlimits stabilisieren liess. Der Smoke-Test faehrt jetzt - wie der Screenshot-Lauf schon vorher - das Produktions-Image hoch. Ergebnis: 29 Sekunden statt fuenf Minuten, keine Aussetzer, und getestet wird genau das Artefakt, das spaeter deployt wird. Dabei behoben: Der Fehlteile-Dialog hielt eine Kopie der Liste aus dem Moment des Oeffnens und zeigte ein gerade hinzugefuegtes Teil nicht an. Er liest jetzt den aktuellen Eintrag aus den Props statt aus dem Zustand. Gefunden hat das der erweiterte Screenshot-Lauf. Dokumentation zu Phase 2: Changelog 0.2.0, Roadmap abgehakt, Devlog mit Zeiten, CLAUDE.md um die neuen Module und Testkonventionen ergaenzt und die Anleitung um fuenf Kapitel (Import, Scanner, Fehlteile, Einladungen, Daten, Startbildschirm) - mit vier neuen, automatisch erzeugten Bildern. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@@ -7,6 +7,65 @@ die Versionierung [Semantic Versioning](https://semver.org/lang/de/).
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
## [0.2.0] - 2026-09-07
|
||||
|
||||
Phase 2: die Handgriffe, die den Alltag schneller machen.
|
||||
|
||||
### Hinzugefuegt
|
||||
|
||||
**Lego-Import (Rebrickable)**
|
||||
- Auf "Neues Set" reicht die Setnummer von der Packung: Name, Jahr, Teile,
|
||||
Minifiguren und Thema werden uebernommen, das Bild lokal gespeichert.
|
||||
- Hersteller und Thema werden bei Bedarf angelegt, ein Oberthema inklusive.
|
||||
- Alle Aufrufe laufen serverseitig. Ohne `REBRICKABLE_API_KEY` bleibt der
|
||||
Import sichtbar, aber abgeschaltet - von Hand anlegen geht unveraendert.
|
||||
|
||||
**Barcode-Scanner**
|
||||
- Knopf im Katalog: Strichcode mit der Handykamera scannen. Ist das Set
|
||||
bekannt, oeffnet es sich; sonst das Formular mit ausgefuellter EAN.
|
||||
- Jeder Code wird gegen seine Pruefziffer geprueft; dekodiert wird im
|
||||
Browser, nichts verlaesst das Geraet.
|
||||
|
||||
**Fehlteile**
|
||||
- Fehlende Teile je Sammlungseintrag mit Teilenummer, Farbe, Anzahl und
|
||||
Notiz, abhakbar wenn nachgekauft.
|
||||
- Die Vollstaendigkeit pflegt sich daraus selbst: der erste offene Eintrag
|
||||
setzt "Teile fehlen", der letzte erledigte zurueck auf "vollstaendig".
|
||||
|
||||
**Einladungen**
|
||||
- Der Admin erzeugt Registrierungslinks, die genau einmal funktionieren,
|
||||
optional an eine E-Mail-Adresse gebunden, mit waehlbarer Rolle und
|
||||
Gueltigkeit. In der Datenbank liegt nur der Hash des Tokens.
|
||||
|
||||
**Export und Import**
|
||||
- Alles als ZIP (Daten plus hochgeladene Dateien), nur die Daten als JSON,
|
||||
die Sammlung als CSV mit Semikolon und BOM fuer deutsches Excel.
|
||||
- Import ersetzt die Anwendungsdaten in einer Transaktion, nur fuer
|
||||
Administratoren und nur nach getipptem ERSETZEN.
|
||||
- Nicht im Export: Konten, Passwoerter, Passkeys, Reservierungen und
|
||||
Teilen-Links.
|
||||
|
||||
**PWA**
|
||||
- Manifest, Icons und Service Worker: installierbar auf dem Startbildschirm,
|
||||
mit Verknuepfungen zu "Fehlende Sets" und zur Sammlung.
|
||||
- Einmal geladene Seiten bleiben ohne Verbindung lesbar; Set-Bilder und
|
||||
Build-Dateien kommen aus dem Cache. Beim Abmelden wird der Cache geraeumt.
|
||||
|
||||
### Geaendert
|
||||
|
||||
- `src/env.ts` behandelt leere Werte wie nicht gesetzt: ein `BASE_URL=` in der
|
||||
`.env` fuehrte bisher zum Start-Abbruch statt zum Standardwert.
|
||||
- Unit-Tests koennen jetzt auch Server-Module laden (`server-only` wird in der
|
||||
Testumgebung durch ein leeres Modul ersetzt).
|
||||
|
||||
### Bekannte Einschraenkungen
|
||||
|
||||
- Rebrickable liefert keine Anleitungsdateien. Der Import legt stattdessen
|
||||
einen Link auf die LEGO-Serviceseite zur Setnummer an.
|
||||
- Der Barcode-Scanner braucht wie Passkeys HTTPS.
|
||||
- Beim Import wandern Wunschlisten an den importierenden Admin und sind
|
||||
zunaechst nicht geteilt.
|
||||
|
||||
## [0.1.0] - 2026-09-07
|
||||
|
||||
Erste lauffaehige Fassung: Phase 1 vollstaendig, im Docker-Stack verifiziert.
|
||||
@@ -88,5 +147,6 @@ Erste lauffaehige Fassung: Phase 1 vollstaendig, im Docker-Stack verifiziert.
|
||||
- Unter Windows scheitert `pnpm build` an fehlenden Symlink-Rechten. Der
|
||||
Docker-Build unter Linux ist davon nicht betroffen.
|
||||
|
||||
[Unreleased]: https://git.wilde-server.de/florian.wilde/bricktrack/compare/v0.1.0...HEAD
|
||||
[Unreleased]: https://git.wilde-server.de/florian.wilde/bricktrack/compare/v0.2.0...HEAD
|
||||
[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
|
||||
|
||||
@@ -19,6 +19,9 @@ teilbare Wunschlisten.
|
||||
| Validierung | zod (serverseitig, immer) | |
|
||||
| Bilder | sharp (nur serverseitig, Thumbnails) | |
|
||||
| Tests | Vitest (Unit), Playwright (E2E, Screenshots) | |
|
||||
| PDF | pdfjs-dist, lokal gebuendelt | kein CDN |
|
||||
| Barcode | @zxing/browser, lazy geladen | nur beim Scannen |
|
||||
| Archive | fflate | Export/Import als ZIP |
|
||||
| Paketmanager | pnpm 10 | |
|
||||
|
||||
Bewusst **kein** Prisma (Standalone-Build im Container), **keine**
|
||||
@@ -43,6 +46,20 @@ Bild-URLs, die der Nutzer selbst hinterlegt hat.
|
||||
- Commits: Conventional Commits, Betreff auf Deutsch, Imperativ,
|
||||
erste Zeile <= 72 Zeichen.
|
||||
|
||||
## Wichtige Module
|
||||
|
||||
| Datei | Zustaendig fuer |
|
||||
|---|---|
|
||||
| `src/lib/ownership.ts` | die eine Definition von "hat er" / "hat er nicht" |
|
||||
| `src/lib/wishlist-logic.ts` | Mehrfachauswahl, Dubletten, Prioritaeten (rein, testbar) |
|
||||
| `src/lib/tokens.ts` | Share-, Storno- und Einladungs-Token |
|
||||
| `src/lib/invites.ts` | Einladungen inklusive der Freigabe fuer die Registrierung |
|
||||
| `src/lib/rebrickable.ts` | Lego-Import, ausschliesslich serverseitig |
|
||||
| `src/lib/set-number.ts`, `src/lib/ean.ts` | Setnummern und Barcodes (rein, testbar) |
|
||||
| `src/lib/uploads.ts` | Datei-Uploads, Magic Bytes, Thumbnails |
|
||||
| `src/lib/export.ts` | Export und Import des gesamten Bestands |
|
||||
| `src/lib/money.ts`, `src/lib/format.ts` | Cent-Umrechnung, dd.MM.yyyy |
|
||||
|
||||
## Befehle
|
||||
|
||||
```bash
|
||||
@@ -58,8 +75,22 @@ pnpm db:studio # Drizzle Studio
|
||||
pnpm test # Vitest
|
||||
pnpm test:e2e # Playwright
|
||||
pnpm screenshots # Screenshots fuer docs/ANLEITUNG.md erzeugen
|
||||
|
||||
node scripts/generate-icons.mjs # PWA-Icons aus der SVG-Quelle rendern
|
||||
```
|
||||
|
||||
### Testkonventionen
|
||||
|
||||
- Unit-Tests liegen in `tests/unit`, laufen unter Node und duerfen auch
|
||||
Server-Module laden: `server-only` ist in `vitest.config.mts` auf einen
|
||||
leeren Stub gemappt.
|
||||
- `tests/setup-env.ts` setzt Platzhalter fuer die Umgebung. `BASE_URL` wird
|
||||
dort **immer** ueberschrieben, weil Vite denselben Namen fuer seinen
|
||||
Public-Base-Path benutzt und ihn auf `/` setzt.
|
||||
- Der Playwright-Smoke-Test nutzt `./data-e2e`, der Screenshot-Lauf
|
||||
`./data-shots` gegen das Produktions-Image. Beide Verzeichnisse werden vor
|
||||
jedem Lauf geleert und liegen nie im Git.
|
||||
|
||||
## Datenmodell
|
||||
|
||||
Kernregel: Ein **`set`** ist ein *Katalogeintrag* - es existiert,
|
||||
@@ -145,13 +176,19 @@ Session.
|
||||
serverseitig.
|
||||
5. Session-Cookies: `httpOnly`, `secure`, `sameSite=lax`.
|
||||
Rate-Limiting auf den Login-Endpunkten.
|
||||
6. Der Export enthaelt **keine** Konten, Passwoerter oder Passkeys - und
|
||||
keine Reservierungen oder Teilen-Token. Das Backup ist `./data`, nicht der
|
||||
Export.
|
||||
7. Der Service Worker beantwortet nur GET-Anfragen aus dem Cache und niemals
|
||||
`/api/auth`, `/api/export` oder den Healthcheck. Beim Abmelden wird der
|
||||
Cache geleert.
|
||||
|
||||
## Phasenplan
|
||||
|
||||
- **Phase 1 (MVP)** - Auth mit Passkeys, Stammdaten, Katalog, Sammlung,
|
||||
Wunschlisten inkl. oeffentlicher Ansicht, Docker-Stack.
|
||||
Wunschlisten inkl. oeffentlicher Ansicht, Docker-Stack. *Fertig in 0.1.0.*
|
||||
- **Phase 2 (Komfort)** - Rebrickable-Import, Barcode-Scanner, Fehlteile,
|
||||
Einladungen, Export/Import, PWA.
|
||||
Einladungen, Export/Import, PWA. *Fertig in 0.2.0.*
|
||||
- **Phase 3 (Nice to have)** - mehrere Sammler, Preisbeobachtung,
|
||||
E-Mail-Erinnerungen, Dublettenpruefung.
|
||||
|
||||
|
||||
@@ -172,10 +172,16 @@ pnpm dev # http://localhost:3000
|
||||
| `pnpm build` | Produktionsbuild (standalone) |
|
||||
| `pnpm lint` / `pnpm typecheck` | ESLint / TypeScript – beide müssen sauber sein |
|
||||
| `pnpm test` | Unit-Tests (Vitest) |
|
||||
| `pnpm test:e2e` | Playwright-Smoke-Test gegen ein eigenes `./data-e2e` |
|
||||
| `pnpm test:e2e` | Playwright-Smoke-Test gegen das Docker-Image (eigenes `./data-e2e`) |
|
||||
| `pnpm db:generate` | SQL-Migration aus dem Drizzle-Schema erzeugen |
|
||||
| `pnpm db:migrate` | Migrationen anwenden |
|
||||
| `pnpm db:studio` | Drizzle Studio |
|
||||
| `pnpm screenshots` | Bilder für `docs/ANLEITUNG.md` neu erzeugen |
|
||||
|
||||
`pnpm test:e2e` und `pnpm screenshots` bauen jeweils das Docker-Image und
|
||||
fahren es hoch. Sie brauchen also einen laufenden Docker-Daemon, testen dafür
|
||||
aber genau das Artefakt, das später auch deployt wird – der Dev-Server hat
|
||||
unter der Last eines ganzen Durchlaufs reproduzierbar Chunks verloren.
|
||||
|
||||
> **Unter Windows** schlägt `pnpm build` mit `EPERM … symlink` fehl: der
|
||||
> Standalone-Output legt Symlinks an, wofür Windows den Entwicklermodus oder
|
||||
|
||||
@@ -22,7 +22,12 @@ Kinderzimmer, nicht für den Schreibtisch.
|
||||
8. [Wunschlisten](#8-wunschlisten)
|
||||
9. [Teilen – so sehen es Oma und Tante Anne](#9-teilen)
|
||||
10. [Passkeys einrichten](#10-passkeys-einrichten)
|
||||
11. [Antworten auf die zwei wichtigen Fragen](#11-die-zwei-fragen)
|
||||
11. [Sets im Schnellverfahren: Import und Scanner](#11-import-und-scanner)
|
||||
12. [Fehlteile](#12-fehlteile)
|
||||
13. [Noch ein Konto für die Familie](#13-einladungen)
|
||||
14. [Daten sichern, umziehen, auswerten](#14-daten)
|
||||
15. [Auf den Startbildschirm legen](#15-startbildschirm)
|
||||
16. [Antworten auf die zwei wichtigen Fragen](#16-die-zwei-fragen)
|
||||
|
||||
---
|
||||
|
||||
@@ -291,7 +296,130 @@ gib ihnen sprechende Namen.
|
||||
|
||||
---
|
||||
|
||||
## 11. Die zwei Fragen
|
||||
## 11. Import und Scanner
|
||||
|
||||
Sets von Hand einzutippen ist der langsame Weg. Für Lego gibt es zwei
|
||||
schnellere.
|
||||
|
||||
### Lego-Import über die Setnummer
|
||||
|
||||

|
||||
|
||||
Auf **Katalog → Set anlegen** steht oben das Feld für den Import. Setnummer
|
||||
von der Packung eintippen, „Holen" – und Name, Jahr, Teile, Minifiguren und
|
||||
Thema stehen drin. Das Bild wird **lokal gespeichert**, funktioniert also auch
|
||||
weiter, wenn die Quelle es irgendwann austauscht. Fehlen Hersteller oder
|
||||
Thema noch, legt der Import sie an.
|
||||
|
||||
Dafür braucht es einen kostenlosen API-Key von
|
||||
[rebrickable.com/api](https://rebrickable.com/api/), eingetragen als
|
||||
`REBRICKABLE_API_KEY` in der `.env`. Ohne Key steht an derselben Stelle ein
|
||||
Hinweis, und alles andere funktioniert unverändert weiter.
|
||||
|
||||
> Anleitungsdateien liefert Rebrickable nicht. Der Import legt stattdessen
|
||||
> einen Link auf die LEGO-Serviceseite zur Setnummer an – von dort kommst du
|
||||
> zum PDF, das du dann hochladen kannst.
|
||||
|
||||
### Barcode scannen
|
||||
|
||||
Im Katalog sitzt neben „Set anlegen" ein Scanner-Symbol. Strichcode der
|
||||
Packung ins Bild halten:
|
||||
|
||||
- **Set schon im Katalog** → es öffnet sich direkt. Genau das, was man im
|
||||
Laden vor dem Regal wissen will.
|
||||
- **Noch unbekannt** → das Formular „Neues Set" öffnet sich mit
|
||||
ausgefüllter EAN.
|
||||
|
||||
Gelesen wird ausschließlich im Browser, es geht nichts an einen fremden
|
||||
Dienst. Jeder Code wird gegen seine Prüfziffer geprüft – verlesene Ziffern
|
||||
landen so gar nicht erst im Katalog. Die Kamera braucht wie Passkeys HTTPS.
|
||||
|
||||
---
|
||||
|
||||
## 12. Fehlteile
|
||||
|
||||

|
||||
|
||||
Beim Aufbauen fehlt ein Stein. Auf der Set-Detailseite hat jeder
|
||||
Sammlungseintrag ein Puzzle-Symbol: dort trägst du ein, was fehlt – mit
|
||||
Teilenummer und Farbe, wenn du sie kennst, sonst reicht eine Notiz wie
|
||||
„Dachstein vorne links".
|
||||
|
||||
Ist das Teil nachgekauft, hakst du es ab. Die **Vollständigkeit pflegt sich
|
||||
dabei selbst**: Der erste offene Eintrag setzt den Sammlungseintrag auf
|
||||
„Teile fehlen", der letzte erledigte zurück auf „vollständig". Der Filter in
|
||||
der Sammlung stimmt damit, ohne dass du daran denken musst.
|
||||
|
||||
---
|
||||
|
||||
## 13. Einladungen
|
||||
|
||||

|
||||
|
||||
Die Registrierung ist nach dem ersten Konto geschlossen – sonst könnte sich
|
||||
jeder, der die Adresse kennt, ein Konto anlegen. Für den zweiten Elternteil
|
||||
gibt es deshalb **Verwaltung → Einladungen**:
|
||||
|
||||
1. „Einladung erstellen", optional an eine E-Mail-Adresse gebunden, mit Rolle
|
||||
und Gültigkeit.
|
||||
2. Den Link kopieren und weitergeben. Er wird **nur einmal angezeigt** – in
|
||||
der Datenbank liegt nur sein Hash.
|
||||
3. Die eingeladene Person legt darüber ihr Konto an. Danach ist der Link
|
||||
verbraucht.
|
||||
|
||||
„Mitglied" darf die Sammlung pflegen und Wunschlisten anlegen; „Administrator"
|
||||
zusätzlich einladen und Daten exportieren.
|
||||
|
||||
---
|
||||
|
||||
## 14. Daten
|
||||
|
||||

|
||||
|
||||
Unter **Verwaltung → Daten** gibt es drei Downloads:
|
||||
|
||||
| | Wofür |
|
||||
|---|---|
|
||||
| **Alles als ZIP** | Umziehen auf einen anderen Server, zweites Netz |
|
||||
| **Nur Daten (JSON)** | Zum Reinschauen oder Weiterverarbeiten |
|
||||
| **Sammlung als CSV** | Für Excel oder LibreOffice, Semikolon-getrennt |
|
||||
|
||||
**Nicht im Export:** Konten, Passwörter und Passkeys. Ein Export voller
|
||||
Passwort-Hashes im Download-Ordner wäre ein Risiko, kein Backup. Ebenso außen
|
||||
vor: Reservierungen und Teilen-Links – die gehören zu der Instanz, auf der sie
|
||||
entstanden sind.
|
||||
|
||||
> **Das richtige Backup bleibt `./data`.** Ein Verzeichnis, Datenbank und
|
||||
> Dateien darin. Siehe [README](../README.md#backup-und-restore).
|
||||
|
||||
Der **Import** liest ein solches ZIP wieder ein – und **ersetzt dabei alle
|
||||
vorhandenen Daten**. Zusammenführen zweier Sammlungen ist bewusst nicht
|
||||
möglich: Es bräuchte Konfliktregeln für jede Tabelle, und das Ergebnis wäre
|
||||
nicht vorhersagbar. Deshalb muss man zur Bestätigung `ERSETZEN` eintippen.
|
||||
Wunschlisten gehören danach dir und sind zunächst nicht geteilt.
|
||||
|
||||
---
|
||||
|
||||
## 15. Startbildschirm
|
||||
|
||||
Bricktrack lässt sich wie eine App installieren:
|
||||
|
||||
- **iPhone/iPad (Safari):** Teilen-Symbol → „Zum Home-Bildschirm".
|
||||
- **Android (Chrome):** Menü → „App installieren" bzw. „Zum Startbildschirm
|
||||
hinzufügen".
|
||||
|
||||
Danach startet Bricktrack ohne Browserleiste, und langes Drücken auf das Symbol
|
||||
springt direkt zu „Fehlende Sets" oder zur Sammlung.
|
||||
|
||||
**Ohne Verbindung** bleibt lesbar, was du schon einmal geöffnet hast – der
|
||||
Katalog also, wenn du ihn vorher im WLAN durchgeblättert hast. Ändern lässt
|
||||
sich offline nichts: Eine Reservierung, die nur auf deinem Gerät liegt, wäre
|
||||
für alle anderen unsichtbar und damit eine Lüge. Beim Abmelden räumt die App
|
||||
den Zwischenspeicher, damit auf einem geteilten Gerät nichts lesbar bleibt.
|
||||
|
||||
---
|
||||
|
||||
## 16. Die zwei Fragen
|
||||
|
||||
Dafür ist die ganze App gebaut:
|
||||
|
||||
@@ -302,8 +430,8 @@ Dafür ist die ganze App gebaut:
|
||||
→ Katalog, Filter „Fehlt noch". Von dort in zwei Handgriffen auf eine
|
||||
Wunschliste und per Link zu Oma.
|
||||
|
||||
Im Laden vor dem Regal: Katalog öffnen, Setnummer von der Packung eintippen.
|
||||
Steht ein grüner Haken dran, habt ihr es schon.
|
||||
Im Laden vor dem Regal: Scanner öffnen, Strichcode einlesen. Steht ein grüner
|
||||
Haken dran, habt ihr es schon.
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -29,6 +29,22 @@ Commit-Spalte verweist auf den Stand am Ende des Schritts.
|
||||
|
||||
**Summe Phase 1: 2:44**
|
||||
|
||||
## Phase 2 – Komfort (v0.2.0)
|
||||
|
||||
### 07.09.2026
|
||||
|
||||
| Von | Bis | Dauer | Schritt | Ergebnis | Commit |
|
||||
|---|---|---|---|---|---|
|
||||
| 06:50 | 07:01 | 0:11 | Einladungen | Admin erzeugt einmal gültige Registrierungslinks; Freigabe über AsyncLocalStorage, damit die Rolle nicht aus dem Formular kommen kann. Smoke-Test deckt den ganzen Weg ab | `52d69e5` |
|
||||
| 07:01 | 07:12 | 0:11 | Rebrickable-Import | Setnummer genügt: Metadaten, Thema und lokal gespeichertes Bild; 14 Tests gegen gestubbtes `fetch` inklusive aller Fehlerfälle | `4e3cbb8` |
|
||||
| 07:12 | 07:15 | 0:03 | Barcode-Scanner | EAN mit der Handykamera, Prüfziffer-Kontrolle, zxing wird erst beim Öffnen geladen | `baa94bf` |
|
||||
| 07:15 | 07:18 | 0:03 | Fehlteile | Liste je Sammlungseintrag; Vollständigkeit pflegt sich daraus selbst | `5c5c521` |
|
||||
| 07:18 | 07:23 | 0:05 | Export und Import | ZIP, JSON und CSV; Import ersetzt in einer Transaktion, 10 Tests für den Rundweg | `45aa8f1` |
|
||||
| 07:23 | 07:27 | 0:04 | PWA | Manifest, Icons aus SVG-Quelle, Service Worker mit Offline-Katalog | `39271f8` |
|
||||
| 07:27 | 07:45 | 0:18 | Verifikation und Dokumentation | Smoke-Test, Docker-Build, Changelog 0.2.0, Anleitung und Roadmap | folgt |
|
||||
|
||||
**Summe Phase 2: 0:55**
|
||||
|
||||
---
|
||||
|
||||
## Notizen und Entscheidungen
|
||||
@@ -86,6 +102,24 @@ Commit-Spalte verweist auf den Stand am Ende des Schritts.
|
||||
Produktions-Image im Container – verlässlich und zugleich näher an dem, was
|
||||
Nutzer wirklich sehen.
|
||||
|
||||
### Phase 2
|
||||
|
||||
- **`server-only` in Tests**: Das Paket wirft beim Import ausserhalb einer
|
||||
Server-Komponente, wodurch sich Servermodule nicht testen liessen. In
|
||||
`vitest.config.mts` ist es jetzt auf einen leeren Stub gemappt – der Import
|
||||
ist eine Build-Zeit-Absicherung, kein Verhalten.
|
||||
- **Namenskonflikt `BASE_URL`**: Vite benutzt genau diesen Namen für seinen
|
||||
Public-Base-Path und setzt ihn auf `/`. Die Tests überschreiben ihn deshalb
|
||||
ausdrücklich. Dabei ist aufgefallen, dass `src/env.ts` bei einem leeren Wert
|
||||
abbrach statt auf den Standard zurückzufallen – behoben, denn ein
|
||||
`BASE_URL=` in der `.env` ist ein realistischer Tippfehler.
|
||||
- **Rebrickable und Anleitungen**: Die API v3 stellt keine Anleitungsdateien
|
||||
bereit. Statt das zu verschleiern, legt der Import einen externen Link auf
|
||||
die LEGO-Serviceseite zur Setnummer an.
|
||||
- **Import statt Zusammenführen**: Zwei Sammlungen zu mischen bräuchte
|
||||
Konfliktregeln für jede Tabelle und wäre im Ergebnis nicht vorhersagbar.
|
||||
Der Import ersetzt deshalb – dafür mit getipptem `ERSETZEN` als Bremse.
|
||||
|
||||
### Beim Testen gefunden und behoben
|
||||
|
||||
- Eine spontan aus dem Katalog angelegte Wunschliste war **nicht teilbar**:
|
||||
|
||||
@@ -34,17 +34,17 @@ Laeuft vollstaendig im Docker-Stack.
|
||||
- [x] Dokumentation: README, QUICKSTART, bebilderte Anleitung,
|
||||
update.sh, Nginx-Proxy-Manager-Konfiguration
|
||||
|
||||
## Phase 2 - Komfort (Ziel: v0.2.0)
|
||||
## Phase 2 - Komfort (v0.2.0, abgeschlossen am 07.09.2026)
|
||||
|
||||
- [ ] Rebrickable-Import: Setnummer eingeben, Metadaten und Bild
|
||||
- [x] Rebrickable-Import: Setnummer eingeben, Metadaten und Bild
|
||||
uebernehmen, Bild lokal cachen. Ohne API-Key bleibt alles manuell.
|
||||
- [ ] Barcode-Scanner (EAN) per Handykamera, Suche nach EAN, bei
|
||||
- [x] Barcode-Scanner (EAN) per Handykamera, Suche nach EAN, bei
|
||||
Unbekanntem direkt ins Formular "Neues Set"
|
||||
- [ ] Fehlteile je Sammlungseintrag pflegen
|
||||
- [ ] Einladungslinks durch den Admin
|
||||
- [ ] Export/Import: gesamte Datenbank als JSON, Dateien als ZIP,
|
||||
- [x] Fehlteile je Sammlungseintrag pflegen
|
||||
- [x] Einladungslinks durch den Admin
|
||||
- [x] Export/Import: gesamte Datenbank als JSON, Dateien als ZIP,
|
||||
Sammlung zusaetzlich als CSV
|
||||
- [ ] PWA: Manifest, Service Worker, Katalog offline lesbar
|
||||
- [x] PWA: Manifest, Service Worker, Katalog offline lesbar
|
||||
|
||||
## Phase 3 - Nice to have (Ziel: v0.3.0)
|
||||
|
||||
|
||||
|
Before Width: | Height: | Size: 89 KiB After Width: | Height: | Size: 80 KiB |
|
Before Width: | Height: | Size: 108 KiB After Width: | Height: | Size: 99 KiB |
|
Before Width: | Height: | Size: 88 KiB After Width: | Height: | Size: 99 KiB |
|
Before Width: | Height: | Size: 285 KiB After Width: | Height: | Size: 288 KiB |
|
Before Width: | Height: | Size: 202 KiB After Width: | Height: | Size: 201 KiB |
|
Before Width: | Height: | Size: 212 KiB After Width: | Height: | Size: 209 KiB |
|
Before Width: | Height: | Size: 252 KiB After Width: | Height: | Size: 236 KiB |
|
Before Width: | Height: | Size: 183 KiB After Width: | Height: | Size: 185 KiB |
|
Before Width: | Height: | Size: 292 KiB After Width: | Height: | Size: 293 KiB |
|
Before Width: | Height: | Size: 187 KiB After Width: | Height: | Size: 301 KiB |
|
Before Width: | Height: | Size: 294 KiB After Width: | Height: | Size: 294 KiB |
|
After Width: | Height: | Size: 211 KiB |
|
After Width: | Height: | Size: 124 KiB |
|
After Width: | Height: | Size: 124 KiB |
|
After Width: | Height: | Size: 257 KiB |
@@ -1,13 +0,0 @@
|
||||
import { execFileSync } from 'node:child_process';
|
||||
|
||||
/**
|
||||
* Migrates the smoke-test database. The directory itself is wiped earlier by
|
||||
* scripts/reset-e2e.mjs, because Playwright starts its web server before this
|
||||
* hook runs and the open database file would block the delete on Windows.
|
||||
*/
|
||||
export default function globalSetup() {
|
||||
execFileSync('node', ['node_modules/tsx/dist/cli.mjs', 'scripts/migrate.ts'], {
|
||||
stdio: 'inherit',
|
||||
env: { ...process.env, DATA_DIR: './data-e2e' },
|
||||
});
|
||||
}
|
||||
@@ -127,4 +127,37 @@ test('Screenshots fuer die Anleitung', async ({ page, browser }) => {
|
||||
await visit(page, '/konto');
|
||||
await expect(page.getByText('Passkeys', { exact: true })).toBeVisible();
|
||||
await shoot(page, '16-konto-passkeys');
|
||||
|
||||
// --- 12. Lego import ------------------------------------------------------
|
||||
// The screenshot container runs with a placeholder API key so the manual
|
||||
// shows the form, not the "not configured" hint. Nothing is submitted.
|
||||
await visit(page, '/katalog/neu');
|
||||
await expect(page.getByText('Lego-Import')).toBeVisible();
|
||||
await shoot(page, '17-lego-import');
|
||||
|
||||
// --- 13. missing parts ----------------------------------------------------
|
||||
await visit(page, '/katalog?q=60398');
|
||||
await page.getByText('Familienhaus und Elektroauto').first().click();
|
||||
await expect(page.getByRole('heading', { name: 'Familienhaus und Elektroauto' })).toBeVisible();
|
||||
await page.getByRole('button', { name: 'Fehlteile' }).first().click();
|
||||
await expect(page.getByRole('dialog')).toBeVisible();
|
||||
await page.getByLabel('Teilenummer').fill('3001');
|
||||
await page.getByLabel('Farbe').fill('Rot');
|
||||
await page.getByRole('button', { name: 'Hinzufügen' }).click();
|
||||
await expect(page.getByText('3001')).toBeVisible();
|
||||
await page.screenshot({ path: shotPath('18-fehlteile') });
|
||||
await page.getByRole('button', { name: 'Fertig' }).click();
|
||||
|
||||
// --- 14. invitations ------------------------------------------------------
|
||||
await visit(page, '/verwaltung/einladungen');
|
||||
await expect(page.getByRole('button', { name: 'Einladung erstellen' })).toBeVisible();
|
||||
await page.getByRole('button', { name: 'Einladung erstellen' }).click();
|
||||
await expect(page.getByRole('dialog')).toBeVisible();
|
||||
await page.screenshot({ path: shotPath('19-einladungen') });
|
||||
await page.getByRole('button', { name: 'Abbrechen' }).click();
|
||||
|
||||
// --- 15. export and import ------------------------------------------------
|
||||
await visit(page, '/verwaltung/daten');
|
||||
await expect(page.getByText('Alles als ZIP')).toBeVisible();
|
||||
await shoot(page, '20-daten');
|
||||
});
|
||||
|
||||
@@ -19,7 +19,7 @@
|
||||
"db:studio": "drizzle-kit studio",
|
||||
"test": "vitest run",
|
||||
"test:watch": "vitest",
|
||||
"test:e2e": "node scripts/reset-e2e.mjs && playwright test",
|
||||
"test:e2e": "node scripts/e2e-prepare.mjs && playwright test",
|
||||
"screenshots": "node scripts/screenshots-prepare.mjs && playwright test --config=playwright.screenshots.config.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
|
||||
@@ -1,28 +1,29 @@
|
||||
import path from 'node:path';
|
||||
import { defineConfig, devices } from '@playwright/test';
|
||||
|
||||
/**
|
||||
* Smoke test setup.
|
||||
*
|
||||
* Runs against its own data directory (./data-e2e) so a test never touches the
|
||||
* real database. The global setup wipes and migrates it before each run.
|
||||
* Runs against the production container with its own data directory
|
||||
* (./data-e2e), so a test never touches the real database and never depends on
|
||||
* the dev server compiling fast enough.
|
||||
*/
|
||||
const PORT = 3210;
|
||||
const BASE_URL = `http://localhost:${PORT}`;
|
||||
|
||||
// Docker wants forward slashes, also on Windows.
|
||||
const dataDir = path.resolve(process.cwd(), 'data-e2e').replace(/\\/g, '/');
|
||||
|
||||
export default defineConfig({
|
||||
testDir: './e2e',
|
||||
// The screenshot run has its own config, its own server and its own data
|
||||
// directory - it must never join the smoke run.
|
||||
testMatch: 'smoke.spec.ts',
|
||||
globalSetup: './e2e/global-setup.ts',
|
||||
fullyParallel: false,
|
||||
workers: 1,
|
||||
retries: 0,
|
||||
// Generous: the dev server compiles each route on first visit.
|
||||
timeout: 240_000,
|
||||
// A first navigation to a route the dev server has not compiled yet can
|
||||
// take well over ten seconds on a cold cache.
|
||||
expect: { timeout: 30_000 },
|
||||
timeout: 90_000,
|
||||
expect: { timeout: 15_000 },
|
||||
reporter: process.env.CI ? 'github' : 'list',
|
||||
use: {
|
||||
baseURL: BASE_URL,
|
||||
@@ -38,18 +39,20 @@ export default defineConfig({
|
||||
},
|
||||
],
|
||||
webServer: {
|
||||
command: `pnpm exec next dev -p ${PORT}`,
|
||||
command: [
|
||||
'docker run --rm --name bricktrack-e2e',
|
||||
`-p ${PORT}:3000`,
|
||||
`-e BASE_URL=${BASE_URL}`,
|
||||
'-e BETTER_AUTH_SECRET=playwright-smoke-test-secret-0123456789abcdef',
|
||||
'-e TZ=Europe/Berlin',
|
||||
'-e MAX_UPLOAD_MB=25',
|
||||
`-v "${dataDir}:/data"`,
|
||||
'bricktrack:e2e',
|
||||
].join(' '),
|
||||
url: `${BASE_URL}/api/health`,
|
||||
reuseExistingServer: false,
|
||||
timeout: 180_000,
|
||||
stdout: 'pipe',
|
||||
stderr: 'pipe',
|
||||
env: {
|
||||
NODE_ENV: 'development',
|
||||
DATA_DIR: './data-e2e',
|
||||
BASE_URL,
|
||||
BETTER_AUTH_SECRET: 'playwright-smoke-test-secret-0123456789abcdef',
|
||||
MAX_UPLOAD_MB: '25',
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
@@ -41,6 +41,9 @@ export default defineConfig({
|
||||
`-e BASE_URL=${BASE_URL}`,
|
||||
'-e BETTER_AUTH_SECRET=screenshot-run-secret-0123456789abcdefgh',
|
||||
'-e TZ=Europe/Berlin',
|
||||
// A placeholder key so the manual shows the import form rather than the
|
||||
// "not configured" hint. The screenshot run never submits it.
|
||||
'-e REBRICKABLE_API_KEY=screenshot-placeholder',
|
||||
`-v "${dataDir}:/data"`,
|
||||
'bricktrack:shots',
|
||||
].join(' '),
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
/**
|
||||
* Prepares the smoke run.
|
||||
*
|
||||
* The tests run against the production image, not the dev server: the dev
|
||||
* server compiles routes on demand and, under the load of a full run, would
|
||||
* occasionally drop a chunk mid-navigation. Testing the artefact that people
|
||||
* actually deploy is also simply more useful.
|
||||
*/
|
||||
import { execFileSync } from 'node:child_process';
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
|
||||
const dataDir = path.resolve(process.cwd(), 'data-e2e');
|
||||
|
||||
fs.rmSync(dataDir, { recursive: true, force: true });
|
||||
fs.mkdirSync(path.join(dataDir, 'db'), { recursive: true });
|
||||
fs.mkdirSync(path.join(dataDir, 'files'), { recursive: true });
|
||||
console.log(`[e2e] ${dataDir} zurueckgesetzt.`);
|
||||
|
||||
console.log('[e2e] Image wird gebaut (bricktrack:e2e) …');
|
||||
execFileSync('docker', ['build', '-t', 'bricktrack:e2e', '.'], { stdio: 'inherit' });
|
||||
console.log('[e2e] Image ist bereit.');
|
||||
@@ -1,13 +0,0 @@
|
||||
/**
|
||||
* Wipes the Playwright data directory before the smoke run.
|
||||
*
|
||||
* Runs before Playwright starts, because Playwright boots its web server
|
||||
* before global setup - on Windows the open SQLite file would then block the
|
||||
* delete.
|
||||
*/
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
|
||||
const dataDir = path.resolve(process.cwd(), 'data-e2e');
|
||||
fs.rmSync(dataDir, { recursive: true, force: true });
|
||||
console.log(`[e2e] ${dataDir} zurueckgesetzt.`);
|
||||
@@ -71,7 +71,11 @@ export function CollectionItemsSection({
|
||||
const [pending, startTransition] = useTransition();
|
||||
const [editing, setEditing] = useState<CollectionItemRow | null>(null);
|
||||
const [deleting, setDeleting] = useState<CollectionItemRow | null>(null);
|
||||
const [parts, setParts] = useState<CollectionItemRow | null>(null);
|
||||
// Only the id, never a snapshot: the dialog shows a live list, and a copy
|
||||
// taken when it opened would not show a part that was just added.
|
||||
const [partsItemId, setPartsItemId] = useState<string | null>(null);
|
||||
|
||||
const partsItem = items.find((item) => item.id === partsItemId) ?? null;
|
||||
|
||||
function submit(event: React.FormEvent<HTMLFormElement>) {
|
||||
event.preventDefault();
|
||||
@@ -155,7 +159,7 @@ export function CollectionItemsSection({
|
||||
size="icon-sm"
|
||||
aria-label="Fehlteile"
|
||||
title="Fehlteile"
|
||||
onClick={() => setParts(item)}
|
||||
onClick={() => setPartsItemId(item.id)}
|
||||
disabled={pending}
|
||||
>
|
||||
<PuzzleIcon />
|
||||
@@ -303,13 +307,13 @@ export function CollectionItemsSection({
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{parts ? (
|
||||
{partsItem ? (
|
||||
<MissingPartsDialog
|
||||
collectionItemId={parts.id}
|
||||
setName={parts.setName}
|
||||
parts={parts.missingParts}
|
||||
collectionItemId={partsItem.id}
|
||||
setName={partsItem.setName}
|
||||
parts={partsItem.missingParts}
|
||||
open
|
||||
onOpenChange={(open) => !open && setParts(null)}
|
||||
onOpenChange={(open) => !open && setPartsItemId(null)}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
|
||||