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>
This commit is contained in:
unknown
2026-09-07 08:18:28 +02:00
co-authored by Claude Opus 5
parent 39271f8af7
commit dcd076498a
29 changed files with 408 additions and 104 deletions
+61 -1
View File
@@ -7,6 +7,65 @@ die Versionierung [Semantic Versioning](https://semver.org/lang/de/).
## [Unreleased] ## [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 ## [0.1.0] - 2026-09-07
Erste lauffaehige Fassung: Phase 1 vollstaendig, im Docker-Stack verifiziert. 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 - 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.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 [0.1.0]: https://git.wilde-server.de/florian.wilde/bricktrack/releases/tag/v0.1.0
+39 -2
View File
@@ -19,6 +19,9 @@ teilbare Wunschlisten.
| Validierung | zod (serverseitig, immer) | | | Validierung | zod (serverseitig, immer) | |
| Bilder | sharp (nur serverseitig, Thumbnails) | | | Bilder | sharp (nur serverseitig, Thumbnails) | |
| Tests | Vitest (Unit), Playwright (E2E, Screenshots) | | | 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 | | | Paketmanager | pnpm 10 | |
Bewusst **kein** Prisma (Standalone-Build im Container), **keine** 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, - Commits: Conventional Commits, Betreff auf Deutsch, Imperativ,
erste Zeile <= 72 Zeichen. 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 ## Befehle
```bash ```bash
@@ -58,8 +75,22 @@ pnpm db:studio # Drizzle Studio
pnpm test # Vitest pnpm test # Vitest
pnpm test:e2e # Playwright pnpm test:e2e # Playwright
pnpm screenshots # Screenshots fuer docs/ANLEITUNG.md erzeugen 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 ## Datenmodell
Kernregel: Ein **`set`** ist ein *Katalogeintrag* - es existiert, Kernregel: Ein **`set`** ist ein *Katalogeintrag* - es existiert,
@@ -145,13 +176,19 @@ Session.
serverseitig. serverseitig.
5. Session-Cookies: `httpOnly`, `secure`, `sameSite=lax`. 5. Session-Cookies: `httpOnly`, `secure`, `sameSite=lax`.
Rate-Limiting auf den Login-Endpunkten. 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 ## Phasenplan
- **Phase 1 (MVP)** - Auth mit Passkeys, Stammdaten, Katalog, Sammlung, - **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, - **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, - **Phase 3 (Nice to have)** - mehrere Sammler, Preisbeobachtung,
E-Mail-Erinnerungen, Dublettenpruefung. E-Mail-Erinnerungen, Dublettenpruefung.
+7 -1
View File
@@ -172,10 +172,16 @@ pnpm dev # http://localhost:3000
| `pnpm build` | Produktionsbuild (standalone) | | `pnpm build` | Produktionsbuild (standalone) |
| `pnpm lint` / `pnpm typecheck` | ESLint / TypeScript – beide müssen sauber sein | | `pnpm lint` / `pnpm typecheck` | ESLint / TypeScript – beide müssen sauber sein |
| `pnpm test` | Unit-Tests (Vitest) | | `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:generate` | SQL-Migration aus dem Drizzle-Schema erzeugen |
| `pnpm db:migrate` | Migrationen anwenden | | `pnpm db:migrate` | Migrationen anwenden |
| `pnpm db:studio` | Drizzle Studio | | `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 > **Unter Windows** schlägt `pnpm build` mit `EPERM … symlink` fehl: der
> Standalone-Output legt Symlinks an, wofür Windows den Entwicklermodus oder > Standalone-Output legt Symlinks an, wofür Windows den Entwicklermodus oder
+132 -4
View File
@@ -22,7 +22,12 @@ Kinderzimmer, nicht für den Schreibtisch.
8. [Wunschlisten](#8-wunschlisten) 8. [Wunschlisten](#8-wunschlisten)
9. [Teilen – so sehen es Oma und Tante Anne](#9-teilen) 9. [Teilen – so sehen es Oma und Tante Anne](#9-teilen)
10. [Passkeys einrichten](#10-passkeys-einrichten) 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
![Lego-Import](bilder/17-lego-import.png)
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
![Fehlteile](bilder/18-fehlteile.png)
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
![Einladungen](bilder/19-einladungen.png)
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
![Daten](bilder/20-daten.png)
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: 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 → Katalog, Filter „Fehlt noch". Von dort in zwei Handgriffen auf eine
Wunschliste und per Link zu Oma. Wunschliste und per Link zu Oma.
Im Laden vor dem Regal: Katalog öffnen, Setnummer von der Packung eintippen. Im Laden vor dem Regal: Scanner öffnen, Strichcode einlesen. Steht ein grüner
Steht ein grüner Haken dran, habt ihr es schon. Haken dran, habt ihr es schon.
--- ---
+34
View File
@@ -29,6 +29,22 @@ Commit-Spalte verweist auf den Stand am Ende des Schritts.
**Summe Phase 1: 2:44** **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 ## 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 Produktions-Image im Container – verlässlich und zugleich näher an dem, was
Nutzer wirklich sehen. 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 ### 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**:
+7 -7
View File
@@ -34,17 +34,17 @@ Laeuft vollstaendig im Docker-Stack.
- [x] Dokumentation: README, QUICKSTART, bebilderte Anleitung, - [x] Dokumentation: README, QUICKSTART, bebilderte Anleitung,
update.sh, Nginx-Proxy-Manager-Konfiguration 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. 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" Unbekanntem direkt ins Formular "Neues Set"
- [ ] Fehlteile je Sammlungseintrag pflegen - [x] Fehlteile je Sammlungseintrag pflegen
- [ ] Einladungslinks durch den Admin - [x] Einladungslinks durch den Admin
- [ ] Export/Import: gesamte Datenbank als JSON, Dateien als ZIP, - [x] Export/Import: gesamte Datenbank als JSON, Dateien als ZIP,
Sammlung zusaetzlich als CSV 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) ## Phase 3 - Nice to have (Ziel: v0.3.0)
Binary file not shown.

Before

Width:  |  Height:  |  Size: 89 KiB

After

Width:  |  Height:  |  Size: 80 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 108 KiB

After

Width:  |  Height:  |  Size: 99 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 88 KiB

After

Width:  |  Height:  |  Size: 99 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 285 KiB

After

Width:  |  Height:  |  Size: 288 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 202 KiB

After

Width:  |  Height:  |  Size: 201 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 212 KiB

After

Width:  |  Height:  |  Size: 209 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 252 KiB

After

Width:  |  Height:  |  Size: 236 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 183 KiB

After

Width:  |  Height:  |  Size: 185 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 292 KiB

After

Width:  |  Height:  |  Size: 293 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 187 KiB

After

Width:  |  Height:  |  Size: 301 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 294 KiB

After

Width:  |  Height:  |  Size: 294 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 211 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 124 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 124 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 257 KiB

-13
View File
@@ -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' },
});
}
+33
View File
@@ -127,4 +127,37 @@ test('Screenshots fuer die Anleitung', async ({ page, browser }) => {
await visit(page, '/konto'); await visit(page, '/konto');
await expect(page.getByText('Passkeys', { exact: true })).toBeVisible(); await expect(page.getByText('Passkeys', { exact: true })).toBeVisible();
await shoot(page, '16-konto-passkeys'); 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');
}); });
+1 -1
View File
@@ -19,7 +19,7 @@
"db:studio": "drizzle-kit studio", "db:studio": "drizzle-kit studio",
"test": "vitest run", "test": "vitest run",
"test:watch": "vitest", "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" "screenshots": "node scripts/screenshots-prepare.mjs && playwright test --config=playwright.screenshots.config.ts"
}, },
"dependencies": { "dependencies": {
+58 -55
View File
@@ -1,55 +1,58 @@
import { defineConfig, devices } from '@playwright/test'; import path from 'node:path';
import { defineConfig, devices } from '@playwright/test';
/**
* Smoke test setup. /**
* * 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
const PORT = 3210; * the dev server compiling fast enough.
const BASE_URL = `http://localhost:${PORT}`; */
const PORT = 3210;
export default defineConfig({ const BASE_URL = `http://localhost:${PORT}`;
testDir: './e2e',
// The screenshot run has its own config, its own server and its own data // Docker wants forward slashes, also on Windows.
// directory - it must never join the smoke run. const dataDir = path.resolve(process.cwd(), 'data-e2e').replace(/\\/g, '/');
testMatch: 'smoke.spec.ts',
globalSetup: './e2e/global-setup.ts', export default defineConfig({
fullyParallel: false, testDir: './e2e',
workers: 1, // The screenshot run has its own config, its own server and its own data
retries: 0, // directory - it must never join the smoke run.
// Generous: the dev server compiles each route on first visit. testMatch: 'smoke.spec.ts',
timeout: 240_000, fullyParallel: false,
// A first navigation to a route the dev server has not compiled yet can workers: 1,
// take well over ten seconds on a cold cache. retries: 0,
expect: { timeout: 30_000 }, timeout: 90_000,
reporter: process.env.CI ? 'github' : 'list', expect: { timeout: 15_000 },
use: { reporter: process.env.CI ? 'github' : 'list',
baseURL: BASE_URL, use: {
trace: 'retain-on-failure', baseURL: BASE_URL,
locale: 'de-DE', trace: 'retain-on-failure',
timezoneId: 'Europe/Berlin', locale: 'de-DE',
}, timezoneId: 'Europe/Berlin',
projects: [ },
{ projects: [
name: 'mobile', {
// The app is built for phones first, so the smoke test uses one. name: 'mobile',
use: { ...devices['Pixel 7'] }, // The app is built for phones first, so the smoke test uses one.
}, use: { ...devices['Pixel 7'] },
], },
webServer: { ],
command: `pnpm exec next dev -p ${PORT}`, webServer: {
url: `${BASE_URL}/api/health`, command: [
reuseExistingServer: false, 'docker run --rm --name bricktrack-e2e',
timeout: 180_000, `-p ${PORT}:3000`,
stdout: 'pipe', `-e BASE_URL=${BASE_URL}`,
stderr: 'pipe', '-e BETTER_AUTH_SECRET=playwright-smoke-test-secret-0123456789abcdef',
env: { '-e TZ=Europe/Berlin',
NODE_ENV: 'development', '-e MAX_UPLOAD_MB=25',
DATA_DIR: './data-e2e', `-v "${dataDir}:/data"`,
BASE_URL, 'bricktrack:e2e',
BETTER_AUTH_SECRET: 'playwright-smoke-test-secret-0123456789abcdef', ].join(' '),
MAX_UPLOAD_MB: '25', url: `${BASE_URL}/api/health`,
}, reuseExistingServer: false,
}, timeout: 180_000,
}); stdout: 'pipe',
stderr: 'pipe',
},
});
+3
View File
@@ -41,6 +41,9 @@ export default defineConfig({
`-e BASE_URL=${BASE_URL}`, `-e BASE_URL=${BASE_URL}`,
'-e BETTER_AUTH_SECRET=screenshot-run-secret-0123456789abcdefgh', '-e BETTER_AUTH_SECRET=screenshot-run-secret-0123456789abcdefgh',
'-e TZ=Europe/Berlin', '-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"`, `-v "${dataDir}:/data"`,
'bricktrack:shots', 'bricktrack:shots',
].join(' '), ].join(' '),
+22
View File
@@ -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.');
-13
View File
@@ -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 [pending, startTransition] = useTransition();
const [editing, setEditing] = useState<CollectionItemRow | null>(null); const [editing, setEditing] = useState<CollectionItemRow | null>(null);
const [deleting, setDeleting] = 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>) { function submit(event: React.FormEvent<HTMLFormElement>) {
event.preventDefault(); event.preventDefault();
@@ -155,7 +159,7 @@ export function CollectionItemsSection({
size="icon-sm" size="icon-sm"
aria-label="Fehlteile" aria-label="Fehlteile"
title="Fehlteile" title="Fehlteile"
onClick={() => setParts(item)} onClick={() => setPartsItemId(item.id)}
disabled={pending} disabled={pending}
> >
<PuzzleIcon /> <PuzzleIcon />
@@ -303,13 +307,13 @@ export function CollectionItemsSection({
</DialogContent> </DialogContent>
</Dialog> </Dialog>
{parts ? ( {partsItem ? (
<MissingPartsDialog <MissingPartsDialog
collectionItemId={parts.id} collectionItemId={partsItem.id}
setName={parts.setName} setName={partsItem.setName}
parts={parts.missingParts} parts={partsItem.missingParts}
open open
onOpenChange={(open) => !open && setParts(null)} onOpenChange={(open) => !open && setPartsItemId(null)}
/> />
) : null} ) : null}