Vier neue Kapitel in der Anleitung (mehrere Sammler, Preise, Erinnerungen, Dubletten) mit vier automatisch erzeugten Bildern, Changelog 0.3.0 samt der bewusst weggelassenen BrickLink-Anbindung, Roadmap abgehakt und um einen offenen Ausblick ergaenzt, Devlog mit Zeiten und Stolpersteinen. Dazu zwei Korrekturen aus dem Testlauf: Der Zeitplan fuer Erinnerungen liess sich nicht aus Next' instrumentation.ts starten - die Datei wird auch fuer die Edge-Runtime uebersetzt, und das native SQLite-Modul bricht dort den Build. Er startet jetzt aus dem Healthcheck heraus, den der Container ohnehin alle 30 Sekunden aufruft, und zusaetzlich aus dem App-Layout; der Aufruf ist idempotent. Der Screenshot-Lauf waehlte ploetzlich die falschen Kacheln aus: Der neue Sammler-Umschalter benutzt ebenfalls aria-pressed. Die Auswahlkacheln tragen jetzt ein eigenes data-set-card. Ausserdem raeumen die Testskripte einen Container weg, den ein abgebrochener Lauf zurueckgelassen hat - sonst blockiert er den Port. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
9.8 KiB
Bricktrack
Self-hosted Webanwendung zur Verwaltung einer Spielzeug-Set-Sammlung (Lego, Playmobil, Cobi, BlueBrixx, Mega Bloks und beliebige weitere Hersteller). Sie beantwortet zwei Fragen sofort: Welche Sets hat er? und Welche Sets hat er noch nicht? Aus den fehlenden Sets entstehen teilbare Wunschlisten.
Stack
| Bereich | Wahl | Anmerkung |
|---|---|---|
| Framework | Next.js 15, App Router, Server Actions | output: 'standalone' |
| Sprache | TypeScript strict | |
| UI | Tailwind CSS 4, shadcn/ui (radix-nova), lucide | mobile first |
| Datenbank | SQLite via better-sqlite3, WAL | Datei unter DATA_DIR/db |
| ORM | Drizzle ORM + drizzle-kit | Migrationen in drizzle/ |
| Auth | better-auth + @better-auth/passkey |
E-Mail/Passwort und WebAuthn |
| Validierung | zod (serverseitig, immer) | |
| Bilder | sharp (nur serverseitig, Thumbnails) | |
| Tests | Vitest (Unit), Playwright (E2E, Screenshots) | |
| 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 Cloud-Dienste, keine externen Requests aus dem Browser ausser zu Bild-URLs, die der Nutzer selbst hinterlegt hat.
Konventionen
- UI-Texte auf Deutsch, Code und Kommentare auf Englisch.
- Datum
dd.MM.yyyy, Waehrung EUR, ZeitzoneEurope/Berlin. - Geldbetraege liegen als Integer in Cent in der Datenbank
(
retail_price_cents,acquired_price_cents). SQLite hat keinen Decimal-Typ; Float-Summen waeren ungenau. Umrechnung ausschliesslich insrc/lib/money.ts. - Alle Datenbankspalten in
snake_case. Auch in den Auth-Tabellen - der Drizzle-Adapter von better-auth loest Modelle ueber die TypeScript-Property-Namen auf, nicht ueber die Spaltennamen. - IDs sind Text-UUIDs (
crypto.randomUUID()), Zeitstempel sindinteger(mode: 'timestamp'). - Routen sind deutsch (
/katalog,/sammlung,/wunschlisten), API-Routen englisch (/api/health,/api/files). - 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/similarity.ts |
Namensvergleich fuer die Dublettenpruefung (rein) |
src/lib/prices.ts |
Wertentwicklung aus notierten Preisen (rein) |
src/lib/reminders.ts |
Regeln, wann eine Erinnerung faellig ist (rein) |
src/lib/reminder-job.ts, src/lib/mailer.ts |
Versand und Zeitplan |
src/lib/export.ts |
Export und Import des gesamten Bestands |
src/lib/money.ts, src/lib/format.ts |
Cent-Umrechnung, dd.MM.yyyy |
Befehle
pnpm dev # Entwicklungsserver auf http://localhost:3000
pnpm build # Produktionsbuild (standalone)
pnpm start # Produktionsserver
pnpm lint # ESLint
pnpm typecheck # tsc --noEmit
pnpm db:generate # SQL-Migration aus dem Schema erzeugen
pnpm db:migrate # Migrationen anwenden (auch im Container-Start)
pnpm db:seed # Beispieldaten einspielen
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-onlyist invitest.config.mtsauf einen leeren Stub gemappt. tests/setup-env.tssetzt Platzhalter fuer die Umgebung.BASE_URLwird 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-shotsgegen das Produktions-Image. Beide Verzeichnisse werden vor jedem Lauf geleert und liegen nie im Git.
Datenmodell
Kernregel: Ein set ist ein Katalogeintrag - es existiert,
unabhaengig davon ob wir es besitzen. Ein collection_item
verknuepft ein Set mit dem tatsaechlichen Besitz.
"Hat er nicht" = Sets im Katalog, zu denen kein
collection_itemmit einem Status ungleichsoldexistiert.
Diese Bedingung steht an genau einer Stelle: src/lib/ownership.ts.
Katalog
| Tabelle | Zweck | Wichtige Felder |
|---|---|---|
manufacturer |
Hersteller | name, slug (unique), website |
theme |
Thema/Serie, hierarchisch | manufacturer_id, parent_theme_id, unique je (Hersteller, Name) |
set |
Katalogeintrag | unique (manufacturer_id, set_number), year, piece_count, minifig_count, ean, image_url oder image_path, retail_price_cents, external_ids (JSON) |
tag, set_tag |
freie Verschlagwortung (n:m) | |
instruction |
Anleitung | kind = pdf_upload (dann file_path) oder external_url (dann url), per CHECK erzwungen |
Besitz
Mehrere Sammler teilen sich den Katalog, nicht den Besitz: collector
haelt Name und Farbe, collection_item.collector_id und
wishlist.collector_id ordnen zu. Solange nur ein Sammler existiert, blendet
die Oberflaeche das Thema vollstaendig aus - Umschalter, Abfragen und Badges
erscheinen erst ab dem zweiten.
| Tabelle | Zweck | Wichtige Felder |
|---|---|---|
collector |
ein Kind | name, color, birthday, sort_order |
price_point |
beobachteter Preis je Set | price_cents, condition, observed_at, source |
collection_item |
ein besessenes Exemplar | status = owned / built / disassembled / lent_out / sold, completeness, condition, location_id, acquired_*, gifted_by |
location |
Aufbewahrungsort, hierarchisch | parent_location_id |
missing_part |
Fehlteile je Exemplar | part_number, color_name, quantity, resolved |
photo |
eigene Fotos | gehoert entweder zu set_id oder collection_item_id (CHECK) |
Wunschlisten
| Tabelle | Zweck | Wichtige Felder |
|---|---|---|
wishlist |
eine Liste | share_token (32 Zufallsbytes, unique), is_public, surprise_mode, occasion, event_date, note |
wishlist_item |
ein Wunsch | unique (wishlist_id, set_id), priority 1-3 (CHECK), sort_order, reserved_by_name, reserved_at, reservation_token_hash, fulfilled_at |
Der Unique-Index auf (wishlist_id, set_id) macht das Ueberspringen von
Dubletten bei der Mehrfachauswahl zu einer Datenbankgarantie, nicht
nur zu einer UI-Regel: Der Bulk-Insert laeuft mit
onConflictDoNothing() und meldet die uebersprungenen Sets zurueck.
fulfilled_at wird gesetzt, sobald das Set in der Sammlung auftaucht.
Auth
user (mit role = admin | member), session, account,
verification, passkey, rateLimit kommen von better-auth.
invite ist selbst gebaut: Der Klartext-Token steht nur im Link, in der
Datenbank liegt sein SHA-256-Hash. Gleiches gilt fuer
wishlist_item.reservation_token_hash. Der wishlist.share_token
bleibt im Klartext, weil er im Dashboard erneut angezeigt werden muss.
Verzeichnisse zur Laufzeit
Ein einziges ./data reicht fuer ein vollstaendiges Backup.
DATA_DIR/
db/bricktrack.db SQLite (+ -wal, -shm)
files/
images/sets/<setId>/... Set-Bilder (oeffentlich lesbar)
photos/... eigene Fotos (nur mit Session)
instructions/<setId>/... Anleitungs-PDFs (nur mit Session)
Set-Bilder sind ohne Login abrufbar, weil die oeffentliche Wunschliste sie braucht. Fotos und Anleitungen erfordern zwingend eine Session.
Sicherheitsregeln
- Jede Eingabe wird serverseitig mit zod validiert. Clientseitige Validierung ist Komfort, nie Schutz.
- Uploads: nur PDF, JPEG, PNG, WebP. Pruefung ueber MIME und
Magic Bytes. Groessenlimit aus
MAX_UPLOAD_MB. Dateinamen werden immer serverseitig erzeugt, nie vom Client uebernommen. - Oeffentliche Wunschlisten-Routen (
/w/[shareToken]) nutzen ausschliesslich den Share-Token als Zugriffsschluessel und geben nichts preis ausser den Wunschlisten-Daten - keine Nutzernamen, keine Sammlungsdaten, keine IDs anderer Objekte. - Externe API-Aufrufe (Rebrickable, ab Phase 2) laufen ausschliesslich serverseitig.
- Session-Cookies:
httpOnly,secure,sameSite=lax. Rate-Limiting auf den Login-Endpunkten. - Der Export enthaelt keine Konten, Passwoerter oder Passkeys - und
keine Reservierungen oder Teilen-Token. Das Backup ist
./data, nicht der Export. - Der Service Worker beantwortet nur GET-Anfragen aus dem Cache und niemals
/api/auth,/api/exportoder den Healthcheck. Beim Abmelden wird der Cache geleert.
Phasenplan
- Phase 1 (MVP) - Auth mit Passkeys, Stammdaten, Katalog, Sammlung, Wunschlisten inkl. oeffentlicher Ansicht, Docker-Stack. Fertig in 0.1.0.
- Phase 2 (Komfort) - Rebrickable-Import, Barcode-Scanner, Fehlteile, Einladungen, Export/Import, PWA. Fertig in 0.2.0.
- Phase 3 (Nice to have) - mehrere Sammler, Preisbeobachtung, E-Mail-Erinnerungen, Dublettenpruefung. Fertig in 0.3.0.
Details und Stand: docs/ROADMAP.md. Zeitaufwaende: docs/DEVLOG.md.
Arbeitsweise
- Nie direkt auf
mainentwickeln; pro Phase ein Branch (feat/phase-1), Merge erst nach manuellem Test. - Nach jedem abgeschlossenen Teilschritt committen.
pnpm lintundpnpm typecheckmuessen sauber sein, bevor eine Phase als fertig gilt.