Skip to content

records-props.md — 종목 상세 + PR 도구 페이지 계약 (UiRecordsDetail / UiPrFavScreen / UiPrManualScreen)

화면 픽스처(앱 저장소): src/react/ui/mobile/fixtures/HomeScreen.fixture.ts, src/react/ui/mobile/fixtures/RecordsDetail.fixture.ts, src/react/ui/mobile/fixtures/PrFavScreen.fixture.ts, src/react/ui/mobile/fixtures/PrManualScreen.fixture.ts

2026-07-22 신설(구 기록 탭). 2026-08-12: 기록 화면(UiRecordsScreen) 폐지 — 히어로·보드는 홈으로 흡수됐고(home 계약), 남는 것은 ① 종목 상세 ② 독립 시트 2종(주요 종목 목록 수정·1RM 직접 입력, 메뉴 드로어 직결)이다. RecordsScreen.tsx는 삭제. PR 달력 코어(RcCalCore/RcCalSheet)는 screens/PrTools.tsx로 이동(일지 추가 모달이 import). 화면 fixture는 위 앱 저장소 경로에 두고 화면 props와 같은 PR에서 갱신한다. 표시 의미의 규범은 이 계약이며, 반입 방식은 전달 계약을 따른다.

UiRecordsDetail.statsStatus"ready" | "pending" | "error"이며, onRetryStats는 소비 측 콜백이다. ready일 때 확정 통계와 빈 기록 여부를 표시한다. 조회 중·실패 시 종목 상세의 숫자는 공통 통계 표시 상태를 따른다. #1570의 v0.17.11 통합에 맞춰 세 종류의 종목 상세 fixture가 statsStatus: "ready"를 명시하고 같은 데이터 타입으로 검사한다.

UiRecordsDetail — 종목 상세 (mobile/screens/RecordsDetail.tsx)

