Skip to content

Club Surface Conventions ​

Status: Active Last reviewed: 2026-09-24

Canonical composition rules for club card and list surfaces in TwoMore. The PHASE 5 — SHIPPED FINAL COMPOSITIONS entry below (shipped 2026-08-03, commits 7299ab89/e1bd5ed4) is the current single source of truth for how club cards are structured. §3.12 ROLE-DEFINED CARDS and the v3 FOUR-ZONE model further down are HISTORICAL — kept for provenance, superseded in sequence: v3 → §3.10's banner+embedded-card composition → §3.12's role-diverged anatomy → Phase 5's shared-floor anatomy. Rationale and history live in CLAUDE.md; the machine-enforced subset is in AGENTS.md.

Club card composition ​

  • THE 클럽 TAB'S TOP PAGE IS ROWS (2026-11-13, UX-1) — current, and it supersedes every ClubCard entry below (the card and its seam are deleted). 내 클럽 and 클럽 찾기 draw ClubFeedRow (club-list/club-feed-row.tsx, the kit FeedItem) from clubRowModel: mark, a name that wraps to two lines and is never cut, region | focus, level | composition | format, then rhythm | dues (a member's club says 다음 일정 and the viewer's own answer as the state Tag). The viewer's relation to the club is ONE table, RELATION_SLOT in club-list/club-relation.ts (member says nothing, guest 게스트, pending 승인 대기 waiting, offered 가입 제안이 와 있어요 task): a new relation is one union member and one line there. Guest clubs sit under 게스트 클럽, archived ones under 보관한 클럽; the official's waiting work is a state Tag. A list of N rows makes the same number of requests whatever N is. The FAB 클럽 is the ONE home of 클럽 만들기 and 초대 코드로 가입 (no header +, no header button, no CTA band); 클럽 찾기 filters through the kit FilterHead and FilterSheet with the words every filtered list shares (filterHeadWords, filterSheetWords, levelFilterGroup from @twomore/app).

  • PHASE 5.1 — SHIPPED FINAL, CORNERS LAW (2026-08-04, iterations 20–23 + final fix batch, commits 184f46d6..fa9aff95 on main) — current: supersedes the 2026-08-03 Phase 5 ship note further below (same ClubCard, packages/features/clubs/src/shared/club-card.tsx — the design kept iterating after that initial ship, this entry documents where it landed). One ClubCard, rendered for BOTH audiences, sharing a photo+seam floor under the CORNERS LAW and diverging below it (buildClubCardModel in club-card-model.ts supplies every field — never re-derived at the card).

    SHARED seam — the CORNERS LAW (club-card.tsx:394-457, doc-labeled "CORNERS LAW, iteration 23-D" in source): the seam straddling the photo/body boundary is a fixed FOUR-CORNER grid — LEFT column = identity, RIGHT column = state/numbers. Every overhanging element's offset is exactly half its own height ("center-dissected by the photo border" — a literal, not a spacing token, so the border bisects each corner element symmetrically).

    • Top-left (identity, clipped inside the photo): TierRangeBadge (size="sm") when minTier/maxTier are set; a tier-less club renders a 실력 무관 surface-chip (t().sessionHeader.open — the SAME shared i18n key the session card's tier-less fallback uses; the earlier '오픈' label was rejected by the owner 2026-08-04 because 오픈부 names the elite amateur division in Korean tennis vernacular, the opposite of "no restriction") in the same slot instead of leaving the corner empty.

    • Bottom-left (identity, overhanging -24): the 48px flag/logo circle (FLAG_SIZE = 48 — bumped from an earlier 40px 2026-08-04, "the identity mark deserves a step more presence") — the club's own logoUrl via ClubLogoBubble, else a deterministic generative FlagMedallion (pure-JS, token-only, no image asset).

    • Top-right (state, member-only, overhanging past the photo edge): ClubAlarmBadge (packages/app/src/presentation/components/status/status-badges.tsx) — a 22px circle, $badgeWarningText fill, BORDERLESS (owner 2026-08-05: chips/badges never carry borders — a ring reads as chrome and shrinks the content box until the numeral can't center; the deep warning fill alone separates against any photo), textRole="badge" numeral with includeFontPadding={false} + textAlignVertical="center" (the GlyphSlot Android-centering fix), overflow="hidden". Renders only for isMember && isAdmin, fed by the admin snapshot's pending-item count — never shown to a plain member or a discover viewer. The count caps at 99+ (final fix batch 2026-08-05).

    • Seam people cluster — placement history: bottom-right per the corners law (2026-08-04) → moved beside the flag on the left (owner 2026-09-06) → back to the bottom-right END (owner 2026-09-08: "the avatar row + membership status shouldn't render right next to the logo — it's supposed to be at the end"). Two reversals: the corners law stands; the flag is alone on the left, the people cluster ends the seam on the right (CLUSTER_RIGHT_INSET = the flag's own 12px inset, club-photo-seam.tsx).

    • Bottom-right (state, overhanging -14): the capacity cluster — an optional seam-scale AvatarStack beside a capacity pill, $surfaceSecondary fill (final fix batch 2026-08-05 — $surface was invisible against a white photo edge; not an outlined pill), height-synced to the seam avatars, textRole="badge" text. The capacityLabel itself guards maxMembers > 0 (falls back to the member-count-only string, never renders N/0).

    • Recruit badge (모집 중) is RETIRED — no RecruitmentStatusBadge renders anywhere on the card; the capacity pill carries recruitment implicitly (an open/near-full club reads through its own N/M number, no separate status chip).

    • Photo, 3:1 window — COVER-ALWAYS (owner 2026-09-26: "we should always render the banner photos. If there is no photo uploaded, we give them default options"). History: always-a-photo with a shared 10-photo stock set → the identity band (2026-09-24, a colour field for photo-less clubs) → this law, which reverses the band. Every club surface that leads with a photo renders one — never a colour field, never an empty slot. ONE rule, resolveClubCoverUrl (packages/app/src/presentation/utils/club-cover.ts): the club's own approved album photo (card: featured, else any other approved) → club.bannerUrl (an admin's upload, or a default cover an admin picked) → the club's assigned default cover (a stable hash of the club id into CLUB_DEFAULT_COVERS). The default set (packages/app/src/config/club-default-covers.ts): 27 court scenes across hard, clay, grass, indoor and night, no people (a default must never pass for the club's own members). Licences are Unsplash/Pexels, which need no on-screen credit; the credit is kept in the config. Files are served from the public club-default-photos bucket at covers/<key>.jpg, built to the uploaded-banner spec (IMAGE_RESIZE_PRESETS.clubBanner, 16:9) by scripts/club-default-covers.mjs --local|--linked. Why this set holds where the old one failed: two neighbouring cards repeat a photo about 1 time in 27 instead of 1 in 10, and an admin who minds picks another in 클럽 설정 or uploads their own. That is the shape of Notion's cover gallery and Band's default covers: preset scenes beside the upload action. The same rule drives four surfaces:

      • the club card (club-photo-seam.tsx, 3:1);
      • the club-detail album banner (club-album-banner.tsx, the 2:1 photo band — resolveHeroBandHeight, 195pt on a 390pt phone, owner 2026-09-27): an empty album shows the cover, plus the 사진 추가 pill bottom-right for canManageClub;
      • the public-profile hero pager (album-pager.tsx): no album means the cover is its one page;
      • the settings image preview (image-section.tsx): a 기본 사진 badge on a default, and a 기본 사진 선택 button that opens DefaultCoverPicker (settings/default-cover-picker.tsx, a ModalPanel grid with the current cover marked). Picking stores that cover's URL as the banner; removing the banner returns the club to its assigned default.

      resolveClubIdentityBandColor survives only as the ground a cover sits on while it loads. The corners-law overlays are unchanged. Club logo and banner uploads write a fresh path per upload (<clubId>/<kind>-<timestamp>) and delete the previous object (useUploadClubMedia, the useUploadAvatar pattern). Phones cache images by URL (expo-image) and the CDN caches public objects, so a replaced logo kept showing under its old, unchanged URL. An uploaded logo wears a $borderSubtle hairline (club-logo-bubble.tsx): a white-ground logo would otherwise dissolve into the dark theme's surface, and the logo itself is shown exactly as uploaded (Slack, Discord and KakaoTalk render uploaded icons as-is; GitHub Primer rings avatars).

    MEMBER body: headline = name + activity indicator ONLY — no region, no tier badge (tier now lives in the photo's top-left corner). Below, render-only session rows, up to 2, as direct body children with NO eyebrow (the "다가오는 일정" label was removed — "rows self-describe" — so the rows inherit the body's own $2 rhythm rather than sitting inside a nested wrapper). Each row (ClubCardSessionRow): a strip-colored status dot (the session's own deriveSessionStrip palette, never a fixed color) + date + start time only (no end time — the full range wrapped on device) in one when-cluster, a flex-ellipsized venue name, and a trailing micro-meter — a 24×3px fill bar (amber past 85% fill, else accent) followed by the N/M count, bar-then-count. The WHOLE row renders in the single cardMeta role with a gray-ladder hierarchy only (date/time $text → venue/count $textSecondary, tabular nums; no font-size or weight variation). Rows are NOT tap targets (no Pressable/onPress — a per-row-clickable design was tried and reverted because the touch targets ate the list's gap; the card itself opens the club, sessions stay one tap away from there). No upcoming sessions → a quiet "예정된 일정 없음" line.

    DISCOVER body: same headline grammar (name + activity indicator — nearingBound is demoted to the neutral onRhythm tone here, never advertise decay to a stranger) plus a conditional tagline line (model.subtitle, 1 line, $textSecondary). Then emoji fact chips — Badge variant="surface" size="sm" with a <GlyphSlot emoji="…" /> icon (the canonical 16×16 glyph slot from @twomore/ui — the earlier raw <Text textRole="micro"> glyph was replaced in the final fix batch 2026-08-05, fixing Android baseline drift) + alignSelf="center" per chip: 📍 region, 👥 composition + age band (space-joined; the chip is skipped only when BOTH halves are absent), 📅 매주 + comma-separated meet-weekday labels. DayDotsRow/dotWeek no longer renders on this card at all — the emoji day-chip fully replaced the dot-week row (see orphans below).

    Activity indicator — a fixed component, cycle-relative tiers (ClubCardActivityIndicator): a 6px static Dot (never PulseDot — many cards render at once) + cardMeta label, deliberately OFF the card's type ramp (its state colors are fixed tokens, not ramp grays). Resolved against a cycle-relative bound = 2× the club's own regular-meet interval, floored at 7 days, capped at 21 days. Three rendered tiers:

    • fresh (age ≤ 24h) — $success dot + text.
    • onRhythm (24h < age, below 70% of the bound) — $textTertiary dot + text (the one ramp gray this fixed component is permitted to use, since it's a state color, not a ramp position).
    • nearingBound (age ≥ 70% of the bound) — $warning dot + text, member-surface only; the discover branch demotes this tier to the neutral onRhythm tone.
    • Past the bound entirely → the label/tier resolve to null and the indicator renders nothing — there is no rendered 4th "bound" state; past-bound is absence, not a visual mark.

    Click-through rule (survives from the earlier ship): no exact times render anywhere on the discover branch — the 📅 day-chip communicates cadence only (which weekdays, not what time). Real HH:MM values appear ONLY on the member branch's session rows; the detail/preview page is where a potential member learns the actual schedule.

    PHASE 5 — earlier ship (2026-08-03, commits 7299ab89 slices A-C + e1bd5ed4 slices D-E) — HISTORICAL, superseded by the corners-law entry above: the initial recomposition shared a photo+seam floor with a 40px flag circle bottom-left + a bordered N/M pill bottom-right (no top corners, no alarm badge), a member branch with a SectionEyebrow + clickable session rows using fixed-width date/time columns, and a discover branch with a TierRangeBadge in the name row, a DayDotsRow appearance="dotWeek" rhythm row, and a three-tone chip row (character/time-bucket/terms). Retired from that ship, now orphaned exports flagged for cleanup (see club-surfaces-unification.md Phase 5 slice E note): the §3.12 TrackerSessionZone and GlimpseVisualZone, plus the identity row's unread "새 글 N" indicator machinery — see the orphans list below, which folds these in with the corners-law pass's own new orphans.

  • HISTORICAL — ROLE-DEFINED CARDS (2026-08-02, §3.12 of club-surfaces-unification.md — superseded 2026-08-03 by Phase 5 above): one Club, two anatomies by viewer role (packages/features/clubs/src/shared/club-card.tsx), diverging below a shared IDENTITY FLOOR (ClubLogoBubble 36px · name · region·member-count, always present, both roles) — no banner, no straddled logo/avatar-cluster overlay on either branch (that grammar is retired; BannerCapacityCluster has zero remaining consumers). Member TRACKING ("what's next, identity is a given"): identity row (+ ClubRoleBadge for admins + a bulk-fetched "새 글 N" unread indicator, useUnreadPostCountsByClubIds) → a conditional attention strip (the SAME deriveSessionStrip/SessionStripView projection every session surface uses) → a venue-led compact session row (48px VenueTile — the session card's own tile component, reused via a new size prop, same photo→map cascade — beside venue name/date-time/format/plain-text N/M participation; no CapacityRing) → ClubAdminRow (staff-only, self-hiding, unchanged). No next session → a quiet "예정된 일정 없음" line. No tier badge (the tracker doesn't need to persuade). Discover GLIMPSE ("spark the tap; the detail page persuades"): a 16:9 photo band ONLY when model.isRealImage is true (an actual photo/banner — a placeholder gradient never counts as imagery) with the recruit-badge overlay; no real photo ⇒ no band at all (the bare anatomy is the PRIMARY majority-state design, not a fallback — most launch clubs are photo-less). Body: logo+name+TierRangeBadge row → graphics-forward visuals replacing the old text fact-line — AvatarStack of real member faces (≤4 @ 32px when a photo exists, promoted to ≤5 @ 44px as the card's own visual anchor when bare — "people become the visual") + a plain N명 count, beside (photo state) or beneath (bare state) a compact DayDotsRow (variant="compact", new — fixed-size non-stretching cells so it can share a row instead of spanning full width) → one quiet terms line (region · dues · join policy, shown only when non-'open') → character-only chips (neutral fill; joinPolicy/dues no longer render as chips — they moved into the terms line). Taps: member → club detail; potential member → club preview.

  • HISTORICAL — superseded 2026-08-01 by §3.10, then 2026-08-02 by §3.12 above (2026-06-12 v3 — FOUR ZONES; owner: composition over component-piles, "if a section needs a text label, the graphic isn't doing its job"): ZONE 1 HERO — banner 3:1 + joinability overlay (discover) + the consolidated MEMBERS CLUSTER spanning the FULL banner width on its bottom edge, summary→detail order: the $background pill (20px capacity ring + N/M count) on the LEFT, AvatarStack outlined aligned to the RIGHT end (left/right="$4" + justifyContent="space-between"; empty spacer keeps avatars at the end when no pill; people facts never split across zones; content below clears via AVATAR_STACK_HALF_HEIGHT paddingTop); ZONE 2 IDENTITY — logo · name · region inline + tagline (the card's ONLY prose; discover only); ZONE 3 SCHEDULE — RETIRED, do not resurrect: the titled "정기 일정" week-strip block described here (ClubScheduleBlock, a numbered week strip with one row per meet below it) is dead code, deleted from the card. Cadence today is folded into ZONE 2's identity fact row as a single inline glyph fact (model.frequencyLabel — Calendar icon + text, potential-member card only); it is not a standalone card zone. The actual next session lives in ZONE 5 below, rendered by ClubNextSessionWell (VenueHero photo/map strip + SessionCompactRow, member card only) with a plain Well + SessionCompactRow fallback when only a raw Session is available. ZONE 4 chips use the CANONICAL SessionCardV2 chip-strip chrome exactly (Badge size="sm", gap="$2") — no card-local chip variants. ZONE 5 NEXT SESSION — <Divider /> + SessionCompactRow (the upcoming session as EVENT-genre content: DateTile + sessionTitle() + HH:MM~HH:MM · location meta; fed by the full Session entity or the stats.nextSession fallback) at the card bottom; renders nothing when no upcoming session. GENRE RULE (research-grounded — Meetup group cards carry NO event dates; date tiles are the Luma EVENT-list pattern), refined 2026-06-12: the group's OWN visuals stay rhythm-only — the week-strip cells encode ONLY regular meets (no solid game-day markers; clubs without regular meets render no cells) with the when·where meta line (timeSlot · courtInfo ?? location) beneath. An upcoming session is EVENT-genre content and appears ONLY as an embedded, divider-separated SessionCompactRow (from @twomore/app — DateTile + generated session title + HH:MM~HH:MM · venue meta, surface-less, no press handler) at the card BOTTOM — the canonical compact representation whenever a session is referenced inside another surface. DateTile itself stays reserved for event-genre contexts (this row, 경기/session lists, club-detail 다음 일정). Location text stays off cards — the detail page carries it; ZONE 4 UTILITY, one quiet row — 24px ring + N/M + the chip strip. Shared composition lives in packages/features/clubs/src/shared/club-vitals.ts (weekday labels, day-index map, avatar-stack/date-tile composition, overlap constant) — cards + profile import ONE source. The superseded v2 details (media precedence, avatar privacy, vocabulary rules) still apply: (v2 — VISUAL VITALS, owner direction: graphics over text on browsing surfaces; NN-G fixed-placement-for-comparison): (1) banner 3:1 with ONE media precedence on every club surface — featured media → any approved media → bannerUrl → preset color (via buildClubCardModel().mediaUrl; never gallery[0] ad-hoc) + ClubJoinabilityBadge OVERLAID top-right on the discover banner; (2) identity row (logo · name · region/district); (3) tagline (discover only — the card's only prose); (4) VITALS ROW — people facts as GRAPHICS, not text: 32px capacity ring (SegmentedDonutChart size 32 strokeWidth 4, filled=$primary/track=$borderSubtle via theme .val) + N/M cardMeta on the left, AvatarStack (≤5 member avatars from ClubDiscoveryStats.memberAvatarUrls — migration 00259 — with +N overflow sized by activeMemberCount) on the right; (5) cadence fact (see ZONE 3 above — RETIRED as a standalone block; folded into the ZONE 2 identity fact row, model.frequencyLabel); (6) labeled 다음 일정 section — flat next-session line ONLY when sessionSummary.tone === 'next'; (7) chip strip = four canonical orthogonal dimensions (single source in buildClubCardModel): format · tier? · focus (omit the 'balanced' default — not a signal) · memberComposition? (omit 'any' — not a signal) — ≤4 chips, one row by construction. The free-form tags field was RETIRED in migration 00278 because it duplicated focus/composition/schedule (that redundancy caused the strip to overflow). 'social' and 'training' canonical tag values were also dropped in migration 00265 for the same reason. Do NOT re-add a tags axis to the chip strip — identity is now the four canonical dimensions. model.peopleText survives for PROFILE text contexts only, not cards. Absent values OMIT their chip — never render a generic placeholder ('오픈'). Avatar privacy (00260, superseded 2026-10-11 by 00691): every face — the discovery stack, the profile page, the leaderboards, rosters — obeys profile_visibility.avatar through the one predicate public.can_view_face (absent key = club members and friends; public is an explicit opt-in; a non-member and an anonymous viewer see the initials, never the photo; the card seam shows real photos only, so a non-member's card has no stack) — controllable from 설정 → 프라이버시 (프로필 사진 row); rule and enforcement in data-and-hooks.md § Faces. Role-grouped LIST screens follow the same de-icon + single-source-of-truth discipline (packages/features/clubs/src/club-members-screen.tsx): the GroupedFeedList section header owns the grouping dimension (the role), so each row drops its redundant per-row role icon AND role badge (role was being shown 3× — header + icon + badge) and reads typography-first (avatar + name + earned trust badge + kebab); never wrap the list in an extra umbrella SectionBlock title when the per-section headers already name each group (that's a double-header). The largest group collapses by default — its section header doubles as a count + chevron toggle (custom renderSectionHeader, identified by title), mirroring the session-detail roster "collapsed → expand on tap" so the meaningful structure (leadership) reads at a glance and the long tail is one tap away. Current (usability audit 2026-09-16, U-25): the row's 조 (조 편성 group) tag renders only once the club has 2+ active groups to actually distinguish between (showsGroupTag, club-groups.rules.ts) — a single group is dropped silently rather than shown as inert noise — and a legend line (clubList.clubGroup.legend) explains the tag above the list whenever it's showing.

  • ClubRoleBadge + ClubAdminRow (B1, 2026-07 owner wireframe review; badge placement HISTORICAL, see below — ClubAdminRow placement current under Phase 5): owner/admin viewers historically got a ClubRoleBadge ("운영", one label — never split owner/admin; brand-tinted accent Badge variant, an identity cue rather than a state signal) — banner-overlaid top-right, then §3.12's inline-in-identity-row placement. ClubRoleBadge is NOT rendered on the Phase 5 card at all (zero import in club-card.tsx post-e1bd5ed4) — the shared floor's identity row has no admin-role affordance today; component still exists (status-badges.tsx:426) for other call sites. The card's 관리 로우 (ClubAdminRow) is unaffected and stays current — its own nested Pressable → the club's 관리 탭 (routes.club(clubId, { tab: 'admin' })), R3's "알림 N건" + chevron treatment, the member-branch card's LAST zone (after the session rows).

Spacing & corners laws (Phase 5.1, corners-law iteration) ​

CORNERS LAW — named and specced under the PHASE 5.1 entry above: the four-corner seam grid (identity left / state right), every overhang literal set to exactly half its own height so the photo border bisects each element symmetrically.

SPACING LAW (club-card.tsx, both branches — three gap classes, $-token only, no raw pixel gaps anywhere in the seam or body):

  • Body rows $2 — the vertical rhythm between headline / session-rows / chips / etc. within a branch body.
  • Glyph↔text $1 — dot-to-label and icon-to-count pairings within one visual cluster (the activity indicator, a session row's when-cluster, the micro-meter's bar+count).
  • Intra-row clusters $3 — the separation between peer clusters inside one row (a session row's when-cluster / venue / meter).

Orphans / cleanup candidates (zero remaining call sites as of the corners-law ship; exported or computed but unconsumed — flag for deletion next time each is touched, not deleted by this docs pass):

  • DayDotsRow's appearance="dotWeek" mode — no JSX call site anywhere passes it; the discover card's 📅 emoji chip replaced the dot-week row entirely.
  • useUnreadPostCountsByClubIds (packages/app/src/presentation/hooks/queries/use-posts.ts) — zero callers app-wide.
  • The unreadPostsIndicator i18n key (ko/clubs.ts, en/clubs.ts) — zero render sites; the identity row's unread "새 글 N" indicator it backed was retired with the earlier Phase 5 ship.
  • buildSessionCountByClub (packages/features/clubs/src/shared/club-card-summary.ts) — not even re-exported from the clubs package barrel, uncalled.
  • model.recruitLabel (club-card-model.ts) — still computed every render (feeding the retired recruit badge), never read by club-card.tsx since that badge retired.
  • GlyphFactRow (packages/ui) — zero <GlyphFactRow call sites anywhere. Its inner GlyphSlot IS now in use (the discover card's emoji fact chips adopted it in the final fix batch, 2026-08-05), but the composed row primitive itself remains uncalled.

Dues, attendance & club metrics ​

  • EXCEPTION-LEDGER (owner ruling, improvement plan 2026-09-23, W1 C2/C7) — a management list states its rule once, sections rows by state, and gives a row a control only when it deviates. 회비 관리 (packages/features/clubs/src/club-dues-screen.tsx) states the per-person amount and the due day ONCE (from the selected month's own rows), then sections dues rows 확인 대기 / 미납 / 완납 / 면제 (an empty section renders nothing — EMPTY-ONCE, components.md); an unpaid/pending row carries exactly ONE pill (납부 확인) and a resolved row carries none; 면제 / 메모 / 납부 취소 all move behind the row's own tap into one actions panel, never inline per-row buttons. Dues rows render through PersonRow (components.md). 출석 확인 rows (packages/features/clubs/src/club-attendance-screen.tsx) are SILENT by default — 참석 is the assumed state, said once by the screen, not per row — and one ✕ control toggles 불참; only a marked row carries a 불참 badge (BADGE-EXCEPTION, components.md). Evidence: decision log (ROUND 4 — EXCEPTION-LEDGER).

  • ONE-ATTENDANCE (owner ruling, improvement plan 2026-09-23, W3 A4/BE-3) — 출석률 has ONE definition app-wide: attended ÷ club sessions held since the member joined, pooled over active members for a club-wide figure (never a mean of per-member rates), from ONE server source. private.club_attendance_since_join (migrations 00659–00662; the numerator/denominator window was matched by 00666 after a guest-turned-member could otherwise read above 100%) backs both the club-wide admin figure and the individual member-stats peek; the label always states the window (가입 후 출석률). Before this, three formulas shared one label — mean-of-rates vs pooled vs RSVP÷all-time-sessions — and disagreed on the same club.

  • ONE-RATING (owner decision 2026-09-24, CLUB-ELO-POOL; supersedes CLUB-ELO-LABEL) — a player has ONE rating per discipline (player_ratings); a club ranking is that rating filtered to the club's active members, never a second pool. This is what rating-system-v2 §7 always specified; the per-club pool (club_elo_ratings, dropped by migration 00670) had drifted from it and put two numbers on one player. The one read is public.get_club_rating_ranks(club, discipline) through useClubRatingRanks(clubId): rank is the server's competition rank (ties share a number) among members with at least one rated match, and an unrated member has no rank and appears in no ranking. Every surface labels the value ELO (t().common.elo); no scope prefix exists. The only per-club state the rating pipeline keeps is private.club_player_match_counts (server-only), which picks a player's home club for calibration (rating-system-v2 §11.4). Every ELO value read before its own row resolves falls back to the ONE named constant DEFAULT_ELO (packages/app/src/domain/rules/tier.config.ts, = 1000 — matches the server's own starting value), never an inline ?? 1200/?? 1000 literal; enforced by @twomore/no-hardcoded-elo-fallback and pinned against the server's own value plus the activity simulator's copy by check:elo-parity (yarn check:static).

  • One 7-check club-profile-completeness rule, parity-gated to SQL. packages/app/src/domain/rules/club-profile-completeness.rules.ts mirrors the SQL profile_signals CTE inside public.get_club_growth_analytics 1:1 — seven checks (logo, banner, tagline, description, schedule, joinPolicy, media — an approved AND publicly-visible item exists) — and feeds BOTH the admin growth-analytics screen and buildClubCardModel's discovery-score computation for every club card, so an RPC round trip per card isn't an option. scripts/check-club-profile-completeness-parity.mjs (yarn check:static) fails the moment the TS list and the SQL CASE-WHEN clauses disagree. The client's prior, DIFFERENT 8-check formula (with an extra focus !== 'balanced' check the server never had) is retired — there is one rule now, not two that can disagree on the same club.

  • DEPARTURE-RELEASES (owner, 2026-10-17) — leaving or being removed releases the member's upcoming seats, in the database, on every departure path. private.release_member_future_seats (00322/00327/00407/00698) runs from the club_members AFTER DELETE trigger (leave and removal) and from deactivate_departed_member_state (account deletion): confirmed and waitlisted RSVPs on the club's sessions that have not started are cancelled (excused, no strike), the leaver's scheduled matches are voided, and RSVPs on open 행사 not yet held are deleted. A session being played keeps its roster. Never a client-side loop; both confirms say 예정된 일정과 행사 참석은 취소돼요 (clubs.departureReleasesRsvps).

Venue provenance (owner decision 2026-08-07) ​

Club-linked courts come from reputable sources ONLY — a court_venues row must originate from the curated venues spine or a Naver Local Search result (its venue_id / naver_place_id / coordinates may be null only because the source didn't resolve them, never because a user typed the venue by hand). No UI offers free-text venue creation: the create-club picker's 직접 입력 escape was removed (2026-08-07); a court missing from the spine is reachable through the picker's Naver supplement, which is the sanctioned escape hatch. clubs.court_info is display metadata (a mirror of the home pick's name, or a description like "실외 하드코트 2면") — it is never a venue identity and never materializes venue rows.

The rule holds for sessions too (2026-09-24 reconciliation, open-issues-plan-2026-09-24.md §3 Lane E) — three remaining free-text paths are closed: club-venues-screen's admin "코트 추가" (its search takeover's keyboard 검색-key onSearchSubmit used to create a name-only, no-coordinates row; it now routes to the 없는 장소 추가 contribution intake instead, same as every other venue-list screen's "can't find it" escape); the create-session wizard's 장소 이름 answer (step2Valid now requires a picked spineVenueId/courtVenueId — typed text that never resolved to a row can no longer become the step's answer, and the takeover has no free-text commit affordance at all, mirroring the create-club picker); and edit-session's 장소 row (a re-link now goes through the SAME canonical VenueSearchTakeover pick, resolving a fresh spine pick into the club's own court_venues row via the 00494 universe bridge, useEnsureClubCourtVenue — updating the display name and courtVenueId together, never a free-text rewrite of just the name). Existing sessions and courts with free-text names created before this rule stay readable; they are not migrated.

Pick-through ingestion (migration 00452) — the automated "ask for inclusion" path: a user finding a court via the picker's Naver supplement IS the inclusion request; there is no form and no moderation queue. On pick-persist, private.ensure_venue_for_court_venue (the court_venues BEFORE-INSERT link trigger from 00284) creates the spine row with real provenance: verification_status='imported', data_source='club_pick', plus a naver_place row in venue_source_observations so the 00362 source-family counter starts the naver family honestly. Promotion into the curated cohort (source_count >= 2 AND not-unverified) then happens automatically when an enrichment pass corroborates from an independent family (kakao/tmap) — no manual step. Guardrails: host_verified is NEVER stamped by picks (reserved for operator-accepted venue_corrections, one rung up the 00335 trust ladder); ingest-venues' resetImported corpus wipe excludes data_source='club_pick' rows (they anchor club home courts via an ON-DELETE-SET-NULL FK); id-less picks stay unverified with no observation — only what a reputable source resolved gets attested.

Activity regions derive from courts, never entered by hand (migration 00650) — clubs.adm1_slug/adm2_slug (primary) and club_play_regions (additional, capped at 3 total) are a PURE FUNCTION of the club's court_venues districts, recomputed by a trigger (trg_sync_club_regions) on every court add/remove/edit — the same server-side path whether the courts came from the create-club wizard or club-venues-screen's admin add. The only way to change a club's regions is to add or remove a court; club-settings' 정보 section reflects this with a read-only row instead of a free-text field once any court has a district.

Club challenges (클럽 대항전; follow-up batch C, 2026-11-12) ​

  • LIFECYCLE. A challenge is proposed, answered, cancelled and settled by four server functions only (propose_challenge, respond_to_challenge, cancel_challenge, and the settle job's private.settle_challenge); no client has INSERT, UPDATE or DELETE on inter_club_challenges (migration 00755 closed the old insert hole; a database test pins the writer list by name). created_by is the caller, a new row is proposed with scores 0 and no winner, and the winner comes from the tally, never from a client value.
  • WHO. Proposing: an owner or admin of the home club whose plan includes challenges (private.club_plan_allows_challenges, parity-gated to interClubChallengesEnabled by check:challenge-plan-parity); receiving and answering never need the plan. Answering: an official of the invited club. Withdrawing a proposed one: an official of the proposing club. Cancelling an accepted one: an official of either club, until a tagged session has a completed match (challenge_has_results).
  • LIMITS. Only 총 승수 and ELO 상승 can be proposed (승률 and 경기 수 stay valid on old rows: 승률 scores as 총 승수, 경기 수 reads 출전 인원); a period of at most 90 days starting today or later; one open (proposed or accepted, legacy in_progress) challenge per unordered pair of clubs (a partial unique index); at most 5 proposals a day per club (the challenge_propose rate-limit policy row). Each refusal has its own hint and its own line on the form.
  • WHAT COUNTS. A match counts when it is completed, on the record, not friendly, in a session tagged to the challenge and dated inside its period, and its sides split cleanly (every player of one side in one club only, the other side in the other club only; a player in both clubs, or in neither, makes it not count). One function (private.challenge_tally, with private.match_winner_side) counts, for the live standing (get_club_challenge_standings) and for the freeze.
  • SETTLING. The score line is the match tally; the rule decides the winner and a tie is a draw (무). The standing is computed on read until end_date + 7 days (the score-review window; the row says 결과 확정 D-n), then frozen once by the hourly job settle-due-challenges; a challenge with no counted match settles 0-0 with no winner (경기 없이 끝났어요). The page keys on settled and counted_matches, never on scores above zero. A proposed challenge past its end date still holds its pair until withdrawn or declined (no expiry is built).

A membership offer (가입 제안; batch 56) ​

  • WHO OFFERS. An official (총무 or owner) offers membership to a person who did not ask: 지명 (nominate_for_membership) and 가입 확정 on a guest (confirm_guest_conversion) each create one pending offer (club_join_request, source admin_nominated). An official never writes a membership and cannot approve an offer.
  • WHO ANSWERS. Only the offered person, with 수락 or 거절 (answer_club_nomination). The official's one verb on a waiting offer is 제안 취소 (the decide path with accept false), which asks first and names the person. An offer is never drawn with 승인 (멤버 관리's applicant list draws it as an offer row: 답을 기다리고 있어요 and 제안 취소; 가입 심사 as the same band).
  • WHAT ENDS IT (one rule; every end closes it). An offer leaves pending when the person answers, an official cancels (제안 취소), the guest is removed, the club leaves 승인제, the club is archived (archive_club cancels the club's waiting offers; an own application to an archived club is left as it was: an official may still reject it, accepting is refused), or the person becomes an active member of the club by any path (an open join, an invite code, an approved application: the trigger on club_members marks their pending requests accepted and converts a proposed guest). Anything but a yes returns a guest from proposed to active (release_guest_when_offer_ends). The person's notice closes with the request and nothing stays open that has been overtaken. A cancelled offer is not a rejected application: the person never applied, so 제안 취소 (and the archive) sends no join_request_decided, and a declined or cancelled offer never turns the visitor band into 다시 신청하기 (the gate reads the person's own applications only). A club at its 정원 cannot take the person in, so the offer waits (a seat may free, or the officials cancel it): the page draws the offer line, the full notice and the quiet 거절 only, no 수락. The officials' badge and 할 일 counts leave offers out: they wait for the person.
  • WHERE THE PERSON ANSWERS (batch 57). The offer reaches the person as a request notice (club_membership_offered, open while the request is pending), as the club's own row in the 클럽 tab (first in 내 클럽, the Tag 가입 제안이 와 있어요, no button), and as the band on the club's page: 수락 (primary) and a quiet 거절 that asks once. A decline tells the club's officials once (club_membership_offer_answered); an accept reaches them as the member-joined notice every join already sends. The official's withdrawal and a guest's removal close the person's notice and tell nobody.
  • THE WEEKLY LIMIT. An official can offer the same person at most three times a week per club (the membership_offer rate-limit policy, a BEFORE INSERT trigger both paths pass; the refusal is the hint membership_offer_rate_limited, which the roster says in the app's words). A pending offer is never counted again.
  • NO OFFER BETWEEN A BLOCKED PAIR (00768, MEM-6). nominate_for_membership, confirm_guest_conversion and invite_club_guest refuse when the acting official and the person have a block in either direction (HINT offer_unavailable, the refusal is a statement, titled 지금은 이 분께 보낼 수 없어요 over a reason class that names no block: the other person's settings or account state can limit invitations. The words name a block only to the person who made it: a guest suggestion or a shortlisted guest the viewer blocked shows 차단한 사용자예요 and no 초대 / 가입 제안 보내기 button (the viewer reads only their own blocks, as one bulk read at the page). A person who blocked the official is left out of that official's suggestions (00777); the unblock stays in 설정's 차단 관리). The check runs last, just before the row is written and after every eligibility check, so a person who is ineligible for another reason gets that reason whether or not a block exists. The residue: an official who knows the person is otherwise eligible can infer a block from the refusal; the app does not pretend the offer was sent. The pair is the acting official, not "any official"; an offer made before the block stays and can be answered or cancelled.
  • ONE WORD, ONE MEANING. 가입 제안 is the offer the person answers; the guest shortlist is 가입 후보 (row action 후보 추가); the step that sends is 가입 제안 보내기; the withdrawal is 제안 취소. See the terminology guide.

See also ​

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