feat(hub): keuzelijsten voor de inboxfilters (IDEA-182) #176

Merged
janpeter merged 20 commits from feat/inbox-filter-keuzelijsten into main 2026-08-19 06:45:25 +02:00
Owner

Filteren in de hub-inbox vereiste vrije invoer die exact de juiste schrijfwijze moest raken — task, pending, DEPLOY. Eén tikfout gaf stil nul resultaten. En de enige picker die er was, voor deelnemer, werd gevuld uit de geladen items (de eerste pagina van 50), met daaronder een tekstveld dat aan dezelfde waarde bond. Die dubbele binding is waarom een gekozen deelnemer niet bleef staan.

Gevonden tijdens de M33-e2e-gate, samen met het 401-defect dat als IDEA-183 apart is opgelost.

Wat erin zit

  • GET /api/hub/inbox/facets?source=queue|job&archived=… levert per veld de waarden die wérkelijk voorkomen, met label en aantal. Dezelfde scoping en hetzelfde archief-predicaat als de lijst, anders bied je opties aan voor data die de lijst nooit teruggeeft.
  • Deelnemers geteld met COUNT(DISTINCT id). Een bericht van mac:claude aan zichzelf staat aan beide kanten; los tellen telt het dubbel. Gemeten: 91 self-messages, naïef 404 waar het er 313 zijn.
  • De M30-jobnaamruimte valt eruit. Tien van de zestien deelnemers waren scrum4us-job:<uuid> — ondoorzichtige job-id's die met elke job aangroeien en op een telefoon onbruikbaar zijn.
  • De server stuurt het label mee (scrum4me-server154), zodat de aliaslijst één keer naast de data staat en niet in een app die opnieuw uitgerold moet worden. De app toont label en verstuurt value.
  • Elk vrij tekstveld is een picker geworden, met het aantal erachter. knownParticipants is weg.

Waar de aandacht naartoe ging

Concurrency. FacetsStore publiceert alleen als zijn sleutel nog de huidige is, en deelt één Task per sleutel. Dat laatste is geen optimalisatie: SwiftUI annuleert de .task van een sheet bij sluiten, en met een "loopt al, doe niets"-vorm zou meteen heropenen een leeg scherm zonder retry opleveren. Een losse Task erft die annulering niet.

De eindreview heeft dat niet aangenomen maar getoetst met twee mutanten: guard huidige == key weghalen maakt precies één test rood, en de gedeelde Task vervangen door een "doe niets"-set maakt precies één andere test rood.

Eén bevinding uit die review was een echt defect in het ontwerp: filters overleven een sleutelwissel, dus een deelnemerfilter dat je op de actieve queue zet en daarna met de archieftoggle meeneemt, kon actief blijven terwijl het veld "geen waarden" toonde — onzichtbaar én niet te wissen. Een gekozen waarde die niet in de lijst staat houdt nu zijn eigen regel. Spec §5 is meegecorrigeerd.

Verificatie

  • Wegwerp-clone-gate groen op e91e0bb: npm ci && npm run verify && npm run build && git diff --exit-code.
  • npm run verify: 270 bestanden, 2149 tests. npm run ios:test: 71/71.
  • Spec en plan zijn acht ronden adversarieel gereviewd (mac:codex) vóór er code was; elke taak apart gereviewd; daarna een brede eindreview over de hele branch.

Nog te doen na merge

  1. De telling één keer tegen de echte database (plan Task 5 Step 4). Het orakel is de onafhankelijke query van dát moment, niet een getal uit het plan — de queue groeit continu. Wat je toetst is de gelijkheid van beide query's en de invariant naïef − DISTINCT = self-messages in dezelfde verzameling.
  2. Gate-item 3 opnieuw op het toestel. Dat item is heropend: de GO van 2026-08-14 gold het vrije-tekstmechanisme, niet de keuzelijsten.

Geen lockstep nodig. Het endpoint is nieuw, dus een oudere app raakt het nooit; de server mag vooruit zonder dat de TestFlight-build meteen mee moet.

🤖 Generated with Claude Code

