Skip to content

플랫폼 공통 Workout headless editor 계약 (v0.18.0 A12)

규칙 한 문장 — 운동 편집(입력 판정·완료 판정·복합 동작·초안·저장 intent)의 뜻은 features/workout/editor/** 한 곳이 정하고, 모바일·데스크톱 화면은 그 상태를 그리고 사용자의 행동을 command 로 넘길 뿐이다. 편집기는 React·DOM·네트워크·IndexedDB·CSS 를 모르며, 시각·행 키·카탈로그·1RM 은 ports 로 받는다.

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

유저 A 가 모바일에서 스쿼트 무게 칸에 12. 까지 치고 [세트 완료]를 누른다. 유저 B 는 데스크톱 작성기에서 같은 세트를 적는다. 두 화면은 "이 칸이 비어 있는가, 0 인가, 아직 치는 중인가, 틀린 값인가" 를 같은 함수로 판정해야 한다.

A12 전에는 그 판정이 모바일 기록 화면(WorkoutRecord.tsx 1,640줄) 안의 Number(x)·mHasValue 조건문과 데스크톱 모델(DesktopPlanEditorModel.ts)의 다른 조건문에 흩어져 있었다. 단위 변환(lb→kg·%→kg·분:초·km)도 화면 안에서 일어났고, 행 키(x8f2k1)와 서버 id 가 같은 필드를 썼으며, 저장 payload 는 컨트롤러가 네 단계로 다시 정리한 뒤에야 S03 조립기에 닿았다. 편집기 쪽에는 "저장 가능한가·무엇이 막는가" 를 묻는 자리가 없어 오류는 alert 로 터졌다.

지금은 편집 중인 운동 한 건이 상태 하나(WorkoutEditorState)이고, 사용자가 한 일은 command(set.input·set.toggleDone·exercise.reorder …)이며, 그 결과는 순수 reducer 가 만든다. 같은 초기 DTO·같은 카탈로그·같은 command 열이면 어느 화면이든 같은 상태, 같은 validation, 같은 CompletedSessionWriteInput, 같은 S03 요청 지문이 나온다(workoutEditorAdapters.test.mjs ①).

2. 상태 모델 (model.ts)

개념타입
편집 종류WorkoutEditorIntent = live | backfill | edit{sessionId, expectedRevision, writeSource} | plan{planId}진행 중 운동 / 지난 기록 작성 / 완료 기록 수정 / 계획. 저장 규칙(완료 세트만 vs 전부, 원천 사본 필요)이 여기서 갈린다
종목 행ExerciseRow{key: LocalRowKey, rowId, kind, exerciseId, fields, composite, sets, …, extra}exerciseId 는 canonical uuid 만(자유 기록은 ""). fields명시 프로필만 — 비어 있으면 판정·저장 시점에 카탈로그 → measurementType 순으로 푼다(effectiveFields)
세트 행SetRow{key, rowId, type, cells: SetCells, loadUnit, loadRaw, loadLb, loadPct, parts, done, rest, …, extra}숫자 칸 여섯 개(load·assistKg·reps·durationSeconds·distanceMeters·calories)는 전부 InputCell<number>. extra 는 뜻을 모르는 통과 필드(서버 투영값·세트 스코어 재료)
행 키LocalRowKey(local:<rowId>)G03 §3. 서버 id 와 섞이지 않는다. rowId 는 화면·저장 답장(세트 스코어)이 쓰는 종전 임시 id
portsWorkoutEditorPorts{clock.nowMs, nextRowId, catalog(exerciseId), oneRmKg(exerciseId)}편집기가 바깥에서 받는 전부. 테스트는 고정 시계·순번 키·표 카탈로그를 넣는다

exercisesDirty(state) 는 완료 기록 수정에서 "종목을 고쳤는가" — 열 때의 지문(확정값 기준)과 비교한다. 원문 공백 차이는 변경이 아니다.

3. 숫자 입력 정책 (numericInput.ts) — G03 §6 의 첫 소비자

numericCell(field, raw, { composing? }) → InputCell<number>
사용자가 한 것저장
아무것도 안 적음 / 지움 / 공백emptynull
0valid(0) — 0 은 값(맨몸 세트)0
12.·-·1etyping(raw)금지(input_incomplete), 원문 보존
비수치·정밀도(kg 소수 3자리)·상한·음수·보조 무게 0invalid(reason)금지, 이유 코드(unit_precision·unit_above_max·unit_below_min…)
IME 조합 중(composing: true)원문만 typing 으로 보관 — 판정하지 않는다조합이 끝나면 그때 판정

원문 → 셀 판정(단위 파서·정밀도·상한)은 운동·계획 공유 policy features/editing/numericInput.ts(A13 이 만들고 A12 와 공동 소유)의 numericInputCell 하나다 — 이 파일은 세트 칸 이름(assistKg·durationSeconds …)을 그 원자 이름(assist·duration …)에 대응시키고 IME 조합 중 처리만 얹는다. 정밀도는 DB 자릿수(무게·보조·파운드·% 2자리, 거리·시간 3자리, 칼로리 2자리, 횟수 정수), 상한은 A02 한도 정본(COMPLETED_WORKOUT_WRITE_LIMITS)에서 — 같은 숫자를 두 곳에 두지 않는다. 보조 무게는 0 초과(한도 정본, A12 합류 시 EDITOR_INPUT_BOUNDS.assist 에 반영).

정규값과 표시값의 분리(loadUnits.ts): 무게 칸은 loadUnit(kg·lb·%)을 갖고 사용자가 친 원문은 loadRaw 에 남는다. 정규값은 언제나 cells.load(kg)다 — lb 는 kgFromPounds(0.01kg 단위), % 는 기준 1RM × %(kgFromPercent, 0.5kg/5kg 반올림) — 둘 다 공유 policy features/editing/loadUnits.ts 이고 그 밑의 수식(poundsToKilograms·roundWorkoutLoadKg)은 contracts/workout/loadUnits.ts 다. % 입력은 0 초과 150 이하(EDITOR_INPUT_BOUNDS.loadPct). 기준 1RM 이 없는 % 는 % 만 보관하고 kg 을 비우며 관문이 pct_base_missing 으로 막는다(#837 D2). 시간은 초(minSecPartsOf/minSecCombine), 거리는 m(kmDisplayOf/kmToMetersText)이 정규값이다. 화면은 viewModel.loadFieldView·durationFieldView·distanceFieldView 가 준 문자열을 그릴 뿐 환산하지 않는다.

4. command 와 reducer (commands.ts·reducer.ts)

reduceWorkoutEditor(state, command, ports) → { state, rejection }. 거절은 상태를 바꾸지 않고 코드만 돌려준다(WorkoutEditorRejection{code, path?, detail?}) — 화면이 문구를 고른다.

묶음command종전 자리
종목exercise.add(canonical id 만) · addComposite(동작 2개 이상, 이름 = compositeExerciseName) · addNote · swap(세트·디테일 보존) · remove · reorder · patch · setActive · confirmSets("세트 설정/작성 완료" — 진행 중은 내용 확정만, 작성·계획·수정은 종목·세트 완료) · finish · reopenWorkoutFlow.addExercise/addCompositeExercise/removeExercise/reorderExercise, WorkoutRecord 저장 버튼
세트set.add/insert(직전 세트 잇기: 무게·횟수·시간·거리·유형·단위, 복합은 동작별 값) · remove · reorder(같은 유형 안, moveSetWithinType) · setType(앞 세트보다 앞설 수 없고 뒤 세트는 따라 올라감) · input(칸 원문) · loadInput(단위 있는 무게) · switchLoadUnit(kg/lb 로 가면 % 지움, % 로 가면 kg 에서 % 실체화) · partInput(복합 동작별 값 + D2 뒤 동작 무게 디폴트) · patch(휴식·메모·RPE·목표·결과·실패) · fillDown · toggleDone(관문 통과 시만, 완료 패치는 실패 마킹 정리 — #927) · applyScoresaddRow/insertSet/updSet/rmSet/reorderSet/setTypeAt/toggleDone/fillDown, patchCompositePartDraft
세션session.patch · setIntent(일반 훈련으로 돌아오면 RM 필드 지움) · finishEarly(완료 세트 있는 종목 = done+manualDone, 0개 = 제외, 전부 0개 = 유지) · reopenLast("한 세트만 더할게요")mobileWorkoutFinishEarlyExercises, 완료 화면 onBack

한도(종목 48·종목당 세트 64·총 세트 240)는 추가 시점에 limit_* 로 거절한다. 뜻이 바뀌는 입력(유형·무게·횟수·RPE·목표·결과)은 세트 extra 의 서버 투영값을 비운다(STRENGTH_PROJECTION_RESET).

5. 판정 (validation.ts)

함수종전 자리
effectiveFields(exercise, ports)명시 프로필 → 카탈로그 행 → measurementTypemakeFlowExercise·dkpFlowExercise·lgNormalizeWorkoutExercises 세 곳이 각자
setInputIssuestyping/invalid 칸 → input_incomplete·이유 코드(path 포함)없음(저장 시 Number() 추측)
setSatisfied단일 = 프로필 논리식(mProfileSatisfied, 명시 requiredInputs → 카탈로그 → 기본), 복합 이종 = 동작별 논리식(compositeSetSatisfied), 무게×횟수 복합 = 동작별 횟수 + 공유 무게toggleDone·hasValidRepInput·dkpMissingRecordingFields
setGate입력 → 프로필 → RM 시도(rmAttemptValidation 코드) → % 기준 부재흩어짐
exerciseRecorded / finishGate기록된 종목(완료 플래그·완료 세트·본문 있는 자유 기록) / 종료 관문: discard{cause} · confirm_empty · note_incomplete{key} · unfinished · okmobileWorkoutExerciseRecorded·attemptFinish
sessionIssues / canSave저장 전 전체 판정(행 수 상한·칸·프로필·RM·%). 진행 중 운동은 완료 세트만, 작성·수정·계획은 전 세트없음

6. view model (viewModel.ts)

cellView·setFieldView·loadFieldView·durationFieldView·distanceFieldView(칸 표시값과 상태·이유 코드), setRowView(저장 가능·막는 이유·입력 문제 수), exerciseRowView(기록 여부·진행·다음 세트), sessionProgress, resumeNeedsSetEditReopen(#1010 재개 판정). CSS·마크업은 없다.

7. 초안 왕복 (draft.ts)

기기 초안(S08 store)·서버 백업·화면 스냅샷은 종전 WorkoutDraft 모양(값이 문자열·숫자·null 로 섞인 평면 행)이다. 이 파일이 그 모양 ⇄ 편집기 상태의 유일한 변환 지점이다.

  • 초안 → 상태(editorStateFromDraft): 값은 numericCell 로 판정 — 원문 12.typing 으로 살아난다. 종목 id 가 canonical 이 아니면 Result 오류(exercise_id_not_canonical, 종전 throw 대신). 통과 필드는 extra 에 보존.
  • 상태 → 초안(draftFromEditorState): 확정값은 숫자, 입력 중/틀린 값은 원문, 빈 값은 "". 다시 읽으면 같은 상태(왕복 안정점 테스트).
  • 화면 전용 상태(step·활성 세트·휴식 타이머·완료 화면 입력)는 편집기 밖 — snapshotFromEditorState(state, screen) 봉투에 그대로 실린다. intentFromDraft(draft, mode)editSessionId·expectedRevision·writeSource 로 수정 intent 를 만든다.
  • 초안 TTL·보관·서버 백업 순번은 S08 몫이다. 편집기는 시각을 ports.clock 으로만 읽는다.

8. 저장 intent → S03 (saveIntent.ts)

saveIntentOf(state, ports, deps) → Result<create|update|plan, editor_invalid|…>
prepareEditorSave(state, ports, {userId, base?, sourceRef?, preparation}) → Result<{prepared: PreparedMutation}, …>
  • sessionIssues 가 하나라도 있으면 editor_invalid{issues}대기열에 넣지 않고 상태는 그대로다(원문 보존).
  • exercisesForSave: 저장용 id 는 uuid(영수증 줄 번호표 대조·세트 스코어 키) — 행 키(rowId)는 그대로 두고 주입된 공장(clientIds)이 만든다(테스트는 순번 uuid). 실패 세트 별칭(failed/failReps)은 canonical(setResult=rep_failure·targetReps·reps)로. 복합 동작 프로필은 카탈로그 재수화, 복합 종목의 체중 계수는 0(동작별 반영은 서버 fan-out). 작성·수정·계획은 관문을 지나는 세트가 곧 기록(done), 진행 중은 사용자가 완료한 세트만.
  • 그 다음은 S03 prepareCompletedSessionCreate/Update 다 — 멱등 키(초안 operationId)·지문·개정번호·쓰기 가능 사본 검사는 거기서. 표시용 사본(display)은 S03 validator 가 write_base_not_writable 로 거부한다. 계획 저장은 A13 조립기 몫이라 여기서 준비하지 않는다(plan_not_prepared_here).
  • 편집기는 mutation identity·재시도·outbox 를 소유하지 않는다.

9. 플랫폼 adapter 와 실제 저장 경로 (adapters/**)

adapter받는 것주는 것
editorPortsFromProps({exerciseCatalog, oneRms})화면 props(카탈로그 행 배열·1RM 표)WorkoutEditorPorts
openMobileWorkoutFlow({mode, backfill, initialDraft, initialSnapshot}) / mobileFlowSnapshot / mobileSaveIntent / guardMobileSavePayload모바일 화면의 initialDraft·재개 스냅샷·buildSession payload편집기 상태 / 스냅샷 봉투 / 저장 intent / 통과한 payload
openDesktopWorkoutEditor(draft, request) / desktopSaveIntent / guardDesktopSavePayload데스크톱 작성기의 계획 모델 행(A13 PlanRow, 작성 모드는 완료 기록 필드 동반)·onSave(draft)같음

실제 경로(2026-09-08 현재): WorkoutFlowBinding(모바일)과 desktopApp(데스크톱)이 저장 콜백 앞에 guard*SavePayload 를 끼운다. 입력 중·틀린 값이 남은 세트가 있으면 LG_EDITOR_INPUT_INCOMPLETE("아직 입력 중이거나 잘못된 값이 있는 세트가 있어요…")로 그 자리에서 돌아오고 payload 원문은 불변, 통과하면 같은 참조를 그대로 컨트롤러로 넘긴다. 컨트롤러의 조립(buildLocalWorkoutSaveInput → S03)은 바뀌지 않았다.

A09 가 이어받은 경계(2026-09-08, 이슈 #1342 이행): 컨트롤러의 lgCanonicalizeWorkoutUiAliases → lgNormalizeWorkoutExercises → ensureWorkoutChildIds 세 단계를 adapters/controllerSave.tsprepareEditorExercises(payload → 편집기 상태 → exercisesForSave → 세션 직렬화)로 대체했다 — 동치 검사 a09EditorSaveParity(같은 payload → 같은 S03 requestHash, live·backfill). 모바일 payload 에는 실패 세트 별칭이 없어 최신 초안 스냅샷에서 별칭만 병합한다(mergeDraftSetAliases). guard*SavePayload 는 화면 관문으로 남고 컨트롤러 쪽 editor_invalid 판정이 fail-closed 뒷받침이다 — 관문이 곧 편집기가 되는 것은 화면이 편집기 상태를 직접 들 때(U02/U03). 알려진 차이 2건(자유 기록 bwFactor null·진행 중 미체크 세트)은 첫 수직 통합 계약 §8. 그 뒤 화면 컴포넌트를 editor 상태·view model 로 갈아 끼우는 것은 U02(모바일)·U03(데스크톱), Phase 3 이다.

10. 공통 순수 정책의 자리 (contracts/workout/**)

ui/sharedfeatures 가 읽을 수 없고, featuresui 가 읽을 수 없다(허용표 §2). 두 층이 함께 읽는 순수 정책은 contracts/workout/** 에 둔다(attendancePolicy·gamificationPolicy 선례). 종전 ui/shared/* 파일은 같은 이름을 재수출한다 — 호출부·테스트는 바꾸지 않았다.

두 층의 관계(A13 #1340 과 직렬 합류, 2026-09-08): 운동·계획 편집기가 함께 쓰는 편집 policy(숫자 입력 셀·kg/lb/% 저장·표시 단위·분:초 칸 정리·세트 타입 단조·필수 입력 판정)는 A13 이 만든 features/editing/** 이 정본이고(plan-editor-model.md §2), 그 밑의 순수 수식·값 모델(세트 유형 순서 토큰·정렬, 측정 원자 판정, 복합 parts, lb⇄kg·분:초⇄초·km⇄m 환산, 체중 계수, 직렬화)은 contracts/workout/** 이다. features/editing/minSecInput·setTypeOrder 는 contracts 를 재수출하고, loadUnits·recordingInputsui/shared 재수출을 거쳐 contracts 의 수식을 부른다. 운동 편집기는 features/editing 을 import 한다(§3·§4·§5).

파일내용종전 자리
setTypes.ts세트 유형 순서(warmup → main → top, barbelicCopy 는 라벨만)·정렬·투영 무효화·RM 시도 검사(코드+문구)ui/shared/setSemantics.ts
measureFields.ts원자 ↔ 세트 칸 대응·값 있음 판정·프로필 논리식 평가·합계ui/shared/measureSemantics.ts(표기 함수는 남음)
compositeParts.ts동작별 값 묶음·게이트·패치·공유 무게 편집기 판정·복합 이름ui/shared/compositeSemantics.ts(표기 함수는 남음)
loadUnits.tslb⇄kg·%⇄kg·분:초⇄초·km⇄mui/shared/workoutCalculations.ts·WorkoutRecordParts.tsx
bodyweight.ts체중 계수·유효 무게·lb 복제 원문ui/shared/bodyweightSemantics.ts(라벨은 남음)
writeProjection.ts초안 → 저장 payload 직렬화(세션/계획 모드)ui/shared/workoutFlowPayloadSerializer.ts

11. 같은 sequence fixture

workoutEditorAdapters.test.mjs ①: 스쿼트 2세트(웜업 60×8·메인 100×5) + 러닝(3000m·900초) + 복합(풀업 8회 + 홀드 20초)을 모바일 initialDraft 모양과 데스크톱 dkpFlowExercise 모양으로 열고, 같은 12개 command(lb 입력·횟수·세트 추가·유형·유산소 원자 비우기·동작별 시간·RPE·종목 정렬·세트 확정)를 넣는다. 저장 입력·validation·S03 요청 지문이 같아야 한다. 잘못된 입력(12.)도 같은 issue 를 낸다.

12. 제거한 중복

중복
"값이 있다" 판정mHasValue(모바일)·dkpRecordingFieldValueIsValid(데스크톱)·lgWorkoutSetHasValidRecordingValue(컨트롤러)contracts/workout/measureFields.mHasValue 하나(데스크톱은 위임, 컨트롤러 것은 A09 제거 대상)
완료 가능 판정mProfileSatisfied 호출 + 데스크톱의 별도 requirement 평가mProfileSatisfied 하나(데스크톱 위임)
lb/%/분:초/km 환산WorkoutRecord·WorkoutRecordParts·workoutCalculationscontracts/workout/loadUnits
세트 유형 순서 토큰barbelicCopy.SET_TYPE_OPTIONS 에서 파생contracts/workout/setTypes.SET_TYPE_KEYS 정본, 라벨만 copy
복합 이름 규칙wfxCompName(모바일)·dkpCompName(데스크톱)compositeExerciseName(편집기가 사용; 화면 둘은 그대로 — U02/U03 정리)
종목 프로필 해석makeFlowExercise·dkpFlowExercise·lgNormalizeWorkoutExerciseseffectiveFields
A13 합류로 접은 것features/editing/minSecInput.ts(분해·합성 사본)·setTypeOrder.ts(순서 색인·정렬 사본) · 편집기 numericInput 의 자체 파서 표 · contracts/workout/loadUnits.percentToLoadKg/loadKgToPercent · 편집기 validation 의 자체 복합/프로필 판정contracts/workout 재수출 · numericInputCell(A13) · kgFromPercent/percentFromKg(A13) · missingRecordingFields/compositeFieldsListOf/compositeSharedLoadOf(A13)

13. 인계

  • A09 (#1342) — 이행(2026-09-08): §9 경계 — 컨트롤러 정규화 → exercisesForSave(prepareEditorExercises). WorkoutWritePort 결과 타입 좁히기는 남았다(A15).
  • A13 (#1340): 공유 편집 policy 는 features/editing/**(공동 소유), 그 밑의 수식·값 모델은 contracts/workout/** — §10. 운동 편집기와 계획 편집기는 한 엔진으로 합치지 않는다. plan intent 의 저장 조립은 A13 planSaveIntent.
  • U02/U03: 화면을 WorkoutEditorState·view model 소비자로 — WorkoutRecord 의 복합 초안 두 벌(compositeRepDrafts·compositePartDrafts)과 단위 오버라이드 상태는 set.partInput·set.switchLoadUnit 로 대체된다. 도크·타이머·모달은 화면 전용 상태로 남는다(§7 봉투).
  • S08 (#1326) 인계 미해결: 호출부의 호환 ref 대입 → 전이 함수 직접 호출은 A09 가 컨트롤러를 옮길 때 함께 한다(편집기는 operationId 를 상태에 들고만 있다).
  • A16: ui/shared 재수출 경로 정리(정본은 contracts/workout).

U03 실제 Desktop 소비·원문 초안 (2026-09-10)

#1421은 완료 기록을 DesktopWorkoutEditorAdapter → A12 command/view model로 연결했다. 계획 상태와 완료 기록 상태를 겸용하던 dkpOpenEditor/backfill extra 조립은 활성 화면에서 제거됐다. 입력 중 원문은 draft의 formattedInputs(분·초/km 문자열) 및 performanceInputRaw(휴식·목표/실패 횟수·RPE 문자열)로 저장한다. 복원은 저장된 typed cell의 kind/value를 신뢰하지 않고 공통 parser로 재검증한다. 2분+3.초 복원 후 3.5초 입력은 123.5초가 된다.

기존 m:ss 휴식 문자열은 Desktop 어댑터에서만 이전 초안 호환으로 초 단위로 변환한다. 신규 모델 원문은 이 갈래를 쓰지 않는다. UI-specific 표시 모드만 남으며 저장 전에 extra로 실패/휴식을 다시 보정하지 않는다. U03 계약, 검증 기록을 따른다.

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

모바일의 실제 입력은 WorkoutFlowBinding.MobileEditorFlowmobileWorkoutEditor의 동기 명령과 typed view로 전환했다. guardMobileSavePayload를 매번 다시 열어 검사하던 실제 화면 경로는 제거했다. 원문/IME와 단위/복합/실패/완료 관문은 A12, 초안과 저장 수명은 S08/A09를 사용한다. §12의 모바일 makeFlowSet/Exercise/Draft, 수정 여부·조기 종료·완료 시각 판단 사본과 UI debounce는 제거됐다. 분:초와 performance 원문 직렬화는 공통 draft.ts, 모바일 조합/표시 토큰은 mobileInputDraft.ts가 보완한다. 실제 경로·보존/확정 경계와 U03/U07/U06 인계는 모바일 편집 연결을 따른다.