플랫폼 공통 Workout headless editor — 입력 판정·완료 판정·복합·초안·저장 intent 를 한 곳에, 화면은 adapter 로 — v0.18.0 A12 (2026-09-08)
- 기간: 2026-09-08 (세션 1개
1706c9e1-4ecc-470b-a56a-229e3015d166, Phase 0~5). 오너 지시 "1339 작업 진행해줘 나한테 물어보지 말고 phase끝까지 완주하고, 아직 선행작업 안끝났으면 기다렸다가 진행해". 계획 ID A12 / Phase 2 Step 2-3. 선행 A02af2a828b·A03530bb4cf·S031bba53eb는 착수 시점에 main 에 있었다(대기 없음). - 랜딩: PR #1378 squash 머지
1514fc52(2026-09-08 03:15 KST, based5e12e9d· A13 #1375 뒤 리베이스·합류, 최종 headfd87c1ad) — staging Deploy run 34150888395 성공, Repository checks run 34150888392 성공(scope 판정 verify-only — 브라우저·마이그레이션 레인 생략, 브라우저 여정은 ci:local 로 로컬 완주). 마이그레이션·엣지 함수·Vercel 설정 변경 없음 — 앱 코드(contracts/workout 신설·features/workout/editor 신설·바인딩/셸 관문·데스크톱 모델 위임)와 테스트·문서만. 스테이징 배포 결과는 이슈 마지막 댓글. - 설계서: 없음 — 착수 분석과 Phase 계획은 #1339 착수 댓글("예상 효과·개선사항" 표 포함).
- 정본:
docs/contracts/workout-editor.md·src/react/features/workout/editor/**·src/react/contracts/workout/**. - 도구: 없음(기존
check-coverage-inventory --render·check-dual-key-tolerance --update만). - 게이트: 새 행동 테스트 3파일 21건(
workoutEditorCore11 ·workoutEditorSave6 ·workoutEditorAdapters4). 기존 소스 앵커 테스트 5파일 재조준(noteStatLines·statisticalLoadRounding·runtimeExerciseIdentity·appContainer·workoutFinishAutoSave— manifest 등재 3건은pending-changes.json신고, 검증 의도 불변). pgTAP·마이그레이션 변경 없음. - 버그리포트: 없음(구조 개선).
- 계약: 신설 workout-editor.md. 갱신 v0-18-0-domain-contracts.md §21(G03 → A12), 허용표 §2(
contracts/workout/**), workout-screen-props.md(관문 절), coverage 장부(workout/policy 행·규칙 3줄).
Phase 현황
| Phase | 내용 | 상태 |
|---|---|---|
| Phase 0 | 선행 SHA·겹침 확인, 분류표, 계획 게시 | ✅ 이슈 댓글 |
| Phase 1 | 순수 운동 정책을 contracts/workout/** 6파일로 이전, ui/shared 재수출 | ✅ be7b1372 |
| Phase 2 | editor 핵심 — InputCell 세트 모델·숫자 입력 정책·command 26종·pure reducer·validation·view model | ✅ ac33db19 |
| Phase 3 | 초안 DTO ⇄ 상태 왕복·스냅샷 봉투·저장 intent → S03 조립기 | ✅ e5ced7d0 |
| Phase 4 | 모바일·데스크톱 adapter, 실제 저장 콜백 앞 관문, 데스크톱 판정 위임, 동일성 fixture | ✅ cc32d766 |
| Phase 5 | 계약 문서·장부·작업 기록·ci:local·PR | ✅ 10498263 |
| 합류 | A13 #1375 뒤 리베이스, 공통 policy 중복 접기 | ✅ PR #1378 → 1514fc52 |
1. 배경
v0.18.0 Phase 2 는 "안전한 저장 경로와 첫 수직 통합" 이다. A02/A03 이 typed 저장 DTO·카탈로그 DTO 를, S03 이 순수 조립기(prepareCompletedSession*)를 만들어 두었다. 그 앞 — 사용자가 세트를 적고 완료하고 종료를 누르는 "편집" — 은 아직 화면 파일 안에 있었다. A12 는 그 편집의 뜻을 UI 와 독립된 모델로 꺼내, 모바일과 데스크톱이 같은 입력에 같은 결과를 내게 하는 트랙이다. A09(수직 통합)·A13(계획 편집기)·U02/U03(화면 이식)이 이것을 소비한다.
2. 문제 제기
입력 원문이 곧 초안이었고 판정은 쓰는 자리마다 달랐다
유저 A 가 무게 칸에 12. 까지 치면 모바일 초안(WorkoutFlow.makeFlowSet)은 그 문자열을 그대로 load 에 넣었다. "값이 있는가" 는 mHasValue, "완료할 수 있는가" 는 toggleDone 의 조건문, "저장할 수 있는가" 는 컨트롤러의 Number(x) — 세 곳이 각자 판정했고, Number("12.") = 12 라 입력 중인 값이 12 로 조용히 저장됐다. 데스크톱 작성기는 dkpRecordingFieldValueIsValid 로 같은 규칙을 다시 썼다. G03 §6 의 InputCell(empty/typing/valid/invalid)은 계약만 있고 소비자가 없었다.
단위 변환이 화면 안에서 일어났다
lb 입력은 기록 화면의 onChange 가 kg 으로 바꿔 넣고 원문을 loadLb 에 남겼다. % 는 화면이 1RM 을 찾아 환산했다. 분:초·km 는 입력 컴포넌트가 초·m 로 바꿨다. 정규값과 표시값의 책임이 화면에 섞여 데스크톱은 같은 규칙을 또 구현했다.
행 키와 서버 id 가 한 필드였고, 완료 판정·빈 세션 종료가 UI 파일 소속이었다
모바일 uid()(x+난수)가 세트·종목의 id 이고 그 값이 저장 답장의 세트 스코어 키로 돌아왔다. mobileWorkoutExerciseRecorded·mobileWorkoutFinishEarlyExercises·attemptFinish 관문·toggleDone 의 프로필/복합/RM 검사는 전부 WorkoutFlow.tsx/WorkoutRecord.tsx 에 있었고, 데스크톱은 dkpMissingRecordingFields 로 따로 판정했다. 복합 종목의 대표 프로필·체중 계수 해석도 두 화면이 달랐다(모바일 무게×횟수 기본·대표 동작 계수 / 데스크톱 카탈로그 재수화·계수 0).
저장 intent 가 느슨한 객체였다
화면 buildSession → 컨트롤러 lgCanonicalizeWorkoutUiAliases → lgNormalizeWorkoutExercises → ensureWorkoutChildIds → completedSessionWriteProjection 네 단계 뒤에야 S03 조립기. 편집기 쪽에는 "저장 가능한가·무엇이 막는가" 를 묻는 자리가 없어 오류는 alert 로 터졌다.
공유 정책이 ui/shared 에 살았다
compositeSemantics·measureSemantics·setSemantics·workoutCalculations·workoutFlowPayloadSerializer 는 순수 함수지만 ui/** 소속이라 features/** 가 읽을 수 없었다(허용표 §2).
어떤 구조라서 가능했나: 편집의 뜻(값 판정·완료·복합·저장 payload)이 "화면 상태" 와 같은 객체·같은 파일에 있어서, 화면을 하나 더 만들면(데스크톱) 뜻도 한 벌 더 생겼다.
3. 해결 방안
원칙
- 오너 결정 없음 — 전제로 진행(착수 댓글 §2): 공통 정책 위치 =
contracts/workout/**(ui·features 둘 다 읽는 층,attendancePolicy선례) · 세트 유형 순서 토큰은 contracts 정본·barbelicCopy는 라벨 · 숫자 칸은 전부 G03InputCell· 행 키는LocalRowKey·주입 공장 · 시계는 ports · 저장 payload 모양은 A02CompletedSessionWriteInput그대로 · 화면 동작·CSS·마커는 바꾸지 않는다. - §22(근본 구조): 핸들러를 한 파일로 옮기는 땜질 대신 상태 union + 순수 reducer + 판정 한 벌 + 얇은 adapter.
접근
| 안 | 내용 | 판단 |
|---|---|---|
| 땜질 | 모바일 핸들러를 useWorkoutEditor.ts 한 파일로 옮기고 데스크톱은 나중에 | 기각 — 이슈가 명시로 금지, 뜻 두 벌 그대로 |
| 채택 | ① 순수 정책 → contracts/workout/**(재수출) ② features/workout/editor/** 상태·reducer·판정·view model ③ 저장 intent → S03 ④ 모바일·데스크톱 adapter + 실제 저장 콜백 앞 관문 ⑤ 같은 sequence fixture 동일성 검증 | 채택 |
| 검토 | 화면 컴포넌트까지 editor 로 교체 | 범위 밖(U02/U03, Phase 3) |
| 검토 | 관문 대신 편집기가 만든 요청으로 컨트롤러를 바로 교체 | 이번엔 관문까지 — 컨트롤러 4단계 정규화 교체는 A09 의 수직 통합과 함께(경계를 계약 §9 에 명시) |
4. 적용한 내용
Phase 1 — 공통 정책 이전 (be7b1372)
contracts/workout/setTypes.ts(세트 유형 순서·정렬·투영 무효화·RM 시도 검사 코드+문구),measureFields.ts(원자↔칸·값 있음·프로필 논리식·합계),compositeParts.ts(동작별 값·게이트·패치·복합 이름),loadUnits.ts(lb⇄kg·%⇄kg·분:초·km),bodyweight.ts(계수·유효 무게),writeProjection.ts(초안 → 저장 payload 직렬화).ui/shared/{setSemantics,measureSemantics,compositeSemantics,workoutCalculations,bodyweightSemantics,workoutFlowPayloadSerializer}.ts는 같은 이름 재수출 + 표기 함수만 남김.barbelicCopy는SET_TYPE_KEYS를 contracts 에서 읽는다.WorkoutRecordParts의 분:초·km 입력 칸은 contracts 환산 함수를 쓴다.- dual-key 기준선은 자리 이동만(총 413 사이트 불변). coverage 장부 workout/policy 행·규칙 추가.
Phase 2 — editor 핵심 (ac33db19)
model.ts:WorkoutEditorIntent(live/backfill/edit/plan)·ExerciseRow·SetRow(칸 여섯 개 =InputCell,extra통과 필드)·WorkoutEditorPorts·exercisesDirty.numericInput.ts:numericCell(field, raw, {composing})— 칸별 단위 파서(A02 한도 상한), IME 조합 중은 원문만 typing.commands.ts·reducer.ts: 종목 11·세트 13·세션 4 command, 거절은 코드만(상태 불변). 직전 세트 잇기·유형 밀어올림·아래 채우기·완료 패치(실패 마킹 정리)·단위 전환·D2 뒤 동작 무게 디폴트.validation.ts:setInputIssues·setSatisfied·setGate(입력 → 프로필 → RM → % 기준)·exerciseRecorded·finishGate·sessionIssues(진행 중은 완료 세트만)·canSave.viewModel.ts: 칸 표시값(단위별)·행/종목/세션 view.- 테스트
workoutEditorCore11건(고정 시계·순번 키·금지 전역·결정성).
Phase 3 — 초안 왕복·저장 intent (e5ced7d0)
draft.ts: 종전 초안 → 상태(원문12.는 typing 으로 복원, canonical 아닌 id 는 typed 오류) → 초안(확정값 숫자·입력 중 원문·빈 값 "") 왕복 안정점. 스냅샷 봉투(화면 전용 상태는 밖).intentFromDraft.saveIntent.ts:sessionIssues→ create/update/plan intent →exercisesForSave(uuid 는 주입 공장, 실패 세트 canonical 화, 프로필·체중 계수 해석) →completedSessionWriteProjection→prepareCompletedSessionCreate/Update. 계획은 A13 몫(plan_not_prepared_here).- 테스트
workoutEditorSave6건(왕복·결정적 요청·editor_invalid 상태 불변·display 사본 거부·plan 직렬화).
Phase 4 — adapter·실경로 관문·중복 제거 (cc32d766)
adapters/ports.ts(화면 props → ports),saveGuard.ts(guardEditorSavePayload+WorkoutEditorSaveRejected{code: LG_EDITOR_INPUT_INCOMPLETE}),mobileFlowAdapter.ts,desktopPlanEditorAdapter.ts.WorkoutFlowBinding(onSaveSession·onAutoSaveSession·onSavePlan)·desktopApp(onSaveBackfill·onSavePlan)이 관문을 끼운다.userErrorPolicy에 문구 등재("아직 입력 중이거나 잘못된 값이 있는 세트가 있어요…").DesktopPlanEditorModel:dkpRecordingFieldValueIsValid→mHasValue,dkpMissingRecordingFields단일 종목 분기 →mProfileSatisfied위임.- 편집기
effectiveFields(명시 → 카탈로그 → measurementType)·복합 동작 프로필 재수화·복합 계수 0 으로 두 플랫폼 해석 통일. - 테스트
workoutEditorAdapters4건(같은 DTO·sequence → 같은 저장 입력·validation·요청 지문 / 관문 / 실경로 앵커 / 데스크톱 판정 동치).
Phase 5 — 문서·검증 (PR #1378)
- 계약 workout-editor.md 신설(모델·입력 정책·command·판정·view model·초안·저장 intent·adapter·공통 정책 자리·sequence fixture·제거한 중복·인계), 도메인 계약 §21, 허용표 §2, 운동 화면 계약 관문 절, 이 기록 + 등록 2곳,
ci:local --full.
주요 결정과 그 근거
- 공통 정책은
contracts/workout/**에. ui 와 features 가 함께 읽을 수 있는 유일한 층이고, contracts 는 types 만 안다(경계 테스트가 지킨다). 재수출로 호출부·테스트 무변경. - 관문까지만 실경로에 붙이고 요청 조립 교체는 A09 로. 컨트롤러의 4단계 정규화는 S08 초안 identity·영수증 처리와 얽혀 있어(#1199 파이프라인) A09 의 수직 통합에서 함께 옮기는 것이 안전하다. 관문은 payload 를 바꾸지 않으므로 컨트롤러 경로는 종전과 동일하고, 편집기 판정만 앞에 선다. 경계는 계약 §9 에 적었다.
12.는 저장을 막는다(G03 §6). 종전엔 12 로 조용히 저장됐다. 의도된 정책 변경이며 문구를 등재해 사용자가 원인을 본다. 진행 중 운동에서 완료 안 한 세트의 입력 중 값은 기록에 실리지 않으므로 막지 않는다.- 복합 종목 대표 프로필·체중 계수는 데스크톱 규칙으로 통일(카탈로그 재수화·계수 0). 모바일의 무게×횟수 기본값은 카탈로그가 없던 시절의 폴백이었고, 동작별 체중 반영은 서버 fan-out 몫이다.
- 행 수 상한은 A02 한도 정본에서, 문자 수 상한은 화면 몫. features 는
ui/shared/writeLimitsCopy를 읽을 수 없고, 바이트 상한은 S03 조립기(codec)가invalid_input으로 잡는다.
작업 중 드러난 것
- 소스 앵커 테스트 5파일이
ui/shared파일 본문·바인딩/셸의 콜백 문자열을 읽고 있어 재조준(manifest 등재 3건 신고). 자리 이동은 앵커 테스트를 깨뜨린다(A02 교훈 재확인). check-typescript-boundary-gate는 새 파일의\bany\b토큰을 주석까지 본다 — 새 파일에서 "any" 단어를 쓰지 않았다(논리식 키 이름도 코드 밖에서 언급하지 않음).- dual-key 게이트: 자리 이동으로
contracts/workout에 6 사이트가 생기고ui/shared에서 같은 수가 줄었다 —--update로 기준선 재생성(총 불변).draft.ts의measurement_type폴백 1건은 초안이 camel 단일 키(lgAssertCurrentWorkoutDraftKeys)라 제거. - 코드 있는 채로
check-coverage-inventory는 git 추적 파일만 세므로git add뒤에--render. - A13(#1340, PR #1375) 과 공통 policy 직렬 합류. A13 이 운동·계획 공유 편집 policy 를
features/editing/**에 먼저 두었고(이슈 인계 댓글), 이 트랙은 같은 규칙을contracts/workout/**에 두었다. A13 이 CI 초록·랜딩 대기 중이라 A13 을 먼저 보내고 이 트랙이 뒤에 합류했다:features/editing/minSecInput·setTypeOrder는contracts/workout재수출로 접고, 편집기numericInput은 A13numericInputCell·EDITOR_INPUT_BOUNDS를 파서로 쓰며(보조 무게 0 초과를 한도 정본대로 반영), 무게 단위 환산은 A13loadUnits(lb→kg 0.01·% 150 상한), 완료 판정은 A13recordingInputs를 호출한다.DesktopPlanEditorModel은 A13 판(얇은 adapter)을 그대로 받았고, 이 트랙의 데스크톱 fixture 는 A13 계획 모델 행(dkpOpenEditor)으로 바꿨다 — 그 상태에서도 두 adapter 동일성 테스트가 통과한다(계약 §10 "두 층의 관계"). - 오너 지시대로 오너 결정 0건.
5. 적용 결과
| 항목 | 전 | 후 |
|---|---|---|
입력 중 값(12.) 판정 위치 | 화면 2곳(Number(x)·mHasValue) + 데스크톱 모델 1곳 + 컨트롤러 1곳 | numericInput.numericCell 1곳(G03 InputCell) |
12. 의 저장 결과 | Number("12.") = 12 로 조용히 저장 | 저장 금지(LG_EDITOR_INPUT_INCOMPLETE, 원문 보존) — 진행 중 운동의 미완료 세트는 예외 |
| IME 조합 중 값 | 판정 없음(원문 그대로 초안) | typing 으로 보관, 조합 끝에 판정 — 원문 불변(테스트) |
| lb·%·분:초·km 환산 코드 | 화면 3곳(WorkoutRecord·WorkoutRecordParts·workoutCalculations) | contracts/workout/loadUnits 1곳 |
| 완료 판정·빈 세션 종료·미수행 세트 제거 | 모바일 4함수 + 데스크톱 2함수 | editor validation·reducer 1벌, 데스크톱 판정은 contracts 위임(동치 테스트) |
| 복합 대표 프로필·체중 계수 해석 | 모바일 ≠ 데스크톱 | effectiveFields·exercisesForSave 한 규칙 |
| 편집기 → S03 경로 | 없음(컨트롤러 4단계 뒤) | prepareEditorSave — 고정 identity·시계·순번 uuid 로 결정적 요청(테스트), 표시용 사본은 S03 이 거부 |
| 양 플랫폼 동일성 검증 | 없음 | 같은 DTO·catalog·sequence → 같은 저장 입력·validation·요청 지문(fixture 테스트) |
| features 가 ui 를 import 하는 파일 | 0 | 0 유지(boundary 테스트) · contracts/workout 6파일 |
| 테스트 | — | 새 21건, 전체 단위 3,136건 통과(리베이스 후 ci:local verify) |
A13 features/editing 과 겹치는 규칙 | 두 벌(분:초·세트 타입 순서 사본, 숫자 파서 표 둘, %·lb 환산 둘, 복합/프로필 판정 둘) | 한 벌 — 편집 policy 는 features/editing, 수식·값 모델은 contracts/workout(계약 §10) |
- 자동 검증: 각 Phase 마다 tsc·ESLint·boundary gate·unused gate·dual-key·coverage 장부·관련 스위트 통과. 전체 단위 스위트(
npm test) Phase 1·Phase 4 시점 실행.검증: ci:local full (A13 #1375·S05 #1374 뒤 main `d5e12e9d` 위로 리베이스 + 합류 커밋 `6ed11b71`) · verify 통과(정적 게이트 전부 + 단위 3,169건) · 샌드박스 db reset(마이그레이션 전체 적용)·schema.sql --check 통과 · pgTAP 117파일 전부 ok · e2e local 13/13 · empty 11/11 · cardio 7/7 · persistence 6+15 통과 · 브라우저 단계는 포트 4173 을 다른 세션 미리보기가 잡고 있어 코드 3 → 같은 빌드(BARBELIC_TARGET=local)를 4179 에 띄우고 `E2E_APP_URL=http://127.0.0.1:4179` + `CI=true` + 샌드박스 `E2E_SUPABASE_*` 로 따로: **browser 39/39 · viewport 14/14 통과**(플레이키 0). 합류 전(`50fdda05`·`8ca9450e`)도 같은 지점까지 전부 통과.`50fdda05` ← main `b672eb38`, 바뀐 파일 50개) · verify 통과(정적 게이트 전부 + 단위 3,136건) · 샌드박스 db reset(마이그레이션 전체 적용)·schema.sql 스냅샷 --check 통과 · pgTAP 117파일 전부 ok · e2e local 13/13 · empty 11/11 · cardio 7/7 · persistence(browser) 6+15 통과 · **브라우저 단계는 포트 4173 을 다른 세션의 미리보기(PID 84748)가 잡고 있어 코드 3 종료** → 같은 빌드(`npm run build`, BARBELIC_TARGET=local)를 4179 에 띄우고 `E2E_APP_URL=http://127.0.0.1:4179` + `CI=true` + 샌드박스 `E2E_SUPABASE_*` 로 따로 실행: **browser 39/39 · viewport 14/14 통과**(플레이키 0). 첫 실행(리베이스 전 `8ca9450e`)도 같은 지점까지 전부 통과. - 미검증: 화면 시각(칸 표시값 문자열이 화면에 어떻게 보이는지)은 view model 문자열 테스트까지만 — 화면 컴포넌트는 바꾸지 않았다. 실기기 확인은 요청하지 않는다(§14).
6. 이번 개선으로 향상된 것
입력의 뜻이 한 곳에 있다
빈 값·0·입력 중·틀린 값이 네 상태로 갈리고, 어느 화면이든 같은 함수가 판정한다. 한글 키보드로 치는 중인 값을 잃지 않으면서 잘못된 값은 저장되지 않는다.
편집이 데이터가 됐다
사용자의 행동이 command 열이고 결과가 상태라, 같은 fixture 를 두 플랫폼 adapter 에 넣어 결과를 비교할 수 있다. U02/U03 이 화면을 갈아 끼울 때 이 fixture 가 회귀 기준이다.
저장 전에 무엇이 막는지 typed 로 안다
sessionIssues 가 코드·경로 목록을 주고, 실제 저장 콜백 앞 관문이 그것으로 사용자를 막는다 — alert 가 아니라 등재된 문구.
구조적으로 남는 것
contracts/workout/**— 플랫폼 공통 운동 정책의 자리(ui·features 공유).- G03 §6
InputCell의 소비자와 §3LocalRowKey의 사용처. - 계약 workout-editor.md §9 의 A09 경계와 §13 인계.
남은 것
- A09 (#1342): 관문 →
prepareEditorSave, 컨트롤러 4단계 정규화 →exercisesForSave교체(계약 §9). S08 인계(호환 ref 대입 → 전이 함수)도 그때. - A13 (#1340): 계획 편집기는 같은 정책을 쓰되 한 엔진으로 합치지 않는다.
- U02/U03: 화면을 상태·view model 소비자로(복합 초안 두 벌·단위 오버라이드 상태 대체).
- A16:
ui/shared재수출 경로 정리,lgWorkoutSetHasValidRecordingValue(컨트롤러 중복) 제거. - HQ: 총괄 A12 카드 갱신안은 이슈 마지막 댓글.