Skip to content

U2 — Clubs: Adversarial UI/UX Audit

Status: Archived Date: 2026-07-11 Scope: packages/features/clubs/src/** (directory/discovery, MyClubCard v3, club-detail tabs, settings, create-club wizard, member management, join-request/guest-application admin UI, dues). Method: direct code read + grep sweep + cross-reference against docs/canon/clubs.md, docs/canon/components.md, docs/canon/data-and-hooks.md, and this repo's own recent fix commits. No code modified.


Executive-severity ranking

#FindingSeverity
1Club join-request approval has NO admin UI anywhere in the appCRITICAL (P0)
2회비 "부분납부" (partial payment) amount is entered, accepted, then silently discardedCRITICAL (P0)
3Member list + club ranking rows are dead-end (no tap-to-profile)HIGH
4localeCompare sorts reintroduce the exact ICU freeze pattern fixed yesterday elsewhereMEDIUM-HIGH (latent)
5use-club-attendance-summary sorts with localeCompare(x, 'ko') (the literal fixed anti-pattern)MEDIUM
6Transfer-ownership modal can show raw truncated userIds + untranslated role enumMEDIUM
7docs/canon/clubs.md ZONE 3 description is stale vs. shipped ClubScheduleBlockLOW (docs)
8Attendance rows: flagged (tappable) vs non-flagged (inert) look visually identicalLOW
9AdminSnapshotStrip 5-cell / 3-per-row layout stretches the last row unevenlyLOW (cosmetic)
10chipGroups.play computed in club-card-model.ts but never rendered by either cardLOW (dead code)
11Known/tracked style debt (ratchet-waived) still spans nearly the whole wizard + several screensINFO (already tracked)

1. CRITICAL — Club join-request approval has no admin UI (dead pipeline)

Evidence:

  • Prospective members CAN submit an application: public-club-profile-screen.tsx:611-669 — an inline "application panel" (StyledInput + submit) wired to useMyJoinRequest / an apply mutation, gated by club.joinPolicy === 'approval'.
  • The admin-attention strip counts them: admin-snapshot-cells.ts:71-76 — cell pendingJoinRequests reads snapshot.pendingJoinRequestsCount and routes to routes.clubMembers(clubId).
  • club-members-screen.tsx (read in full, 618 lines) has zero join-request UI — no pending-request section, no approve/reject action, no badge. It only manages already-active members (role change / remove via kebab menu).
  • The query hook that would back a review screen exists and is even doc-commented for this purpose but is never imported by any screen: packages/app/src/presentation/hooks/queries/use-club-join-requests.ts:8-9 ("Intended for the admin review screen (Phase 4 chunk 4/5)"), exported at packages/app/src/index.ts:579. grep -rl useClubJoinRequests packages/features apps → 0 hits.
  • The matching mutation is even further orphaned: packages/app/src/presentation/hooks/mutations/use-decide-join-request.ts exists (approve/reject action) but is not exported from @twomore/app's public index at all, and has zero consumers anywhere in the repo.

Impact: for any club with joinPolicy = 'approval', a prospective member's application goes into a black hole. The admin sees "가입 신청 N건" highlighted in red on the AdminTab snapshot strip, taps it, lands on the Members screen, and there is nothing there to act on. There is currently no way — anywhere in the product — for an owner/admin to approve or reject a join request. This is exactly the "half-built screen, dead-end text" failure pattern flagged as a standing owner complaint in CLAUDE.md ("no save or complete button").

Fix shape: build the admin review surface (pending-requests section on ClubMembersScreen, or a dedicated routes.clubJoinRequests) wired to the already-built useClubJoinRequests + useDecideJoinRequest; export the mutation from @twomore/app/index.ts; update admin-snapshot-cells.ts's route comment once the real destination exists.


2. CRITICAL — 회비 partial-payment amount is entered then silently discarded

Evidence:

  • dues/dues-action-modals.tsx:95-138DuesPartialPaymentModal collects a real numeric amount from the admin (StyledInput keyboardType="number-pad"), parses it, and calls onSubmit(parsed).
  • club-dues-screen.tsx:183-196:
    ts
    function handlePartialSubmit(_amount: number): void {
      if (!actionTarget || !userId) return;
      updateDuesStatus.mutate({
        duesId: actionTarget.id,
        clubId,
        year,
        month,
        status: 'partial',
        // amount is stored in the dues record; we pass a note as fallback
        note: actionTarget.note ?? null,
        clearedByUserId: userId,
      });
      setActionTarget(null);
    }
    The parameter is literally underscore-prefixed (_amount) — the entered value is never referenced again. The comment claims "amount is stored in the dues record" but that is false: UpdateDuesStatusInput (packages/app/src/domain/entities/dues.entity.ts:51-63) has no amount field at all, and note is set to the row's existing note, not a new one capturing the entered amount.
  • Downstream, dues/dues-row.tsx:62 always renders formatAmount(dues.amount) — the club's full monthly due — for a "partial" row; there is no field anywhere to hold or display how much was actually paid.
  • dues/dues-summary-card.tsx:17-21 sums partialSum = partialItems.reduce((acc, d) => acc + d.amount, 0) — i.e. the aggregate "부분납부" bucket totals the FULL amount for every partially-paid member, not the actual outstanding/received split.

Impact: the 총무 opens a numeric keypad, types a real partial-payment figure, taps confirm — the UI behaves as if it worked (modal closes, row flips to "부분납부" badge) — and the number is thrown away with no error, toast, or trace. The entire partial-payment feature is decorative: it can never answer "how much is still owed by this partially-paid member," which is the entire point of a partial-payment status. This is a silent data-loss bug in money-handling UI, the highest-stakes category in the app.

Fix shape: either (a) add a paidAmount/amountPaid field to the dues table + UpdateDuesStatusInput, thread it through handlePartialSubmitDuesRow (show "₩10,000 / ₩30,000") → SummaryCard (sum actual paid, not face amount), or (b) if partial tracking is intentionally out of scope for now, remove the numeric-amount UI and make "부분납부" a pure status flag (no fake precision prompt).


3. HIGH — Member list & club-ranking rows are dead ends (no tap-to-profile)

Evidence:

  • club-members-screen.tsx:144-177 (MemberRow) — a bare <XStack><Card>…</Card>{kebab}</XStack>. The Card is not wrapped in Pressable at all; only the kebab Button (admin-only, hidden for isCurrentUser) is interactive. A plain member (the vast majority of rows) has literally nothing tappable.
  • club-detail/ranking-tab.tsx:58-103 (renderItem for the ELO leaderboard) — same shape: a Card inside a YStack, no Pressable, no onPress.
  • grep -rn "publicProfile\|profile(" packages/features/clubs/src0 hits in the entire package. No club surface links a member/leaderboard row to their public profile.

Canon violation: docs/canon/components.md — "Backend-backed data never renders as dead text… a player name → public profile… renders as a deep link via the entity's canonical clickable component." Every row here has a real userId (a backing entity with its own profile screen elsewhere in the app — e.g. records/leaderboard surfaces do link out), but neither the member-management list nor the in-club leaderboard does.

Impact: in a 30–100-member club (explicitly an audit edge case), an admin reviewing the roster or a member checking the club leaderboard cannot tap a name to see who that person is beyond a nickname — a basic, expected social affordance is simply absent on the two surfaces most likely to prompt "who is that?".

Fix shape: wrap MemberRow's Card in Pressable variant="card"routes.publicProfile(userId) (kebab keeps its own stopPropagation, per the multi-target-card contract in docs/canon/components.md); same for the ranking-tab row.


4. MEDIUM-HIGH (latent) — localeCompare sorts reintroduce the ICU freeze pattern fixed yesterday

Evidence:

  • git log shows commit 030420b3 ("perf(app): remove ICU localeCompare sort — the real 3-4s directory freeze", 2026-07-10, i.e. the day before this audit) fixed the exact same class of bug in venue-directory-content.tsx: .localeCompare('ko') on ~1,875 rows blocked the JS thread 3–4s because Hermes routes any localeCompare call through ICU (~1000× a plain compare), and the sort re-ran on every render/nav-chip tap.
  • The identical pattern is live, today, in the clubs discovery sort path:
    • shared/club-card-model.ts:396,399sortClubCardModelsForDiscovery (used by DiscoverTab in club-list-screen.tsx:76-88, recomputed via useMemo on every accumulated infinite-scroll page) ties-break with a.nextSessionSortKey.localeCompare(b.nextSessionSortKey) and [a.regionText, a.title].join('|').localeCompare(...).
    • shared/club-card-summary.ts:30,55,59buildNextSessionByClub/sortClubsByNextSessionAndRegion (used by the "내 클럽" tab) sort ISO-ish date-time strings and region/name strings with localeCompare.
  • These are all comparing ISO-8601-shaped strings (YYYY-MM-DDTHH:MM) or region|district|name composites — values that sort correctly with a plain </> compare and need zero locale awareness. This is the same redundancy the venue fix called out ("the adapter already .order('name')s… drop the sort entirely" / "tie-break with a PLAIN string compare, never localeCompare").
  • Discovery pages 20 clubs at a time (DISCOVER_CLUBS_PAGE_SIZE = 20, use-discover-clubs.ts:31) and accumulates via infinite scroll — as the platform grows (this is an active club-growth engineering priority per recent commits), a popular region's discovery feed will hit the same three-figure row count that caused the venue-directory freeze.

Impact today: likely sub-perceptible with current club counts (dozens, not thousands). Impact at scale: the same multi-second JS-thread block that was just fixed in the sibling surface, reappearing in the flagship growth surface (club discovery) — worth pre-empting rather than rediscovering via telemetry later.

Fix shape: replace all 5 call sites with plain string compares (a < b ? -1 : a > b ? 1 : 0), mirroring buildRows's fix in venue-directory-content.tsx:116-118.


5. MEDIUM — use-club-attendance-summary.ts uses localeCompare(x, 'ko') (the literal fixed anti-pattern)

Evidence: packages/app/src/presentation/hooks/queries/use-club-attendance-summary.ts:78

ts
return a.displayName.localeCompare(b.displayName, 'ko');

This is the tie-break for the 총무 attendance dashboard (club-attendance-screen.tsx, in scope), consumed via useClubAttendanceSummary with @freshness frequent (staleTime frequent → recomputes often). This is exactly the localeCompare('ko') shape the venue-directory postmortem comment explicitly warns against verbatim ("never localeCompare('ko') — Hermes routes that through ICU"). Member counts here are typically smaller than the venue directory's 1,875, so the absolute freeze is less dramatic, but it is the same anti-pattern, in a hook whose docstring literally asks for "near-realtime feedback," on a screen this audit was asked to check for 100-member-list perf specifically.

Fix shape: plain string compare (Korean display names still sort byte-wise "close enough" for a tie-break after flagged/strikes already dominate the sort — exactness of Korean collation order is not decision-critical here).


6. MEDIUM — Transfer-ownership modal can show raw truncated userIds + untranslated role text

Evidence:

  • club-settings-screen.tsx:125-134memberNameMap is built only from member.nickname ("useClubMembers only returns role/nickname… the lightest source available"). A member who never set a nickname has no entry.
  • transfer-ownership-modal.tsx:49-54getDisplayName falls back to member.userId.slice(0, 8) when the map misses, i.e. a raw hex fragment.
  • transfer-ownership-modal.tsx:132-135 — the role column renders {member.role} directly with textTransform="capitalize" (raw enum: owner/admin/match_director/member), not the Korean labels the sibling club-members-screen.tsx:getRoleLabel already defines for the same enum. textTransform="capitalize" on match_director renders "Match_director" — neither Korean nor properly cased English.

Impact: ownership transfer is a one-way, high-stakes action (style: 'destructive', confirmed via useConfirm). If the picker shows a1b2c3d4 · Match_director instead of a real name and Korean role label, the owner is confirming an irreversible action against an identifier they can't verify is the right person — the exact opposite of what a confirmation dialog for a destructive action should guarantee.

Fix shape: source getDisplayName from useClubProfiles's displayName (already fetched elsewhere on the settings screen's sibling surfaces) instead of the nickname-only stub map; route member.role through the existing getRoleLabel helper (worth hoisting to a shared location since two files now want it).


7. LOW (docs) — docs/canon/clubs.md ZONE 3 description is stale vs. the shipped ClubScheduleBlock

Evidence: canon doc (docs/canon/clubs.md:10) describes ZONE 3 as "a numbered week strip where active cells carry the meet NUMBER in ONE brand tone." The shipped component (shared/club-schedule-block.tsx:1-19) is a different, newer design: a flat 7-cell strip with no numbers, a three-state monochromatic ladder (off/regular/next) and a bottom-center pip for the "next" cell — the component's own docstring says it "Replaces the old titled '정기 일정' block… day-time and venue detail now live on the card's chip strip instead." The canon doc was not updated when this component shipped.

Impact: low (doesn't cause a bug), but violates the project's own "single source of truth" documentation discipline and will mislead the next agent/engineer who reads canon before code.

Fix shape: rewrite the ZONE 3 paragraph in docs/canon/clubs.md to match the current pip/ladder design; drop the "numbered week strip" language.


8. LOW — Attendance rows: flagged (tappable) vs non-flagged (inert) are visually identical

Evidence: club-attendance-screen.tsx:192-198<Pressable variant="card" onPress={member.isFlagged ? onPress : undefined} …>. Pressable's inert path (packages/ui/src/pressable.tsx:105) triggers whenever !props.onPress, which skips press-feedback — but isOff (pressable.tsx:135, drives the 0.5-opacity dim) only checks disabled/loading, not the missing-onPress case. So a non-flagged row renders at full opacity with no dim and no press feedback, visually indistinguishable from a flagged (actually tappable) row except for the small warning Badge. There's also no chevron/affordance hint on flagged rows to signal "tap to review" beyond the badge itself.

Impact: minor discoverability gap — a 총무 might not realize flagged rows are tappable (no visual cue signals interactivity), and might tap a non-flagged row expecting something to happen.

Fix shape: either dim non-flagged rows explicitly, or add a trailing chevron only on flagged rows (mirrors the AdminNavRow / DuesRow trailing-icon convention used elsewhere in this same package).


9. LOW (cosmetic) — AdminSnapshotStrip 5-cell / 3-per-row layout stretches unevenly

Evidence: admin-snapshot-strip.tsx:10,23-26CELLS_PER_ROW = 3 chunks 5 cells into rows of [3, 2]; each cell is flex={1} within its own row, so the 2 cells in row 2 render visibly wider than the 3 cells in row 1. Minor visual inconsistency on a strip that's otherwise a clean, well-built component (good highlight/route/non-pressable handling).

Fix shape: either a 5-up single row with smaller cells, or pad row 2 with an invisible flex spacer to match row-1 cell width.


10. LOW (dead code) — chipGroups.play computed but never rendered by either card

Evidence: club-card-model.ts:184-209 (buildChipGroups) builds { play: [formatLabel, tierLabel?], people: [...] }. Both MyClubCard (my-club-card.tsx:252-260) and DiscoverClubCard (discover-club-card.tsx:189-197) render model.formatLabel directly (not chipGroups.play) plus model.chipGroups.people plus model.daytimeLabels. The only remaining consumer of chipGroups.play is public-club-profile-screen.tsx:407. This isn't wrong, just worth noting: the two canonical club-card components carry a computed-but-unused field on every render (cheap, but it's the kind of stale field that invites the next edit to "fix" the wrong array).


11. INFO — Ratchet-tracked style debt already spans most of this scope

Not a new finding — surfaced for completeness since the audit asked for a canon-compliance sweep. eslint.ratchet.config.mjs already waives, for this package: no-inline-style-with-vars (club-settings-screen, create-club-step 1/2/3/6, join-club-screen, settings/image-section, post-detail-screen), no-stylesheet-create (create-club-styles.ts — the wizard's shared style module uses raw px values, not $N tokens, for row/tile/dayChip/ chip/togglePill/bannerTile etc.), no-padding-top-in-features (14 club files), and no-raw-rn-pressable-in-features (create-club-step4.tsx). This is the widest single canon-violation surface in U2 but is already tracked and explicitly deferred ("each waivered file is fixed when next touched"), so it's listed here for completeness only — no new action implied beyond what's already on the ratchet-shrink backlog.


Going well (for balance — things checked and found correct)

  • Four-zone card composition (my-club-card.tsx, discover-club-card.tsx) matches canon closely: banner hero + members cluster overhang, identity row, schedule block, canonical chip strip, SessionCompactRow-based next session, admin row as last zone. Archived-club 55% opacity muting present.
  • ClubAdminRow (R3 owner-QA'd) correctly nests its own Pressable distinct from the card's primary tap, uses the canonical warning intent pair, and is a clean example of the multi-target-card contract done right.
  • AdminTab/AdminSnapshotStrip/buildAdminSnapshotCells are well factored: pure-function cell builder unit-tested separately from the Tamagui render, correct role-gating (canManageClub matches the RPC's own authorization), honest route: null handling for the not-yet-built 신고 review surface (self-aware about its own gap — contrast with the join-request cell, finding #1, which is NOT self-aware about its gap).
  • SegmentedTabs usage on ClubDetailScreen/ClubListScreen is correct: all hook-bearing panes wrapped in React.memo, renderTab properly useCallback'd with complete dep arrays; preload="visited" is safe here because SegmentedTabs never unmounts a visited pane (verified by reading packages/ui/src/segmented-tabs.tsx — panes persist via display:none, not remount), so no hook-remount-on-tab-switch bug exists despite one canon sentence reading ambiguously "ALWAYS preload='all'" for hook-bearing tabs (worth a canon-doc clarification, not a code fix).
  • Protocol A (bulk-fetch) discipline is consistently applied: club-detail/home-tab.tsx, club-sessions-screen.tsx, club-challenges-screen.tsx (explicit comment citing Protocol A) all batch RSVP/payment/weather/club lookups instead of N per-row hooks.
  • Archived-club and non-member gating: ClubDetailScreen correctly bounces non-members to the preview screen (ctx.ready gated to avoid mis-bouncing a still-loading real member); ClubStatusBanner self-hides for active clubs; ClubSettingsScreen/ClubVenuesScreen/ ClubAttendanceScreen all gate on canManageClub with a proper EmptyState (not a silent blank screen) for unauthorized viewers.
  • Destructive-action discipline: leave club, archive club, remove member, waive dues, delete venue, transfer ownership — every one goes through useConfirm with style: 'destructive', no bespoke confirm panels found in this package.
  • ClubChallengesScreen is a clean, well-scoped screen: Protocol A bulk club lookup across every challenge row, single lifted accept-modal instead of N inline CTAs, correct read-only vs. tappable card split.
  • Create-club wizard submit path (create-club-screen.tsx) correctly routes the final-step CTA through WizardShell's onPrimaryPress / primaryLoading / primaryDisabled — no missing-submit-button pattern found here (unlike the dues partial-payment bug, this flow's data actually reaches the mutation).

Adversarial probes run (explicit list)

  1. Archived club → MyClubCard opacity muting confirmed; ClubStatusBanner confirmed on detail screen; did NOT verify session-creation is actually blocked server-side for archived clubs (out of UI scope, flag for B1).
  2. 1-member club → hasMembersCluster/hasCapacity logic in card model degrades gracefully (renders ring/avatar only when data present); no division-by-zero found (capacityRatio guards maxMembers > 0).
  3. 100-member list perf → traced to two ICU localeCompare sites (findings #4/#5); did not find virtualization gaps (GroupedFeedList/FeedList used correctly throughout).
  4. Pending-join spam → confirmed there is no UI to even see the list. no spam-specific defense evaluated since the base flow doesn't exist.
  5. Role changes mid-view → useClubRole/ctx.role are query-cache-backed (TanStack), so a role change elsewhere invalidates and re-renders the admin gates; did not find a stale-role-cache bug in this package.
  6. Kebab-menu double-fire on nested pressables → checked MemberRow, DuesRow, ClubAdminRow — all either have no card wrapper (finding #3) or correctly use a sibling Button, not a nested one inside the same Pressable, so no double-nav risk found (the flip side of finding #3: the missing wrapper avoids the double-fire bug but at the cost of zero navigation).

Canon violations summary

  • components.md "Backend-backed data never renders as dead text" — member rows + ranking rows (#3).
  • components.md "one contextual primary action / no half-built screens" spirit (via CLAUDE.md's standing owner complaint) — join-request review (#1), partial-payment amount (#2).
  • clubs.md ZONE 3 doc accuracy — stale vs. shipped component (#7).
  • data-and-hooks.md ICU/localeCompare lesson (implicit, from the sibling fix committed 2026-07-10) — 5 call sites across 2 files (#4, #5).
  • Everything else audited (Card tones, Pressable primitive usage, XxxStatusBadge wrappers, FeedList/GroupedFeedList usage, Protocol A, useConfirm for destructive actions, ModalPanel archetypes) was found compliant across this scope.

Improvement candidates (design-spec seeds for the synthesis pass)

  1. Join-request admin review screen — new screen or Members-screen section: pending list (name, message, requested-at) with approve/reject row actions via useDecideJoinRequest (needs export) + useClubJoinRequests. Likely fits the existing "collapsed section, tap to expand" pattern already used for the 멤버 group on the same screen.
  2. Dues partial-amount schema — either add paidAmount end-to-end, or downgrade the UI to a flag-only status (remove the numeric prompt) until the schema work lands. Needs a migration + entity + mapper + 3 UI sites (row, summary card, modal) — an feedback_entity_migration_checklist.md- shaped unit of work.
  3. Member/ranking row → profile link — wrap in Pressable variant="card", route to the existing public-profile screen; trivial, ≤2 files.
  4. ICU sort removal — mechanical, 5 call sites, 2 files (club-card-model.ts, club-card-summary.ts) + 1 file outside this package (use-club-attendance-summary.ts) — same shape as the venue fix, safe to batch as one small PR.

Markdown remains the source of truth. Run yarn docs:check before handoff.