Skip to content

계획 편집의 판단을 화면 밖 한 벌로 — 플랫폼 공통 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 2 e7f08edc · Phase 3 a785cacc · Phase 4 문서) — 마이그레이션·엣지 함수·Vercel 설정 변경 없음. 앱 코드(계획 모델 신설·데스크톱 편집기 adapter 화·컨트롤러 저장 경로·저장 행 변환 이전)와 테스트·문서만. 머지 해시·staging 결과는 이슈 종결 댓글에 기록한다.
  • 설계서: 없음 — 착수 분석·비교표·Phase 계획은 #1340 착수 댓글("예상 효과·개선사항" 표 포함).
  • 정본: docs/contracts/plan-editor-model.md · 모델 src/react/features/plan/editor/**(10파일 + adapters/mobilePlanAdapter.ts) · 운동·계획 공유 policy src/react/features/editing/**(5파일, A12 공동 소유) · 데스크톱 adapter src/react/ui/desktop/screens/DesktopPlanEditorModel.ts.
  • 도구: 없음(기존 check-coverage-inventory --render·check-dual-key-tolerance --update 만).
  • 게이트: 새 행동 테스트 3파일 22건(planEditorModel 14 · planEditorPlatformParity 4 · desktopPlanEditorAdapter 4), 명부 등재 앵커 테스트 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줄 파일에서 전역 카탈로그를 읽었다

컨트롤러 persistPlanintent.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(G03 InputCell + 쓰기 계약 한도·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.mjs 14건. 장부 규칙 2줄(features/plan/editor·features/editing) + workout/policy 행 + --render.

Phase 2 — 데스크톱 adapter (e7f08edc)

  • DesktopPlanEditorModel.ts 330 → 184줄: 판단 사본 0(모델 재수출) + 표시 전용(날짜·타입 라벨·휴식 표기·디테일 프리셋) + 커스텀 등록 칩 규칙 + 지난 기록 편집의 통과 필드(dkpOpenEditor·dkpBackfillSetExtras).
  • DesktopPlanEditor.tsx: 편집 상태 = PlanEditorState(React state + 동기 ref), 모든 편집 = dispatch(command), 저장 가능 = planReadiness(막히면 focusRowId 로 이동), 거부는 그 자리에서 안내. CardParts.tsx 761 → 664줄: 값 변환·% 기준·분:초·복합 값 패치가 전부 모델 명령/selector/view model 로, pctDraft·lbDraft 로컬 초안 제거(입력 중 원문은 모델 inputs). PickerParts.tsx: 동작별 횟수 칩이 횟수 배열을 내보낸다. JSX·CSS·클래스·마커 불변.
  • 명부 앵커 3파일(statisticalLoadRounding·backgroundTokens·desktopExerciseIdentity) 모델 실행·모델 파일 앵커로 재조준 + 신고, 비명부 4파일 갱신, 새 테스트 desktopPlanEditorAdapter 4건. 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: 모바일 콜백 어휘(패치·세트 추가/삽입/삭제/순서·종목 추가/변경/삭제/순서·확정) → 모델 명령. 화면은 미변경.
  • 테스트 planEditorPlatformParity 4건: 같은 순서 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 수정은 만들어지지 않음
계획 저장 행 변환의 카탈로그전역 exerciseByIdport 주입(planEditorCatalog)
분:초·세트 타입·필수 입력·% 범위 규칙파일 2곳씩features/editing 1곳(A12 공유)
테스트새 22건, npm run check 3,133건 통과
  • 자동 검증: npm run check Phase 마다 통과(단위 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: mobilePlanAdapterWorkoutFlowBinding 계획 모드에 연결하고 WorkoutRecord 의 판단 사본 제거, 저장 게이트(전부 확정 / 종목 ≥ 1) 오너 결정.
  • U03: 데스크톱 계획/지난 기록 겸용 편집기 분리 — 통과 필드(dkpOpenEditor backfill 갈래)가 완료 기록 편집기(A12)로.
  • A09: PlanSaveIntent → dispatcher. 컨트롤러 persistPlan 의 두 갈래(온라인 호출·대기열 폴백)가 dispatcher 로 바뀌면 legacyEditorIntentOf 제거.
  • A12: features/editing/** 공동 소유 — 파일을 넓히면 계약 §2 표에 한 줄.
  • 이슈 #1340 은 v0.18.0 릴리스까지 open([v0.18.0 스테이징]).