Filteren in de hub-inbox vereiste vrije invoer die exact de juiste schrijfwijze moest raken — `task`, `pending`, `DEPLOY`. Eén tikfout gaf stil nul resultaten. En de enige picker die er was, voor deelnemer, werd gevuld uit de geladen items (de eerste pagina van 50), met daaronder een tekstveld dat aan **dezelfde** waarde bond. Die dubbele binding is waarom een gekozen deelnemer niet bleef staan. Gevonden tijdens de M33-e2e-gate, samen met het 401-defect dat als IDEA-183 apart is opgelost. ## Wat erin zit - **`GET /api/hub/inbox/facets?source=queue|job&archived=…`** levert per veld de waarden die wérkelijk voorkomen, met label en aantal. Dezelfde scoping en hetzelfde archief-predicaat als de lijst, anders bied je opties aan voor data die de lijst nooit teruggeeft. - **Deelnemers geteld met `COUNT(DISTINCT id)`.** Een bericht van `mac:claude` aan zichzelf staat aan beide kanten; los tellen telt het dubbel. Gemeten: 91 self-messages, naïef 404 waar het er 313 zijn. - **De M30-jobnaamruimte valt eruit.** Tien van de zestien deelnemers waren `scrum4us-job:<uuid>` — ondoorzichtige job-id's die met elke job aangroeien en op een telefoon onbruikbaar zijn. - **De server stuurt het label mee** (`scrum4me-server` → `154`), zodat de aliaslijst één keer naast de data staat en niet in een app die opnieuw uitgerold moet worden. De app toont `label` en verstuurt `value`. - **Elk vrij tekstveld is een picker geworden**, met het aantal erachter. `knownParticipants` is weg. ## Waar de aandacht naartoe ging **Concurrency.** `FacetsStore` publiceert alleen als zijn sleutel nog de huidige is, en deelt één `Task` per sleutel. Dat laatste is geen optimalisatie: SwiftUI annuleert de `.task` van een sheet bij sluiten, en met een "loopt al, doe niets"-vorm zou meteen heropenen een leeg scherm zonder retry opleveren. Een losse `Task` erft die annulering niet. De eindreview heeft dat niet aangenomen maar getoetst met twee mutanten: `guard huidige == key` weghalen maakt precies één test rood, en de gedeelde `Task` vervangen door een "doe niets"-set maakt precies één andere test rood. **Eén bevinding uit die review was een echt defect in het ontwerp:** filters overleven een sleutelwissel, dus een deelnemerfilter dat je op de actieve queue zet en daarna met de archieftoggle meeneemt, kon actief blijven terwijl het veld "geen waarden" toonde — onzichtbaar én niet te wissen. Een gekozen waarde die niet in de lijst staat houdt nu zijn eigen regel. Spec §5 is meegecorrigeerd. ## Verificatie - Wegwerp-clone-gate groen op `e91e0bb`: `npm ci && npm run verify && npm run build && git diff --exit-code`. - `npm run verify`: 270 bestanden, 2149 tests. `npm run ios:test`: 71/71. - Spec en plan zijn acht ronden adversarieel gereviewd (`mac:codex`) vóór er code was; elke taak apart gereviewd; daarna een brede eindreview over de hele branch. ## Nog te doen na merge 1. **De telling één keer tegen de echte database** (plan Task 5 Step 4). Het orakel is de onafhankelijke query van dát moment, niet een getal uit het plan — de queue groeit continu. Wat je toetst is de gelijkheid van beide query's en de invariant *naïef − DISTINCT = self-messages in dezelfde verzameling*. 2. **Gate-item 3 opnieuw op het toestel.** Dat item is heropend: de GO van 2026-08-14 gold het vrije-tekstmechanisme, niet de keuzelijsten. **Geen lockstep nodig.** Het endpoint is nieuw, dus een oudere app raakt het nooit; de server mag vooruit zonder dat de TestFlight-build meteen mee moet. 🤖 Generated with [Claude Code](https://claude.com/claude-code)
Vijf bevindingen: cache-semantiek (een mislukte load liet de lijst van een andere
bron staan), een onuitvoerbaar fout-/retrycontract, de SQL-telling die alleen als
tekstfragment getest werd, een 422 bij bron Alles, en ontbrekend-veld dat gelijk
werd aan leeg-veld.

Zelf gevonden bij het meten van die SQL op de echte DB: de deelnemerlijst bevat
tien scrum4us-job:<id>-regels van de zestien. Die naamruimte valt er nu uit.

Bestanden hernoemd naar 2026-08-18; ze waren als 2026-08-14 weggeschreven.
Generatietoken in FacetsStore (een trage load mag een nieuwere niet overschrijven),
clear() zodat bron Alles geen fout van een andere bron toont, en de gate-rijen
herschreven: het orakel is de onafhankelijke DB-query op het moment zelf, niet een
getal uit dit plan. De rij die eiste dat scrum4me-server:claude ontbreekt was
feitelijk onjuist geworden — die deelnemer heeft er inmiddels 134.
Cache is nu latest-wins per sleutel (keyGeneration): een trage load kon de cache
van dezelfde sleutel terugzetten, waarna die permanent oud bleef omdat de
sessiecache per ontwerp nooit herververst.

