Skip to content

U3 — Sessions: Adversarial UI/UX Audit

Status: Archived Date: 2026-07-11 Scope: packages/features/sessions/src/** — create-session wizard + draft-resume, session-detail two-surface IA (BottomCtaBand one-CTA rule), RSVP/waitlist, roster/participants, 정산 (session-payments-screen.tsx), attendance (session-attendance-screen.tsx), UnsettledPoolNotice, host-role surfaces (호스트 대시보드, 참가자 관리, 일정 취소, 공유/수정). Method: direct code read (session-detail-screen.tsx in full, 1576 lines; all screens listed above in full) + grep sweep for canon literals (<Badge variant=, StyleSheet.create) + cross-reference against docs/canon/sessions.md, docs/canon/status-and-recruitment.md, the payments canon in CLAUDE.md, and query-key/invalidation tracing through packages/app/src/presentation/hooks/composites/use-live-session-data.ts + packages/app/src/presentation/hooks/mutations/use-session-payment*.ts. No code modified.


Executive-severity ranking

#FindingSeverity
1Payment-hold gate UI can go stale for up to 5 min during an active live view — countdown disappears but the reserved-payment panel (with live 송금완료/checkout buttons) keeps rendering even after the backing RSVP is auto-cancelledHIGH
2Canon says "never put a CTA inside a card"; 4 screens do it anyway (self-acknowledged as intentional)MEDIUM (canon drift)
3Header status-banner payment badge can't distinguish 결제 대기 (reserved) from 확인 대기 (submitted) — both render SessionPaymentStatusBadge status="pending"LOW (copy gap)
4StyleSheet.create in 4 create-session-wizard filesINFO (already ratchet-tracked)

1. HIGH — Payment-hold gate UI goes stale mid-view; can outlive its own RSVP

Evidence:

  • packages/app/src/presentation/hooks/composites/use-live-session-data.ts:170-183 — the 10-second focus-gated poll that gives session-detail its "live" feel invalidates sessionKeys.detail, matchKeys.bySession, rsvpKeys.bySession, matchKeys.scoreCallsBySession, tournamentKeys.bySession, guestApplicationKeys.bySession. sessionPaymentKeys is not in this list.
  • packages/app/src/presentation/hooks/queries/use-session-payments.ts:26useSessionPayments (backing useMySessionPayment, which backs the screen's gateState) uses staleTime: STALE_TIME.standard = 5 minutes (packages/app/src/query-keys.ts:19). Nothing refetches it proactively while the screen stays focused; only refetchOnWindowFocus or a full 5-minute expiry will refresh it.
  • packages/features/sessions/src/session-detail-screen.tsx:195-199gateState is computed purely from myPayment?.status / myPayment?.submittedAt; it has no cross-check against myRsvp?.status.
  • session-detail-screen.tsx:1311-1374 — the "예약됨 · 참가비 결제" callout (with the 송금 완료 / dev 결제하기 buttons) renders whenever gateState === 'reserved' — full stop, regardless of whether the RSVP behind that hold is still alive.
  • Per CLAUDE.md's payment canon: the expire_unpaid_session_holds cron (every 5 min) cancels the RSVP + pre-seeds an EXCUSED attendance record when an idle hold's window elapses. rsvpKeys IS in the 10s poll, so myRsvp correctly flips to cancelled within ~10s of the cron firing — but the payment panel doesn't listen to that signal, so it keeps showing the reserved-payment card with live buttons, now referring to a hold whose RSVP is already gone, for up to ~5 more minutes (or until the user leaves and returns / pulls to refresh).
  • Same staleness cuts the other way: if the host confirms 송금 완료 receipt (useMarkPaymentPaid, which does correctly invalidate sessionPaymentInvalidationKeys, packages/app/src/presentation/hooks/mutations/use-session-payments.ts:114-119) while the paying member is sitting on session-detail, that member's own screen has no live channel to learn about it — the 10s poll doesn't touch payment keys, so their "reserved"/"submitted" card can visibly disagree with reality for minutes.

Impact: a member watching the countdown hit zero sees the timer text vanish (!holdCountdown.isPast gate at line 1319) but the surrounding "예약됨" panel + live CTAs stay exactly as they were — a dead-end/misleading state precisely of the kind CLAUDE.md's owner-complaint history calls out ("no save/complete button", stale screens). Tapping 송금 완료 on an already-expired-and-cancelled hold is a foot-gun the UI doesn't prevent or even flag.

Fix shape: add sessionPaymentKeys.bySession(sid) (and/or byUser) to the useLiveSessionData 10s invalidation list, OR gate the reserved/submitted panel's render on viewerParticipating (myRsvp?.status === 'confirmed') in addition to gateState, so an already-cancelled RSVP can never keep the payment CTA alive.


2. MEDIUM — Canon "no CTA in a card" vs. 4 shipped screens that do it

Evidence:

  • docs/canon/components.md:11 — unqualified: "Cards are display and navigation only. Never put a CTA inside a card."
  • session-detail-screen.tsx:1104-1106 narrows this in a code comment to "Button doesn't sit inside a Card here, so ActionButton is fine (COMP-3 only forbids Button-in-Card)" — i.e. the team already knows the literal rule is broader than what's mechanically enforced (@twomore/no-button-in-card only matches raw <Button> JSX, confirmed at packages/eslint-plugin/rules/no-button-in-card.js).
  • Four session screens nest ActionButton directly inside Card:
    • session-payments-screen.tsx:87-146PaymentRow: <Card tone="default" size="sm"> wrapping 납부확인/면제 ActionButtons.
    • session-attendance-screen.tsx:85-124ParticipantRow: <Card tone="default" size="sm"> wrapping 출석/결석 ActionButtons.
    • participants-screen.tsx:69-97ParticipantSection row: <Card tone="default" size="sm"> wrapping a 제외 ActionButton.
    • pickup-host-dashboard-screen.tsx:315-340 — the pending-applications <Card tone="default" size="md"> wraps every ApplicationRow, each of which has 승인/거절 ActionButtons.

Assessment: this is not drift-by-accident — it is a consistent, working, self-documented pattern across every "row of pending items with per-row mark-done actions" surface in the sessions feature, and functionally these read fine (small pill buttons, clearly scoped to their row). But it directly contradicts the literal canon sentence, is invisible to the mechanical gate, and is exactly the shape of rule the audit is meant to surface. docs/canon/components.md:12's own "≤3 nested affordances, Pressable is the canonical clickable" carve-out would suggest these rows should be Pressable-driven, not ActionButton-in-Card.

Fix shape: either (a) amend docs/canon/components.md to explicitly sanction "row-level ActionButton pairs inside a list-row Card" as a named exception (matching what's already shipped), or (b) migrate the 4 rows above to the Pressable-based nested-affordance pattern. (a) is cheaper and matches actual usage; (b) is a real behavior change across 4 files.


3. LOW — Header payment badge can't show 확인 대기 (submitted) distinctly

Evidence: session-detail-screen.tsx:1083-1096 — the SessionStatusBanner trailing slot renders <SessionPaymentStatusBadge status="pending" size="sm" /> for both gateState === 'reserved' and gateState === 'submitted'. Meanwhile the body callout further down (lines 1311-1374) DOES differentiate — "예약됨" (reserved, with 송금완료/checkout buttons) vs. "제출됨" (submitted, awaiting host confirm, no buttons). So a member who already tapped 송금 완료 still sees the same generic "결제 대기" chip at the top of the screen as before they tapped it.

Root cause: SessionPayment.status (the DB enum) is only pending|paid|waived; submitted is a purely client-derived state from submittedAt, so SessionPaymentStatusBadge (which maps the entity status) structurally can't express it without a new prop. Soft/copy gap, not a data violation — flagging for completeness since the payments canon explicitly calls out sessionPaymentGateState's 4-state taxonomy (free/reserved/ submitted/confirmed) as canonical, and the header chip erases one of the four everywhere it's used.


4. INFO — Ratchet-tracked StyleSheet.create (not new)

create-session-screen.tsx:459, create-session-fields.tsx:44, create-session-step3.tsx:383, create-session-step4.tsx:305 all use StyleSheet.create, violating STYLE-1 literally — but all 4 are already listed in eslint.ratchet.config.mjs (lines ~22-100) with a documented rationale (RN-native Pressable/SafeAreaView/ScrollView primitives that Tamagui can't style-extract). Confirmed real, confirmed already tracked — not a new finding, no action needed beyond the existing ratchet-shrink backlog.


What works (verified, not just assumed)

  • SessionInfoModel is the actual single source, not just documented as one. session-detail-screen.tsx:345-358 resolves buildSessionInfoModel once and every downstream card (SessionIdentityCard cost/cancellation, SessionWeatherCard rainRisk) consumes the projection — grepped for ad-hoc re-derivation of cost/eligibility in the sessions package and found none.
  • The owner's historical "정산/attendance have no save or complete button" complaint (R1) is resolved, not reproduced. Both session-payments-screen.tsx:491-503 and session-attendance-screen.tsx:312-323 end in a real BottomCtaBand closeout CTA, correctly disabled/labelled by unresolved count (allSettled / unresolvedCount > 0), and both are pure UI acknowledgements (appRouter.back()), not fake buttons.
  • Payment confirmation authority correctly matches migration 00303: session-payments-screen.tsx:366 gates on !canManageDues && !isSessionHost (host OR dues-manager), not admin-only.
  • UnsettledPoolNotice + partitionSettledMatchPool are correctly wired end-to-end into match-board-screen.tsx:161,359 — the client mirrors the server settle-before-play trigger rather than re-deriving eligibility ad-hoc.
  • Draft-resume is genuinely scoped per-club (create-session-screen.tsx:118-126, store.setDraftClub) — confirms the 2026-05-27 revival fix is intact, not regressed.
  • COMP-2 badge-literal compliance is clean: grepped every <Badge variant="..."> in the sessions package — all instances use only the sanctioned neutral/warning/live literals (plus one dynamic refundState-driven variant in session-payments-screen.tsx:109, explicitly commented as COMP-2-safe since it's a runtime value, not a literal). No violations found.
  • BottomCtaBand precedence is exhaustive and state-aware: traced every branch in session-detail-screen.tsx:559-898 (open/locked/in_progress/ completed × host/participant/guest/tier-blocked/cooldown) — exactly one CTA renders per state, refund-ack correctly overrides everything else, no dead/no-op buttons found.
  • Host≠club-admin pickup flows are fully built, not stubbed: pickup-host-dashboard-screen.tsx has real approve/reject + bulk-approve (2+) with useConfirm gates, correctly permission-gated to session.createdBy === userId.

Adversarial probes run (that came back clean)

  • Traced routes.clubSession('', sessionId) in pickup-host-dashboard-screen.tsx:227 as a suspected clubId-loss bug — false lead: routes.ts:75 takes _clubId unused, route resolves to /sessions/${sessionId} regardless.
  • Checked free-vs-fee session create/review round-trip (create-session-step4.tsx toggle clears costPerPerson on OFF; create-session-step6.tsx:49 review row reflects it) — correct.
  • Checked host===viewer at every session-status CTA branch — each state branches isHost distinctly and correctly (초대하기/경기 시작/경기 보드/ 정산 vs the participant paths).
  • Checked settle-before-play + 0-match/social-session attendance auto-recording claim against the attendance screen's own state hint (session-attendance-screen.tsx:262) — consistent with the documented migration 00384 behavior; no UI contradiction found.

Canon violations summary

  1. Payment-gate render (§1) doesn't cross-check viewerParticipating — arguably a violation of "surfaces never re-derive gate values ad-hoc" in spirit (it partially re-derives by omission: it trusts a payment-only signal for a state that also depends on RSVP liveness).
  2. §2 — literal canon text ("never CTA in a card") vs. 4 shipped screens; flagged as a doc/code reconciliation item, not a functional bug.

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