ADR: Sphere Filter Chip-Row in gym_app Search
ADR: Sphere Filter Chip-Row in gym_app Search
Section titled “ADR: Sphere Filter Chip-Row in gym_app Search”Status
Section titled “Status”PROPOSED
AC ↔ D# mapping
Section titled “AC ↔ D# mapping”| AC | Primary decisions | Notes |
|---|---|---|
| AC-1 | D1 (chip-row widget choice), D2 (sphere fetch on init) | Horizontal chip-row above existing type-row in search_page.dart. |
| AC-2 | D2 (Chopper fetch + client-side targetApp filter) | Single fetch on init; per-page state, no Riverpod. |
| AC-3 | D3 (backend targetApp query param) | Optional filter, backward-compatible. |
| AC-4 | D4 (“All” chip always includes targetApp=GYM_APP) | Prevents cross-app activity leakage. |
| AC-5 | D2 + D5 (reactive type filter) | Specific sphere narrows type chip-row. |
| AC-6 | D5 (auto-clear type + snackbar) | Mirror of business-panel pattern from P0.5 #11. |
| AC-7 | D6 (SphereClientDto decorator fix) | CLAUDE.md OpenAPI authoring rules. |
| AC-8 | D7 (test plan) | Mobile widget + backend e2e. |
Decisions
Section titled “Decisions”D1 — Chip-row widget (Wrap / horizontal scroll), styled like existing type chips
Section titled “D1 — Chip-row widget (Wrap / horizontal scroll), styled like existing type chips”Decision: mirror the existing type-chip implementation in search_page.dart. Reuse the page-local _FilterChip widget (GestureDetector + Container, search_page.dart:396-435) with the same theme. Place sphere chip-row as a new horizontal ListView row ABOVE the existing filter ListView (currently a single 44px-high row inside AppBar.bottom PreferredSize — height will grow from 104 to ~152 to fit a second chip row).
Rationale: visual consistency. The user pattern “pick a chip, results filter” is already established with type chips — sphere chip-row is one more row above it. No new widget vocabulary.
Rejected alternatives: dropdown (less discoverable than chips on mobile), radio (verbose), bottom-sheet (extra tap). Chip-row is the established sphere-selection idiom on tktspace-web (explore.page.ts:114-150 area).
D2 — Single fetch on initState, page-local cache, plain setState
Section titled “D2 — Single fetch on initState, page-local cache, plain setState”Decision: in search_page.dart initState, fire one call to Api.invoke((s) => s.apiClientSpheresGet()) (Chopper-generated method for operationId spheresClientList, returns List<SphereClientDto> — verified in packages/api/lib/src/generated/swagger_api.swagger.dart:2270). Store result in a List<SphereClientDto> _availableSpheres instance field. Filter client-side by targetApp == 'GYM_APP', sort by sortOrder before storing.
State management: plain setState, matching every other filter on this page (verified — _SearchPageState is a plain StatefulWidget with fields _selectedType, _q, _contributorUserId, _maxAgeRating; no Riverpod / Bloc).
Rationale:
- 5 spheres is small. One fetch per page-open is cheap.
- No need for global state — search_page is short-lived, sphere data is static enough.
- Matches existing pattern — no scope creep into state-management refactor.
D3 — Backend targetApp query parameter on activitiesClientList
Section titled “D3 — Backend targetApp query parameter on activitiesClientList”Decision: add optional targetApp field to FindActivitiesClientDto (libs/features/activities/src/lib/dto/find-activities-client.dto.ts — NOT ActivitiesClientListFiltersDto, which does not exist). The endpoint is GET /api/client/activities, operationId activitiesClientList, implemented in activities-global-client.controller.ts (the company-scoped activities-client.controller.ts shares the same DTO via findAll and inherits the filter for free). No controller change needed — Nest reads @Query() dto from the DTO, same pattern as maxAgeRating:
@ApiPropertyOptional({ enum: SphereTargetAppEnum.enumValues, enumName: 'SphereTargetApp' })@IsOptional()@IsIn(SphereTargetAppEnum.enumValues)targetApp?: string;(SphereTargetAppEnum from @tktspace/shared/data-access-db, activities.schema.ts:26 — Drizzle pg enum with .enumValues = ['GYM_APP','TICKETS_APP','DINING_APP'].)
In service layer (activities-client.service.ts, where = and(...) block around line 101-127 — add alongside the dto.sphereId arm at line 111), if present, narrow query via subquery:
if (filters.targetApp) { query = query.where(inArray( activities.sphereId, db.select({ id: spheres.id }).from(spheres).where(eq(spheres.targetApp, filters.targetApp)) ));}Rationale:
- Backward-compatible: omitted = no filtering (old clients work). Existing filters (
q,search,contributorUserId,type,categoryId,sphereId,minPrice/maxPrice,maxAgeRating,sortBy) compose via the existingand(...)— no interaction changes. - Filters at SQL level — not client-side (avoids over-fetching).
- Performance: sphere count is 5, subquery is O(1) per request. No index exists on
spheres.target_app(verified inactivities.schema.ts:75-89) and none is needed — table has ≤5 rows; seq scan is optimal. No migration.
Alternative rejected: infer from sphereId (require sphereId always) — breaks the “All” chip behavior; we want broad multi-sphere queries scoped to a target app.
D4 — “All” chip always sends targetApp=GYM_APP
Section titled “D4 — “All” chip always sends targetApp=GYM_APP”Decision: in gym_app, the “All” chip is NOT a no-filter — it sends targetApp=GYM_APP. Specific sphere chips send both targetApp=GYM_APP AND sphereId=<id>.
Rationale:
- Prevents cross-app activity leakage (e.g. a CINEMA activity created in business panel and mis-tagged with
target_appaccidentally pointing to GYM_APP — but more importantly, a TICKETS_APP-targeted sphere would never leak in). - “All” semantics in gym_app context = “all gym-related” =
targetApp=GYM_APPactivities. - App-scoping is the established platform pattern.
D5 — Reactive type filter + auto-clear on incompatible
Section titled “D5 — Reactive type filter + auto-clear on incompatible”Decision: when sphere selection changes, derive visible activity-type chips from selectedSphere?.allowedActivityTypes (or all known activity types when “All” is selected). When sphere changes:
- Recompute visible type chips.
- If
_selectedTypeis not in new sphere’sallowedActivityTypes→setState(() => _selectedType = null). - Show snackbar (
ScaffoldMessenger) with message via i18n keysearch#sphere_type_cleared(new key — gym_app search keys use thesearch#<name>convention, e.g.search#filter_all,search#contributor_match_chip; NOT dot-namespacedgym.search.*as the spec sketched).
Type chips today are hard-coded in _typeOptions (search_page.dart:21-27, all 5 ActivityType values). D5 derives the visible subset from selectedSphere?.allowedActivityTypes (mapping enum wire values to the existing ActivityType Dart enum); “All” sphere → full _typeOptions.
Rationale: mirror of the business-panel pattern from P0.5 #11 (D5 in that ADR). User experience consistent across surfaces. Auto-clear prevents the “selected option is no longer visible but still bound” confusion.
Alternative rejected: pre-fill from sphere.defaultActivityType — silent pre-fill risks unnoticed wrong type (mirrors decision from P0.5 #11 D5 — same rationale).
D6 — SphereClientDto decorator fix (CLAUDE.md authoring rules)
Section titled “D6 — SphereClientDto decorator fix (CLAUDE.md authoring rules)”Decision: verified current state of libs/features/spheres/src/lib/dto/sphere.dto.ts (lines 16-30): allowedActivityTypes ALREADY carries enumName: 'ActivityType' (landed in P3 #10). Only two decorators remain non-compliant:
// targetApp — currently bare @ApiProperty():@ApiProperty({ enum: SphereTargetAppEnum.enumValues, enumName: 'SphereTargetApp', example: 'GYM_APP' })targetApp!: string;
// sortOrder — currently bare @ApiProperty():@ApiProperty({ type: 'integer', example: 0 })sortOrder!: number;sortOrder currently emits as number → Dart double (verified: generated SphereClientDto.targetApp is plain String, no enum). type: 'integer' corrects to Dart int. While here, the sphere mutation DTOs (CreateSphereDto/UpdateSphereDto) use inline ['GYM_APP', 'TICKETS_APP', 'DINING_APP'] literals without enumName — optionally normalise to SphereTargetAppEnum.enumValues + enumName: 'SphereTargetApp' so the super-admin contract reuses the same named enum (low-risk, same file, but builder may defer if it ripples).
Rationale: CLAUDE.md OpenAPI authoring checklist explicitly requires enumName for named enums and type: integer for whole-number number fields. Fixing now since we’re touching the sphere surface for AC-3.
D7 — Test plan (mobile widget + backend e2e)
Section titled “D7 — Test plan (mobile widget + backend e2e)”Decision: two test files:
apps/gym_app/integration_test/sphere_filter_test.dart— widget tests for chip-row rendering, tap behaviors, reactive type filtering. Rich precedent exists:apps/gym_app/integration_test/already has 13+ tests incl.contributor_search_test.dart(same SearchPage, same API-mocking seams;Key('search_text_field')-style test keys are the established locator pattern — addKeys to sphere chips).apps/api-e2e/src/activities/activities-client-target-app-filter.spec.ts— backend e2e fortargetAppquery param filtering. Seeds activities under SPORT + SHOW, asserts narrowing.
Rationale: integration test on mobile captures the auto-clear UX behavior (hard to unit-test reactively). Backend e2e captures the SQL-level filter correctness.
Out of scope (explicitly deferred)
Section titled “Out of scope (explicitly deferred)”tickets_appsphere filter (TICKETS_APP target) — separate per-app ticket.- Multi-sphere selection — out of scope, single-select only.
- New sphere taxonomy (e.g. BEAUTY) — out of scope, separate ADR if business demands.
- Persistent sphere preference across sessions — page-local state only.
- Backend sphere CRUD UI (super-admin) — separate deferred ticket (P3 #9 deferred).
Risks (post-mitigation)
Section titled “Risks (post-mitigation)”- Mis-classified activity leaking into wrong chip — admin assigns wrong sphere at create time. Mitigation: this is a UX problem at admin layer (P0.5 #11 already deployed), not a data problem. No automatic semantic check on classification. Out of scope per spec Context.
- Chopper mock setup complexity — RESOLVED at validation:
apps/gym_app/integration_test/has 13+ existing tests (incl.contributor_search_test.dartagainst the same SearchPage). Reuse its harness. sortOrder: integerchange ripples — Dartintvsdouble. Verify mobile callsite usingsortOrderdoesn’t break on type narrowing. (Existing usage in P0.5 #11 —availableSpheres().sort((a, b) => a.sortOrder.compareTo(b.sortOrder))works for bothintanddouble.)
Verification
Section titled “Verification”- Edit
SphereClientDtodecorators (D6) +FindActivitiesClientDto.targetApp(D3). - Run
pnpm run sync:contracts. Diffclient.openapi.yamlfor both changes. - Add backend service-layer filter in
activities.service.ts. - Run backend e2e:
pnpm exec jest --testPathPattern=activities-client-target-app-filter→ AC-8.3 + AC-8.4 GREEN. - Regenerate mobile client:
melos run sync:spec && melos run generate:api. - Edit
apps/gym_app/lib/pages/home/search_page.dart— add chip-row + reactive type filtering. - Run widget tests:
flutter test integration_test/sphere_filter_test.dart→ AC-8.1 + AC-8.2 GREEN. - Manual smoke: boot api + gym_app on simulator, open search, verify chip-row + filter behavior.
STATUS: READY_FOR_REVIEW