상단 요약 숫자 배치 (#1552, 2026-09-10)

  • 최고 기록과 총 누적 값은 태그·숫자·단위를 한 묶음으로 중앙 정렬한다. 숫자 줄은 부모 칸의 가용 폭을 넘겨 늘어나지 않으며, 실제 글자 폭에 따라 숫자만 축소한다(기존 16px 하한 유지).
  • 초기 렌더, 값 변경, 화면 폭 변경, 글자 치수 변경과 후속 폰트 로드에서 다시 맞춘다. ResizeObserver의 측정 단계와 크기 적용 단계를 분리하고 적용 후 치수가 같은 알림은 무시한다.
  • kg 계산·반올림·천 단위 구분, 누적 상세 모달과 키보드 동작은 유지한다. 잘림·말줄임표·단위 치환으로 숫자를 감추지 않는다.
  • 자동 검증은 실제 UiRecordsDetail 및 CSS를 합성 props로 렌더한다. 320/360/375/390/430 CSS px, 0~12,345,678 경계 값, 늦은 폰트와 확대된 숫자 스타일의 경계를 검사한다. 전체 기기 접근성 설정이나 실제 사용자 서버 여정 검증과는 구분한다.
  • 적용 대상은 release/v0.17.10이며 Production 반영 완료를 의미하지 않는다. 이슈.

진입: 홈 주요 종목 기록 행·종목 검색(pr.open), 리포트 종목 행. 컨테이너는 상세 오브젝트를 d로 공급하고 뒤로가기 시 origin 화면으로 복귀시킨다. 루트 마커: data-lg-hook="prDetailView" data-lg-view="dashboard.pr.detail".

propshape
d아래 상세 오브젝트
profile{ name, photo?, short? } — 리뷰 말풍선 아바타
ownerNamestring | null (선택, 2026-09-14) — 친구 스코프에서 열린 상세: 있으면 워드마크 행이 fband("{ownerName} 님의 바벨릭 페이지", 행 중앙)로 그려진다. 미공급 = 내 페이지. 컨테이너는 친구 스코프 prDetail에서 친구 이름을 넘기고 profile도 친구 프로필로 준다. 타입 정식 등재(types/prPresentation.ts RecordsDetailProps)·친구 상세 배선은 Codex 후속
statsStatus"pending" | "ready" | "error" — 컨테이너가 확인한 최신 통계 숫자 공개 상태 (#1563)
onRetryStats() => unknown — 최신 상세 및 필수 조각을 다시 읽는 콜백 (#1563)
콜백onBack() · onToggleFavorite(id) · onOpenSession(record)(pr.log.open)

최신 통계 숫자 공개 — 모바일·PC 공통 (#1563, 2026-09-11)

  • 대상은 본인 종목 상세(UiRecordsDetail, PC DkPrDetail2)이며 홈 UI는 변경하지 않는다. release/v0.17.11앱 PR #1570, merge 782248231b59f1005d44aff68bd5a84044badd7c로 반영됐으며 Production 미출시다. 이슈 #1563.
  • d 또는 PR 개요의 캐시 숫자가 있어도 해당 상세의 최신 여부를 아직 모르면 첫 렌더부터 pending이다. 종목명·카드·라벨·단위·뒤로가기 등은 유지하고 최고 기록·RM 표·누적·기간 집계 숫자는 스켈레톤으로 표시한다. 관련 현재 등급·비율·증감·그래프 값도 같은 조건으로 공개한다. pending을 기록 없음/0/최종 값으로 해석하지 않는다.
  • 공개 근거는 현재 owner 범위에서 채택한 live initial 응답이다. requestedVersion·관측된 통계 요청 세대 이상이 appliedVersion에 반영되고, 동일 exerciseId·fragmentVersion·appliedVersion·asOf의 현재 연도 요약·PR 기록 첫 페이지·운동 이력 첫 페이지가 모두 확인되어야 ready다. initial만 새로 도착한 시점이나 캐시 필드의 stale:false만으로 완료시키지 않는다.
  • 영수증 등에서 새 통계 요청 세대가 확인되면 기존 숫자를 공개하기 전에 동기적으로 pending으로 바꾼다. 기존 회복 절차로 재계산 게시와 필수 조각을 다시 확인한 뒤 최신 수치를 표시한다. Home/Report 탭의 화면 스택에서 연 종목 상세도 활성 상세로 재조회한다. 임의의 고정 타이머로 완료를 판정하지 않는다.
  • 계정·종목·세대·기준일이 달라진 늦은 응답은 숫자 공개의 근거가 될 수 없다. 동일 조건에서 최신 상태가 확인된 상세를 단순 왕복하면 메모리 캐시의 확인 결과를 유지해 숫자를 다시 가리지 않는다.
  • initial/필수 조각 조회 실패 또는 기존 회복 시도 소진은 error다. 이전 숫자를 최신 값으로 확정하지 않고 onRetryStats를 통한 다시 불러오기를 제공한다. 이전 계산 오류가 남아 있어도 현재 회복이 실제 진행 중이면 pending을 유지하며, 진행 중과 정지한 실패를 구분한다.
d = { id, name, nameEn, isFavorite, rank: { tier, percentile } | null, gender?, weightClass?,
  cutlines: [{ tier, weight, percentile }],
  hasRecords: boolean,                               // 빈 상태 판정 — false = 전면 빈 상태("아직 기록이 없어요"). 훈련 로그·PR·RM 어느 하나라도 있으면 true (매퍼 산출, 2026-08-19 정정)
  current1RM: { value, unit, date } | null,          // null/0 = 실측 1RM 미기록 — 히어로·기록표 1RM 셀은 "—" 표시 (빈 상태 판정에 쓰지 않는다)
  recordMetric: { kind: "one_rm"|"max_reps"|"max_hold"|"distance_time"|"none", unit, label, supported }, // 대표 기록 지표 (이슈 #1100, docs/contracts/record-metric.md) — 매퍼가 필수 입력 조합으로 판정. kind === "max_reps"면 히어로 1RM 자리 = "최고 반복수 N회"(RM 시트 없음), 변화 그래프·PR 목록 = 최고 반복수 시간선
  currentMaxReps: { value, date, previousValue, delta } | null, // 실측 최고 반복수 현재값 (max_reps 종목만, 추정치 아님)
  maxRepRecords: [{ date, value, previousValue, delta, sourceSetId, sessionId }], // 실측 최고 반복수 갱신 기록(최신순 ≤ 64) — PR 목록 행 = "날짜 · 최고 · N회 · +Δ회"
  currentMaxHold: { value(초), date, previousValue, delta, loadKg } | null, // #1102 kind === "max_hold": 히어로 "최고 버티기 1:30", 변화 그래프·PR 목록은 maxHoldRecords
  maxHoldRecords: [{ date, value(초), previousValue, delta, loadKg, sourceSetId, sessionId }],
  cardioLongest: { value(m), date, previousValue, delta, seconds } | null, // #1103 kind === "distance_time": 히어로 "최장 거리 12.6km"
  cardioLongestRecords: [{ date, value(m), previousValue, delta, seconds, sourceSetId, sessionId }],
  cardioByDistance: [{ distanceMeters, bestSeconds, recordedDistanceMeters, date, sourceSetId, sessionId }], // 거리별 최고 기록 표(기준 거리 오름차순, ≤ 8) — 히어로 아래
  repMaxes: { 3,5,8,10 },                            // B 기록표 (없는 키 = —)
  estimatedByYear: { "2026": [{ iso:"YYYY-MM-DD", label, value }] }, // A 라인 — 실데이터 = 일 단위 strength point(iso 오름차순). 화면이 iso로 월 버킷(마지막 관측 승)해 12칸 라인을 그린다. iso 없는 픽스처는 "n월" 라벨 폴백 (2026-08-21 정정). y축은 전 연도 범위 고정
  availableYears: [2026, 2025, ...],                 // 훈련 기록이 있는 해(서버 available_years, 내림차순). **연도 스테퍼 목록 = availableYears ∪ estimatedByYear 키 ∪ yearStatsByYear 키** — 추정 1RM 유무와 무관하게 그 해에 기록이 있으면 이동 가능 (이슈 #1100, 2026-09-02 정정). 비면 올해 하나. 기본 선택 = 올해(목록에 없으면 가장 최근 해)
  trainDays: { iso: 1~3 },                           // C 잔디 (연도 보기, 표시 전용)
  volumeWeeks: [{ label, volume }] ×12,              // D1
  intensityWeeks: { avgPct, series: [{ label, value }] }, // D 스탯 + D2
  sessionWeeks: [{ label, value }],                  // D3
  prRecords: [{ date, value, rm }],                  // E1 분기 성장·E2 리스트(같은 RM 직전 대비 delta 화면 계산)
  history: [{ monthKey, records: [{ id, date, title, topSet, sets:[{load,reps}], isPR? }] }], // E3 탑세트 + F 피드
  reviews: [{ date, text }],                         // F 리뷰 말풍선 (우측 정렬 + 아바타)
  yearStatsByYear: { "2026": { sets, reps, sessions, volume, durationSeconds?, distanceMeters? } } } // F 스탯 — sets = 메인 세트만(웜업·다운 제외, 오너 결정 2026-08-19), reps = 메인 세트 반복수 합(year_summary.main_reps), sessions = 세션 수. volume은 전 세트 포함. durationSeconds/distanceMeters = 시간·거리 종목 누적(2026-09-02 신설)

대표 기록 지표 (2026-09-02 오너 결정 → v85 반입 2026-09-03 D1~D3)

지표 종류는 매퍼가 정한다d.recordMetric.kind(정본 src/react/recordMetric.ts · docs/contracts/record-metric.md). 화면은 kind로 분기만 하고, 표기 규칙(태그·값 분해·개선분·세트 토큰·누적 라벨·그래프 눈금)은 mobile/lib/recordMetric.ts(rcMetric(kind)) 한 곳이 소유한다. 종목별 수기 지정 없음.

kind히어로 좌(최고 기록)히어로 우(누적)그래프 제목PR 행 태그표 모달
one_rm(무게+횟수, 기본)1RM · current1RM(kg) — 종전 그대로총 누적 볼륨 kg1RM 변화nRMRM별(repMaxes)
max_reps(횟수만)MAX · currentMaxReps.value총 누적 횟수최대 반복수 변화MAX없음
max_hold(시간만 · 무게+시간, D2)HOLD · currentMaxHold.value 초(60초 이상 m:ss)총 누적 시간(서버 미공급 → "—")최대 버티기 변화HOLD없음 — 무게별 표는 보류, 세트 토큰에 무게 병기(40kg 1:00)
distance_time(거리+시간, D3)MAX · cardioLongest.value km — 탭 = 거리별 표 모달총 누적 거리(서버 미공급 → "—")최장 거리 변화MAX거리별 최고 기록(cardioByDistance, 3열: 기준 거리 · 최단 시간 · 달성일, 최근 갱신 = 페리윙클)
none1RM 경로로 그린다(종전)

화면 요소(v85 배송 모양):

  • 등급 = 종목명 바로 아래 행 .rc-rank(등급 있는 종목만, 탭 = 등급표 모달 prTierGradeOpen). 등급 단위는 rank.unit(회/kg)을 따른다(#1142).
  • 최고 기록 아래 메타 = YYYY.MM.DD 기록(1RM은 current1RM.date, 그 외는 실측 최고의 date).
  • 누적 숫자 탭(prDetailCumOpen) → 연도별 누적 모달(prDetailCumModal): 단위 세그먼트(지표 누적 | 세트수 | 반복수 | 훈련일수) + 연도 행(최대값 상대 바). 데이터 = yearStatsByYear 그대로(추가 공급 없음) — 반복수 탭은 지표가 이미 반복수이거나 전부 0이면 생략, 훈련일수 = trainDays 연도 카운트.
  • PR 행 = 태그 · 값(m.split) · 개선분(delta > 0일 때 +2회 · +15초 · +4.9km). 훈련 기록 행 요약 = 4세트 · 49회 / 3세트 · 5:30 / 5km · 24:31, 세트 토큰 12회 x2 / 1:30 x3 / 40kg 1:00 x1 / 5km 24:31 x1(1rm은 140kg 8x2 그대로), PR 칩 = 1rm nRM PR / 그 외 PR.
  • 그래프: 실측 PR 점만(추정 보조선 없음). 눈금·빈 상태 문구는 지표가 소유. 마지막 값 라벨 = 현재 1RM 180kg / 최고 12회.

추가 필드(1rm 외 종목, 매퍼 공급):

recordMetric: { kind, unit, label, supported }        // 지표 판정 결과 — 화면은 kind만 읽는다
currentMaxReps / currentMaxHold / cardioLongest: { value, date, previousValue, delta } | null   // 실측 최고(히어로)
maxRepRecords / maxHoldRecords / cardioLongestRecords: [{ date, value, delta, ... }]         // 실측 갱신 시간선(최신순) — PR 목록·그래프
cardioByDistance: [{ distanceMeters, bestSeconds, recordedDistanceMeters, date }]           // 기준 거리별 최단 시간 — 표 모달
rank.unit: "reps" | "kg"                                                                    // 등급표 단위
availableYears: number[]                                                                    // 훈련한 해 — 연도 스테퍼(#1110)
history[].records[].sets: [{ load?, reps?, durationSeconds?, distanceMeters? }]              // M_FIELD 키 그대로 — 세트 토큰

남은 것: yearStatsByYeardurationSeconds·distanceMeters 합계가 없어 버티기·유산소의 총 누적은 "—"(서버 연간 요약 공급 시 자동 채워짐). 섬세한 것: RC_FEED_NOTE_UI·RC_FAV_STAR_UI 게이트·마커는 지표와 무관하게 유지. prDetailRmOpen/prDetailRmModal은 1rm에서, prDetailLongest 탭 + prDetailRmModal(안에 prDetailCardioTable)은 distance_time에서만 렌더.

섹션: 히어로(1RM·PR일·등급) → B 기록표 및 등급(3/5/8/10RM 셀 + 등급 진행 래더 슬라이더 — 탭 = 등급표 드로어) → A 추정 1RM 추이 → C 훈련 빈도 → D 최근 12주 훈련 요약(스탯3 + 볼륨/강도/세션 바) → E 최고기록 추세(스탯3 + 1RM 성장 + PR 기록·탑세트 5행 + 전체 시트) → F 훈련 목록(연도 스테퍼 + 스탯3 + 세트 토큰 + 리뷰 말풍선).

독립 페이지 (mobile/screens/PrTools.tsx) — 2026-08-12 신설, 같은 날 재지시로 시트 → 풀스크린 페이지 전환

메뉴 드로어 항목 "주요 종목 목록 수정"·"1RM 직접 입력"이 별도 페이지로 내비게이트한다(탭바 유지 — 탭바 탭 = 스택 초기화 + 해당 탭 이동, 페이지 위 시작 FAB 미노출). 페이지 크롬 = 상단 우측 바벨릭 워드마크(mb-brand, 전 탭 공용 위치) + 행 좌측 뒤로 버튼(onBack) + 라벨(mb-lead). 이슈 #1393 Phase 5(오너 결정 D1=a): 페이지는 연 탭 화면 스택 위 슬라이드 화면(recordsTool, 셸 .pg-overlay.prt-stack) — 뒤로 버튼·앱 안 스와이프·iOS 가장자리·Android 뒤로 버튼으로 원래 탭에 한 겹 돌아간다. 드로어에서 열면 그 탭에 열려 있던 깊이 화면은 먼저 비운다. 검색 결과·달력은 페이지 위 오버 드로어 유지. 스타일은 eager base/primitives.css(prt-*). 마커는 전부 기존 등록명 재사용 — 신규 0.

UiPrFavScreen — 주요 종목 리스트 관리 페이지 (prFavEditModal)

타이틀 2줄 = "{profile.name}(액센트) 님의 / 주요 종목 리스트 관리" + 서브 캡션(종목 수·안내). 완료 버튼 없음 — 모든 액션 즉시 적용. 지면 = 순백, 행 = 헤어라인 리스트(별 좌측, 이름+영문 스택, 순번 없음), 검색 필 = 카드(--sh-card). 영문 부제는 첫 글자만 대문자 표시(표시 레벨 — 데이터 불변). 드래그 행 = 액센트 워시 + 인셋 링.

propshape
board{ rows[] } — 즐겨찾기 종목 행({ id, name, en, rm1, unit?, lastPr? })
search컨트롤러 소유 검색 모델 { input, results, hasQuery, isPending, onInput, onCompositionStart, onCompositionEnd, onCommit, onClear } — 컨트롤러가 보드 행과 전체 종목 카탈로그를 union·정규화·필터링
favorites[exerciseId] 순서 보존 — 초기 목록 순서
profile{ name } — 타이틀 접두
콜백별 탭/검색 추가 = 즉시 onToggleFavorite(id) + onReorderFavorites(ids) · 드래그 드롭 = onReorderFavorites(ids) · 스낵바 취소하기 = 동일 콜백 재발화로 복원

한 페이지에서 순서 변경(핸들 드래그 — 라이브 재정렬, 드래그 행 = accent-wash 하이라이트로 삽입 위치 표시) + 제거(별 탭) + 추가(상시 검색 → 검색창 아래 플로팅 드롭다운, 바깥 탭 = 닫힘). 액션마다 하단 스낵바("삭제했습니다/추가했습니다" + 취소하기, 2초 자동 닫힘). 일괄 적용 아님 — 즉시 적용.

UiPrManualScreen — 1RM 직접 입력 페이지 (prManualAddModal)

propshape
search위와 동일한 컨트롤러 소유 검색 모델
콜백onBack() · onAddManualPr({ exerciseId, value(kg 환산), unit("kg"|"lb"), date|null }) — 데스크톱 prManualAdd 계약 동일(date null = 모름), 저장 후 onBack

흰 검색창 + 결과 오버 드로어 + kg/lb 토글(lb = kg 환산 캡션) + 커스텀 달력 오버 시트(prManualAddCal, 월 스테퍼·미래 비활성). 보기모드: 진행 중 운동 시 드로어에서 "1RM 직접 입력" 항목 미노출(호출부 게이트) — 구 보드의 isWorkoutActive 게이트 승계. ※ hook명 "Modal" 접미는 페이지 전환 후에도 등록명 제약으로 유지 — rename은 designContract 갱신 시 Codex와 함께(선택).

RcCalCore / RcCalSheet — PR 달력 코어 (export)

일지 "지난 기록 추가" 모달이 인라인 드롭다운으로 재사용(import { RcCalCore } from "./PrTools.jsx"). props: { value, onPick(iso), title?, allowFuture? }.

마커 — 신규 등록 0

view dashboard.pr.detail, action pr.open·pr.detail.fav·pr.log.open, field pr.favEdit.search·pr.manual.search, hook prFavEdit(Modal)·prFavAddInput/AddResults·prManualAdd(Modal/Date/Unit/Cal/Results/Input)·prDetailView 및 상세 내부 훅 일체(prDetailBack·prChart·prVolChart·prE1Growth·prFreqGrass·prDetailPrList/Topsets/Feed·prDetailPrAllLink/TopsMoreLink·prDetailPrModal/TopsModal). 구 보드 전용 훅(mobilePrView·prLifterGrade·prBoard(Row)·prTape·prSearchFab·prFavSearchDrawer·prMainDiscipline·prTierGradeOpen/Modal·prScopeToggle)은 모바일 기록 화면에서 소멸 — 홈 이식본은 home 계약 참조.

Codex 후속 (2026-08-12 폐지 반영)

  • 기록 탭/라우트에서 UiRecordsScreen 마운트 제거(파일 삭제됨). 메뉴 드로어의 "기록" 항목 삭제, "주요 종목 목록 수정"·"1RM 직접 입력" 항목이 각각 UiPrFavSheet/UiPrManualSheet를 셸 레벨로 연다(진행 중 운동 시 1RM 항목 미노출).
  • 페이지 데이터: 드로어 진입 시 board.rows(즐겨찾기 행) + favorites를 공급하고, 컨테이너 검색 컨트롤러에는 보드 행 + 전체 카탈로그를 공급한다. 구 기록 탭 화면 없이도 조회 가능해야 한다.
  • 상세 진입은 홈 보드·리포트 경유 유지: onOpenExercise(id) → 상세 오브젝트 공급 → UiRecordsDetail 렌더, onBack = origin 복귀.
  • onAddManualPr·onToggleFavorite·onReorderFavorites 저장 의미론 기존 그대로.