리포트 도넛·막대를 세트 스코어 기준으로 — 무게 % 구간에서 세트 스코어 히스토그램·4구간 도넛까지, 중량 풀업도 1RM판 (2026-09-03)
- 기간: 2026-09-03 (세션 2개 — 분석·설계·Phase 1·2 절반
ee4db6e6, 완주·랜딩e397e483; 오너 지시 원문 "리포트 세트 강도 도넛·무게 분포 막대를 세트 스코어 기준으로 교체", 추가 "중량풀업, 중량딥스 처럼 맨몸에 중량달고 하는 종목들 전체에 동일하게 적용해줘 … 그냥 스쿼트 이런애들이랑 똑같은거지") - 랜딩: PR #1197 (Phase 1~2,
9a80d951) — 마이그레이션20260907005900_set_score_report_bands_v1, Vercel 배포 O(릴리스 PR #1201 v0.16.0 머지 뒤 Production) - 설계서: 이슈 #1180 본문(분석·Phase 계획·"예상 효과·개선사항") + 댓글(Phase 1·2 완료 요약, 중량 맨몸 추가 반영, Phase 3 랜딩)
- 정본:
docs/data/rpe-performed-intensity.md§5(리포트·하루 상세 집계 계약) ·supabase/migrations/20260907005900_set_score_report_bands_v1.sql(관측 조회set_score_observations_v1, 기간 집계refresh_user_set_score_period_stats_v1) ·src/react/ui/shared/setSemantics.ts(setScore) ·src/react/ui/mobile/screens/ReportScreen.tsx(RpScoreHist·RP_BANDS.score) ·src/react/ui/mobile/lib/DayDetailBody.tsx(DSV_BANDS.score) - 도구: 마이그레이션 빌더
build-migration-1180.mjs+m1180-head.sql/m1180-tail.sql(세션 스크래치패드, 레포 밖) — schema.sql의 함수 최신 본문을 잘라 세트 스코어 집계만 끼워 넣는다 - 게이트: pgTAP
set_score_report_bands_v1(27단언) ·tests/react/setScoreReportBands.test.mjs(8건) ·setScore·mappers·clientStrengthFallback·reportLoadHistogram갱신 · 마이그레이션 자가 검증(새 열 8개·함수 계약·이전 키 생존·auth.uid 1회) - 버그리포트: 없음(기능 트랙)
- 계약:
docs/contracts/report-props.md(report.scoreHist·intensity.score·eStats.avgScore) ·docs/data/rpe-performed-intensity.md§2 표시 대상·§5 ·docs/data/app-screen-rpc-contract.md(+ko)set_score_*키
Phase 현황
| Phase | 내용 | 상태 |
|---|---|---|
| Phase 0 | 분석(리포트·하루 상세 도넛이 무게 ÷ 직전세션 추정 1RM 구간으로 세는 것 확인)·Phase 계획·오너 결정 D1~D7 | ✅ 이슈 본문·댓글 |
| Phase 1 | 서버: 기간 통계·일 요약 집계 열 8개, 관측 조회·기간 집계 함수, 투영 묶음·일 요약·읽기 RPC 5종 재발행, 전원 재계산 | ✅ PR #1197 |
| Phase 2 | 클라: 리포트 히스토그램·4구간 도넛, 주/월 하루 상세 도넛, 어댑터·compact 행·매퍼, 중량 맨몸 예외 삭제, 테스트·문서 | ✅ PR #1197 |
| Phase 3 | 랜딩 1회(잠금·renumber·CI·verify·머지) → 릴리스 PR #1201 | ✅ 9a80d951 / 릴리스 머지 대기 |
| Phase 4 | 이 기록 + 등록 2곳 | ✅ |
1. 배경
이슈 #1164(2026-09-03 랜딩, v0.15.0)로 세션 상세의 세트 칩은 세트 스코어(추정 1RM ÷ 최근 수행 1RM × 10, 반복수 종목은 최대 반복수판)를 보여주게 됐다. 그런데 리포트 탭의 "세트 강도 분포" 도넛과 "세트 무게 분포" 막대, 주/월 하루 상세의 도넛은 여전히 옛 기준(세트 무게 ÷ 직전세션 추정 1RM, 50/70/85% 구간)으로 같은 세트를 세고 있었다. 유저 A가 세션 상세에서 9.8을 본 세트가 리포트에서는 "70–85% 메인세트"로 분류되는 식이라, 같은 세트를 두 화면이 다른 말로 설명했다.
2. 문제 제기
리포트·하루 상세 도넛이 세트 스코어와 다른 잣대로 세트를 센다
user_exercise_period_stats·user_calendar_day_summaries에는 intensity_*(무게 % 4구간)만 있고 세트 스코어 집계가 없었다. 화면은 이 열을 RP_BANDS.str·DSV_BANDS.str로 그렸다.
막대는 "1RM 대비 %" 히스토그램 계약만 있고 서버는 공급하지 않았다
report.loadHist는 픽스처 전용이라 리포트 막대 블록은 실서비스에서 비어 있었다.
중량 풀업·중량 딥스는 세트 스코어가 —였다
#1164 때 "맨몸 계수 종목 + 추가 무게는 추정 1RM이 추가 무게만으로 계산돼 뜻이 없다"고 보고 미산출로 두었다. 오너가 09-03에 "중량이 기준이니 스쿼트와 똑같이 1RM으로 본다"로 뒤집었다.
반복수판·복합 등 경계 사례가 문서와 달랐다
반복수판(풀업·딥스) 복합세트에는 제외 규칙이 없어 문서("복합은 —")와 어긋났다 — 이건 이슈 #1189로 분리(복합종목 세트 스코어 = 동작별 점수 평균).
3. 해결 방안
원칙 (오너 결정 D1~D7, 2026-09-03 채팅 + go 댓글; 추가 결정 09-03)
- D1 도넛·막대 모두 메인 세트만 센다. D4 풀업·딥스 등 반복수판도 같은 도넛·막대에 합산(같은 0~10 척도).
- D2 "세트 무게 분포" 막대는 세트 스코어 히스토그램으로 교체, 1.0 간격. D6 범위
5 미만 · 5–6 · 6–7 · 7–8 · 8–9 · 9–10 · 10–11 · 11 이상(8막대) + 평균 점선, 막대 색 = 도넛 구간 색. - D5 도넛 4구간
7.0 미만 여유 / 7.0–8.5 볼륨 / 8.5–10.0 최대 근접 / 10.0 이상 최대 갱신. D7 제목 "세트 스코어 분포"(도넛) · "세트 스코어 히스토그램"(막대). - D3 데스크톱(기간 도넛 2곳·세션 상세 열)은 이번 제외.
- 추가(09-03): 맨몸에 무게를 다는 종목 전체(중량 풀업·중량 딥스 등)는 스쿼트와 같은 무게 기준 1RM판 — 추가 무게 기준 추정 1RM ÷ 추가 무게 기준 최근 수행 1RM × 10. 무게 0 세트는 종전대로 반복수판, 두 기준값은 따로 저장.
접근
| 대안 | 판단 |
|---|---|
| 클라가 세션 상세 지표 맵으로 도넛을 직접 합산 | 기각 — 리포트는 기간 통계 RPC 한 번으로 그리는 구조(#1013 탭 워밍), 세션 수만큼 지표 맵을 부르면 성능·계약 위반 |
| 기간 통계·일 요약에 세트 스코어 집계 열을 두고 읽기 RPC가 키를 추가 공급 | 채택 — 기존 intensity_* 키는 그대로(데스크톱·구 소비자 생존), 어댑터는 새 키가 없는 옛 응답을 0으로 채워 배포 순서 무관 |
| 범위 평균을 하루 평균(소수 1자리)의 세트 수 가중 평균으로 | 기각 — pgTAP에서 10.6 vs 10.5 오차. 점수 합 열(set_score_sum)을 두고 상위 평균 = 합 ÷ 세트 수로 채택 |
| 중량 맨몸: 체중 + 추가 무게로 추정 1RM 재정의 | 이번 범위 밖 — 오너 결정은 "추가 무게 기준으로 스쿼트와 동일". 예외 규칙만 삭제 |
4. 적용한 내용
Phase 1 — 서버 (#1197, 20260907005900_set_score_report_bands_v1)
user_exercise_period_stats·user_calendar_day_summaries에 8열:set_score_set_count, 구간 4(set_score_lt7/7_85/85_10/gte10_set_count),set_score_sum,average_set_score,set_score_histogram_counts[8](CHECK 8칸·비음수).set_score_observations_v1(uuid, uuid[], date, date)— 1RM판 ∪ 반복수판 메인 세트의 점수를 한 줄로(service_role 전용).refresh_user_set_score_period_stats_v1— 기간 5종 집계. 투영 묶음refresh_user_strength_estimation_projection이 최대 반복수 투영 뒤 이를 호출(나누는 값 확정 뒤).refresh_user_calendar_day_summaries·get_calendar_range_summary·get_volume_overview_v2_core·get_volume_overview_v3_core·calendar_strength_day_metrics_map_v1·empty_strength_aggregate_json_v1재발행 — schema.sql 최신 본문(#1175 uuid 재발행본)에 집계만 삽입. 관측 행 있는 사용자 전원 재계산 + 자가 검증.- pgTAP 27단언: 백스쿼트 5세션·핸드스탠드 푸시업 3세션·중량 풀업 1세션 — 종목별 연간 구간·평균·히스토그램·점수 합, 일 요약, 읽기 맵, 범위 요약(평균 10.5), 볼륨 개요 v2, 중량 풀업 첫 세션 10.0.
Phase 2 — 클라 (#1197)
- 타입·어댑터:
set_score_set_count·average_set_score·set_score_counts[4]·set_score_histogram_counts[8]모양·합 검사, 없으면 0/null. 달력 집계setScore*·가중 평균·히스토그램 칸별 합. - compact 기간 행(
barbelicMappers.compactStrengthPeriodProjection)에 7필드 — 리포트 화면이 읽는 유일한 통로. - 리포트:
RpScoreHist(8막대·구간 색·평균 점선·축 눈금 7/8.5/10·"5 미만"/"11+")·RP_BANDS.score도넛,scoreHist있으면 옛loadHist대신. 주/월 하루 상세DSV_BANDS.score도넛(zScore, 서버 정확 합계만; 구역 게이트 = 세트 스코어 세트 수, 구 응답은 무게 % 세트 수). setScore: 중량 맨몸 예외 삭제(종목 인자 제거). 서버 조회도 같은 조건 삭제.- 테스트 8건 신설 + 기존 4파일·픽스처 갱신,
pending-changes.json신고,data-lg-hookreportScoreHistogram등록. 문서 3종 갱신.
주요 결정과 그 근거
- 점수 합 열: 표시 1자리 반올림을 상위 집계에 다시 넣지 않기 위해. 화면 어디서 봐도 같은 세트 묶음의 평균이 같다.
- 어댑터의 옛 응답 0 채움: 앱과 DB가 다른 순서로 배포돼도 화면이 깨지지 않는다(릴리스 레인 원칙).
- 하루 상세 도넛 구역 게이트: 종전
intensitySetCount(무게 %)에서setScoreSetCount || intensitySetCount로 — 스코어가 없는 구 응답에서도 세트 목적·RPE 도넛이 사라지지 않게.
작업 중 드러난 것
- 마이그레이션 빌더는 schema.sql의 마지막 정의를 대소문자 무시로 잡아야 한다 — #1175가
pg_get_functiondef원문(대문자CREATE OR REPLACE FUNCTION)으로 재발행한 볼륨 개요 v2·v3를 놓치고 옛 본문('__other__'text id)을 다시 발행해 다른 pgTAP 5파일이 uuid 오류로 깨졌다. - 랜딩 직전 main에 #1175(종목 id uuid)·#1188(parts 단일화)이 들어와 리베이스 2회 — 새 함수 인자
text[]→uuid[],exercises.origin컬럼 소멸(테스트 필터owner_user_id is null), 임시 번호 충돌(20260905235900·20260906235900) → 재번호. - 리포트 화면은 compact 기간 행이 필드를 골라 담는 구조라, 어댑터가 새 키를 내도 거기에 안 실으면 화면이 전부 0이다.
- 이중 키 게이트(
check:dual-key): 새 필드는 snake_case 한 형태만 읽는다(어댑터 출력이 경계). - 셸(
node -e) 경유 코드 치환에서 정규식\/의 백슬래시가 사라진다 — 패치는 Write 툴로 파일에 쓴 뒤 실행. - 릴리스 v0.15.0 Production smoke가 "연속 수정" 1건에서 한 번 실패(55000 통계 갱신 예약 검사) → 같은 코드 CI·샌드박스 통과, 재실행 통과. 동시 실행 의심, 메모만.
5. 적용 결과
| 항목 | 결과 |
|---|---|
| 리포트 탭 막대 | "세트 무게 분포"(서버 미공급, 비노출) → 세트 스코어 히스토그램 8막대 + 평균 점선(서버 set_score_histogram_counts) — 렌더 테스트 통과 |
| 리포트 탭 도넛 | 무게 % 4구간 → 세트 스코어 4구간(여유/볼륨/최대 근접/최대 갱신) — 매퍼·렌더 테스트 통과 |
| 주/월 하루 상세 도넛 | 무게 % 4구간(클라 폴백 포함) → 세트 스코어 4구간(서버 정확 합계만) — 렌더 테스트 통과 |
| 범위(주/월) 평균 세트 스코어 | pgTAP 픽스처 10.6(반올림 재가중) → 10.5(점수 합 ÷ 세트 수) |
| 중량 풀업 세트 스코어 | — → 1RM판(pgTAP 첫 세션 10.0, 단위 30 ÷ 25 × 10 = 12.0) |
| 서버 검증 | 샌드박스(main 마이그레이션 전부 + 이번) pgTAP 96파일 1,500건 통과 |
| 로컬 게이트 | npm run check 2,465/2,466(남은 1건 = Windows 줄바꿈에서만 실패하는 탭 전환 계측 테스트, CI 통과분) · 타입·미사용·이중 키 통과 |
| CI | PR #1197 verify·scope·migration-smoke 통과(15분) |
| Production | 미적용 — 릴리스 PR #1201(v0.16.0) 머지 대기. 적용 뒤 프로브: set_score_set_count > 0 행 수, 볼륨 개요 히스토그램 합 = 세트 수, client_error_events 새 kind 0 |
| 시각 품질 | 히스토그램 막대 색·평균 점선 위치·도넛 범례는 자동 렌더 검증만(사람 눈 확인은 하지 않음) |
6. 이번 개선으로 향상된 것
같은 세트를 화면마다 같은 말로 설명한다
세션 상세 칩(9.8)과 리포트 도넛·히스토그램·하루 상세 도넛이 모두 세트 스코어 하나로 센다. 옛 무게 % 구간은 데스크톱 후속(D3)까지 데이터로만 남는다.
리포트 막대가 실제 데이터를 그린다
픽스처 전용이던 히스토그램 블록이 서버 8칸 집계로 채워진다. 평균 점선은 서버 가중 평균(소수 1자리).
중량 맨몸 종목도 점수가 있다
중량 풀업·중량 딥스가 스쿼트와 같은 규칙으로 계산되고, 같은 종목의 무게 0 세트는 반복수판으로 따로 산다.
구조적으로 남는 것
- 기간 통계·일 요약의 세트 스코어 8열이 정본 — 데스크톱(D3 후속)·#1189(복합)이 같은 키를 쓴다.
- 관측 조회
set_score_observations_v1한 곳이 "무엇을 세트 스코어로 세는가"를 정한다(메인만, 1RM판 ∪ 반복수판). - 어댑터의 옛 응답 0 채움 + 자가 검증의 이전 키 생존 단언 — 키 추가형 변경의 표준 형태.
남은 것
- 릴리스 PR #1201 머지 → Production 프로브 → 이슈 #1180 [반영완료].
- 복합종목 세트 스코어(동작별 점수 평균) — 이슈 #1189(D1~D6 확정, #1180 랜딩 뒤 착수).
- 데스크톱 기간 도넛 2곳·세션 상세 열의 세트 스코어 전환 — D3 후속, 별도 이슈.
- 세트 수 통계가 복합세트를 동작별로 세는지 1세트로 세는지 — #1189 Phase 1에서 실측.
- 릴리스 레인 Production smoke의 동시 실행 의심 실패 — 재발 시 deploy.yml smoke에 concurrency 그룹 검토.