De volgorde-test draaide op een tijdheuristiek (20ms tegen 200ms). MockURLProtocol
krijgt nu een poort die de test zelf openzet, met een annuleerbaar DispatchWorkItem
zodat stopLoading() lopend werk echt beeindigt. Same-key-test erbij.

Self-message-orakel telt nu met dezelfde naamruimtefilter als de participantquery,
en de archiefrij eist niet langer dat twee correcte verzamelingen verschillen.
Codex vond twee fouten in mijn teststeiger: de poort las een teller die de
responder pas ophoogde, en het orakel hing aan completion-volgorde waardoor het
juist de regressie zou belonen. In plaats van die steiger te repareren is het
ontwerp vereenvoudigd: per sleutel hooguit een load tegelijk, en publiceren
alleen als de sleutel nog de huidige is. Daarmee vervallen beide generatietellers
en wordt de test 'precies een verzoek'.

stopLoading laat nu ook de wachter los en gebruikt een gestopt-vlag die niet
gewist wordt, in plaats van een optional die bij cancel op nil gaat.
De dedupe uit ronde 4 had een levensduurgat: sheet sluiten (SwiftUI annuleert de
.task) en meteen heropenen liet de tweede aanroep weglopen op de eerste, die
geannuleerd eindigde zonder te publiceren. Leeg scherm, geen retry.

lopend is nu [FacetKey: Task] in plaats van een Set. Een losse Task erft geen
annulering van zijn aanroeper, dus het werk loopt door en de tweede aanroeper
publiceert het resultaat. Het ophalen zit in een eigen haal(_:) die de cache
vult; publiceren blijft achter de huidige-sleutel-guard.

Regressietest erbij die precies dat scenario naspeelt.
De same-key-test liep deterministisch vast: door de nieuwe semantiek wacht de
tweede aanroeper op de gedeelde taak, terwijl de poort pas na diens await open
ging. Mijn fout: ik veranderde de semantiek en liet de test staan.

De annuleringstest telde geen verzoeken en zou dus ook met de kapotte oude vorm
slagen. Beide tests hebben nu dezelfde vorm: twee aanroepers in een eigen Task,
een gesloten poort, en het aantal verzoeken als scheduler-onafhankelijk orakel.
Delen = 1, eigen verzoek = 2. Teller onder een lock, want de responder draait op
een achtergrondthread.
De teller stond in de responder, die pas na de poort en na de gestopt-guard
draait. Een geannuleerd verzoek telde dus niet mee, waardoor een verse tweede
fetch op precies een uitkwam en de kapotte variant groen bleef. Tellen gebeurt
nu in gestart.

Caller twee signaleert bovenaan zijn taak; tussen dat signaal en het join-punt
zit geen await, en de test staat zelf op de MainActor, dus bij hervatting staat
caller twee gegarandeerd op de join. De poort krijgt meer permits dan er
wachters kunnen zijn, zodat een variant zonder deling faalt op de telling in
plaats van vast te lopen.
Fix-ronde 1: hubConfig()!.userId en device.user_id waren in de fixture
gelijk, dus de job-scoping-assertie zou ook slagen als de route per
ongeluk op de verkeerde bron scoopt. Divergeer de fixture zodat de
assertie het verschil echt afdwingt.
Review-fix ronde 1 op Task 4: de karakteriseringstest dekte alleen de
pass-through in InboxStore, niet het risico in InboxView waar de Picker
per ongeluk .tag(o.label) i.p.v. .tag(o.value) had kunnen gebruiken —
dat zou stil een lege lijst opleveren die niet van een kapot filter te
onderscheiden is. FacetOption.keuzeTitel/keuzeWaarde maken dat testbaar
op de plek waar het risico zit.
De GO van 2026-08-14 is verdiend met vrije-tekstinvoer; onder de keuzelijsten
moet dit item opnieuw afgetekend worden. De andere acht items zijn niet geraakt.
Een lege facetlijst verstopte "geen waarden" ook als er nog een filterwaarde
actief was (bv. na de archieftoggle, die alleen showArchived omzet terwijl
InboxStore.query() de filters blijft meesturen). En een niet-lege lijst zonder
de huidige waarde liet de Picker een blanco selectie tonen terwijl het filter
nog gewoon verstuurd werd. Beide gefixt: "geen waarden" verschijnt alleen als
er ook geen waarde gekozen is, en een gekozen waarde die niet in de lijst
staat krijgt een eigen rij zodat hij zichtbaar en met één tik wisbaar blijft.

