diff --git a/CHANGELOG.md b/CHANGELOG.md index 0eecc79..2cbb832 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/CLAUDE.md b/CLAUDE.md index 37b21c4..142e680 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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. diff --git a/README.md b/README.md index 409040a..91bc6a7 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/docs/ANLEITUNG.md b/docs/ANLEITUNG.md index 5578bc2..71a73c8 100644 --- a/docs/ANLEITUNG.md +++ b/docs/ANLEITUNG.md @@ -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 + +![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: @@ -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. --- diff --git a/docs/DEVLOG.md b/docs/DEVLOG.md index 2769344..60d4e5c 100644 --- a/docs/DEVLOG.md +++ b/docs/DEVLOG.md @@ -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**: diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index af1460c..fe01b00 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -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) diff --git a/docs/bilder/01-registrierung.png b/docs/bilder/01-registrierung.png index 38e44e1..7788f17 100644 Binary files a/docs/bilder/01-registrierung.png and b/docs/bilder/01-registrierung.png differ diff --git a/docs/bilder/02-start.png b/docs/bilder/02-start.png index 7a0be01..441cab0 100644 Binary files a/docs/bilder/02-start.png and b/docs/bilder/02-start.png differ diff --git a/docs/bilder/03-verwaltung-hersteller.png b/docs/bilder/03-verwaltung-hersteller.png index d64c892..e4db9b5 100644 Binary files a/docs/bilder/03-verwaltung-hersteller.png and b/docs/bilder/03-verwaltung-hersteller.png differ diff --git a/docs/bilder/04-katalog.png b/docs/bilder/04-katalog.png index f9a3021..77f9643 100644 Binary files a/docs/bilder/04-katalog.png and b/docs/bilder/04-katalog.png differ diff --git a/docs/bilder/05-fehlende-sets.png b/docs/bilder/05-fehlende-sets.png index 8c1a8e1..2a1bd7f 100644 Binary files a/docs/bilder/05-fehlende-sets.png and b/docs/bilder/05-fehlende-sets.png differ diff --git a/docs/bilder/06-mehrfachauswahl.png b/docs/bilder/06-mehrfachauswahl.png index fa3787b..eb08bc7 100644 Binary files a/docs/bilder/06-mehrfachauswahl.png and b/docs/bilder/06-mehrfachauswahl.png differ diff --git a/docs/bilder/07-wunschliste-dialog.png b/docs/bilder/07-wunschliste-dialog.png index 518254e..3527da4 100644 Binary files a/docs/bilder/07-wunschliste-dialog.png and b/docs/bilder/07-wunschliste-dialog.png differ diff --git a/docs/bilder/08-set-detail.png b/docs/bilder/08-set-detail.png index d33cc18..dade78c 100644 Binary files a/docs/bilder/08-set-detail.png and b/docs/bilder/08-set-detail.png differ diff --git a/docs/bilder/11-wunschliste-bearbeiten.png b/docs/bilder/11-wunschliste-bearbeiten.png index 94816f6..becf7c2 100644 Binary files a/docs/bilder/11-wunschliste-bearbeiten.png and b/docs/bilder/11-wunschliste-bearbeiten.png differ diff --git a/docs/bilder/14-reserviert.png b/docs/bilder/14-reserviert.png index 5988793..e9ec42c 100644 Binary files a/docs/bilder/14-reserviert.png and b/docs/bilder/14-reserviert.png differ diff --git a/docs/bilder/15-wunschliste-reserviert.png b/docs/bilder/15-wunschliste-reserviert.png index a4c315e..f406a05 100644 Binary files a/docs/bilder/15-wunschliste-reserviert.png and b/docs/bilder/15-wunschliste-reserviert.png differ diff --git a/docs/bilder/17-lego-import.png b/docs/bilder/17-lego-import.png new file mode 100644 index 0000000..380e195 Binary files /dev/null and b/docs/bilder/17-lego-import.png differ diff --git a/docs/bilder/18-fehlteile.png b/docs/bilder/18-fehlteile.png new file mode 100644 index 0000000..a857431 Binary files /dev/null and b/docs/bilder/18-fehlteile.png differ diff --git a/docs/bilder/19-einladungen.png b/docs/bilder/19-einladungen.png new file mode 100644 index 0000000..fb4e304 Binary files /dev/null and b/docs/bilder/19-einladungen.png differ diff --git a/docs/bilder/20-daten.png b/docs/bilder/20-daten.png new file mode 100644 index 0000000..6d51743 Binary files /dev/null and b/docs/bilder/20-daten.png differ diff --git a/e2e/global-setup.ts b/e2e/global-setup.ts deleted file mode 100644 index e0bb8a4..0000000 --- a/e2e/global-setup.ts +++ /dev/null @@ -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' }, - }); -} diff --git a/e2e/screenshots.spec.ts b/e2e/screenshots.spec.ts index 8289d7e..695c6af 100644 --- a/e2e/screenshots.spec.ts +++ b/e2e/screenshots.spec.ts @@ -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'); }); diff --git a/package.json b/package.json index 903181a..06c3cb8 100644 --- a/package.json +++ b/package.json @@ -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": { diff --git a/playwright.config.ts b/playwright.config.ts index 069409c..4e407e1 100644 --- a/playwright.config.ts +++ b/playwright.config.ts @@ -1,55 +1,58 @@ -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. - */ -const PORT = 3210; -const BASE_URL = `http://localhost:${PORT}`; - -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 }, - reporter: process.env.CI ? 'github' : 'list', - use: { - baseURL: BASE_URL, - trace: 'retain-on-failure', - locale: 'de-DE', - timezoneId: 'Europe/Berlin', - }, - projects: [ - { - name: 'mobile', - // 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}`, - 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', - }, - }, -}); +import path from 'node:path'; +import { defineConfig, devices } from '@playwright/test'; + +/** + * Smoke test setup. + * + * 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', + fullyParallel: false, + workers: 1, + retries: 0, + timeout: 90_000, + expect: { timeout: 15_000 }, + reporter: process.env.CI ? 'github' : 'list', + use: { + baseURL: BASE_URL, + trace: 'retain-on-failure', + locale: 'de-DE', + timezoneId: 'Europe/Berlin', + }, + projects: [ + { + name: 'mobile', + // The app is built for phones first, so the smoke test uses one. + use: { ...devices['Pixel 7'] }, + }, + ], + webServer: { + 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', + }, +}); diff --git a/playwright.screenshots.config.ts b/playwright.screenshots.config.ts index f921090..5bf0038 100644 --- a/playwright.screenshots.config.ts +++ b/playwright.screenshots.config.ts @@ -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(' '), diff --git a/scripts/e2e-prepare.mjs b/scripts/e2e-prepare.mjs new file mode 100644 index 0000000..a0aff46 --- /dev/null +++ b/scripts/e2e-prepare.mjs @@ -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.'); diff --git a/scripts/reset-e2e.mjs b/scripts/reset-e2e.mjs deleted file mode 100644 index 8a11602..0000000 --- a/scripts/reset-e2e.mjs +++ /dev/null @@ -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.`); diff --git a/src/components/sammlung/collection-items-section.tsx b/src/components/sammlung/collection-items-section.tsx index 1e9c970..5128f31 100644 --- a/src/components/sammlung/collection-items-section.tsx +++ b/src/components/sammlung/collection-items-section.tsx @@ -71,7 +71,11 @@ export function CollectionItemsSection({ const [pending, startTransition] = useTransition(); const [editing, setEditing] = useState(null); const [deleting, setDeleting] = useState(null); - const [parts, setParts] = useState(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(null); + + const partsItem = items.find((item) => item.id === partsItemId) ?? null; function submit(event: React.FormEvent) { 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} > @@ -303,13 +307,13 @@ export function CollectionItemsSection({ - {parts ? ( + {partsItem ? ( !open && setParts(null)} + onOpenChange={(open) => !open && setPartsItemId(null)} /> ) : null}