Skip to content

계획 편집 모델 계약 — 플랫폼 공통 headless editor (v0.18.0 A13)

규칙 한 문장 — 계획 편집의 판단(무엇을 허용하고, 값이 무슨 뜻이고, 언제 저장 가능한가)은 src/react/features/plan/editor/** 한 곳에 있고, 데스크톱·모바일 화면은 그 모델에 명령을 보내고 결과를 그리기만 한다. 같은 카탈로그·같은 초기 계획·같은 입력 순서면 어느 화면에서 왔든 같은 상태·같은 저장 행·같은 저장 요청이 된다.

1. 유저 이야기 — 이 계약이 지키는 것

유저 A 가 데스크톱에서 내일 계획에 스쿼트 3세트를 적는다. 무게 칸에 "12." 까지 쳤을 때 칸은 "12." 그대로 보이고(12 로 되돌려 쓰지 않는다), 그 상태로 [저장] 은 막힌다. "120" 을 마저 치고 횟수 5 를 넣고 [세트 작성 완료] 를 누르면 종목이 확정된다. 벤치프레스는 % 로 넣는다 — 1RM 100kg 의 70% 는 70kg, 5kg 반올림을 켜면 70kg 그대로. 풀업은 맨몸이라 % 입력이 열리지 않는다. 모두 확정하면 저장이 열리고, 저장 실패가 나도 적은 값은 그대로다.

유저 B 가 모바일에서 같은 계획을 같은 순서로 적으면 — 모바일은 값을 문자열("120") 로, 빈 칸을 "" 로, 무게 단위를 세 필드 묶음으로 보내지만 — 모델 상태는 A 의 것과 같고, 저장 행도, 서버로 갈 요청의 지문(requestHash)도 같다(planEditorPlatformParity.test.mjs).

A13 전에는 이 판단이 화면 파일에 있었다: 데스크톱 DesktopPlanEditorModel.ts(330줄) + CardParts.tsx 의 JSX 핸들러, 모바일 WorkoutRecord.tsx 안에 같은 규칙이 한 벌씩. 값의 모양도 플랫폼마다 달랐다(숫자·null / 문자열·""). 이 계약이 그 판단을 화면 밖 한 벌로 옮긴 결과다.

2. 운동·계획이 공유하는 작은 policy (features/editing/**, A12 와 공동 소유)

운동 편집기(A12 #1339)와 뜻이 같은 것만 공유한다. 전체 editor 를 boolean 옵션으로 합치지 않는다(총괄 A13 카드).

파일규칙근거
numericInput.ts원자 칸(무게·보조·횟수·거리·시간·칼로리·파운드·%) 의 원문 → G03 InputCell(empty/typing/valid/invalid). 범위 = 쓰기 계약 한도(COMPLETED_WORKOUT_WRITE_LIMITS), 정밀도 = DB 컬럼 자릿수(무게·보조·파운드·% 2자리, 거리·시간 3자리(numeric(12,3)), 칼로리 2자리), 횟수 정수, % 는 0 초과 150 이하G03 §5·§6, #975 D1
loadUnits.tskg/lb/% 변환: lb → kg 0.01kg 단위(DB 자릿수), % → kg 0.5kg(5kg 반올림 켜면 5kg), kg → lb 표시 0.5lb, % 범위 문구 하나2026-07-18 lb 재적용, #676, #1150 D2
minSecInput.ts[분]:[초] ↔ 초. 자동 0 채움 없음, 초 0–59 클램프, 소수점 첫 1개만#788, #864, #975 Phase 3-5
setTypeOrder.ts세트 타입 단조(웜업 < 메인 < 다운): 앞 세트가 하한, 뒤 세트는 끌려 올라감, 순서 변경 뒤 타입 순 안정 정렬모바일·데스크톱 종전 동일 규칙
recordingInputs.ts"세트가 기록 항목을 다 채웠는가": 명시 required_inputs → 카탈로그 행 → 프로필 기본(유산소-단독 = 하나 이상, 그 외 = 전부). 복합은 동작별 논리식 전부(이종·맨몸) 또는 동작별 횟수 + 공유 무게. 값 판정: 무게 0 이상, 횟수 양의 정수, 그 밖 0 초과#947(오너 결정 2026-08-30)

이 파일들은 types/recordingRequirement·ui/shared/compositeSemantics·ui/shared/workoutCalculations 의 평가기·수식을 부를 뿐 재구현하지 않는다. A12(#1339) 합류(2026-09-08): 그 수식·값 모델의 정본은 contracts/workout/**(ui·features 공용 층, ui/shared 는 재수출)이며 minSecInput·setTypeOrder 는 contracts 를 재수출한다 — workout-editor.md §10. G03 §5 문서가 "초는 정수" 라 적었으나 DB 컬럼과 기존 화면(1:30.5)이 소수를 받아들이므로 이 계약이 소수 3자리로 정정한다(정본 = DB 자릿수).

3. 카탈로그 port (planEditorCatalog.ts)

종목 identity 는 서버 uuid 하나(A03). PlanEditorCatalog = { byId(exerciseId) → PlanCatalogEntry | null }. 만드는 길은 둘: 화면이 받은 항목 목록 + 세션 커스텀(planEditorCatalogOf(catalog, customs), 같은 id 는 커스텀 우선 dedupe #674) 과 컨트롤러의 조회 함수(planEditorCatalogFromLookup(exerciseById)). 이름·slug 로 id 를 추측하는 결과는 없다. 항목은 {id, name, nameEn, fields, measurementType, requiredInputs, bwFactor, loadMultiplier, isCustom, synonymId?} 로 좁힌다.

4. 열기 (openPlanEditor.ts)

openPlanEditor(initial, ctx, {date?, confirmRows?}) → Result<PlanEditorState, PlanEditorOpenError>.

  • 두 플랫폼의 저장물 모양(숫자·null / 문자열·"")을 한 모양으로 읽는다 — 확정값은 숫자 또는 null, 숫자로 못 읽는 저장값("abc")은 버리지 않고 inputs 에 원문으로 남긴다(유저가 그 자리에서 고친다).
  • canonical uuid 가 아닌 종목(복합 동작 포함)은 stored_row_invalid 로 돌려준다 — 저장물 해석 오류이며 유저 입력 오류(validation)와 다른 종류다. 화면은 조용히 버리지 않고 시끄럽게 멈춘다(종전 dkpRequireDraftExercise throw 와 같은 문구).
  • 카탈로그 재수화: 명시 fields 가 없으면 카탈로그 프로필, 체중 계수·measurementType 도 카탈로그, 복합 동작 프로필도 카탈로그, parts 없는 복합 세트는 동작별 1회.
  • 기존 계획(종목이 있는 초기값)의 행은 확정 상태로 시작(데스크톱 종전 동작, confirmRows 기본값).
  • 계획이 모르는 키(휴식·완료·메모·리뷰 등 완료 기록 편집의 뜻)는 읽지 않는다. 지난 기록 편집이 같은 데스크톱 화면을 쓰므로 그 통과 필드는 adapter(dkpOpenEditor) 가 같은 자리의 행·세트에 덧붙인다(§9).

5. 상태와 명령 (planEditorTypes.ts·planEditorCommands.ts·planEditorSelectors.ts)

PlanEditorState { date, title, scheduledTime, rows: PlanRow[] }
PlanRow = PlanExerciseRow { id, exerciseId, name, synonymId?, pctBase?, pctRoundKg?, bwFactor, measurementType, fields, requiredInputs?, details, composite?, sets, confirmed }
        | PlanNoteRow { id, name, title, note, sets: [], confirmed }
PlanSetRow { id, type, load, loadLb, loadPct, userLoadOverrideUnit, assistKg, reps, distanceMeters, durationSeconds, calories, parts, statsLoad, statsEffectiveLoad, inputs? }
  • 값의 모양: 확정값은 숫자·null. 입력 중 원문(typing/invalid)은 inputs[칸] 에 문자열로만 남고 그 칸의 확정값은 null 이다 — 저장 직전 추측이 없다(G03 §6). 확정되면 키가 사라진다.
  • identity: id 는 화면 사본 키(로컬), 종목 identity 는 exerciseId(복합은 "" + composite[].exerciseId). 서버 id 는 만들지 않는다.
  • 명령 applyPlanEditorCommand(state, command, ctx) → { state, rejection } (순수, React·DOM·네트워크·IDB 없음, 새 키·1RM·카탈로그는 ctx). 27종: 제목/날짜/시각 · 종목 추가(카탈로그 항목)/복합 추가/자유 기록 추가/종목 변경/행 삭제/행 순서 · 자유 기록 제목·본문(40자/1,000자) · 디테일(종목·동작, 최대 3, 중복 제거)/복합 구성 수정(src 로 값 재매핑) · 세트 추가(직전 세트 복제, 상한)/삭제/순서(뒤 타입 정렬)/타입(단조) · 원자 입력(setInput)/무게 단위 입력(setLoadInput kg·lb·%)/복합 동작별 입력(setPartInput, 무게 자동 채움 #947 D2)/동작별 횟수 · % 기준/5kg 반올림(% 세트 kg 재환산) · clearPercentLoads/derivePercentFromKg(모바일 단위 전환 뜻) · 확정/재편집 · 통과 필드 patchRowExtras/patchSetExtras.
  • 거부: 상한(종목 48·종목당 세트 64·전체 240)·% 범위 밖·미완성 확정·빈 자유 기록·없는 행/세트·카탈로그 밖 종목은 상태를 바꾸지 않고 {code, message} 를 돌려준다. 예외를 던지지 않는다. 문구는 종전 UI 문구 그대로(UI_WRITE_LIMIT_MESSAGES·% 범위 문구).
  • 무게 편집은 서버 통계 무게 투영(statsLoad·statsEffectiveLoad)을 무효(null)로 만든다 — 종전 화면 패치 리터럴의 규칙을 명령이 갖는다.
  • selector: percentBaseOf(명시 기준 → 단일 = 자기 종목·복합 = 첫 1RM 보유 동작, 사라진 1RM 은 잠금, 맨몸은 % 불가), compositeSharedLoad, setMissingFields, compositeRowName.

6. 저장 가능 판단 (planEditorValidation.ts)

모델은 사실만 낸다: planReadiness(state, ctx) → { canSave, blockers[], focusRowId }. blocker = no_rows · row_unconfirmed · no_sets · set_incomplete{missing} · input_pending{fields} · note_empty. canSave = 행 ≥ 1, 전부 확정, 문제 0. 어느 blocker 로 저장 버튼을 막을지는 adapter 의 결정이다 — 데스크톱은 종전대로 전부(미확정이면 그 행으로 이동), 모바일 화면은 지금 "종목 ≥ 1" 만 보므로 Phase 3 U02 이식 때 어느 게이트를 쓸지 정한다(제품 정책, 이 트랙은 바꾸지 않았다).

7. 화면 표시값 (planEditorViewModel.ts)

setFieldCellView(set, field)·loadCellView(row, set, unit, ctx)·partFieldCellView(...){ raw, cell, pending }. 표시 원문 = 입력 중 원문이 있으면 그것, 없으면 확정값(lb 는 0.5lb 표시, % 는 원본 % 없으면 kg 에서 역산 — 1RM 없으면 빈 칸). inferredLoadUnit(저장된 원본에서 첫 단위 모드)·inferredDurationMinutesMode(60초 이상 기록이 있으면 분). 화면은 이 값을 그대로 넣는다 — 카드 안에 % 초안·lb 초안 로컬 state 가 없다.

8. 저장 뜻과 저장 시도 (planSaveIntent.ts·planEditorSession.ts)

PlanSaveIntent = { kind:"create", capability:"online", date, operationId, sourceRef }
               | { kind:"edit",   capability:"durable", date, sessionId, base: { sourceRef, updatedAt, serverRevision } }
PlanDeleteIntent = { kind:"delete", capability:"durable", sessionId, base }
  • planSaveIntentOf(편집기를 연 뜻, 서버 원본) → Result: 수정인데 sourceRef·updatedAt 이 없으면 base_missing — durable 수정이 원본 없이 만들어지는 길이 없다(G03 §11, ADR P1: 신규 계획 생성은 온라인 전용). planEditPrepareInputOf(intent, userId, plan) 이 S03 preparePlanEdit 입력을 만든다 — hash·mutation id 는 편집기가 만들지 않는다(S03 몫). (A09 #1342, 2026-09-08: legacyEditorIntentOf 는 지웠다 — workoutPlanCommands.save 가 새 계획 생성 intent 를 그대로 받고, 수정·삭제는 features/plan/commands/planWriteCommand.ts 가 대기열로 보낸다.)
  • 컨트롤러(workoutWriteController.persistPlan)는 intent.capability === "durable" 일 때만 서버 미도달을 대기열로 보낸다(종전 intent.mode === "edit" 문자열 검사).
  • planEditorSession: 저장 시도 번호. beginPlanSave(저장 중이면 새 시도 안 엶) → settlePlanSave(state, attempt, outcome) — 현재 시도가 아닌 답(늦은 응답)은 무시. 편집 원문(PlanEditorState)은 이 상태와 분리돼 있어 저장 실패가 입력을 지우지 않는다. 데스크톱 화면은 시도 순서 보호를 DesktopSessionScreencomposeSaveAttemptRef 로 이미 갖고 있어 이번에 바꾸지 않았다(모바일 이식 때 이 모듈을 쓴다).
  • 옛 계획 대기행(needs_preparation)의 준비는 S03 prepareLegacyPlanRow 가 한다 — 편집기는 저장물을 decode 하지 않는다(§4 의 stored_row_invalid 는 편집기가 받은 초안 의 해석 오류이고, 대기열 행 decode 오류(DecodeResult)는 S01 몫).

9. adapter 계약

9-1. 데스크톱 (ui/desktop/screens/DesktopPlanEditor*.tsx, DesktopPlanEditorModel.ts)

  • 편집 상태 = PlanEditorState(React state + 동기 ref). 모든 편집은 dispatch(command) — 카드는 모델 명령을 1:1 로 보낸다(별도 번역 없음). 거부는 그 자리에서 alert(상한·%) 또는 오류 표시(확정).
  • 표시값은 view model. 화면 로컬로 남은 것: 무게 단위 표시 모드(데스크톱은 전환해도 값을 바꾸지 않는다), 분|초 모드, 확정 뷰 kg 환산 체크, 탭·드래그·열림·포커스, 재편집 라벨(reeditIds), 저장 버튼 busy(savePending).
  • DesktopPlanEditorModel.ts 는 재수출 + 표시 전용(날짜 표기·타입 라벨·휴식 표기·디테일 프리셋·복합 이름 줄바꿈 방지) + 커스텀 종목 등록 칩 규칙(1~3 항목) + 지난 기록 편집(backfill)의 통과 필드(dkpOpenEditor·dkpBackfillSetExtras: 휴식·완료·메모·리뷰·통계 줄·기존 자식 id) — 이것은 완료 기록 편집의 뜻(A12 소유)이고 계획 모델은 판단하지 않는다. 편집기 화면의 완전 분리는 Phase 3 U03.
  • JSX·CSS·클래스·data-lg-* 마커는 바꾸지 않았다.

9-2. 모바일 (features/plan/editor/adapters/mobilePlanAdapter.ts)

모바일 화면 콜백 어휘(onAddSet·onInsertSet·onRemoveSet·onReorderSet·onUpdateSet(exId, i, patch)·onUpdateExercise(exId, patch)·종목 추가/변경/삭제/순서) → 모델 명령. 패치 번역 규칙: parts 묶음 → 동작별 횟수 또는 동작별 입력 · 무게 세 필드 묶음은 원본 필드가 값을 가진 단위로(loadPct → %, loadLb → lb, 그 밖 kg; {loadPct:null} 만 오면 kg 값 유지) · done → 확정/재편집 · pctBase·pctRoundKg·details·composite → 각 명령 · 나머지 키 → 통과 필드. 화면(WorkoutFlow.tsx·WorkoutRecord.tsx)은 이번에 바꾸지 않았다 — Phase 3 U02 가 WorkoutFlowBinding 의 콜백에 이 adapter 를 꽂고 WorkoutRecord 의 판단 사본을 지운다.

9-3. 컨트롤러 (controllers/workoutWriteController.ts)

saveUiWorkoutPlan(payload): 두 플랫폼의 payload → openPlanEditor(해석 오류는 client_contract_error, 진단 키 plan_editor_<code>) → planRowsToPlanSets(rows, catalog port)planSaveIntentOfpersistPlan(plan, intent). 세션 편집 경로의 계획 수정(updateUiSession)과 계획 삭제(deleteSession)도 같은 intent 함수를 쓴다.

10. 플랫폼 동치 fixture

tests/react/planEditorPlatformParity.test.mjs — 같은 카탈로그(스쿼트·벤치·풀업)·같은 초기 계획(계획 상세 초안 모양, 빈 값 "")·같은 1RM·같은 입력 순서 16단계(재편집 → "12." → 120/3 → 세트 추가 → 웜업 → 확정 → 벤치 70% → 5kg 반올림 → 풀업 10회 → 확정 → 제목)를 데스크톱 어휘(모델 명령)와 모바일 어휘(adapter 이벤트)로 넣어 deepEqual(상태·readiness·저장 행·S03 준비 결과의 requestHash·clientMutationId·codec payload). 모바일 직렬화(serializeWorkoutFlowExercises mode:"plan")를 거친 payload 를 다시 열어도 같은 저장 행. 새 순서를 더할 때는 두 배열에 같은 단계를 추가한다.

11. 옮긴 것·남긴 것

종전 자리종전 이름지금
DesktopPlanEditorModel.tsdkpFlowExercise·dkpFlowSet(계획 갈래)openPlanEditor·planSetRowOf. backfill 갈래는 dkpOpenEditor+dkpBackfillSetExtras(adapter, 통과 필드)
dkpMissingRecordingFields·dkpSetRecordingIsComplete·dkpExerciseSetsAreComplete·dkpRecordingFieldsOf·dkpRecordingFieldValueIsValidfeatures/editing/recordingInputs.ts(재수출 유지)
dkpCloneSetMeasurements·dkpSortSets·dkpRequireDraftExercise·dkpDraftExerciseIsResolvable·dkpBwFactorFor삭제 — 세트 복제는 addSet 명령, 정렬은 setTypeOrder.sortSetsByType, 해석 실패는 stored_row_invalid
dkpMergeCatalogWithCustomsplanEditorCatalog.mergeCatalogWithCustoms(재수출 유지)
dkpOneRmFor·dkpCatalogExerciseFor·dkpExerciseId유지(재수출·조회 도우미, 앵커 테스트가 읽는다)
CardParts.tsxkg/lb/% 변환·% 기준 해석·% 범위·분:초 합성·세트 타입 단조·복합 값 패치·동작별 무게 자동 채움·pctDraft/lbDraft 로컬 초안모델 명령·selector·view model. 카드는 원문을 보내고 표시값을 받는다
DKP_NOTE_MAX = 1000·DKP_NOTE_TITLE_MAX = 40openPlanEditor.PLAN_NOTE_BODY_MAX/TITLE_MAX(카드는 참조)
DesktopPlanEditor.tsxdraft 로컬 상태·patchSets/patchEx·doneIds 집합·allDone·anyIncompleteSetsPlanEditorState·dispatch·행 confirmed·planReadiness
services/barbelicViewMappers.tslgExercisesToPlanSets(전역 exerciseById)features/plan/editor/planRows.planRowsToPlanSets(rows, catalog port). 소비 테스트 6파일은 같은 이름 shim
controllers/workoutWriteController.tspersistPlan(plan, WorkoutPlanEditorIntent)·intent.mode === "edit" 분기persistPlan(plan, PlanSaveIntent)·capability === "durable"·planEditPrepareInputOf·planDeleteIntentOf
모바일 WorkoutRecord.tsx·WorkoutRecordParts.tsxhasValidRepInput·addRow·setTypeAt·% 변환·minSecPartsOf/Combine·PCT_RANGE_MSG남아 있다(화면 미이식). U02 가 mobilePlanAdapter 를 꽂으며 제거
services/barbelicViewMappers.tslgPlanToWorkoutDraft·lgPlanSetsToExercises(계획 상세 → 초안, 읽기 매퍼)남김 — 읽기 모델(A09/U02). openPlanEditor 가 그 출력을 입력으로 받는다

12. 인계

받는 작업가져가는 것
A12(#1339) 운동 편집기features/editing/** 5파일(공동 소유). 같은 뜻이면 이 파일을 쓰고, 다른 뜻이면 자기 폴더에 둔다. numericInput.EDITOR_INPUT_BOUNDS 의 상한을 바꾸려면 쓰기 계약·한도 장부부터. 파일을 넓히면 이 계약 §2 표에 한 줄
A09(#1342) 첫 수직 통합 — 이행(2026-09-08)PlanSaveIntent(§8)의 capability 가 갈래를 정한다: durable(수정·삭제)은 enqueuePlanEdit/enqueuePlanDelete(S03 조립 → 대기열 → dispatcher), online(생성)은 workoutPlanCommands.save 직접. 컨트롤러의 온라인 우선·대기열 폴백 두 갈래와 legacyEditorIntentOf 는 지웠다. 첫 수직 통합 계약 §4
U02(모바일 화면)mobilePlanAdapter.applyMobilePlanEventsWorkoutFlowBinding 계획 모드 콜백에 연결. 저장 게이트(§6) 결정 필요(제품 정책). lb→kg 반올림이 0.1kg → 0.01kg 로 통일된다(모델 규칙)
U03(데스크톱 화면)계획/지난 기록 겸용 편집기 분리 — 통과 필드(§9-1)가 없어지는 지점. dkpOpenEditor 의 backfill 갈래는 완료 기록 편집기(A12)로
G03§5 "초는 정수" 정정(이 계약 §2). §6 의 소유자 A13 이행 완료

U03 이식 결과 (2026-09-10)

#1421에서 §9-1·§12의 잔여 backfill 이식 항목을 수행했다. 계획은 A13만, 완료 기록은 A12만 소유한다. DesktopPlanEditorModel은 계획 adapter 재수출과 표시 helper를 유지하고 완료 기록 extra 조립 소비자를 제거했다. 분/초 raw 입력은 공통 formattedInput 명령으로 전달한다. 지연 저장 도중 바뀐 계획은 열린 상태로 남고, 새 계획의 저장 응답 ID를 채택하여 다음 저장을 수정으로 보낸다. U03 작업 기록. Production 적용 전이다.

U02 모바일 적용 (2026-09-10, #1420)

§9-2의 실제 화면 이식은 active-workout/mobilePlanEditor.tsWorkoutFlowBinding.MobileEditorFlow에 연결됐다. WorkoutRecord/Parts는 A13 상태에서 나온 typed 칸과 command를 사용하며, 숫자 원문·배열 복사·단위 환산 사본을 소유하지 않는다. 모바일 저장 버튼의 기존 정책(0행/저장 중에 잠김)을 유지하고 실제 payload는 공통 관문으로 검증한다. 모든 행 확인을 요구하는 데스크톱 정책을 추가하지 않는다. 유산소 표시 항목은 원문이 잠시 미완성일 때에도 유지한다. 모바일 편집 연결의 브라우저·인계 증거를 참고한다.