Skip to content

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”

PROPOSED

ACPrimary decisionsNotes
AC-1D1 (chip-row widget choice), D2 (sphere fetch on init)Horizontal chip-row above existing type-row in search_page.dart.
AC-2D2 (Chopper fetch + client-side targetApp filter)Single fetch on init; per-page state, no Riverpod.
AC-3D3 (backend targetApp query param)Optional filter, backward-compatible.
AC-4D4 (“All” chip always includes targetApp=GYM_APP)Prevents cross-app activity leakage.
AC-5D2 + D5 (reactive type filter)Specific sphere narrows type chip-row.
AC-6D5 (auto-clear type + snackbar)Mirror of business-panel pattern from P0.5 #11.
AC-7D6 (SphereClientDto decorator fix)CLAUDE.md OpenAPI authoring rules.
AC-8D7 (test plan)Mobile widget + backend e2e.

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 existing and(...) — 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 in activities.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_app accidentally 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_APP activities.
  • 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:

  1. Recompute visible type chips.
  2. If _selectedType is not in new sphere’s allowedActivityTypessetState(() => _selectedType = null).
  3. Show snackbar (ScaffoldMessenger) with message via i18n key search#sphere_type_cleared (new key — gym_app search keys use the search#<name> convention, e.g. search#filter_all, search#contributor_match_chip; NOT dot-namespaced gym.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 — add Keys to sphere chips).
  • apps/api-e2e/src/activities/activities-client-target-app-filter.spec.ts — backend e2e for targetApp query 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.


  • tickets_app sphere 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).

  • 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.dart against the same SearchPage). Reuse its harness.
  • sortOrder: integer change ripples — Dart int vs double. Verify mobile callsite using sortOrder doesn’t break on type narrowing. (Existing usage in P0.5 #11 — availableSpheres().sort((a, b) => a.sortOrder.compareTo(b.sortOrder)) works for both int and double.)

  1. Edit SphereClientDto decorators (D6) + FindActivitiesClientDto.targetApp (D3).
  2. Run pnpm run sync:contracts. Diff client.openapi.yaml for both changes.
  3. Add backend service-layer filter in activities.service.ts.
  4. Run backend e2e: pnpm exec jest --testPathPattern=activities-client-target-app-filter → AC-8.3 + AC-8.4 GREEN.
  5. Regenerate mobile client: melos run sync:spec && melos run generate:api.
  6. Edit apps/gym_app/lib/pages/home/search_page.dart — add chip-row + reactive type filtering.
  7. Run widget tests: flutter test integration_test/sphere_filter_test.dart → AC-8.1 + AC-8.2 GREEN.
  8. Manual smoke: boot api + gym_app on simulator, open search, verify chip-row + filter behavior.

STATUS: READY_FOR_REVIEW