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
| # | Finding | Severity |
|---|---|---|
| 1 | Club join-request approval has NO admin UI anywhere in the app | CRITICAL (P0) |
| 2 | 회비 "부분납부" (partial payment) amount is entered, accepted, then silently discarded | CRITICAL (P0) |
| 3 | Member list + club ranking rows are dead-end (no tap-to-profile) | HIGH |
| 4 | localeCompare sorts reintroduce the exact ICU freeze pattern fixed yesterday elsewhere | MEDIUM-HIGH (latent) |
| 5 | use-club-attendance-summary sorts with localeCompare(x, 'ko') (the literal fixed anti-pattern) | MEDIUM |
| 6 | Transfer-ownership modal can show raw truncated userIds + untranslated role enum | MEDIUM |
| 7 | docs/canon/clubs.md ZONE 3 description is stale vs. shipped ClubScheduleBlock | LOW (docs) |
| 8 | Attendance rows: flagged (tappable) vs non-flagged (inert) look visually identical | LOW |
| 9 | AdminSnapshotStrip 5-cell / 3-per-row layout stretches the last row unevenly | LOW (cosmetic) |
| 10 | chipGroups.play computed in club-card-model.ts but never rendered by either card | LOW (dead code) |
| 11 | Known/tracked style debt (ratchet-waived) still spans nearly the whole wizard + several screens | INFO (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 touseMyJoinRequest/ an apply mutation, gated byclub.joinPolicy === 'approval'. - The admin-attention strip counts them:
admin-snapshot-cells.ts:71-76— cellpendingJoinRequestsreadssnapshot.pendingJoinRequestsCountand routes toroutes.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 atpackages/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.tsexists (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-138—DuesPartialPaymentModalcollects a real numeric amount from the admin (StyledInput keyboardType="number-pad"), parses it, and callsonSubmit(parsed).club-dues-screen.tsx:183-196:tsThe parameter is literally underscore-prefixed (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); }_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 noamountfield at all, andnoteis set to the row's existing note, not a new one capturing the entered amount.- Downstream,
dues/dues-row.tsx:62always rendersformatAmount(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-21sumspartialSum = 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 handlePartialSubmit → DuesRow (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>. TheCardis not wrapped inPressableat all; only the kebabButton(admin-only, hidden forisCurrentUser) is interactive. A plain member (the vast majority of rows) has literally nothing tappable.club-detail/ranking-tab.tsx:58-103(renderItemfor the ELO leaderboard) — same shape: aCardinside aYStack, noPressable, noonPress.grep -rn "publicProfile\|profile(" packages/features/clubs/src→ 0 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 logshows commit030420b3("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 invenue-directory-content.tsx:.localeCompare('ko')on ~1,875 rows blocked the JS thread 3–4s because Hermes routes anylocaleComparecall 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,399—sortClubCardModelsForDiscovery(used byDiscoverTabinclub-list-screen.tsx:76-88, recomputed viauseMemoon every accumulated infinite-scroll page) ties-break witha.nextSessionSortKey.localeCompare(b.nextSessionSortKey)and[a.regionText, a.title].join('|').localeCompare(...).shared/club-card-summary.ts:30,55,59—buildNextSessionByClub/sortClubsByNextSessionAndRegion(used by the "내 클럽" tab) sort ISO-ish date-time strings and region/name strings withlocaleCompare.
- These are all comparing ISO-8601-shaped strings (
YYYY-MM-DDTHH:MM) orregion|district|namecomposites — 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, neverlocaleCompare"). - 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
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-134—memberNameMapis built only frommember.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-54—getDisplayNamefalls back tomember.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 withtextTransform="capitalize"(raw enum:owner/admin/match_director/member), not the Korean labels the siblingclub-members-screen.tsx:getRoleLabelalready defines for the same enum.textTransform="capitalize"onmatch_directorrenders"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-26 — CELLS_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 ownPressabledistinct 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/buildAdminSnapshotCellsare well factored: pure-function cell builder unit-tested separately from the Tamagui render, correct role-gating (canManageClubmatches the RPC's own authorization), honestroute: nullhandling 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).SegmentedTabsusage onClubDetailScreen/ClubListScreenis correct: all hook-bearing panes wrapped inReact.memo,renderTabproperlyuseCallback'd with complete dep arrays;preload="visited"is safe here becauseSegmentedTabsnever unmounts a visited pane (verified by readingpackages/ui/src/segmented-tabs.tsx— panes persist viadisplay: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:
ClubDetailScreencorrectly bounces non-members to the preview screen (ctx.readygated to avoid mis-bouncing a still-loading real member);ClubStatusBannerself-hides for active clubs;ClubSettingsScreen/ClubVenuesScreen/ClubAttendanceScreenall gate oncanManageClubwith a properEmptyState(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
useConfirmwithstyle: 'destructive', no bespoke confirm panels found in this package. ClubChallengesScreenis 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 throughWizardShell'sonPrimaryPress/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)
- Archived club →
MyClubCardopacity muting confirmed;ClubStatusBannerconfirmed on detail screen; did NOT verify session-creation is actually blocked server-side for archived clubs (out of UI scope, flag for B1). - 1-member club →
hasMembersCluster/hasCapacitylogic in card model degrades gracefully (renders ring/avatar only when data present); no division-by-zero found (capacityRatioguardsmaxMembers > 0). - 100-member list perf → traced to two ICU
localeComparesites (findings #4/#5); did not find virtualization gaps (GroupedFeedList/FeedListused correctly throughout). - 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.
- Role changes mid-view →
useClubRole/ctx.roleare 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. - Kebab-menu double-fire on nested pressables → checked
MemberRow,DuesRow,ClubAdminRow— all either have no card wrapper (finding #3) or correctly use a siblingButton, not a nested one inside the samePressable, 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,
XxxStatusBadgewrappers,FeedList/GroupedFeedListusage, Protocol A,useConfirmfor destructive actions,ModalPanelarchetypes) was found compliant across this scope.
Improvement candidates (design-spec seeds for the synthesis pass)
- 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. - Dues partial-amount schema — either add
paidAmountend-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) — anfeedback_entity_migration_checklist.md- shaped unit of work. - Member/ranking row → profile link — wrap in
Pressable variant="card", route to the existing public-profile screen; trivial, ≤2 files. - 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.