Idempotenz der alternativen Titel in pgTAP statt Deno, Auswirkung auf 06/00_schema, alternative Titel an Testspiel B, Merge-Reihenfolge 2b -> 2c -> 3a und Erweiterung des Fremdschlüssel-Wächters in 07. Refs #3 Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
65 KiB
Spec #3 – KAT-01 Spiele suchen
| Issue | #3 (KAT-01) |
| Status | Entwurf, wartet auf Freigabe durch Chris (Spec-F1 bis Spec-F7 in § 9.2 offen, keine blockiert) |
| Autor | architect |
| Stand | 2026-11-03 |
| Etappe | R1.1 Fundament |
| Abhängigkeiten | #2 KAT-05 Teil 2a (gemergt: catalog_games, publishers, game_publishers, manual_fields, GameCover, Startbestand-Werkzeug). Teilabhängigkeit: Krit. 6 verweist auf #13 KAT-04 (später). Von #3 hängen ab: #4 KAT-02 (Einstieg in die Detailseite), #13 KAT-04 (Melden aus dem Leerzustand), #29 QUE-09 (Startseite ohne Anmeldung zeigt die Suche), #14 KTO-02 (Lieblingsspiele per Katalogsuche). |
| Quellen | Issue #3 inkl. Kommentare, requirements/20-r1-akzeptanzkriterien.md § KAT-01 und § Querschnitt, requirements/r1-klaerungen.md (3, 4, 13, 14, 15, 36, O6, O7), requirements/backlog.md (Datenmodell-Änderungen 3, 4), docs/specs/2-katalog-daten.md (E3, E4, E6, 3.2, § 5, 9.5), docs/datenmodell-r1.md, supabase/migrations/20261102100200_catalog.sql, …/20261103142204_catalog_foundation.sql, supabase/catalog/build_startbestand.ts, app/lib/core/widgets/game_cover.dart, app/lib/core/routing/*, app/lib/features/auth/presentation/providers/auth_redirect.dart, docs/design-system.md, e2e/README.md, ADR-0001, ADR-0002 |
Namenskonvention: „Spec-F1“ bis „Spec-F7“ sind die Fragen an Chris aus § 9.2. „E1“ usw. sind Kernentscheidungen dieser Spec. „Spec #2 E3“ verweist auf Entscheidungen der Katalog-Spec. „B1“, „F1“, „U1“, „T1“, „D1“, „C1“ sind Aufgaben (§ 8).
1. Ziel
Jeder, auch ohne Anmeldung (O6), findet ein Spiel im Katalog schnell über Titel, alternativen Titel oder Verlag und kann den Katalog über Filter (Spielerzahl, Spieldauer, Mindestalter, Genre, Mechanik) auch ohne Suchbegriff entdecken. Die Suche ist die Grundlage für spätere Einstiege: Detailseite (#4), Melden (#13), Startseite ohne Anmeldung (#29), Lieblingsspiele (#14), Sammlung und Partien.
Nicht im Scope (Issue): Barcode-Suche, Offline-Katalog (O7), Empfehlungen. Ebenfalls nicht hier: Detailseite (#4), Melden-Formular (#13), Startseite (#29), Tippfehler-Toleranz („Catna“ → „Catan“), Suche in Beschreibungstexten.
Kernentscheidungen (Kurzfassung)
| # | Entscheidung | Begründung |
|---|---|---|
| E1 | Eine RPC search_catalog_games (PostgreSQL-Funktion, security invoker, stable) liefert Treffer, Gesamtzahl und Verlage in einer Anfrage. Sie liest über die View catalog_games (Spec #2 E3), damit ausgeblendete und zusammengeführte Spiele nie erscheinen und die Anzeige-Maskierung (Spec #2 E6) gilt. |
Relevanz über Titel, alternative Titel und Verlage, UND-Filter über Zuordnungstabellen und Normalisierung lassen sich mit PostgREST-Filtern auf der View nicht sauber ausdrücken. Eine Anfrage je Seite hält Krit. 7 (100 ms RTT) ein. security invoker heißt: RLS gilt für den Aufrufer, die Funktion umgeht nichts. |
| E2 | Alternative Titel als eigene Tabelle game_alt_titles (Titel + Sprache de/en), nur lesbar für Clients, Pflege über Studio und das Startbestand-Werkzeug (Spalte alt_titles der CSV, Spec #2 4.3). Neuer manual_fields-Schlüssel alt_titles. |
Klärung 3, Backlog „Datenmodell-Änderungen“ Nr. 3. Nutzer haben in R1 keinen Anwendungsfall zum Schreiben; ohne Schreibrecht entfällt die Angriffsfläche. |
| E3 | Normalisierung in der Datenbank: eine Funktion public.search_normalize(text) (Kleinschreibung, Akzente und Umlaute entfernt, alles außer a–z/0–9 wird zu einem Leerzeichen). Ergebnis als gespeicherte generierte Spalten games.title_search, game_alt_titles.title_search, publishers.name_search mit Trigramm-Index. |
Krit. 1 „unabhängig von Groß- und Kleinschreibung und Sonderzeichen“. Eine zentrale Funktion statt Logik im Client; #13 nutzt sie für „gleicher normalisierter Titel“ (Klärung 15) wieder. Generierte Spalten können nicht vergessen werden und brauchen keine Trigger. |
| E4 | Treffer-Regel: Der Suchbegriff wird in Wörter zerlegt; jedes Wort muss als Teilzeichenkette im Titel, in einem alternativen Titel oder in einem Verlagsnamen des Spiels vorkommen. Zusätzlich trifft der Begriff ohne Leerzeichen („7wonders“) den Titel ohne Leerzeichen. | Krit. 1 („ein Titel oder ein Teil davon“), Krit. 2, Krit. 3 in einem Suchfeld. „siedler catan“ findet „Die Siedler von Catan“, „catan kosmos“ findet Catan von Kosmos. |
| E5 | Relevanz als feste Stufen (genau gleich → beginnt mit → Wortanfang → enthält → nur über Verlag), danach Trigramm-Ähnlichkeit, Titel, ID. | Krit. 1 „nach Relevanz sortiert“ wird damit testbar und deterministisch; die eindeutige Reihenfolge ist Voraussetzung für stabiles Nachladen (Krit. 5). |
| E6 | Nachladen über limit/offset, Seitengröße 30, Gesamtzahl über count(*) over (). |
Der Katalog ändert sich selten (Studio-Pflege), die Reihenfolge ist eindeutig (E5). Keyset-Paging über eine zusammengesetzte Relevanz wäre deutlich komplexer ohne spürbaren Nutzen bei einigen hundert bis tausend Spielen. |
| E7 | Filter sind UND-verknüpft, auch mehrere gewählte Genres oder Mechaniken untereinander (Spec-F1). Filter auf Spielerzahl, Dauer und Alter schließen Spiele mit fehlender Angabe aus (Spec-F2). | Wörtliche Lesart von Krit. 4/8 „zu allen gewählten Filtern passen“ (Klärung 13). |
| E8 | Ohne Suchbegriff und ohne Filter zeigt die Suche den ganzen Katalog alphabetisch (Spec-F3). | Krit. 8 (Entdecken) ohne Sonderfall: „keine Filter“ ist der leere Filter. Bei ~200 Spielen nützlich zum Stöbern. |
| E9 | Neues Feature features/catalog/ mit Domäne (CatalogGameSummary, CatalogSearchQuery, CatalogTerm, CatalogFailure), Repository und einem Notifier mit eigenem Seitenzustand. Alle Datenklassen sind nur lesend (createToJson: false); das Feature schreibt nichts in games. |
Merkpunkt aus Spec #2 § 10: Kein ungefiltertes toJson() darf an insert/update gehen. Ohne toJson ist das hier nicht nur dokumentiert, sondern unmöglich. |
| E10 | Öffentliche Route /catalog mit Suchbegriff und Filtern als Query-Parameter (Web-Deep-Links). Die Startseite bekommt nur einen Einstieg; die Startseite ohne Anmeldung baut #29. |
Web-URLs (ADR-0001, go_router); privateRoutePaths bleibt leer (Spec #1). |
| E11 | Krit. 6 in #3 als Leerzustand mit Hinweis und Einstiegspunkt; die Aktion „Spiel melden“ wird mit #13 freigeschaltet (Spec-F4). | Issue: „Teilabhängigkeit: Kriterium 6 verweist auf KAT-04 (#13), das später umgesetzt wird“. |
| E12 | Krit. 7 wird mit einem eigenen, nur lesenden Playwright-Projekt perf gegen Staging gemessen (Chrome-Drosselung 100 ms / 10 Mbit/s, mindestens 100 Suchen, Messung vom letzten Tastendruck bis zum ersten sichtbaren Treffer). Datenbank-Budget: p95 ≤ 150 ms bei 10 000 Spielen. |
Klärung 14. Die bestehenden E2E-Helfer (db.ts, E2E_ALLOWED_HOSTS) sind bewusst nur lokal; Staging wird nur über die Web-App angesprochen. |
| E13 | Drei PRs: 3a Backend, 3b App (parallel gegen den Vertrag aus § 4.2), 3c E2E und Messung. Nur 3c schließt das Issue. | Wie bei #2 (PR #52/#53); 3b kann mit gemocktem Repository sofort beginnen. |
| E14 | Keine neuen Dart-Pakete, keine Edge Function. Neu sind nur die PostgreSQL-Erweiterungen pg_trgm und unaccent (Bestandteil des Supabase-Postgres-Images, auch self-hosted; Prüfung in B5). |
CLAUDE.md, ADR-0002. Kein ADR nötig (§ 9.4). |
2. Akzeptanzkriterien
Übernommen aus Issue #3 / 20-r1-akzeptanzkriterien.md § KAT-01 (Stand 2026-11-02 inkl. Klärungen). Referenz in Tests und PRs: „KAT-01 Kriterium n“.
- Gegeben der Katalog enthält Spiele, wenn ich einen Titel oder einen Teil davon eingebe, dann erhalte ich passende Treffer nach Relevanz sortiert, unabhängig von Groß- und Kleinschreibung und Sonderzeichen.
- Gegeben ein Spiel hat neben dem Haupttitel alternative Titel (je mit Sprache, zum Beispiel einen deutschen und einen englischen), wenn ich nach einem davon suche, dann finde ich dasselbe Spiel (Klärung 3).
- Gegeben ich suche nach einem Verlag, dann erhalte ich dessen Spiele; ein Spiel kann mehreren Verlagen zugeordnet sein (Klärung 4).
- Gegeben eine Trefferliste, wenn ich nach Spielerzahl, Spieldauer, Mindestalter, Genre oder Mechanik filtere, dann enthält sie nur Spiele, die zu allen gewählten Filtern passen (Klärung 13).
- Gegeben mehr Treffer als auf eine Seite passen, wenn ich bis zum Ende scrolle, dann werden weitere nachgeladen.
- Gegeben ich suche ohne Treffer, dann sehe ich einen Hinweis und die Möglichkeit, das Spiel zu melden (KAT-04).
- Gegeben ein mobiles 4G-Profil (mindestens 10 Mbit/s, etwa 100 ms Round-Trip-Zeit), dann liefern 95 Prozent der Suchen gegen Staging die ersten Treffer in höchstens 2 Sekunden (Klärung 14).
- Gegeben ich habe keinen Suchbegriff eingegeben, wenn ich einen oder mehrere Filter (Genre, Mechanik, Spielerzahl, Spieldauer, Mindestalter) wähle, dann sehe ich alle Katalogspiele, die zu allen gewählten Filtern passen, und kann so neue Spiele entdecken (Klärung 13).
Konkretisierungen dieser Spec (zur Freigabe):
- Krit. 1 „Sonderzeichen“ umfasst Satz- und Sonderzeichen (
:,-,®,'usw.) und diakritische Zeichen (é→e,ä→a,ß→ss). Die Schreibweise „ae“ für „ä“ findet in R1 nicht automatisch „ä“ (Spec-F5). - Krit. 1 „Teil davon“: Die Suche beginnt ab 2 Zeichen (nach Entfernen von Leerzeichen am Rand). Ein einzelnes Zeichen wirkt wie „kein Suchbegriff“ (§ 5.4).
- Krit. 4 Filtersemantik: siehe § 4.2 und Spec-F2.
- Krit. 5: Seitengröße 30. Nachgeladen wird automatisch kurz vor dem Listenende und über eine Schaltfläche „Weitere laden“ am Listenende (Tastatur und Screenreader, QUE-06 Krit. 4).
- Krit. 6: siehe E11 und Spec-F4.
- Krit. 7 Messpunkt: vom letzten Tastendruck (inklusive Entprellung von 300 ms) bis der erste Treffer sichtbar ist; Staging mit eingespieltem Startbestand (§ 7.3).
Zuordnung zu den PRs
| PR | Kriterien |
|---|---|
| 3a Backend | 1, 2, 3, 4, 5, 8 (Datenbankanteil, pgTAP); 7 (Datenbank-Budget, B5) |
| 3b App | 1–6, 8 (App-Anteil, Unit/Widget); KAT-05 Krit. 10 (Platzhalter-Cover in der Trefferliste) |
| 3c E2E/Messung | 1–6, 8 (Web-E2E); 7 (Messung gegen Staging, Abnahme durch Chris) |
Relevante Querschnittskriterien
- Sicherheit der Daten Krit. 2: Suche ohne Anmeldung; die RPC ist für
anonausführbar und liefert nur Katalogspiele, nie private Spiele (auch nicht die eigenen). Nichts ist schreibbar. - Datenschutz (QUE-04): Suchbegriffe werden nicht gespeichert und nicht protokolliert (weder App-Logger noch Datenbank). Die RPC wird per
POSTaufgerufen (Standard vonsupabase_flutter), der Suchbegriff steht damit nicht in Zugriffsprotokollen von URLs. Keine Such-Analytik. - Sprachen (QUE-05) Krit. 2, 3: alle Texte aus ARB (de/en); Genre- und Mechaniknamen nach Oberflächensprache (
name_de/name_en); Trefferzahl mit Pluralform. - Barrierefreiheit (QUE-06) Krit. 1–4: Suchfeld und Filter beschriftet, Trefferzahl wird angesagt, Touch-Ziele ≥ 48 dp, 200 % Schrift, Tastaturbedienung im Web inkl. „Weitere laden“.
- Zustände, Fehler und Netz Krit. 1–3: Lade-, Leer-, Fehler- (mit „Erneut versuchen“) und Normalzustand; Offline-Hinweis; Suchbegriff und Filter bleiben bei Fehlern erhalten.
- Plattformen Krit. 1, 2: gleiche Kriterien auf iOS, Android, Web; ab 1024 dp Filter als feste Seitenleiste.
3. Datenmodell
3.1 Bestand gegen die Kriterien
| Bedarf | Bestand nach 2a | Handlung |
|---|---|---|
| Nur sichtbare Katalogspiele (KAT-05 Krit. 4) | View catalog_games |
RPC liest darüber (E1) |
| Normalisierter Titel (Krit. 1) | Index games_catalog_listed_idx auf lower(title) |
search_normalize, generierte Spalte games.title_search + Trigramm-Index (bestehender Index bleibt unverändert) |
| Alternative Titel mit Sprache (Krit. 2) | – (CSV-Spalte alt_titles reserviert, Spec #2 4.3) |
Tabelle game_alt_titles |
| Verlage (Krit. 3) | publishers, game_publishers |
generierte Spalte publishers.name_search + Trigramm-Index |
| Spielerzahl, Dauer, Alter (Krit. 4) | Spalten an games |
keine |
| Genre, Mechanik (Krit. 4, 8) | genres, mechanics, game_genres, game_mechanics mit Index auf genre_id/mechanic_id und unique (game_id, …) |
keine |
| Kennzeichnung manuell gepflegt | manual_fields, Trigger game_links_mark_manual(tg_argv) |
Schlüssel alt_titles ergänzen, Trigger an game_alt_titles |
3.2 Migration 3a supabase/migrations/<ts>_catalog_search.sql
Nur additiv. Es gilt die Rechte-Regel aus Spec #2 3.2: Jede neue Tabelle zuerst revoke all … from anon, authenticated, jede neue Funktion zuerst revoke execute … from public, anon, authenticated, service_role, danach nur die Grants aus der Tabelle unten.
Grants je Objekt (verbindlich)
| Objekt | anon |
authenticated |
Hinweis |
|---|---|---|---|
game_alt_titles |
select |
select |
kein Schreibrecht für API-Rollen; Policy nur für select |
search_normalize(text) |
– | – | intern (generierte Spalten, RPC, später #13) |
search_catalog_games(…) |
execute |
execute |
erste Funktion im Projekt, die API-Rollen ausdrücklich ausführen dürfen; service_role und public bleiben entzogen |
games, publishers |
unverändert | unverändert | neue generierte Spalten sind über das bestehende Tabellenrecht select lesbar; die Spaltenrechte für insert/update (Spec #2 3.2) bleiben unverändert und enthalten die neuen Spalten nicht |
1. Erweiterungen
create extension if not exists pg_trgm with schema extensions;
create extension if not exists unaccent with schema extensions;
Beide gehören zum Supabase-Postgres-Image und zu PostgreSQL-contrib. Unverifizierte Annahme (B5 prüft lokal und auf Staging mit select * from pg_available_extensions where name in ('pg_trgm','unaccent')): Beide sind im self-hosted Image verfügbar und im Schema extensions anlegbar. Rückfall siehe R1.
2. Funktion public.search_normalize(p text) returns text
language sql,immutable,strict,parallel safe,set search_path = ''.- Ablauf:
extensions.unaccent('extensions.unaccent'::regdictionary, p)(die Variante mit ausdrücklichem Wörterbuch, damit die Funktion alsimmutablegelten darf) →lower(…)→regexp_replace(…, '[^a-z0-9]+', ' ', 'g')→btrim(…)→nullif(…, ''). - Beispiele (Pflichtfälle im Test):
'Die Siedler von Catan'→'die siedler von catan';'CATAN®'→'catan';'7 Wonders: Duel'→'7 wonders duel';'Café Größe'→'cafe grosse';'Ticket to Ride – Europe'→'ticket to ride europe';'!!!'und''→null. - Zeichen außerhalb des lateinischen Alphabets (z. B. japanische Titel) werden entfernt; in R1 gibt es nur deutsche und englische Titel (Spec-F7).
- Regel für spätere Änderungen: Ändert sich die Funktion, müssen die generierten Spalten neu berechnet werden (Spalte in einer neuen Migration entfernen und neu anlegen). Steht als Kommentar an der Funktion.
3. Generierte Spalten und Indizes
| Tabelle | Spalte | Definition | Index |
|---|---|---|---|
games |
title_search |
text generated always as (public.search_normalize(title)) stored |
games_title_search_trgm_idx using gin (title_search extensions.gin_trgm_ops) where visibility = 'catalog' and hidden_at is null and merged_into_id is null |
publishers |
name_search |
text generated always as (public.search_normalize(name)) stored |
publishers_name_search_trgm_idx using gin (name_search extensions.gin_trgm_ops) |
game_alt_titles |
title_search |
wie games.title_search |
game_alt_titles_title_search_trgm_idx using gin (title_search extensions.gin_trgm_ops) |
Hinweise: Das Anlegen einer gespeicherten Spalte schreibt games einmal neu (unkritisch bei der Größe). Generierte Spalten kann kein Client setzen. Der Trigger games_mark_manual_fields arbeitet mit einer festen Spaltenliste und ist nicht betroffen. Die View catalog_games bleibt unverändert; die RPC verbindet sie per Primärschlüssel mit games, um title_search zu lesen.
4. Neue Tabelle game_alt_titles (Klärung 3, E2)
| Spalte | Typ | Hinweis |
|---|---|---|
id |
uuid | PK, gen_random_uuid() |
game_id |
uuid | not null, FK → games (id) on delete cascade |
title |
text | not null, 1–200 Zeichen (wie games.title) |
locale |
text | not null, Check in ('de','en') (wie games.description_locale; Spec-F7) |
title_search |
text | generiert (oben); Check title_search is not null (ein Titel nur aus Sonderzeichen ist nicht zulässig) |
created_at, updated_at |
timestamptz | Standard, Trigger set_updated_at |
unique (game_id, locale, title_search)(Konfliktziel für das Startbestand-Werkzeug; derselbe Titel in derselben Sprache nur einmal, unabhängig von Schreibweise). Mehrere Titel je Sprache sind erlaubt (z. B. alter und neuer deutscher Titel).- Der Index der Unique-Constraint deckt Abfragen nach
game_idab; kein zusätzlicher Index nötig. - Trigger
game_links_mark_manual after insert or update or delete … execute function public.game_links_mark_manual('alt_titles')(Funktion aus 2a, generisch übertg_argv, braucht nurgame_id).
RLS in Klartext
enable row level security;revoke all … from anon, authenticated;grant select … to anon, authenticated.- Lesen (
game_alt_titles_select,anon,authenticated): wer das Spiel sehen darf (exists (select 1 from public.games g where g.id = game_id), Mustergame_publishers_select). Alternative Titel eines fremden privaten Spiels sind damit unsichtbar. - Schreiben: keine Policy, kein Recht für API-Rollen. Pflege nur über Studio (
postgres) und das Startbestand-Werkzeug. QUE-08 (R4) ergänzt bei Bedarf eine Admin-Policy (Spec #2 R8).
5. manual_fields-Schlüssel ergänzen
alter table public.games drop constraint games_manual_fields_check, add constraint games_manual_fields_check check (manual_fields <@ array[…]::text[]) mit der vollständigen Liste aus 2a plus 'alt_titles'.
Regel für alle Stories, die Schlüssel ergänzen (#3, #12, 2b, Import): Jede Migration nennt die vollständige Liste aller bis dahin bekannten Schlüssel. Wer später merged, gleicht die Liste mit main ab (sonst entfernt die spätere Migration einen Schlüssel, und bestehende Zeilen verletzen den Check).
6. RPC public.search_catalog_games – Vertrag in § 4.2.
3.3 Reihenfolge zu 2b und 2c (Spec #2)
Merge-Reihenfolge: 2b → 2c → 3a. 2b (admin_merge_games, repoint_game_references, Branch feature/2b-katalog-zusammenfuehren) und 2c (game_reports, Erweiterung des Fremdschlüssel-Wächters) sind in Arbeit; 3a wird danach gemergt. Folgen für 3a:
- Migration: Zeitstempel von
<ts>_catalog_search.sqlliegt nach den Migrationen von 2b und 2c. B1 erstellt bzw. aktualisiert den Branch vonmainnach dem Merge von 2c (bei früherem Start: vor dem PR aufmainaktualisieren und den Zeitstempel bei Bedarf neu vergeben, solange 3a nicht gemergt ist). manual_fields-Check: vollständige Liste vonmainübernehmen,alt_titlesergänzen (3.2 Nr. 5).- Fremdschlüssel-Wächter in
07_catalog_merge.test.sql: Die neue FKgame_alt_titles.game_idmacht den Wächter rot. 3a ergänzt sie imset_eqin der Gruppe „bewusst ausgelassen: bleiben am ausgeblendeten Quell-Eintrag“ (nebengame_genres,game_mechanics,game_categories,game_publishers), gemäß Spec-F6 A.admin_merge_games/repoint_game_referencesbleiben unverändert. Die Einträge von 2c (game_reports.target_game_idbehandelt,game_reports.provisional_game_idausgelassen) bleiben erhalten. 00_schema.test.sql: Tabellenzahl vonmain(nach 2c) + 1.- pgTAP-Nummer:
09_catalog_search.test.sql(07 = 2b, 08 = 2c).
3.4 Doku zum Datenmodell
docs/datenmodell-r1.md: neue Tabelle game_alt_titles, generierte Spalten, Funktion search_normalize, RPC search_catalog_games, Erweiterungen, neuer manual_fields-Schlüssel; Abschnitt „API für das Frontend“ um die RPC ergänzen. supabase/README.md: Tabellenzahl, Hinweis „erste RPC mit execute für anon“. docs/katalog-pflege.md: Abschnitt „Alternative Titel pflegen“ (Studio-SQL und CSV-Spalte alt_titles).
4. Backend
4.1 Überblick
| Objekt | Art | PR | Aufrufbar von |
|---|---|---|---|
pg_trgm, unaccent |
Erweiterungen (Schema extensions) |
3a | – |
search_normalize |
interne Funktion | 3a | nur Eigentümer (generierte Spalten, RPC) |
game_alt_titles |
Tabelle | 3a | Lesen: alle; Schreiben: Studio |
title_search, name_search |
generierte Spalten | 3a | Lesen über bestehende Rechte |
search_catalog_games |
RPC | 3a | anon, authenticated |
| Startbestand-Werkzeug | Deno-Skript (Erweiterung) | 3a | Chris lokal, CI |
Keine Edge Function.
4.2 RPC public.search_catalog_games (Vertrag zwischen 3a und 3b)
language plpgsql, stable, security invoker, set search_path = ''. Alle Objekte schema-qualifiziert (public.…, extensions.similarity(…)). Aufruf über PostgREST POST /rest/v1/rpc/search_catalog_games (in Dart client.rpc('search_catalog_games', params: {...})).
Eingabe (alle optional, null = nicht gesetzt)
| Parameter | Typ | Zulässig | Bedeutung |
|---|---|---|---|
p_query |
text | ≤ 100 Zeichen | Suchbegriff. null oder nur Leerzeichen = kein Suchbegriff. Ergibt die Normalisierung null (z. B. '!!!'), gibt es keine Treffer. |
p_players |
smallint | 1–99 | Spielerzahl n: passt, wenn min_players <= n <= max_players (beide bekannt) |
p_max_minutes |
smallint | 1–1440 | „höchstens X Minuten“: passt, wenn coalesce(max_minutes, min_minutes) <= X (mindestens einer bekannt) |
p_age |
smallint | 0–99 | „für Spielende ab X Jahren geeignet“: passt, wenn min_age <= X (bekannt) |
p_genre_ids |
uuid[] | ≤ 20 Einträge | Spiel hat jedes dieser Genres (Spec-F1; Duplikate werden entfernt; unbekannte IDs ergeben 0 Treffer) |
p_mechanic_ids |
uuid[] | ≤ 20 Einträge | wie Genres |
p_limit |
integer | 1–50, Standard 30 | Seitengröße |
p_offset |
integer | 0–10 000, Standard 0 | Versatz |
Die Semantik von Spielerzahl, Dauer und Alter steht unter Vorbehalt von Spec-F2.
Ausgabe (returns table)
| Spalte | Typ | Inhalt |
|---|---|---|
id |
uuid | Shuffle-ID |
title |
text | Haupttitel |
year |
smallint | nullable |
min_players, max_players, min_minutes, max_minutes, min_age |
smallint | nullable |
image_path |
text | aus catalog_games (maskiert nach Spec #2 E6); im Startbestand immer null |
publisher_names |
text[] | alle Verlage, sortiert nach name_search, leer statt null |
matched_alt_title |
text | der alternative Titel, über den das Spiel besser getroffen wurde als über den Haupttitel; sonst null (Anzeige „auch: …“) |
match_rank |
smallint | Relevanzstufe 0–4 (unten), null ohne Suchbegriff; für Tests und Fehlersuche, die App zeigt sie nicht an |
total_count |
bigint | Gesamtzahl der Treffer (gleich in jeder Zeile); hasMore = offset + Zeilen < total_count |
Beschreibung, Genres und Mechaniken sind nicht enthalten (Listenansicht, kleine Antwort). Die Detailseite (#4) liest sie selbst.
Treffer-Regel (E4), mit q := search_normalize(p_query), Wörter w := string_to_array(q, ' ') (höchstens die ersten 10), qc := replace(q, ' ', ''):
- Kandidaten-Titel eines Spiels:
games.title_searchund allegame_alt_titles.title_search. - Ein Spiel trifft, wenn jedes Wort aus
wals Teilzeichenkette (like '%' || wort || '%'; Wörter enthalten nura–z0–9, ein Escapen entfällt) in einem Kandidaten-Titel oder in einempublishers.name_searchdes Spiels vorkommt, oder wennqcTeilzeichenkette eines Kandidaten-Titels ohne Leerzeichen ist. - Ein Spiel erscheint immer genau einmal, auch wenn mehrere Titel treffen.
Relevanz (E5): match_rank ist die beste Stufe über alle Kandidaten-Titel.
| Stufe | Bedingung |
|---|---|
| 0 | ein Kandidaten-Titel ist gleich q (oder ohne Leerzeichen gleich qc) |
| 1 | ein Kandidaten-Titel beginnt mit q |
| 2 | q steht in einem Kandidaten-Titel am Wortanfang (`like '% ' |
| 3 | alle Wörter kommen in einem Kandidaten-Titel vor (oder Treffer über qc) |
| 4 | sonst (mindestens ein Wort trifft nur über einen Verlag) |
Sortierung: match_rank aufsteigend, dann extensions.similarity(title_search, q) absteigend, dann title_search, dann id. Ohne Suchbegriff: title_search, dann id. Bei gleicher Stufe von Haupttitel und alternativem Titel gilt der Haupttitel (matched_alt_title = null).
Filter (E7): alle gesetzten Filter UND-verknüpft, zusätzlich zum Suchbegriff. Genres: (select count(*) from public.game_genres gg where gg.game_id = g.id and gg.genre_id = any(p_genre_ids)) = cardinality(<p_genre_ids ohne Duplikate>); Mechaniken entsprechend.
Fehler (raise exception … using errcode = 'P0001'; PostgREST liefert HTTP 400 mit code = 'P0001' und der Meldung als message)
| Fall | Meldung |
|---|---|
p_query länger als 100 Zeichen |
search_query_too_long |
p_players, p_max_minutes oder p_age außerhalb des Bereichs; mehr als 20 IDs in einem Array |
search_filter_invalid |
p_limit oder p_offset außerhalb des Bereichs |
search_paging_invalid |
Die App verhindert diese Fälle schon bei der Eingabe; tritt einer trotzdem auf, ist das ein Programmfehler (Log, allgemeiner Fehlerzustand).
Sicherheit: security invoker – für anon gelten die Policies von games, catalog_sources, game_publishers, publishers, game_alt_titles, game_genres, game_mechanics. Die Eingabegrenzen begrenzen die Last je Aufruf; zusätzlich greift das statement_timeout der Rollen anon/authenticated (Supabase-Standard; B5 prüft den Wert im Self-Hosting, Ergebnis in docs/katalog-pflege.md bzw. supabase/README.md). Eine Mengenbegrenzung je Client gibt es in R1 nicht (R4).
Leistung (E12): Ziel für die Ausführung in der Datenbank: p95 ≤ 150 ms bei 10 000 sichtbaren Katalogspielen, 20 000 alternativen Titeln, 2 000 Verlagen und je Spiel zwei Genres und drei Mechaniken. B5 misst lokal mit synthetischen Daten (Skript supabase/tests/perf/catalog_search_perf.sql, nicht Teil von supabase test db, läuft nie gegen Staging/Produktion) und dokumentiert explain (analyze, buffers) für typische Fälle im PR. Wird das Ziel verfehlt: Rückfall in R2.
4.3 Startbestand-Werkzeug: Spalte alt_titles auswerten
Erweiterung von supabase/catalog/build_startbestand.ts (Spec #2 4.3, E9). Die Spalte ist schon reserviert.
- Format:
de:Titel;en:Title. Einträge durch;getrennt; je Eintrag Sprache und Titel am ersten:getrennt (Titel dürfen:enthalten, z. B.de:Catan: Das Duell). Leerzeichen am Rand werden entfernt. - Prüfungen (Abbruch mit Zeilennummer und Grund): Sprache nicht
de/en; leerer Titel; Titel länger als 200 Zeichen; derselbe Titel in derselben Sprache doppelt; alternativer Titel gleich dem Haupttitel. „Gleich“ heißt im Skript: Kleinbuchstaben, diakritische Zeichen entfernt (Unicode NFD), alles außera–z0–9zu Leerzeichen – eine Annäherung ansearch_normalize; maßgeblich bleibt die Unique-Constraint in der Datenbank. - Erzeugte SQL je Startbestandsspiel (Abgleich wie bei den Zuordnungen, Spec #2 4.3 Idempotenz Regel 3):
delete from public.game_alt_titles a where a.game_id = <id> and (a.locale, a.title_search) not in (select locale, public.search_normalize(title) from (values …) v(locale, title)),insert into public.game_alt_titles (game_id, locale, title) values … on conflict (game_id, locale, title_search) do update set title = excluded.title where game_alt_titles.title is distinct from excluded.title. Unveränderte Zeilen bleiben unberührt (updated_atundmanual_fieldsstabil). Spiele ohnealt_titlesbekommen nur Schritt 1 (entfernt also früher gesetzte Titel).
- Alternative Titel bekommen keine abgeleitete ID (wie Verlage); Konfliktziel ist die Unique-Constraint.
checks.sql: keine neue Pflichtregel (alternative Titel sind optional); eine Informationszeile „Startbestandsspiele mit alternativen Titeln: n“.- Die erzeugten Dateien (
startbestand.sql, Testdaten untersupabase/catalog/testdata/,supabase/tests/support/*.inc) werden neu erzeugt; der CI-Schritt „erzeugte Dateien aktuell“ verlangt das. Die Testdaten-CSV bekommt bei einem Testspiel zwei alternative Titel (de und en). - Bestehender Deno-Test bricht absichtlich:
"facts_source, alt_titles und base_game_key landen nicht in der SQL"(build_startbestand_test.ts, Z. 253) wird zu „facts_sourceundbase_game_keylanden nicht in der SQL“ geändert; neue Tests füralt_titles(§ 7.1). - Das Einspielen markiert
alt_titlesinmanual_fields(Trigger, Spec #2 SB8). Startbestand ab Go-live eingefroren (Spec #2 Spec-F6): Pflegt Chris alternative Titel vorher, kommen sie über die CSV; danach nur über Studio.
5. Frontend
5.1 Struktur
app/lib/features/catalog/
domain/
catalog_game_summary.dart CatalogGameSummary (freezed, ohne JSON)
catalog_search_query.dart CatalogSearchQuery + CatalogFilters (freezed), Grenzen als Konstanten
catalog_search_page.dart CatalogSearchPage(items, totalCount) (freezed)
catalog_term.dart CatalogTerm(id, nameDe, nameEn) + localizedName(String languageCode)
catalog_failure.dart CatalogFailure (sealed: network, invalidInput, unexpected)
catalog_repository.dart abstract interface class CatalogRepository
data/
supabase_catalog_repository.dart
catalog_game_summary_dto.dart @JsonSerializable(createToJson: false, fieldRename: snake)
catalog_term_dto.dart dito
catalog_failure_mapper.dart
presentation/
catalog_routes.dart
providers/catalog_providers.dart Repository, Genres, Mechaniken
search/catalog_search_controller.dart (+ state)
search/catalog_search_screen.dart
search/widgets/… Suchfeld, Filterbereich, Treffer-Kachel, Listenfuß, Leerzustand
Domäne ohne Flutter- und Supabase-Importe (freezed_annotation ist erlaubt). Datenklassen ohne toJson (E9); das Feature schreibt nichts in die Datenbank.
5.2 Domänenmodelle und Repository
CatalogGameSummary:id,title,year?,minPlayers?,maxPlayers?,minMinutes?,maxMinutes?,minAge?,imagePath?,publisherNames(List<String>, nienull),matchedAltTitle?. Abweichung von Spec #2 § 5: Dort heißt es, KAT-01 legeCatalogGamean. Die Suche braucht nur die Listenansicht; das vollständige ModellCatalogGame(mit Beschreibung, Genres, Quellenangabe) legt KAT-02 (#4) an.CatalogSearchQuery:text(getrimmt, ≤ 100 Zeichen),filters(players?,maxMinutes?,minAgeSuitable?,genreIdsSet<String>,mechanicIdsSet<String>). Hilfen:hasText(≥ 2 Zeichen nach Trim),hasFilters,activeFilterCount,isEmpty. Zulässige Werte als Konstanten (Spielerzahl 1–12, Dauer-Stufen 30/45/60/90/120/180, Alters-Stufen 6/8/10/12/14/16/18; Spec-F2).CatalogTerm: Genre bzw. Mechanik;localizedName('de')→nameDe, sonstnameEn(QUE-05 Krit. 4: Englisch als Rückfall).CatalogFailure:network(keine Verbindung, Zeitüberschreitung),invalidInput(P0001 aus § 4.2, Programmfehler),unexpected.CatalogRepository:Future<Result<CatalogSearchPage, CatalogFailure>> search(CatalogSearchQuery query, {required int offset, int limit = 30})Future<Result<List<CatalogTerm>, CatalogFailure>> fetchGenres(),fetchMechanics()(PostgRESTselect id, name_de, name_enaufgenres/mechanics; Sortierung im Client nach lokalisiertem Namen)
SupabaseCatalogRepository: baut die RPC-Parameter nur aus gesetzten Werten (keinnullfür leere Mengen, kein Suchbegriff unter 2 Zeichen), Zeitlimit 10 s je Aufruf. Fehlerabbildung incatalog_failure_mapper.dart:isIoNetworkError(core/platform/io_error.dart),http.ClientException,TimeoutException→network;PostgrestExceptionmitcode == 'P0001'→invalidInput; sonstunexpected. Geloggt wird über den zentralen Logger nur die Fehlerart, nie der Suchbegriff (Datenschutz). Kein Connectivity-Paket; Offline wird am Aufruf erkannt (wie Spec #1).
5.3 Provider
catalogRepositoryProvider(keepAlive) auf Basis vonsupabaseClientProvider.catalogGenresProvider,catalogMechanicsProvider(FutureProvider, keepAlive; Vokabular ändert sich selten). Fehler beim Laden der Begriffe: Filterbereich zeigt für Genre/Mechanik einen Hinweis mit „Erneut versuchen“; die übrigen Filter und die Suche bleiben nutzbar.CatalogSearchController(@riverpod class, autoDispose) mitCatalogSearchState.
5.4 Zustand und Verhalten
CatalogSearchState (freezed): query, status (loading | data | empty | error), items, totalCount, failure?, refreshing (neue Suche läuft, alte Treffer bleiben sichtbar), loadingMore, loadMoreFailure?. Abgeleitet: hasMore = items.length < totalCount.
Methoden des Controllers
| Methode | Verhalten |
|---|---|
init(CatalogSearchQuery initial) |
einmalig beim Öffnen (Startwerte aus der URL), startet die erste Suche sofort |
setText(String) |
Entprellung 300 ms, dann Suche ab Versatz 0. Unter 2 Zeichen gilt „kein Suchbegriff“ (Liste nach Filtern bzw. ganzer Katalog, E8). |
setFilters(CatalogFilters) / clearFilters() |
sofort Suche ab Versatz 0 (laufende Entprellung wird mit ausgeführt) |
loadMore() |
nur wenn hasMore und nicht schon loadingMore; Versatz = items.length; Ergebnis wird an items angehängt, IDs, die schon vorhanden sind, werden verworfen |
retry() |
wiederholt die zuletzt fehlgeschlagene Aktion (erste Seite oder Nachladen) |
- Nur die letzte Anfrage zählt: Jede neue Suche erhöht einen Zähler; Antworten älterer Anfragen werden verworfen (kein Flackern, keine falsche Reihenfolge).
- Suchbegriff und Filter bleiben bei jedem Fehler erhalten (Zustände Krit. 3).
- Die Entprellung nutzt einen
Timer; Tests mitfakeAsync.
5.5 Route und Startseite
AppRoutes.catalog = '/catalog', öffentlich (nicht inprivateRoutePaths). Registrierung übercatalogRoutesinapp_router.dart(MusterauthRoutes).- Query-Parameter:
q(Suchbegriff),players,maxMinutes,age,genreundmechanic(IDs, durch Komma getrennt). Ungültige oder unbekannte Werte werden still ignoriert (geparst inpresentation, geprüft gegen die Konstanten der Domäne). - URL aktualisieren (Web): Nach jeder ausgeführten Suche wird die URL ohne neuen Verlaufseintrag ersetzt, sodass Neuladen und Teilen den Stand erhalten. flutter-dev prüft mit go_router 18, dass dabei weder Seite noch Controller neu entstehen (Zustand, Scrollposition, Fokus im Suchfeld). Falls doch: Das Schreiben der URL entfällt, nur das Lesen beim Öffnen bleibt; Abweichung im PR begründen.
- Startseite: Der bestehende Platzhalter
HomeScreenbekommt eine Schaltfläche „Spiele suchen“ →/catalog. Die Startseite ohne Anmeldung (Suche direkt auf/) baut #29 (QUE-09 Krit. 13); dafür ist der Inhalt des Screens als eigenes WidgetCatalogSearchViewgekapselt. - Antippen eines Treffers: Die Kachel hat einen
onTap-Parameter; in #3 ist ernull(Kachel nicht interaktiv, kein toter Link). KAT-02 (#4) verdrahtet die Navigation zur Detailseite.
5.6 Screen CatalogSearchScreen
Aufbau (Details und Texte liefert ui-designer in U1, Komponenten ins Design-System):
- Suchfeld oben: Beschriftung „Spiele suchen“ (Semantics-Label + sichtbarer Hinweistext „Titel oder Verlag“),
textInputAction.search,maxLength100 ohne Zähler, Löschen-Schaltfläche (≥ 48 dp, Label „Suchbegriff löschen“). Kein Autofokus auf Mobilgeräten (Tastatur verdeckt sonst die Liste); im Web Autofokus. - Filter:
- kompakt (< 600 dp) und mittel (600–1023 dp): Schaltfläche „Filter“ mit Anzahl aktiver Filter; öffnet ein Bottom Sheet. Änderungen wirken sofort (Liste dahinter aktualisiert sich), „Fertig“ schließt.
- groß (≥ 1024 dp,
AppWidthClassausapp_spacing.dart): feste Seitenleiste links (ca. 320 dp), Trefferliste rechts mit begrenzter Breite (Plattformen Krit. 2). - Inhalte: Spielerzahl (Auswahl „beliebig“, 1–12), Spieldauer (Einfachauswahl „bis 30/45/60/90/120/180 Min.“), Alter (Einfachauswahl „ab 6 … ab 18 Jahren geeignet“), Genre und Mechanik (Mehrfachauswahl als Filter-Chips, nach lokalisiertem Namen sortiert), „Filter zurücksetzen“. Die genaue Beschriftung von Alter und Dauer folgt Spec-F2.
- Trefferzahl über der Liste („23 Spiele“, ARB-Plural) und Ansage für Screenreader nach jeder abgeschlossenen Suche (Live-Region bzw. aktuelle Flutter-API für Ansagen).
- Treffer-Kachel:
GameCoverklein mittitleVisibleNearby: true(Platzhalter, KAT-05 Krit. 10), Titel (max. 2 Zeilen), Jahr, Verlage (bis zu zwei, sonst „+n“), Kurzangaben Spielerzahl („2–4 Spieler“), Dauer („30–60 Min.“), Alter („ab 10“); fehlende Angaben werden weggelassen. BeimatchedAltTitleeine Zeile „auch: {Titel}“. Ein zusammengefasstes Semantics-Label je Kachel. - Listenfuß: während des Nachladens eine Ladeanzeige; bei
hasMoreeine Schaltfläche „Weitere laden“; bei Fehler beim Nachladen Hinweis + „Erneut versuchen“ (bereits geladene Treffer bleiben). Automatisches Nachladen, sobald die letzten 5 Kacheln in Sicht kommen.
Zustände (Zustände Krit. 1, 2)
| Zustand | Anzeige |
|---|---|
| Laden (erste Seite, noch keine Treffer) | Platzhalter-Kacheln oder Ladeanzeige; Suchfeld und Filter bedienbar |
| Neue Suche mit sichtbaren alten Treffern | alte Treffer bleiben, schmale Fortschrittsanzeige oben |
| Daten | Trefferzahl + Liste |
| Leer mit Suchbegriff (Krit. 6) | „Keine Spiele zu „{q}“ gefunden.“ + Hinweis „Prüfe die Schreibweise oder entferne Filter.“; „Filter zurücksetzen“ (nur wenn Filter aktiv); Einstiegspunkt „Spiel melden“, sichtbar erst mit #13 (E11, Spec-F4) |
| Leer nur mit Filtern (Krit. 8) | „Keine Spiele passen zu deinen Filtern.“ + „Filter zurücksetzen“ |
| Leer ohne Suchbegriff und Filter | „Der Katalog ist noch leer.“ (nur lokal/Staging ohne Startbestand denkbar) |
| Fehler: keine Verbindung | „Keine Verbindung. Für die Suche brauchst du Internet.“ + „Erneut versuchen“ |
| Fehler: sonstige | „Die Suche ist gerade nicht möglich.“ + „Erneut versuchen“ |
Der Einstiegspunkt für #13 ist ein optionaler Callback am Leerzustand-Widget (onReportGame, erhält den Suchbegriff); #3 übergibt null, #13 übergibt die Navigation zum Melden (mit dem Suchbegriff als vorbelegtem Titel, Anmeldung über die bestehende from-Weiterleitung).
Neue gemeinsame Komponenten (ui-designer, core/widgets/): Zustandsanzeige für Leer/Fehler mit optionaler Aktion (AppStateMessage) und Listenfuß für Nachladen. Begründung: Zustände Krit. 1 gilt für jede Liste in R1; Sammlung, Partien und Listen nutzen sie als nächste.
ARB-Schlüssel (de/en, Präfix catalogSearch…): Titel, Suchfeld-Label und -Hinweis, Löschen, Filter (mit Anzahl), alle Filterbezeichnungen und -werte, Zurücksetzen, Fertig, Trefferzahl (Plural), Ansage, „auch: {title}“, Kurzangaben Spielerzahl/Dauer/Alter (Plural bzw. Bereich), Weitere laden, alle Leer- und Fehlertexte, Startseiten-Schaltfläche. Kein hartkodierter Text.
6. Plattform-Besonderheiten
- iOS, Android, Web: gleiche Funktion. Unterschied nur beim Autofokus des Suchfelds (Web ja, Mobil nein) und beim URL-Abgleich (wirkt sichtbar nur im Web; Deep Links auf
/catalog?q=…funktionieren auch mobil über go_router). - Web, Tastatur (QUE-06 Krit. 4): Tab-Reihenfolge Suchfeld → Filter → Liste → „Weitere laden“; Enter im Suchfeld löst die Suche sofort aus (ohne Entprellung); sichtbarer Fokus.
- Offline: Die Suche braucht eine Verbindung (O7, kein Offline-Katalog). Hinweis statt technischem Fehler; Eingaben bleiben.
- Bilder: Der Startbestand hat keine Cover;
GameCoverzeigt den Platzhalter. Kommen später Cover aus dem eigenen Storage, gelten die Hinweise im Doc-Kommentar vonGameCover(CORS im Self-Hosting, signierte URLs). - Kamera, Dateien, Push: nicht betroffen (Barcode-Suche nicht im Scope).
7. Teststrategie
Werkzeuge: pgTAP (supabase test db), Deno-Tests (Startbestand-Werkzeug), flutter_test + mocktail (Unit/Widget), Playwright (Web-E2E und Messung). Testnamen enthalten die Referenz („KAT-01 Kriterium n“).
pgTAP-Datei supabase/tests/database/09_catalog_search.test.sql (3a). Die Nummern 07 und 08 sind für Spec #2 (2b, 2c) reserviert. Die Tests laufen mit eingespieltem Startbestand: Fixtures mit festen Test-UUIDs und einem eindeutigen Kunstwort in allen Titeln und Verlagen (z. B. qxz), sodass Suchanfragen nie Startbestandsspiele treffen; bei Abfragen ohne Suchbegriff wird nur auf die Fixture-IDs geprüft.
Auswirkung auf bestehende Tests (geprüft gegen main und feature/2b-katalog-zusammenfuehren):
00_schema.test.sql: nur die Tabellenzahl (+ 1 gegenüber dem Stand vonmainbeim Merge von 3a, also nach 2b und 2c, siehe 3.3). Es gibt keinecolumns_are-Prüfungen; die neuen generierten Spaltentitle_search/name_searchbrechen dort nichts.06_catalog_foundation.test.sql: Die Spaltenrechte-Prüfungen (Z. 293–302) arbeiten mit festen Spaltenlisten und sind von den generierten Spalten nicht betroffen. Aber Z. 532–535 prüftmanual_fieldsvon „Testspiel A“ exakt. Bekäme Testspiel A alternative Titel, kämealt_titleshinzu und der Test würde rot. Vorgabe für B3: die alternativen Titel insupabase/catalog/testdata/startbestand.csvan Testspiel B hängen (Testspiel A bleibt unverändert); nur falls das nicht geht, passt B2 die erwartete Liste in Z. 532–535 an. Testspiel B hat bereits zwei Verlage und eignet sich auch für Krit. 3.07_catalog_merge.test.sql(2b): Fremdschlüssel-Wächter umgame_alt_titles.game_iderweitern (3.3).
7.1 Abdeckungsmatrix
| AK | Unit | Widget | Integration/E2E | RLS/pgTAP | Task |
|---|---|---|---|---|---|
| Krit. 1 | Repository: Parameter nur aus gesetzten Werten, Suchbegriff < 2 Zeichen wird nicht gesendet; DTO-Parsing (alle Felder nullable, publisher_names leer); Controller: Entprellung 300 ms, Enter sofort, nur letzte Antwort zählt |
Suchfeld: Eingabe → Liste; Löschen-Schaltfläche; Treffer-Kachel mit Kurzangaben, fehlende Angaben weggelassen | Web: Teil eines Titels in anderer Schreibweise und mit Sonderzeichen findet das Spiel | search_normalize-Pflichtfälle (3.2); Teilzeichenkette, Groß/Klein, Sonderzeichen, Umlaut/ß; mehrere Wörter (UND); Schreibweise ohne Leerzeichen (qc); Relevanzstufen 0–4 in erwarteter Reihenfolge; gleiche Stufe → Ähnlichkeit, Titel, ID; '!!!' → 0 Treffer; null/Leerzeichen → kein Suchbegriff |
B1, B2, F1, F2, T1, T2 |
| Krit. 2 | DTO: matched_alt_title |
Kachel zeigt „auch: …“ | Web: Suche nach deutschem und englischem alternativen Titel findet dasselbe Spiel | Treffer über de- und en-Titel; Spiel nur einmal, auch wenn Haupt- und alternativer Titel treffen; matched_alt_title nur bei besserer Stufe; Tabelle: Checks (Sprache, Länge, nur Sonderzeichen → 23514), Unique (Schreibweise egal → 23505); RLS: anon liest Titel von Katalogspielen, nicht die eines fremden privaten Spiels; anon/authenticated können nicht schreiben (42501); Einfügen als postgres markiert alt_titles in manual_fields; Check akzeptiert alt_titles, lehnt unbekannte Schlüssel ab. Idempotenz in pgTAP über die neu erzeugten .inc-Fixtures (startbestand_fixture.inc zweimal einspielen, wie in 06): zweites Einspielen ändert keine Zeile von game_alt_titles (Vergleich ohne updated_at, dazu updated_at unverändert); ein in der CSV entfernter alternativer Titel wird gelöscht; geänderte Schreibweise aktualisiert nur title. Deno (nur das Skript, ohne Datenbank): Format, Sprache, Länge, Duplikat, gleich Haupttitel, : im Titel; erzeugte SQL (Snapshot) |
B1, B2, B3, T1, T2 |
| Krit. 3 | – | Kachel: Verlage (bis zwei, „+n“) | Web: Verlagsname findet dessen Spiele | Verlag findet alle seine Spiele; Spiel mit zwei Verlagen über beide; Wort aus Titel + Wort aus Verlag (UND); publisher_names sortiert, vollständig; Stufe 4 hinter Titeltreffern |
B1, B2, T2 |
| Krit. 4 | CatalogSearchQuery: Grenzen, activeFilterCount; URL-Parameter lesen/schreiben (Rundreise, ungültige Werte ignoriert); Controller: Filteränderung startet ab Versatz 0 |
Filterbereich: jede Filterart setzt den Wert; Zurücksetzen; Bottom Sheet (kompakt) und Seitenleiste (≥ 1024 dp); Begriffe nach Sprache sortiert und benannt; Fehler beim Laden der Begriffe | Web: Filter Genre + Spielerzahl mit Suchbegriff | je Filter allein; Kombination aller Filter (UND); zwei Genres → nur Spiele mit beiden (Spec-F1); fehlende Angaben werden ausgeschlossen (Spec-F2); unbekannte Genre-ID → 0 Treffer; Duplikate im Array zählen einfach | B1, B2, F1, F2, U1, T1, T2 |
| Krit. 5 | Controller: loadMore hängt an, verwirft doppelte IDs, nicht doppelt gleichzeitig; Fehler beim Nachladen behält Treffer; retry |
Listenfuß: Ladeanzeige, „Weitere laden“, Fehler + „Erneut versuchen“; automatisches Nachladen beim Scrollen | Web: ≥ 31 Fixture-Treffer, Scrollen lädt die zweite Seite; „Weitere laden“ per Tastatur | Seiten ohne Überschneidung, Vereinigung = Gesamtmenge, Reihenfolge stabil über Aufrufe; total_count gleich in allen Zeilen und Seiten |
B2, F2, T1, T2 |
| Krit. 6 | – | Leerzustand mit Suchbegriff: Hinweis, Text mit Suchbegriff, „Filter zurücksetzen“ nur mit Filtern, Melden-Einstieg nur bei gesetztem Callback | Web: Suche ohne Treffer zeigt Hinweis | – | F3, U1, T2 |
| Krit. 7 | – | – | Projekt perf gegen Staging (7.3); lokal: Datenbank-Budget mit synthetischen Daten |
– (Messung, nicht pgTAP) | B5, T3, D1, C1 |
| Krit. 8 | Controller: ohne Suchbegriff mit Filtern; Startzustand ohne beides | Leerzustand nur mit Filtern | Web: nur Filter, kein Suchbegriff → passende Spiele | ohne Suchbegriff: Filter wirken, Sortierung nach Titel, match_rank null; ohne beides: alle sichtbaren Katalogspiele (Fixtures vollständig enthalten) |
B2, F2, T1, T2 |
| KAT-05 Krit. 4 | – | – | – | ausgeblendete und zusammengeführte Fixture-Spiele erscheinen nie, auch nicht über alternative Titel oder Verlag | B2 |
| KAT-05 Krit. 10 | – | Kachel zeigt GameCover-Platzhalter, aus dem Semantics-Baum ausgeblendet |
– | image_path maskiert bei Quelle ohne Erlaubnis (über die View) |
B2, T2 |
| Sicherheit Krit. 2 | – | – | Web: Suche ohne Anmeldung | anon und authenticated dürfen search_catalog_games ausführen, public/service_role nicht (has_function_privilege); search_normalize für keine API-Rolle ausführbar; eigene und fremde private Spiele erscheinen nie (auch mit gleichem Titel); keine Schreibrechte auf game_alt_titles |
B2 |
| Eingabegrenzen | CatalogSearchQuery lässt nur zulässige Werte zu |
Suchfeld begrenzt auf 100 Zeichen | – | search_query_too_long, search_filter_invalid (je Parameter, Array > 20), search_paging_invalid (limit 0/51, offset −1/10 001) |
B2, T1 |
| Zustände Krit. 1–3 | Fehlerabbildung: Netzwerkfehler, Zeitlimit, P0001, sonstige; Suchbegriff/Filter bleiben nach Fehler | alle Zustände aus 5.6; „Erneut versuchen“ | Web: context.setOffline(true) → Offline-Hinweis, danach online → „Erneut versuchen“ liefert Treffer |
– | F1, F2, T1, T2 |
| QUE-04 | Logger erhält bei Fehlern keinen Suchbegriff | – | – | – | F1, T1 |
| QUE-05 Krit. 2–4 | CatalogTerm.localizedName (Rückfall Englisch) |
Screen in de und en; Trefferzahl Plural | – | – | F1, T2 |
| QUE-06 Krit. 1–4 | – | a11y-Guidelines (labeledTapTargetGuideline, androidTapTargetGuideline, iOSTapTargetGuideline, textContrastGuideline); textScaler 2.0 ohne Überlauf (kompakt und groß); Ansage der Trefferzahl |
Web: Bedienung nur per Tastatur | – | U1, T2, T4 |
| Plattformen Krit. 2 | – | Layout ≥ 1024 dp mit Seitenleiste | – | – | U1, T2 |
7.2 E2E (Projekt web, lokal und CI)
- Neue Datei
e2e/tests/web/kat01-suche.spec.ts. Testdaten legt ein Helfer übere2e/support/db.ts(nur lokal, bestehende Host-Prüfung) alspostgresan: 40 Katalogspiele mit dem Kunstwort im Titel, zwei Verlagen, alternativen Titeln, Genres und Mechaniken aus dem Vokabular; Aufräumen nach dem Lauf über die festen IDs. Tests laufen ohne Anmeldung. - Fälle: Krit. 1, 2, 3, 4, 5, 6, 8, Offline (siehe Matrix), Tastaturbedienung.
supabase/catalog/testdatareicht nicht (3 Spiele); der echte Startbestand ist für E2E nicht verlässlich (Inhalt wächst).
7.3 Messung Krit. 7 (Projekt perf)
- Eigenes Playwright-Projekt
perfine2e/playwright.config.ts, nur registriert, wennPERF_WEB_URLgesetzt ist (Musterthrottle/spike). Nur lesend, kein Zugriff aufdb.ts, keine Anmeldung, keine Schlüssel außer dem öffentlichen Anon-Key im Web-Build. - Drosselung über CDP
Network.emulateNetworkConditions:latency100 ms, Download und Upload je 10 Mbit/s (1 250 000 Byte/s). - Ablauf: Web-App von Staging öffnen (Startbestand eingespielt), Semantics einschalten; aus einer festen Liste von mindestens 100 Suchen (vollständige Titel, Teile, alternative Titel, Verlage, Wortkombinationen, nur Filter) je Suche das Feld leeren, den Begriff tippen, Zeit ab dem letzten Tastendruck bis zur Sichtbarkeit der ersten Treffer-Kachel (bzw. des Leerzustands bei absichtlich leeren Suchen) messen. Die erste Suche nach dem Laden der App zählt mit (Verbindungsaufbau).
- Ergebnis: p50, p95, Maximum als Testausgabe und Anhang; bestanden, wenn p95 ≤ 2000 ms.
- Mobile Apps: Stichprobe von 20 Suchen auf einem Android-Gerät oder Emulator mit 4G-Profil (Emulator-Netzwerkprofil bzw. Network Link Conditioner auf iOS), manuell durch Chris oder test-engineer, Ergebnis im PR 3c. Die Web-Messung gilt als maßgeblicher Nachweis; Abweichung im PR begründet.
- Ausführung manuell (
workflow_dispatch) bzw. lokal gegen Staging, nicht bei jedem PR.
Nicht automatisiert testbar, manuelle Prüfung: Krit. 7 auf Mobilgeräten (Stichprobe), Screenreader-Durchgang (TalkBack, VoiceOver, NVDA/VoiceOver im Web), inhaltliche Plausibilität der Relevanz mit dem echten Startbestand (Chris).
8. Aufgaben
8.1 PR 3a – Backend (R1.1), Branch feature/3a-katalog-suche-backend, Refs #3
| # | Agent | Aufgabe | abhängig von |
|---|---|---|---|
| B1 | backend-dev | Migration <ts>_catalog_search.sql nach 3.2: Erweiterungen, search_normalize, generierte Spalten + Trigramm-Indizes, game_alt_titles (Checks, Unique, Trigger set_updated_at und game_links_mark_manual('alt_titles'), RLS, Grants), manual_fields-Check mit vollständiger Liste + alt_titles, RPC search_catalog_games nach 4.2 (Prüfungen, Treffer-Regel, Relevanz, Filter, Sortierung, Rechte: revoke von allen, dann grant execute an anon, authenticated) |
– |
| B2 | backend-dev | pgTAP 09_catalog_search.test.sql nach 7.1 (inkl. Idempotenz der alternativen Titel über die .inc-Fixtures); 00_schema.test.sql Tabellenzahl; Fremdschlüssel-Wächter in 07_catalog_merge.test.sql um game_alt_titles.game_id („bewusst ausgelassen“) ergänzen (3.3); 06 Z. 532–535 nur anpassen, falls B3 Testspiel A ändern muss |
B1, B3 |
| B3 | backend-dev | Startbestand-Werkzeug nach 4.3: alt_titles parsen, prüfen, SQL erzeugen; Deno-Tests (neu und geänderter Test Z. 253); Testdaten-CSV: alternative Titel (de und en) an Testspiel B, nicht an Testspiel A (§ 7, Auswirkung auf 06); alle erzeugten Dateien neu erzeugen |
B1 |
| B4 | backend-dev | Doku nach 3.4: docs/datenmodell-r1.md, supabase/README.md, docs/katalog-pflege.md (Abschnitt „Alternative Titel pflegen“, SQL-Bausteine wie in pgTAP ausgeführt) |
B1, B3 |
| B5 | backend-dev | Annahmen prüfen und messen: Erweiterungen lokal verfügbar (und mit devops auf Staging, D1); statement_timeout von anon/authenticated; Skript supabase/tests/perf/catalog_search_perf.sql mit synthetischen Daten (nur lokal, in einer zurückgerollten Transaktion), explain (analyze, buffers) und p95 im PR dokumentieren |
B1 |
| T1a | test-engineer | Review der pgTAP- und Deno-Abdeckung gegen 7.1, Lücken ergänzen | B2, B3 |
8.2 PR 3b – App (R1.1), Branch feature/3b-katalog-suche-app, Refs #3
| # | Agent | Aufgabe | abhängig von |
|---|---|---|---|
| U1 | ui-designer | Entwurf Suche (kompakt, mittel, groß), Treffer-Kachel, Filterbereich (Sheet und Seitenleiste), alle Zustände; gemeinsame Komponenten AppStateMessage und Listenfuß in core/widgets/; ARB-Texte de/en; Eintrag in docs/design-system.md |
– (Vertrag § 4.2 und 5.6) |
| F1 | flutter-dev | Domäne und Daten nach 5.1–5.3: Modelle ohne toJson, CatalogRepository, SupabaseCatalogRepository (Parameterbau, Zeitlimit, Fehlerabbildung, Logging ohne Suchbegriff), DTOs, Provider |
– (gegen den Vertrag § 4.2; Integration gegen echte RPC nach 3a) |
| F2 | flutter-dev | CatalogSearchController und CatalogSearchState nach 5.4 (Entprellung, letzte Anfrage zählt, Nachladen, Wiederholen) |
F1 |
| F3 | flutter-dev | Route /catalog mit Query-Parametern, URL-Abgleich (5.5, mit Rückfall), CatalogSearchScreen/CatalogSearchView nach 5.6 mit den Komponenten aus U1, Einstieg auf der Startseite, onTap = null, Melden-Callback null |
F2, U1 |
| T1 | test-engineer | Unit-Tests (Repository mit gemocktem Client, DTO, Fehlerabbildung, Query, URL-Rundreise, Controller mit fakeAsync) |
F1, F2 |
| T2 | test-engineer | Widget-Tests nach 7.1 (Zustände, Filter in beiden Layouts, Listenfuß, a11y-Guidelines, textScaler 2.0, de/en) |
F3 |
8.3 PR 3c – E2E und Messung (R1.1), Branch feature/3c-katalog-suche-e2e, Closes #3 (siehe Spec-F4)
| # | Agent | Aufgabe | abhängig von |
|---|---|---|---|
| T4 | test-engineer | Web-E2E kat01-suche.spec.ts nach 7.2 inkl. Fixture-Helfer und Aufräumen |
3a, 3b gemergt |
| T3 | test-engineer | Projekt perf nach 7.3 (Drosselung, Suchliste, Auswertung p50/p95) |
3b gemergt |
| D1 | devops | Staging bereitstellen bzw. aktualisieren: Migrationen, Web-Build, Erweiterungen prüfen (B5); CI-Job für das E2E-Projekt web um die neue Datei ergänzen (falls nicht automatisch); workflow_dispatch-Job für perf mit PERF_WEB_URL |
3a gemergt |
| C1 | Chris | Startbestand auf Staging einspielen (Spec #2 Runbook), Messung auslösen bzw. abnehmen; mobile Stichprobe; Relevanz mit echten Daten plausibilisieren | D1, T3 |
8.4 Reihenfolge und Parallelität
#2 2b gemergt → 2c gemergt (Voraussetzung für den Merge von 3a, 3.3)
3a B1 → B3, B5 (parallel) → B2 → B4 → T1a → Merge (Chris)
3b U1 ‖ F1 → F2 → F3 → T1, T2 (startet parallel zu 3a, gegen den Vertrag § 4.2)
└─ Integration gegen die echte RPC, sobald 3a gemergt ist → Merge (Chris)
3c D1 (nach 3a) → T4, T3 (nach 3b) → C1 → Merge (Chris), Issue #3 geschlossen
- Der Vertrag in § 4.2 ist verbindlich für 3a und 3b; Änderungen daran nur über diese Spec.
- 3b wird erst nach 3a gemergt (sonst ruft die App eine fehlende RPC auf).
- Merge-Reihenfolge im Backend: 2b → 2c → 3a (3.3). Arbeiten an 3a und 3b dürfen vorher beginnen; vor dem PR von 3a wird auf
mainaktualisiert. - Merge nur durch Chris. Kein ADR (9.4).
9. Risiken & offene Fragen
9.1 Risiken
| # | Risiko | Auswirkung | Gegenmaßnahme |
|---|---|---|---|
| R1 | pg_trgm oder unaccent sind im self-hosted Image nicht verfügbar (unverifiziert) |
Migration schlägt auf Staging/Produktion fehl | B5 prüft vor dem Merge von 3a. Rückfall ohne unaccent: search_normalize mit translate() und replace() für die gängigen Zeichen (äöüàáâéèêíóúñç…, ß → ss), ebenfalls immutable. Rückfall ohne pg_trgm: keine Trigramm-Indizes, Sortierung ohne Ähnlichkeit (Titel, ID); bei R1-Größe ausreichend schnell. |
| R2 | Abfrage mit mehreren exists/or über drei Tabellen wird bei wachsendem Katalog langsam |
Krit. 7 verfehlt | Budget 150 ms bei 10 000 Spielen (B5); Rückfall: zusammengefasster Suchtext je Spiel in einer eigenen Spalte, gepflegt per Trigger an games, game_alt_titles, game_publishers, publishers |
| R3 | Staging existiert noch nicht oder hat keinen Startbestand (ADR-0002: ein Server für Staging/Produktion) | Krit. 7 nicht abnehmbar, #3 bleibt offen | D1 früh einplanen; Startbestand-Fixtures für die Messung reichen nicht, es braucht die echte Größenordnung |
| R4 | search_catalog_games ist ohne Anmeldung aufrufbar |
Last durch automatisierte Aufrufe | Eingabegrenzen, statement_timeout (B5), nur lesend; Mengenbegrenzung am Gateway erst bei Bedarf (nicht in R1) |
| R5 | Nachladen per offset bei gleichzeitiger Katalogänderung |
Ein Spiel doppelt oder übersprungen | Doppelte IDs werden verworfen; Katalog ändert sich selten; akzeptiert |
| R6 | Änderung von search_normalize später |
Generierte Spalten veralten nicht automatisch | Kommentar an der Funktion; Neuanlage der Spalten per Migration |
| R7 | Parallelarbeit mit 2b/2c und #12 | Konflikte bei manual_fields-Check, Tabellenzahl in 00_schema, Fremdschlüssel-Wächter |
Merge-Reihenfolge 2b → 2c → 3a (3.3); Regel „vollständige Liste“ (3.2 Nr. 5); Tabellenzahl beim Merge abgleichen; Wächter-Einordnung von game_alt_titles durch 3a (3.3) |
| R8 | Relevanz wirkt mit echten Daten unpassend | Nutzer finden das erwartete Spiel weiter unten | Stufen sind einfach anpassbar (nur RPC); Plausibilisierung durch Chris (C1) |
| R9 | Schreibweise „ae“/„oe“/„ue“ findet Umlaut-Titel nicht | „Kaese“ findet „Käse“ nicht | Spec-F5; spätere Erweiterung ohne Datenmigration der Clients möglich (nur Funktion + Spalten) |
| R10 | Krit. 7 wird im Web gemessen, gilt aber für mobile Apps | Abweichung der Messbedingung | Web als maßgeblicher Nachweis, mobile Stichprobe (7.3), Begründung im PR |
9.2 Offene Fragen an Chris (keine blockiert die Umsetzung)
| # | Frage | Optionen | Empfehlung | Wirkung |
|---|---|---|---|---|
| Spec-F1 | Mehrere Genres (bzw. Mechaniken) gewählt: muss ein Spiel alle haben oder eines davon? | A: alle (UND); B: eines davon (ODER innerhalb einer Filterart, UND zwischen Filterarten) | A, wörtliche Lesart von Krit. 4/8 („zu allen gewählten Filtern“, Klärung 13). B liefert beim Entdecken mehr Treffer; Umstellung später nur in der RPC. | E7, 4.2, 7.1 Krit. 4 |
| Spec-F2 | Bedeutung von Spielerzahl, Dauer und Alter als Filter; Umgang mit fehlenden Angaben | Spielerzahl: Zahl n, passt wenn n im Bereich min–max. Dauer: „bis X Minuten“ (30/45/60/90/120/180), passt wenn die längste Angabe ≤ X; alternativ Bereiche („kurz/mittel/lang“). Alter: „ab X Jahren geeignet“, passt wenn Mindestalter ≤ X. Fehlende Angabe: ausschließen oder einschließen. | Wie links beschrieben (n im Bereich; „bis X Minuten“; Mindestalter ≤ X) und fehlende Angaben ausschließen (ein Spiel ohne Spielerzahl passt nicht nachweislich). Im Startbestand sind alle Angaben Pflicht (Spec #2 SB2), die Regel betrifft nur spätere Einträge. | 4.2, 5.2, 5.6, 7.1 |
| Spec-F3 | Was zeigt die Suche ohne Suchbegriff und ohne Filter? | A: den ganzen Katalog alphabetisch; B: nur einen Hinweis „Suchbegriff eingeben oder Filter wählen“ | A (Entdecken ohne Sonderfall, E8) | E8, 4.2, 5.6 |
| Spec-F4 | Krit. 6 („Möglichkeit, das Spiel zu melden“) hängt an KAT-04 (#13). Wann gilt #3 als erledigt? | A: #3 liefert Hinweis + Einstiegspunkt; Krit. 6 wird im PR als „Hinweis erfüllt, Melden mit #13“ abgehakt, #3 wird mit 3c geschlossen, #13 übernimmt die Aktion als eigenes Kriterium; B: #3 bleibt offen, bis #13 fertig ist | A (entspricht der Teilabhängigkeit im Issue; bei B hinge der Abschluss von #3, einer R1.1-Story, an #13 aus R1.2) | E11, 5.6, 8.3 |
| Spec-F5 | Soll „ae/oe/ue/ss“ als Schreibweise für „ä/ö/ü/ß“ in R1 treffen (z. B. „Kaese“ → „Käse“)? | A: nein in R1 (nur Akzente/Umlaute entfernen); B: ja (zweite normalisierte Form je Titel) | A; Krit. 1 verlangt Groß/Klein und Sonderzeichen. B ist später ohne App-Änderung nachrüstbar (R9). | 2 (Konkretisierung), 3.2 Nr. 2 |
| Spec-F6 | Alternative Titel beim Zusammenführen zweier Katalogspiele (2b, admin_merge_games) |
A: bleiben am ausgeblendeten Quell-Eintrag (wie Genres/Verlage); der Admin ergänzt am Ziel bei Bedarf Titel per SQL (Runbook-Schritt vor dem Zusammenführen); B: werden automatisch auf das Ziel übertragen (ohne Duplikate) | A für R1: einfach, kein Eingriff in die freigegebene Spec #2; der Haupttitel der Quelle hat keine Sprache und ließe sich ohnehin nicht automatisch als alternativer Titel übernehmen | 9.5 (2b), Fremdschlüssel-Wächter |
| Spec-F7 | Sprachen alternativer Titel | A: nur de und en (wie description_locale); B: beliebige Sprache (ISO 639-1, z. B. Originaltitel auf Französisch) |
A (Klärung 3 nennt Deutsch und Englisch; erweiterbar per Migration) | 3.2 Nr. 4, 4.3 |
9.3 Designwahlen (zur Bestätigung mit der Freigabe)
| # | Wahl | Begründung | Verworfene Alternative |
|---|---|---|---|
| W1 | RPC statt PostgREST-Filter auf catalog_games (E1) |
Relevanz, UND-Filter über Zuordnungen und Verlagssuche in einer Anfrage | mehrere Anfragen bzw. Filter im Client |
| W2 | Generierte Spalten statt Ausdrucksindex (E3) | einfache Abfragen, Normalisierung einmal beim Schreiben | Ausdrucksindex search_normalize(title); Suchtext-Spalte per Trigger (Rückfall R2) |
| W3 | limit/offset mit total_count (E6) |
einfach, Trefferzahl für Anzeige und Ansage | Keyset-Paging |
| W4 | Suche ab 2 Zeichen | ein Zeichen trifft fast alles und belastet die Datenbank ohne Nutzen | ab 1 Zeichen; ab 3 Zeichen (Trigramm-optimal, aber „Go“ wäre nicht suchbar) |
| W5 | Alternative Titel ohne Schreibrecht für Nutzer (E2) | kein Anwendungsfall in R1, keine Angriffsfläche | Schreiben für Ersteller privater Spiele (Muster game_genres_write) |
| W6 | Datenklassen ohne toJson (E9) |
macht den Merkpunkt aus Spec #2 technisch unverletzbar | Dokumentation allein |
| W7 | Kachel in #3 nicht antippbar, #4 verdrahtet | kein toter Link auf eine fehlende Detailseite | Platzhalter-Detailseite in #3 |
| W8 | Keine Suchbegriffe in Logs, Aufruf per POST |
Datensparsamkeit (QUE-04) | Suchanalytik |
9.4 Ausstehende Prozessschritte
- Spec-Review durch Chris; Label
status/spec-reviewist gesetzt, die Fragen stehen zusätzlich als Kommentar im Issue #3.needs-clarificationwird nicht gesetzt, weil keine Frage die Umsetzung blockiert (jede hat eine Empfehlung, die Umstellung betrifft jeweils nur die RPC bzw. einen Text). - Kein ADR nötig:
pg_trgmundunaccentsind Standard-Erweiterungen von PostgreSQL-contrib im Supabase-Image, ohne neue Dienste, Pakete oder Lizenzen; die Entscheidung lässt sich per Migration zurücknehmen. Die übrigen Entscheidungen bleiben innerhalb von ADR-0001/0002. - Nach Freigabe: B1 (backend-dev) und parallel U1, F1 (ui-designer, flutter-dev); D1 (devops) früh wegen R3.
9.5 Hinweise für andere Stories, nicht Teil von #3
- KAT-05 2b/2c (#2): Keine Änderung an 2b oder 2c nötig, weil beide vor 3a gemergt werden (3.3). 3a ordnet
game_alt_titles.game_idim Fremdschlüssel-Wächter von07_catalog_merge.test.sqlals „bewusst ausgelassen“ ein (bleibt am Quell-Eintrag, passt zuadmin_merge_games, das die Zuordnungen ebenfalls nicht umhängt; Spec-F6 A). Für das Runbook (Abschnitt Zusammenführen) ergänzt 3a (B4) den Schritt „vor dem Zusammenführen relevante Titel der Quelle als alternative Titel am Ziel anlegen“. - KAT-02 (#4): Route
/catalog/games/:id,onTapder Treffer-Kachel verdrahten; DomänenmodellCatalogGame(Detail) neu,CatalogGameSummarybleibt für Listen. Alternative Titel sind in KAT-02 Krit. 1 nicht gefordert. - KAT-04 (#13):
onReportGame-Callback im Leerzustand setzen (Suchbegriff als vorbelegter Titel, Anmeldung überfrom). „Gleicher normalisierter Titel“ (Klärung 15) auf Basis vonsearch_normalizeplus Entfernen von Artikeln (eigene Funktion, nicht insearch_normalize, sonst ändert sich die Suche); Trigramm-Ähnlichkeit überextensions.similarityauftitle_search. Krit. 6 von KAT-01 wird mit #13 vollständig (Spec-F4). - QUE-09 (#29): Startseite ohne Anmeldung bettet
CatalogSearchViewein. - KTO-02 (#14), SAM-01, PAR-01 (#15): Katalogsuche über
CatalogRepository.searchwiederverwenden. Wo vorläufige private Spiele wählbar sein sollen (Klärung 36), ist das eine eigene Abfrage auf eigene private Spiele, nicht Teil vonsearch_catalog_games(die RPC liefert bewusst nur den Katalog). - KAT-03 (#12): neuer
manual_fields-Schlüssel mit vollständiger Liste inkl.alt_titles. - Import (SAM-08 #25, Import-Spec): alternative Titel aus der Quelle schreibt der Import als
catalog_importer(Ausnahme im Trigger greift bereits).
10. Änderungsprotokoll
2026-11-03 – Entwurf
Erste Fassung durch architect, wartet auf Freigabe und Antworten zu Spec-F1 bis Spec-F7.
2026-11-03 – Korrekturen am Entwurf
- § 7.1 Krit. 2: Idempotenz der alternativen Titel nach pgTAP verschoben (über die erzeugten
.inc-Fixtures); Deno prüft nur das Skript. - § 7: Auswirkung auf bestehende Tests:
00_schemanur Tabellenzahl;06Z. 532–535 prüftmanual_fieldsvon Testspiel A exakt → alternative Titel an Testspiel B (B3), sonst Anpassung in B2. - Neuer Abschnitt 3.3: Merge-Reihenfolge 2b → 2c → 3a; 3a erweitert den Fremdschlüssel-Wächter in
07_catalog_merge.test.sqlumgame_alt_titles.game_id(„bewusst ausgelassen“, Spec-F6 A). Abschnitt „Doku zum Datenmodell“ wird 3.4. B2, B3, § 8.4, R7, 9.5 angepasst.