계획 편집의 판단을 화면 밖 한 벌로 — 플랫폼 공통 Plan headless editor — v0.18.0 A13 (2026-09-08)
- 기간: 2026-09-08 (세션 1개 —
b0457494-68b0-4450-a924-ca8d676f852b, Phase 0~4). 오너 지시 "1340 작업 진행해줘 나한테 물어보지 말고 phase끝까지 완주하고, 아직 선행작업 안끝났으면 기다렸다가 진행해". 계획 ID A13 / Phase 2 스텝 2-3. 직접 선행 A02(#1354)·A03(#1351)·S03(#1363) 은 착수 시점에 전부 main 에 머지돼 있었다. - 랜딩: PR #1375 (Phase 1
9b97af36· Phase 2e7f08edc· Phase 3a785cacc· Phase 4 문서) — 마이그레이션·엣지 함수·Vercel 설정 변경 없음. 앱 코드(계획 모델 신설·데스크톱 편집기 adapter 화·컨트롤러 저장 경로·저장 행 변환 이전)와 테스트·문서만. 머지 해시·staging 결과는 이슈 종결 댓글에 기록한다. - 설계서: 없음 — 착수 분석·비교표·Phase 계획은 #1340 착수 댓글("예상 효과·개선사항" 표 포함).
- 정본:
docs/contracts/plan-editor-model.md· 모델src/react/features/plan/editor/**(10파일 +adapters/mobilePlanAdapter.ts) · 운동·계획 공유 policysrc/react/features/editing/**(5파일, A12 공동 소유) · 데스크톱 adaptersrc/react/ui/desktop/screens/DesktopPlanEditorModel.ts. - 도구: 없음(기존
check-coverage-inventory --render·check-dual-key-tolerance --update만). - 게이트: 새 행동 테스트 3파일 22건(
planEditorModel14 ·planEditorPlatformParity4 ·desktopPlanEditorAdapter4), 명부 등재 앵커 테스트 4파일 재조준(statisticalLoadRounding·backgroundTokens·desktopExerciseIdentity·mappers,pending-changes.json신고), 비명부 8파일 갱신(옮긴 함수 shim·모델 앵커). 마이그레이션·pgTAP 변경 없음.npm run ci:local -- --full결과는 §5. - 버그리포트: 없음(구조 개선).
- 계약: 신설 plan-editor-model.md. 갱신 v0-18-0-domain-contracts.md §5(초 정밀도 정정)·§21(G03 → A13).
Phase 현황
| Phase | 내용 | 상태 |
|---|---|---|
| Phase 0 | 선행 머지 확인, 데스크톱 ↔ 모바일 계획 모드 비교표, Phase 계획 게시 | ✅ 이슈 댓글 |
| Phase 1 | 공유 policy(features/editing) + 계획 모델(features/plan/editor: 타입·카탈로그 port·열기·명령 27종·validation·view model·저장 행·저장 intent·저장 시도) + 테스트 14건 | ✅ 9b97af36 |
| Phase 2 | 데스크톱 편집기 3파일을 모델 위 얇은 adapter 로(JSX·CSS 불변), 앵커 테스트 재조준, 테스트 4건 | ✅ e7f08edc |
| Phase 3 | 컨트롤러 저장 경로 연결(모델 읽기 → 저장 행 → intent union → S03/온라인), lgExercisesToPlanSets 이전, 모바일 adapter, 플랫폼 동치 테스트 4건 | ✅ a785cacc |
| Phase 4 | 계약 문서·작업 기록·장부·인계·ci:local·PR | ✅ PR #1375 |
1. 배경
v0.18.0 Phase 2 는 "안전한 저장 경로와 첫 수직 통합" 이다. A02 가 계획 쓰기 codec 을, A03 이 종목 카탈로그 port 를, S03 이 순수 저장 준비(preparePlanEdit) 를 자기 자리로 옮겨 두었다. 그 앞단 — 유저가 계획을 편집하는 동안의 판단(어떤 종목을, 몇 세트, 어떤 값으로, 다 채웠는가, 저장해도 되는가) — 은 여전히 화면 파일 안에 있었고, 화면이 둘(데스크톱·모바일)이라 판단도 두 벌이었다. A09(첫 수직 통합)가 저장 경로를 dispatcher 로 바꾸고 U02/U03 가 화면을 이식하려면 그 전에 이 판단이 화면 밖 한 벌로 있어야 한다.
2. 문제 제기
계획 편집의 규칙이 화면 파일 안에, 플랫폼마다 한 벌씩 있었다
유저 A 가 내일 계획에 스쿼트 5세트를 적는 판단은 데스크톱 DesktopPlanEditorModel.ts(330줄) + CardParts.tsx(761줄)의 JSX 핸들러(kg/lb/% 변환·% 기준 종목 해석·분:초 합성·세트 타입 단조·복합 동작별 무게 자동 채움)에, 모바일은 WorkoutRecord.tsx(1,640줄) 안에 같은 규칙이 또 한 벌(hasValidRepInput·addRow·setTypeAt·% 변환·WfMinSecInput). 분:초 합성 함수는 두 파일에 글자까지 같은 사본이 있었다.
값의 모양이 플랫폼마다 달라 저장 직전까지 추측했다
데스크톱은 숫자(load: 100)·빈 값 null, 모바일은 문자열(load: "100")·빈 값 "". "12." 처럼 입력 중인 문자열은 type="number" 입력이 "" 로 지워 버리거나(값 소실) React 로컬 state(pctDraft·lbDraft) 로만 보존됐다 — G03 §6 이 막으려는 "입력 중인지 값인지 저장 시점에 추측" 이 그대로였다.
저장 뜻이 문자열 분기였고, 저장 행 변환이 2,200줄 파일에서 전역 카탈로그를 읽었다
컨트롤러 persistPlan 은 intent.mode === "edit" 문자열로 "온라인 생성 / durable 수정" 을 갈랐고, 수정의 서버 원본(sourceRef·updatedAt·개정번호)이 빠진 채 durable 로 가는 것을 타입이 막지 못했다. 편집 행 → 계획 저장 행 변환(lgExercisesToPlanSets)은 barbelicViewMappers.ts(2,258줄) 안에서 전역 exerciseById 를 읽었다 — 테스트·컨트롤러가 카탈로그를 주입할 수 없었다.
어떤 구조라서 가능했나: "계획 편집이 무엇을 허용하고 언제 저장 가능한가" 를 정의하는 자리가 없었다. 화면이 곧 규칙이라 화면마다 규칙이 생겼고, 값 모양은 화면의 입력 요소가 정했다.
3. 해결 방안
원칙
- 오너 결정 없음 — 전제 3개로 진행(착수 댓글): ① 모바일 계획 화면의 저장 게이트(미확정 종목 저장 허용)는 이번에 바꾸지 않는다 ② 공통 policy 폴더 이름은
src/react/features/editing/③ 데스크톱 편집기의 계획/지난 기록 겸용 구조는 유지(분리는 U03). - §22(근본 구조): 화면 안 함수 정리(안 A)나 계획·완료 기록 공용 옵션 엔진(안 B) 대신, 별도
PlanEditorModel+ 운동과 뜻이 같은 작은 policy 만 공유(안 C, 총괄 A13 카드의 금지 사항 준수).
접근
| 안 | 내용 | 판단 |
|---|---|---|
| A. 화면 안에서 함수만 정리 | DesktopPlanEditorModel.ts 정돈, 모바일과 이름 맞추기 | 기각 — 규칙이 화면 파일에 남고 값 모양도 두 벌 그대로 |
| B. 계획·완료 기록 공용 editor 엔진 | 옵션 플래그로 두 도메인을 한 상태 머신에 | 기각 — 총괄 금지(boolean 옵션 엔진). 계획(서버 id 없이 시작·온라인 생성·durable 수정)과 완료 기록(초안·즉시 저장)은 상태 전이가 다르다 |
| C. 별도 PlanEditorModel + 작은 공통 policy | 순수 상태·명령·validation·view model·저장 intent union, 기존 화면은 얇은 adapter | 채택 |
4. 적용한 내용
Phase 1 — 공유 policy + 계획 모델 (9b97af36)
features/editing/:numericInput(G03InputCell+ 쓰기 계약 한도·DB 자릿수 정밀도),loadUnits(kg/lb/% 변환·% 범위),minSecInput(분:초 ↔ 초),setTypeOrder(단조 규칙·정렬),recordingInputs(필수 입력 판정 — 기존 평가기 호출만).features/plan/editor/:planEditorTypes(상태 = 확정값 숫자·null + 입력 중 원문inputs),planEditorCatalog(A03 port: 항목 목록 + 커스텀 dedupe / 조회 함수),openPlanEditor(두 플랫폼 저장물 → 한 모양, canonical 아니면stored_row_invalid, 카탈로그 재수화),planEditorCommands(순수 reducer 27종, 거부 사유 반환, 통과 필드 명령),planEditorSelectors(% 기준·복합·완성),planEditorValidation(planReadiness: 사실만),planEditorViewModel(표시 원문·단위 역산),planRows(→ Phase 3 에서 저장 행 이전),planSaveIntent(create=online / edit·delete=durable union, S03 입력),planEditorSession(저장 시도 번호).- 테스트
planEditorModel.test.mjs14건. 장부 규칙 2줄(features/plan/editor·features/editing) + workout/policy 행 +--render.
Phase 2 — 데스크톱 adapter (e7f08edc)
DesktopPlanEditorModel.ts330 → 184줄: 판단 사본 0(모델 재수출) + 표시 전용(날짜·타입 라벨·휴식 표기·디테일 프리셋) + 커스텀 등록 칩 규칙 + 지난 기록 편집의 통과 필드(dkpOpenEditor·dkpBackfillSetExtras).DesktopPlanEditor.tsx: 편집 상태 =PlanEditorState(React state + 동기 ref), 모든 편집 =dispatch(command), 저장 가능 =planReadiness(막히면focusRowId로 이동), 거부는 그 자리에서 안내.CardParts.tsx761 → 664줄: 값 변환·% 기준·분:초·복합 값 패치가 전부 모델 명령/selector/view model 로,pctDraft·lbDraft로컬 초안 제거(입력 중 원문은 모델inputs).PickerParts.tsx: 동작별 횟수 칩이 횟수 배열을 내보낸다. JSX·CSS·클래스·마커 불변.- 명부 앵커 3파일(
statisticalLoadRounding·backgroundTokens·desktopExerciseIdentity) 모델 실행·모델 파일 앵커로 재조준 + 신고, 비명부 4파일 갱신, 새 테스트desktopPlanEditorAdapter4건. dual-key 기준선 1건 감축.
Phase 3 — 저장 경로·저장 행·모바일 adapter·동치 (a785cacc)
workoutWriteController:saveUiWorkoutPlan이 payload 를openPlanEditor로 읽고(해석 오류 →client_contract_error, 진단 키plan_editor_<code>)planRowsToPlanSets로 저장 행을 만든 뒤planSaveIntentOf로 저장 뜻을 확정한다.persistPlan(plan, PlanSaveIntent): 대기열 폴백은capability === "durable", S03 입력은planEditPrepareInputOf(intent 의 서버 원본). 세션 편집 경로의 계획 수정·계획 삭제(planDeleteIntentOf)도 같은 union.barbelicViewMappers.ts:lgExercisesToPlanSets(131줄) 제거 →features/plan/editor/planRows.planRowsToPlanSets(rows, catalog port). 소비 테스트 6파일은 같은 이름 shim(port =S.exerciseById), 명부분(mappers) 신고.adapters/mobilePlanAdapter.ts: 모바일 콜백 어휘(패치·세트 추가/삽입/삭제/순서·종목 추가/변경/삭제/순서·확정) → 모델 명령. 화면은 미변경.- 테스트
planEditorPlatformParity4건: 같은 순서 16단계를 두 어휘로 → 같은 상태·readiness·저장 행·S03 준비 결과(requestHash·clientMutationId·codec payload), 모바일 직렬화 왕복 동일, create=online/edit=durable, 저장 실패·늦은 응답에 원문 불변.
Phase 4 — 문서·검증 (PR #1375)
- 계약 plan-editor-model.md 신설(모델·adapter·동치 fixture·옮긴/남긴 함수·인계), 도메인 계약 §5·§21, 총괄 A13 카드, 이 기록 + 등록 2곳, 장부 규칙(계약 문서) +
--render, A12/A09 인계 댓글.
주요 결정과 그 근거
- 확정값(숫자·null) + 입력 중 원문(
inputs) 의 이중 표현. 원문만 두면 저장 행·복합 parts·S03 조립 등 하류 전부가 문자열을 다시 읽어야 하고, 확정값만 두면 "12." 가 사라진다. 확정값을 투영으로, 입력 중 원문을 진실로 두면 화면은 원문을 그대로 보이고 저장은 확정값만 싣는다(G03 §6). 입력 중인 칸이 있으면planReadiness가 막는다. - 거부는 예외가 아니라 결과. 상한·% 범위·미완성 확정은 상태를 바꾸지 않고
{code, message}를 돌려준다 — 두 플랫폼이 같은 문구를 같은 자리에서 낸다. - 통과 필드(
patchRowExtras·patchSetExtras). 데스크톱 편집기는 계획과 지난 기록 편집을 겸한다. 완료 기록 편집의 뜻(휴식·리뷰·통계 줄)은 A12 소유라 계획 모델이 판단하지 않고 행에 붙여 보존한다 — boolean 옵션 엔진을 만들지 않으면서 backfill 을 깨지 않는 자리. - 모바일 화면은 바꾸지 않는다. 이슈가 "모든 화면 이식은 Phase 3" 라 했고, 모바일 저장 게이트(미확정 종목 저장 허용)는 제품 정책이라 U02 때 오너가 정한다. 대신 adapter 와 동치 테스트가 "같은 입력 → 같은 저장 요청" 을 지금 증명한다.
- 데스크톱 카드는 모델 명령을 1:1 로 보낸다(별도 adapter 파일 없음). 데스크톱 어휘가 곧 모델 어휘라 번역 층이 불필요하고, 동치 테스트의 "데스크톱 쪽" 은 모델 명령 그대로다.
- lb → kg 는 0.01kg 으로 통일. 데스크톱 0.01kg / 모바일 0.1kg 이 달랐다. DB 컬럼
numeric(8,2)자릿수와 같은 0.01kg 을 모델 규칙으로(모바일 화면은 미이식이라 지금은 그대로, U02 때 자동 통일).
작업 중 드러난 것
- 두 플랫폼 저장물 모양 차이(숫자·null / 문자열·"") 를
openPlanEditor가 흡수하는 것으로 통일 — 동치 테스트가 두 초기값을 같은 상태로 읽는 것을 확인. - 초 단위 정밀도: G03 §5 문서는 "횟수·초·칼로리는 정수" 라 적었으나 DB 컬럼(
duration_seconds numeric(12,3))·기존 화면(1:30.5)·기존 테스트(90.5초)는 소수를 받아들인다. 모델은 DB 자릿수(3자리)를 따르고 §5 를 정정했다. - 명부 앵커 테스트 3건이 데스크톱 소스 문자열(옛 패치 리터럴·
dkpFlowSet)을 봤다 — 모델 실행 결과·모델 파일 앵커로 재조준. 저장 버튼·시도 순서 앵커(workoutFlowDraftSafety)는 화면 로컬 상태(savePending)를 그대로 두어 무변경. - 커버리지 장부 문서가 main 에서 이미 규칙 파일과 어긋나 있었다(문서 영역 32 → 30) —
--render결과를 그대로 반영.--render는 git 이 아는 파일만 세므로 새 파일은git add -N뒤 돌려야 미분류가 잡힌다. - dual-key 래칫이 두 번(DesktopPlanEditorModel 1 → 0, viewMappers 65 → 64) 감축 델타를 요구했다 —
--update로 재생성. - ci:local 브라우저 단계는 다른 세션의 미리보기(포트 4173)·샌드박스 스택 3개와 겹쳐
--only로 나눠 돌렸다(§5).
5. 적용 결과
| 항목 | 전 | 후 |
|---|---|---|
| 계획 편집 판단 함수의 벌 수 | 2(데스크톱 dkp*+카드 핸들러 / 모바일 WorkoutRecord) | 모델 1 + 데스크톱 adapter(판단 사본 0). 모바일 사본은 화면 미이식으로 잔존(U02 제거, 계약 §11 목록) |
| 데스크톱 화면 파일 안의 업무 판단 | DesktopPlanEditorModel.ts 330줄 + 카드 핸들러(변환·% 기준·분:초·복합 값 패치) | 0 — 재수출·표시 전용 184줄(desktopPlanEditorAdapter.test 가 사본 부재를 잠근다) |
| 입력 중 값의 표현 | 로컬 state(pctDraft·lbDraft) 또는 소실 | InputCell 4상태, 원문 보존, 입력 중이면 저장 차단(테스트) |
| 두 플랫폼 같은 입력 → 같은 저장 요청 | 검증 없음 | 동치 테스트 4건(상태·저장 행·requestHash 일치) |
| 저장 뜻 | intent.mode 문자열 분기 | union(create: online / edit·delete: durable), 원본 없는 durable 수정은 만들어지지 않음 |
| 계획 저장 행 변환의 카탈로그 | 전역 exerciseById | port 주입(planEditorCatalog) |
| 분:초·세트 타입·필수 입력·% 범위 규칙 | 파일 2곳씩 | features/editing 1곳(A12 공유) |
| 테스트 | — | 새 22건, npm run check 3,133건 통과 |
- 자동 검증:
npm run checkPhase 마다 통과(단위 3,125 → 3,129 → 3,133건, 정적 게이트 전부). ci:local:검증: ci:local full · <PR 본문의 실제 결과 줄>(§5 갱신은 PR 본문과 같은 값). CI 1회(PR #1375). - 미검증: 모바일 화면은 바꾸지 않았으므로 모바일 실제 화면의 동작 변화는 0 이고, 모바일 adapter 는 동치 테스트로만 검증됐다(화면 연결은 U02). 데스크톱 편집기의 시각·조작감(드래그·포커스)은 코드 불변이라 자동 검증 대상이 아니다.
6. 이번 개선으로 향상된 것
계획 편집 규칙이 한 벌이 됐다
다음 세션(A09·U02·U03)이 규칙을 찾으려면 features/plan/editor/** 만 읽으면 된다. 화면을 이식해도 규칙을 다시 쓰지 않는다.
입력 중 값과 확정값이 구분된다
"12." 가 화면에서 사라지지 않고, 저장은 확정값만 싣는다. 잘못된 저장물("abc")도 버리지 않고 유저가 그 자리에서 고친다.
저장 뜻이 타입이다
신규 계획은 온라인, 기존 계획은 서버 원본을 든 durable — 원본 없이 대기열로 가는 길이 컴파일·런타임 둘 다에서 막힌다.
구조적으로 남는 것
- 계약 plan-editor-model.md — 모델·adapter·동치 fixture·인계.
features/editing/**— 운동 편집기(A12)가 같은 뜻을 같은 파일로 쓴다.- 플랫폼 동치 fixture(
planEditorPlatformParity) — 새 편집 단계를 두 어휘로 추가하는 규칙.
남은 것
- U02:
mobilePlanAdapter를WorkoutFlowBinding계획 모드에 연결하고WorkoutRecord의 판단 사본 제거, 저장 게이트(전부 확정 / 종목 ≥ 1) 오너 결정. - U03: 데스크톱 계획/지난 기록 겸용 편집기 분리 — 통과 필드(
dkpOpenEditorbackfill 갈래)가 완료 기록 편집기(A12)로. - A09:
PlanSaveIntent→ dispatcher. 컨트롤러persistPlan의 두 갈래(온라인 호출·대기열 폴백)가 dispatcher 로 바뀌면legacyEditorIntentOf제거. - A12:
features/editing/**공동 소유 — 파일을 넓히면 계약 §2 표에 한 줄. - 이슈 #1340 은 v0.18.0 릴리스까지 open(
[v0.18.0 스테이징]).