Spec §5/§6 en plan Task 5 Step 5 bijgewerkt naar dit gedrag.
- facets-server.ts: comment legt nu het invariant vast (verschil naïef vs.
  DISTINCT = self-messages binnen dezelfde verzameling) i.p.v. een absolute
  meting die al binnen een dag verouderd was; 2026-08-18-cijfers blijven
  staan als expliciet niet-bindende illustratie.
- inbox-route.test.ts: hubConfig().userId is nu 'andere-gebruiker' i.p.v.
  'u1', zodat de job-scoping-assertie ook zou falen als de route per
  ongeluk hubConfig()!.userId zou lezen i.p.v. ctx.device.user_id — zelfde
  fixture-vorm als facets-route.test.ts:32.
- inbox/facets/route.ts: guardrail-comment citeert nu "spec §3.5" i.p.v.
  "docs/runbooks/hub-device-signature.md §4", gelijk aan de twee bestaande
  device-signed routes, zodat de comment grep-baar blijft.
- InboxFacetsTests.swift: de gedeelde comment boven de twee
  gedeelde-taak-tests beweerde dat ze hetzelfde bewijzen; herschreven zodat
  hij zegt dat alleen sámen ze het ontwerp pinnen (test 1 sluit geen
  coördinatie uit, test 2 sluit coördinatie door onderdrukking uit). Ook een
  comment toegevoegd op de .tag(o.keuzeWaarde)-regel in InboxView.swift die
  de twee tests noemt waarop die extractie leunt.
docs(spec): IDEA-182 — §7 toetst de invariant, niet een momentopname
All checks were successful
CI / Lint, Typecheck, Test & Build (pull_request) Successful in 5m13s
CI / Deploy Manual (workflow_dispatch) (pull_request) Has been skipped
CI / Detect deploy-relevant changes (pull_request) Has been skipped
CI / Deploy Preview (PR) (pull_request) Has been skipped
CI / Deploy Production (main) (pull_request) Has been skipped
e91e0bb2c0
Dezelfde drift als in de code-comment: 312/403 was al 313/404. De gelijkheid van
de twee query's en het verband naief-minus-DISTINCT = self-messages zijn wat
telt; de getallen staan er nog als niet-bindende illustratie.
s4m-codex-reviewer left a comment

Verdict: APPROVED

Geen gekoppeld plan gevonden — beoordeeld op codekwaliteit + product-standaarden.

Findings

  • Geen blokkerende of error-severity findings.

Reviewnotities

De nieuwe /api/hub/inbox/facets route volgt de Hub device-signing route-helper, valideert de query met Zod en gebruikt de device-user voor job-scoping. De queue-facetquery houdt rekening met archived_at, COUNT(DISTINCT id) voor participant-tellingen en sluit de scrum4us-job namespace uit, conform de gewijzigde Hub-filterdocumentatie.

De iOS-wijziging cachet facetten per bron/archiefstand, voorkomt dat trage loads nieuwere selectie overschrijven, en houdt label en value gescheiden zodat labels niet terug naar de API gaan. De toegevoegde Vitest- en XCTest-dekking raakt de relevante regressierisico's: scoping, archiefscheiding, ontbrekende velden, cache/race-gedrag en waarde-vs-label verzending.

Docs zijn bijgewerkt met runbook/spec/plan-context en de E2E-gate is terecht heropend voor de nieuwe filterkeuzelijsten.

# Verdict: APPROVED Geen gekoppeld plan gevonden — beoordeeld op codekwaliteit + product-standaarden. ## Findings - Geen blokkerende of error-severity findings. ## Reviewnotities De nieuwe `/api/hub/inbox/facets` route volgt de Hub device-signing route-helper, valideert de query met Zod en gebruikt de device-user voor job-scoping. De queue-facetquery houdt rekening met `archived_at`, `COUNT(DISTINCT id)` voor participant-tellingen en sluit de `scrum4us-job` namespace uit, conform de gewijzigde Hub-filterdocumentatie. De iOS-wijziging cachet facetten per bron/archiefstand, voorkomt dat trage loads nieuwere selectie overschrijven, en houdt `label` en `value` gescheiden zodat labels niet terug naar de API gaan. De toegevoegde Vitest- en XCTest-dekking raakt de relevante regressierisico's: scoping, archiefscheiding, ontbrekende velden, cache/race-gedrag en waarde-vs-label verzending. Docs zijn bijgewerkt met runbook/spec/plan-context en de E2E-gate is terecht heropend voor de nieuwe filterkeuzelijsten.
Sign in to join this conversation.
No reviewers
No labels
No milestone
No project
No assignees
2 participants
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference
janpeter/Scrum4Me!176
No description provided.