이 페이지에서
JunkBox apps/workout-ui 가이드
1. 애플리케이션 역할
apps/workout-ui는 workout 운영 화면 전용 프런트엔드 앱임.
workout-api가 제공하는 서버 렌더링 앱 셸 위에 React SPA를 마운트하는 역할을 담당함.
운동 세션 목록, 세션 편집, 운동 계획, 운동 관리, 러닝 계획, 러닝 실행 화면 라우트를 단일 React 엔트리에서 소유함.
공통 스타일과 공통 React 유틸은 libs/shared-ui를 소비함.
2. 런타임 구성
번들러는 Vite 6 기반 React + TypeScript 구조를 사용함.
운영 산출물은 apps/workout-ui/dist에 생성됨.
workout-api는 이 디렉터리를 /workout-ui 경로로 직접 마운트함.
개발 서버 기본 포트는 15003임.
루트 진입 /, /workout-ui, /workout-ui/는 /workout-sessions로 리다이렉트됨.
workout 앱은 workout-api가 제공하는 manifest와 service worker, shared-ui 앱 아이콘 자산과 함께 설치형 PWA 진입을 지원함.
3. 엔트리 구조
src/entries/workout.tsx
단일 엔트리는 BrowserRouter와 WorkoutApp을 마운트함.
화면 전환은 React Router가 담당하며, 서버 앱 셸의 상단 네비게이션 클릭은 shared-ui SPA navigation 이벤트를 통해 React Router로 전달됨.
WorkoutApp은 /workout-sessions, /workout-sessions/new, /workout-sessions/:sessionId/edit, /workout-plans, /workout-exercises, /workout-exercises/new, /workout-exercises/:exerciseCode/edit, /running-plans, /running-plans/new, /running-plans/:planId/edit, /running-run/:planId 라우트를 소유함.
WorkoutApp은 junkbox:spa-navigate 이벤트를 수신해 앱 셸 네비게이션을 React Router navigation으로 연결함.
WorkoutApp은 라우트 본문을 SpaRouteTransition으로 감싸고, 라우트 변경 후 useSpaRouteSettling을 호출함.
4. 페이지 구조
단일 엔트리는 서버 템플릿이 주입한 workout-ui-root, workout-ui-page-props를 기준으로 마운트함.
workout-ui-page-props는 currentUserId, allowedModules 같은 앱 공통 초기값 중심으로 사용하고, 화면별 식별자와 모드는 URL path/query를 기준으로 해석함.
페이지 구현은 src/pages/*.tsx에 위치함.
운동 도메인 응답 계약과 화면 props 계약은 src/types/models.ts 기준으로 관리함.
브라우저 전역 타입과 import.meta.env 타입은 src/global.d.ts 기준으로 관리함.
5. 공통 의존 구조
공통 React 유틸은 @shared-ui/* alias를 통해 libs/shared-ui/src/react를 참조함.
공통 SVG 아이콘, 토스트, HTTP 요청, 페이지 props 파서, 숫자 선택 시트를 직접 복제하지 않음.
공통 CSS는 서버 템플릿이 shared-ui stylesheet 매크로를 호출해 hub-ui-base.css + hub-ui-mobile.css 조합으로 로드함.
workout 모바일 화면은 Tabulator를 사용하지 않으며, 서버 템플릿은 Tabulator CSS를 로드하지 않음.
SPA 전환, skeleton, 터치 피드백, 로딩 안정화 같은 화면 크기 무관 공통 장치는 shared-ui base CSS와 React 계층에서 소비함.
모바일 고정 폭, 바텀 액션바, 바텀시트 같은 모바일 레이아웃 종속 규칙만 shared-ui mobile CSS에서 소비함.
6. 개발 서버 프록시 구조
/api/workout, /workout-sessions, /workout-plans, /workout-exercises, /running-plans, /running-run, /error/403, /shared-ui, /health, /workout.webmanifest, /workout-sw.js는 workout-api로 프록시됨.
/error/403 프록시는 하위 호환 진입점 유지를 위한 것이며 별도 권한 오류 페이지를 의미하지 않음.
/workout.webmanifest와 /workout-sw.js는 workout-api가 제공하는 PWA 자산이며, Vite 개발 서버 origin에서 화면을 열 때도 동일 origin 요청이 404로 떨어지지 않도록 프록시 대상에 포함함.
/api/auth, /api/hub, /api/portal, /login은 인증 발급과 hub 공통 API를 위해 hub-api로 프록시됨.
개발 모드 엔트리 자산 경로는 /workout-ui/src/entries/workout.tsx 기준임.
Vite dev client 경로는 /workout-ui/@vite/client임.
React refresh runtime 경로는 /workout-ui/@react-refresh임.
7. 로그인 복귀 규칙
workout 보호 페이지는 비로그인 시 hub-api 공통 로그인으로 리다이렉트됨.
개발 모드에서 workout-api는 요청 헤더의 dev server origin을 사용하여 redirect=http://localhost:15003/...를 유지함.
hub-api 로그인 성공 후 required_module=WORKOUT과 허용 origin 검증을 통과하면 15003 개발 서버 경로로 복귀함.
WORKOUT 권한이 없으면 hub-api 로그인 화면으로 돌아가며 permission_denied=1을 통해 권한 없음 alert를 한 번 표시함.
운동 관리 경로는 WORKOUT과 HUB 권한을 모두 요구하며, 누락된 권한이 있으면 공통 로그인 권한 없음 흐름으로 처리함.
8. 구현 원칙
workout 전용 화면만 포함하고 hub 화면 엔트리와 타입은 포함하지 않음.
화면 이동은 window.location.href가 아니라 React Router navigation을 사용함. 단, 파일 다운로드와 로그인/로그아웃처럼 문서 이동이 필요한 흐름은 예외임.
라우트 전환은 shared-ui의 SpaRouteTransition과 View Transition 기반 SPA navigation 옵션을 사용해 이전 화면 스냅샷이 빠지고 다음 화면 스냅샷이 들어오는 흐름으로 처리함.
라우트 변경 직후에는 shared-ui의 useSpaRouteSettling을 사용해 스크롤 위치를 초기화하고 주요 오브젝트가 가볍게 안착하는 흐름을 적용함.
최상위 화면 데이터 로딩은 텍스트 로딩 박스보다 shared-ui의 SkeletonBlock을 우선 사용함.
빠르게 끝나는 요청의 skeleton 깜빡임은 shared-ui의 useStableLoading으로 방지하고, 화면별 임의 타이머를 만들지 않음.
터치 피드백과 카드/패널 안착 애니메이션은 shared-ui 공통 base CSS에 위임하고, 모바일 바텀 액션바 같은 모바일 전용 레이아웃 효과만 모바일 CSS에 둠.
SPA 효과를 위해 화면별 CSS를 만들지 않으며, 새 앱에 같은 체계를 적용할 때도 shared-ui의 SPA 공통 틀을 먼저 사용함.
페이지 전용 CSS 파일을 추가하지 않음.
공통 시각 규칙은 shared-ui 클래스 조합으로만 표현함.
공통 유틸 또는 아이콘이 필요하면 먼저 libs/shared-ui/src/react 확장을 우선함.
모바일 화면은 360px 고정 폭 기준을 전제로 마크업과 공통 클래스를 조합함.
사용자 노출 텍스트는 화면 컴포넌트에 한국어 문구로 직접 작성함.
공통코드 라벨과 메타 설정값처럼 데이터 자체가 마스터데이터인 값만 DB 조회 구조를 사용함.
9. 화면 동작 구조
9.1 운동 기록 목록
/workout-sessions는 카드형 목록 화면임.
카드 선택과 새 세션 생성은 클라이언트 라우팅으로 이동하며 앱 shell을 다시 로드하지 않음.
목록 조회 로딩 중에는 SkeletonBlock 목록 variant를 표시함.
상단 제목 외 장식성 설명 카피를 기본 전제로 두지 않음.
총 세션 수 문구는 컴포넌트 코드에 한국어 문구로 직접 작성함.
페이지 크기 선택은 shared-ui의 SegmentedTabs를 사용하며 10개, 20개, 50개 옵션을 같은 탭 표준으로 표시함.
세션 카드의 운동 날짜는 YYYY-MM-DD (화)처럼 날짜 옆에 요일을 같은 텍스트 위계로 붙여 표시함.
세션 카드의 요일 표시는 별도 뱃지, 색상, 수식 요소를 사용하지 않음.
세션 카드의 운동 종목 뱃지는 API가 반환한 순서를 그대로 사용하며, 백엔드는 workout 운동 기준정보의 sort_order_no 오름차순 기준으로 정렬한 운동 요약을 제공함.
하단 고정 액션바에서 다운로드, 새 세션 생성, 운동 계획 생성 진입을 제공함.
하단 고정 액션바의 버튼 순서는 다운로드 -> 새 세션 -> 계획 생성임.
다운로드와 새 세션은 고스트 버튼을 사용하고, 계획 생성은 흰색 강조 버튼을 사용함.
운동 계획 화면은 상단 앱 셸 메뉴에 독립 메뉴로 노출하지 않으며, 운동 기록 화면의 계획 생성 버튼에서 /workout-plans로 진입함.
9.2 세션 상세
/workout-sessions/new, /workout-sessions/{session_id}/edit는 같은 React 페이지를 사용함.
생성/수정 모드는 서버 props가 아니라 React Router path와 path parameter 기준으로 결정함.
생성 화면의 기본 운동일은 GET /api/workout/meta/default-workout-date로 조회함.
폼 초기 메타와 상세 조회 로딩 중에는 SkeletonBlock form variant를 표시함.
상단에는 운동 날짜, 컨디션, 메모를 배치하고, 하단에는 운동 그룹 카드와 세트 입력 그리드를 배치함.
세트 입력은 jb-workout-set-grid 계열 공통 클래스를 사용하여 헤더와 행을 같은 컬럼 템플릿으로 맞춤.
세트 입력 그리드의 중량 헤더는 화면 컴포넌트의 한국어 문구 중량(kg)로 표기함.
세트 입력 그리드 헤더는 공통 스타일에서 대문자 변환을 강제하지 않으며, 단위 kg는 소문자를 유지함.
세트 상태는 계획, 성공, 실패를 순환하며, 성공 시 RPE, 실패 시 FP 계열 입력만 활성화함.
운동 그룹 카드의 보조 액션은 우측 정렬로 배치하며, 직전 기록, 추가, 삭제 순서를 사용함.
운동 그룹 카드의 추가 버튼은 해당 기구에 새 세트 행을 추가하며, 새 행의 상태는 계획으로 생성함.
새 세트 행의 중량과 반복수는 같은 화면 안에서 같은 기구의 가장 최근 성공 세트가 있으면 그 값을 우선 사용하고, 없으면 직전 기록 조회 API가 제공하는 같은 기구의 가장 최근 성공 세트 값을 사용함.
같은 화면과 직전 기록 조회 결과에 사용할 수 있는 성공 세트가 없으면 새 세트 행은 기구별 최소 중량과 기본 반복수 1회를 사용함.
직전 기록 버튼은 모달을 먼저 열고, 사용자가 가져오기를 누른 뒤 확인창에서 승인할 때만 현재 그룹 세트를 PLAN 기준으로 덮어씀.
직전 기록 가져오기 확인창 문구는 화면 컴포넌트에 한국어 문구로 직접 작성함.
직전 기록 가져오기 확인창에서 취소하면 직전 기록 모달은 유지되고 현재 세트 입력값은 변경되지 않음.
직전 기록 조회는 현재 화면의 운동일과 저장된 세션 식별자를 API query로 함께 전달함.
저장된 세션 화면에서 직전 기록을 조회하면 현재 세션은 제외되고, 현재 세션보다 이전인 운동 세션 중 같은 운동 종목이 포함된 가장 최근 세션의 기록 전체가 표시됨.
신규 세션 화면에서 직전 기록을 조회하면 현재 화면의 운동일 이하에서 같은 운동 종목이 포함된 가장 최근 세션의 기록 전체가 표시됨.
직전 후보 세션 안에 같은 운동 종목 기록이 여러 묶음 또는 여러 세트로 존재하면 모달은 해당 기록을 모두 보여줌.
직전 후보가 없으면 같은 모달 안에서 직전 기록 없음 상태를 표시하고 가져오기 버튼을 비활성화함.
직전 기록 모달은 직전 세트의 상태, 중량, 반복, 그리고 상태에 따라 RPE 또는 FP만 한 줄 카드로 보여줌.
직전 기록 모달의 상태 칩은 세션 입력 화면과 같은 색 규칙을 사용함.
운동 선택 바텀시트와 직전 기록 시트의 닫기 버튼은 고스트 버튼을 사용하며, 버튼 텍스트가 줄바꿈되지 않도록 최소 가로폭을 확보함.
운동 선택 바텀시트의 운동 부위 필터는 shared-ui의 SegmentedTabs를 사용하며, 부위 항목 수가 늘어날 수 있으므로 가로 스크롤 가능한 탭 구조를 사용함.
중량 숫자는 정수면 소수점 없이 표시하고, 소수점이 있을 때만 소수부를 유지함.
숫자 선택 시트는 shared-ui의 BottomNumberPicker를 사용하며, 실제 하단 선택 시트 외형과 스크롤 동작은 공통 BottomOptionPicker 계약을 재사용하고 숫자 옵션은 중앙 정렬함.
숫자 선택 시트와 직전 기록 모달은 호출 시점의 문서 스크롤 위치를 보존하고 닫기, 가져오기, 오류 처리 후 같은 위치로 복원함.
하단 고정 액션바는 목록, 연결, 삭제, 추가, 저장 흐름을 사용하며, 추가와 저장이 남은 폭을 균등 분할함.
하단 고정 액션바는 모바일 viewport 하단에 고정되며, SPA route transition stage 안에서도 페이지 스크롤 끝으로 밀리지 않는 구조를 사용함.
9.3 운동 계획
/workout-plans는 최근 계획 조회와 새 계획 생성 화면을 겸함.
/workout-plans는 독립 상단 메뉴 항목으로 노출하지 않고 운동 기록 메뉴의 하위 흐름으로 취급함.
/workout-plans 경로에서 앱 셸의 활성 메뉴는 운동 기록임.
특정 계획 조회는 planId query string을 React Router search params로 해석함.
계획 조회 로딩 중에는 SkeletonBlock detail variant를 표시함.
컨디션 슬라이더와 운동 구분 선택 버튼을 상단에 두고, 결과 영역에 규칙 엔진이 생성한 루틴 카드를 보여줌.
운동 구분 선택은 jb-select 외형의 버튼과 shared-ui의 BottomOptionPicker 기반 모바일 하단 선택 시트를 사용함.
운동 구분 선택지는 GET /api/workout/meta/routine-modes 응답을 option으로 사용함.
운동 구분 기본값은 GET /api/workout/plans/default-routine-mode 응답을 사용함.
기본값 API가 반환하는 루틴 모드가 option 목록에 없거나 메타 조회가 실패하면 화면은 UPPER를 fallback으로 사용함.
jb-select 외형 선택 버튼은 공통 select chevron 배경만 사용하고 별도 텍스트 화살표를 추가하지 않음.
운동 계획 화면은 네이티브 <select>를 사용하지 않음.
계획 생성 요청에는 condition_score, routine_mode_code를 포함함.
루틴 카드의 kg 표기는 정수와 불필요한 trailing zero를 제거하고 실제 소수부가 있을 때만 소수점을 유지함.
생성 버튼은 POST /api/workout/plans/generate를 호출하고, 성공 응답의 저장된 계획 DTO로 화면을 갱신함.
생성 로딩 중에는 버튼과 화면 상태로 요청 진행 상태를 표시함.
실패 토스트는 생성 API 오류 메시지를 기준으로 노출함.
연결된 세션 이동 버튼은 linked session 존재 여부를 확인한 뒤 동작함.
9.4 운동 관리
/workout-exercises는 workout 앱 자체 운동 기준정보를 관리하는 모바일 카드형 목록 화면임.
운동 관리는 공유 기준정보 관리 기능이며 WORKOUT과 HUB 권한을 모두 가진 사용자에게만 앱 셸 메뉴, 서버 페이지 경로, SPA 라우트, 운동 기준정보 API를 제공함.
allowedModules에 두 권한이 모두 없으면 앱 셸에서 운동 관리 메뉴를 렌더링하지 않고, SPA의 운동 관리 라우트는 운동 기록 경로로 복귀시킴.
운동 관리 화면은 PC 기준 Tabulator 화면을 사용하지 않음.
목록 조회는 GET /api/workout/exercises/progressive를 호출하며, 카드 선택 시 /workout-exercises/{exerciseCode}/edit로 이동함.
목록 카드는 운동명을 주요 제목으로 표시하고, 코드값, 운동 부위, 중량 단위, 최소 중량, 최대 중량, 정렬 순서, 사용 여부를 하단 뱃지 묶음으로 표시함.
목록 카드에는 운동 설명을 표시하지 않음.
하단 고정 액션바에는 새 운동 등록 버튼을 배치함.
/workout-exercises/new와 /workout-exercises/{exerciseCode}/edit는 같은 폼 화면을 재사용함.
신규 등록 화면의 운동 코드는 직접 입력하지 않으며, 코드 입력칸은 disabled 상태로 표시하고 저장 시 API가 자동 채번함.
수정 화면의 운동 코드는 읽기 전용으로 표시하며, 코드 변경 저장은 허용하지 않음.
운동 코드와 사용 여부 체크박스는 같은 입력 행에 배치함.
운동 설명 입력은 jb-input 기반 textarea 스타일을 사용함.
운동 부위 선택은 jb-select 외형의 버튼과 shared-ui의 BottomOptionPicker 기반 모바일 하단 선택 시트를 사용함.
운동 부위 선택지는 GET /api/workout/meta/body-parts 응답을 사용하며, 선택 안 함을 빈 문자열 option으로 함께 제공함.
jb-select 외형 선택 버튼은 공통 select chevron 배경만 사용하고 별도 텍스트 화살표를 추가하지 않음.
운동 관리 등록/수정 화면은 네이티브 <select>를 사용하지 않음.
저장은 생성 시 POST /api/workout/exercises, 수정 시 PATCH /api/workout/exercises/{exerciseCode}를 호출함.
생성 성공 후 화면은 API 응답의 exercise_code를 기준으로 수정 라우트로 전환함.
삭제는 DELETE /api/workout/exercises/{exerciseCode}를 호출하며, API는 운동 기준정보를 soft delete 처리함.
화면 문구와 토스트 문구는 React 컴포넌트에 한국어 문구로 직접 작성함.
9.5 체중 관리
/weight-records는 WORKOUT 권한 사용자에게 제공하는 모바일 체중 관리 카드 목록 화면임.
앱 셸 상단 메뉴 순서는 운동 기록 -> 러닝 기록 -> 체중 관리 -> 운동 관리임. 운동 관리는 WORKOUT과 HUB 권한을 모두 가진 사용자에게만 마지막 메뉴로 노출함.
목록 진입 시 GET /api/workout/weight/summary와 GET /api/workout/weight/records를 병렬 조회함.
요약은 최근 체중, 최근 90일, 최근 투약 3열을 같은 위계로 표시함.
최근 90일 변화량의 계산 기준은 API 응답을 그대로 사용하며 클라이언트가 재계산하지 않음.
최근 투약은 API의 최신 투약 일시를 기준으로 현재 날짜와의 일수 차이를 클라이언트에서 계산해 N일 전 형식으로 표시함. 약명·용량을 보여 주는 별도 회색 보조 문구는 표시하지 않음.
목록 카드는 YYYY-MM-DD (화) 날짜 텍스트, 체중 주요 텍스트, 선택 투약 통합 뱃지로 구성함.
통합 투약 뱃지는 약명 · 용량 · 부위 형식이며 투약 부위만 단독 뱃지로 중복 표시하지 않음.
카드 우측 보조 영역은 현재 비워 두며 후속 상태·지표 확장을 위한 레이아웃만 유지함.
/weight-records/new, /weight-records/{recordId}/edit는 WeightRecordFormPage를 재사용함.
상세 폼은 체중 입력과 선택 투약 입력을 하나의 저장 payload로 전송함. 체중 또는 투약 기록 중 하나는 반드시 입력함.
투약 입력은 약명, 용량(mg), 투약 시간, 투약 부위, 투약 메모로 구성함.
투약 기록 추가를 처음 선택할 때 투약 시간이 비어 있으면 클라이언트의 현재 시각을 HH:mm 형식으로 기본 입력함. 사용자가 입력하거나 기존 기록에서 불러온 투약 시간은 다시 선택해도 덮어쓰지 않음.
textarea 라벨을 포함한 독립 폼 그룹은 블록 요소로 구성하고 space-y-* 공통 세로 간격을 적용함.
상세 하단 고정 액션바는 목록 이동에 IconMenu 최소 폭 아이콘 버튼을 사용함. 저장은 남은 폭을 사용하고, 수정 모드 삭제는 IconTrash 최소 폭 아이콘 버튼을 사용함.
9.6 러닝 기록 목록
/running-plans는 근력운동 기록과 분리된 러닝 계획 목록 화면임.
앱 셸 메뉴와 목록 화면 제목은 러닝 기록으로 표시함.
러닝 계획 생성·수정과 실행 화면은 기능을 구분하기 위해 각각 러닝 계획 생성, 러닝 계획 수정, 러닝 실행 제목을 사용함.
목록 카드는 러닝 이름, 러닝 날짜, 예상 총 시간, 실행 상태, 블록 수, 단계 수를 표시함.
실행 상태는 계획 단위 running_status를 기준으로 수행 전, 수행 중, 수행 완료를 표시함.
카드 선택은 /running-plans/{planId}/edit로 이동함.
카드 내부 수정 버튼은 같은 수정 화면으로 이동함.
카드 내부 러닝 시작 버튼은 /running-run/{planId}로 이동함.
하단 고정 액션바에는 새 계획 생성 버튼만 배치함.
러닝 기록 목록 화면은 별도로 제공하지 않음.
9.7 러닝 계획 편집
/running-plans/new, /running-plans/{planId}/edit는 같은 React 폼 화면을 사용함.
러닝 계획 편집 화면은 근력운동 세션 편집 화면을 확장하지 않고 별도 RunningPages 계열 컴포넌트에서 관리함.
상단 입력 영역은 러닝 이름, 러닝 날짜, 실행 상태, 메모, 전체 예상 운동시간과 예상 거리를 순서대로 표시함.
러닝 날짜는 type=date 입력으로 관리하며 시간은 입력하지 않음.
실행 상태는 현재 running_status를 표시하며 계획 입력 payload에는 포함하지 않음.
수정 화면에서 상태가 COMPLETED가 아니면 상태 카드에 상태 변경 버튼을 제공함. 버튼은 BottomOptionPicker 하단 선택 시트를 열어 상태 전환 항목을 선택하게 함.
IN_PROGRESS 상태의 선택 항목은 수행 전으로 되돌리기와 수행 완료 처리임. 수행 전으로 되돌리기는 확인창 승인 뒤 PUT /api/workout/running/plans/{planId}/progress에 running_status=READY, current_timeline_index=null을 전송하고 성공 응답의 running_status로 화면 상태를 갱신함.
READY 상태의 선택 항목은 수행 완료 처리만 제공함. 수행 완료 처리는 확인창 승인 뒤 POST /api/workout/running/plans/{planId}/performed를 호출하고 성공 응답의 running_status로 화면 상태를 갱신함.
COMPLETED 상태에서는 상태 변경 버튼을 표시하지 않음.
러닝 블록은 카드 하나가 연속 구간 또는 반복 패턴 하나를 의미함.
러닝 블록 카드는 카드 이름, 반복 횟수, 총 시간, 예상 거리, 단계 목록을 포함함.
블록 카드는 개별 접힘/펼침 상태를 가지며, 한 블록을 펼친다고 다른 블록이 자동으로 접히지 않음.
블록 제목 옆 요약 뱃지는 반복 횟수 · 총 시간 · 예상 거리를 한 뱃지에 표시함.
예상 거리는 단계별 목표 속도와 실행 시간을 기준으로 화면에서 계산하며 별도 저장값으로 관리하지 않음.
거리 표시는 소수점 둘째 자리까지 반올림하고 불필요한 trailing zero는 제거함.
블록 순서 이동은 카드 우측 상단의 위/아래 아이콘 버튼으로 수행함.
블록 삭제와 복사는 펼친 카드 하단의 아이콘 버튼으로 제공함.
단계 추가는 펼친 카드 하단의 텍스트 버튼으로 제공함.
러닝 단계는 별도 단계 이름 입력을 받지 않음.
러닝 단계의 표시명과 음성 안내명은 단계 유형 공통코드 라벨을 기준으로 함.
단계 입력 행은 유형, 분, 속도, 경사를 한 줄로 배치함.
단계 유형 선택은 네이티브 select가 아니라 shared-ui의 BottomOptionPicker 기반 모바일 하단 선택 시트를 사용함.
단계 유형 option은 GET /api/workout/running/meta/steps 응답을 사용함.
블록 반복 횟수는 1~20의 BottomOptionPicker 선택값으로 입력하며 기본값은 1임.
단계 시간 입력 헤더는 분으로 표기함. 입력 중인 분 문자열은 저장 단위인 초와 분리해 관리하므로 사용자가 기존 값을 완전히 지운 뒤 새 값을 입력할 수 있음.
단계 시간 입력은 숫자, -, .만 편집 중간값으로 허용함. 비어 있거나 숫자로 해석할 수 없는 중간값은 단계 상태에 NaN으로 반영하지 않음.
비어 있는 단계 시간 입력값으로는 저장하거나 러닝을 시작할 수 없으며, 저장 시 유한한 숫자와 0.1분 이상을 검증함. 유효한 분 입력값은 가장 가까운 초 단위 정수로 변환해 저장 payload에 반영함.
경사는 고밀도 단계 입력 행의 직접 키 입력 필드로 제공함. 숫자만 입력할 수 있고 기존 값을 완전히 지울 수 있으며, 편집 중 빈 값은 단계 상태에 NaN으로 반영하지 않음.
저장 또는 러닝 시작 전 경사는 0~20 범위의 정수인지 검증하며, 기본값은 0임.
목표 속도는 선택 입력값이며 비어 있으면 저장 payload에서 null로 정규화됨.
하단 고정 액션바는 수정 화면에서 목록 아이콘 -> 삭제 아이콘 -> 복사 아이콘 -> 러닝 시작 -> 저장 구조를 사용함.
삭제 아이콘은 즉시 CUD를 일으키는 삭제 액션이므로 jb-button 강조 버튼 외형을 사용함.
복사 아이콘은 확인창을 거친 뒤 POST /api/workout/running/plans/{planId}/copy를 호출함.
복사 성공 시 러닝 계획을 복사했습니다. 성공 토스트를 표시하고 복사된 계획의 /running-plans/{newPlanId}/edit 화면으로 이동함.
생성 화면에서는 삭제 아이콘과 복사 아이콘을 표시하지 않음.
저장은 생성 시 POST /api/workout/running/plans, 수정 시 PUT /api/workout/running/plans/{planId}를 호출함.
일반 저장은 IN_PROGRESS 상태의 계획만 progress API로 READY와 빈 진행 단계로 정리한 뒤 계획 저장을 수행함. COMPLETED 상태의 계획은 수정 저장 후에도 수행 완료 상태를 유지함.
러닝 시작은 계획을 먼저 저장하되 실행 상태와 진행 단계 번호를 변경하지 않고 저장된 계획 식별자를 기준으로 /running-run/{planId}로 이동함.
러닝 시작 버튼은 고스트 버튼을 사용하고, 저장 버튼은 흰색 강조 버튼을 사용함.
9.8 러닝 실행
/running-run/{planId}는 러닝 실행 전용 화면임.
러닝 실행 화면은 계획 편집 화면과 분리되며, 트레드밀 위에서 멀리 두고 볼 수 있도록 현재 단계명과 남은 시간을 크게 표시함.
러닝 실행 화면은 앱 셸 아래 별도 화면 제목을 표시하지 않고 실행 카드가 첫 콘텐츠가 되도록 구성함.
남은 시간은 CSS 빌드 산출 여부에 의존하지 않도록 컴포넌트에서 명시적인 font size를 적용하며 크게 표시함.
실행 화면 상단은 러닝 계획 이름과 실행 상태를 표시하고, 러닝 날짜와 백그라운드 소리 안내 보조 문구는 표시하지 않음.
실행 화면은 현재 단계 남은 시간, 현재 단계 유형명, 속도, 경사도, 현재 블록 이름, 반복 회차, 다음 단계, 전체 진행률, 경과 시간과 전체 시간을 표시함.
현재 단계 유형명은 타이머 위의 별도 대형 제목으로 표시하지 않고, 메트릭 카드 첫 번째 항목 유형으로 표시함.
메트릭 카드는 유형 -> 속도 -> 경사도 순서로 3열 배치함.
실행 카드의 상태 영역 아래, 남은 시간, 3열 메트릭, 현재 블록/반복 회차, 다음 단계, 진행 바, 경과/전체 시간은 트레드밀 위에서 빠르게 읽을 수 있도록 일관된 세로 여백으로 구분함.
실행 화면은 jb-mobile-page의 공통 --jb-mobile-bottom-bar-clearance를 적용해 마지막 콘텐츠가 하단 고정 조작 바에 가려지지 않도록 함. 화면별 pb-* 유틸리티로 이 여백을 취소하지 않음.
실행 화면의 여백과 하단 여백은 인라인 스타일이나 workout 전용 CSS가 아니라 shared-ui 공통 모바일 CSS 계약으로 관리함. 공통 CSS 변경 시 shared-ui 스타일 산출물을 다시 빌드해야 함.
실행 timeline은 저장된 블록과 단계 패턴을 실행 시점에 반복 횟수만큼 메모리에서 펼쳐 구성함.
반복 블록의 단계는 저장소에 반복 횟수만큼 복제되어 있지 않음.
실행 화면은 상세 응답의 running_status=IN_PROGRESS와 current_timeline_index를 우선 사용하며, 저장된 단계의 0초부터 실행을 준비함.
실행 시작과 자동·수동 단계 이동은 PUT /api/workout/running/plans/{planId}/progress로 IN_PROGRESS와 현재 timeline 단계 번호를 저장함.
러닝 진행 시간은 화면 갱신 interval의 누적값이 아니라 러닝 시작 기준 시각, 현재 기준 경과 시간, 수동 이동 시점의 offset을 기준으로 계산함.
화면 갱신 interval은 표시 상태를 자주 동기화하기 위한 트리거이며, 경과 시간의 소스 오브 트루스로 사용하지 않음.
시작 후 실제 경과 시간이 현재 단계 종료 시각을 지나면 다음 단계로 자동 이동함.
일시정지 상태에서는 단계 시간이 감소하지 않음.
다음 단계 수동 이동은 현재 단계의 남은 시간을 경과 시간에 반영하고 다음 단계로 이동함.
이전 단계 수동 이동은 해당 단계 시작 시점의 경과 시간과 남은 시간으로 위치를 갱신함.
처음 단계와 마지막 단계 이동은 실행 timeline의 첫 번째 또는 마지막 단계로 현재 위치를 이동함.
브라우저가 백그라운드 탭 또는 비활성 PWA의 JavaScript timer를 지연하거나 중단하더라도, 화면이 다시 visible 상태가 되면 실제 시각 기준으로 현재 단계, 남은 시간, 경과 시간, 완료 여부를 재계산함.
복귀 계산 결과 마지막 단계가 끝났으면 운동 완료 음성 안내가 끝난 뒤 POST /api/workout/running/plans/{planId}/performed를 호출해 계획을 수행 완료로 표시함.
수행 완료 저장이 성공하면 러닝 목록으로 이동함.
운동 종료 버튼은 확인창을 표시하고, 운동 종료 음성 안내가 끝난 뒤 progress API로 READY와 빈 진행 단계를 저장하고 러닝 목록으로 이동함.
러닝 실행 하단 고정 액션바는 모두 아이콘 버튼으로 구성함.
러닝 실행 하단 버튼 순서는 목록 -> 처음 단계 -> 이전 단계 -> 시작/일시정지 -> 다음 단계 -> 마지막 단계 -> 종료임.
러닝 실행 하단 버튼은 하나의 조작 그룹처럼 붙어 배치하며, 목록 버튼만 버튼 그룹과 분리해 보이지 않도록 함.
시작/일시정지 버튼은 텍스트 없이 아이콘만 표시함.
음성/효과음/진동 안내 모드는 실행 화면 상단 jb-select 외형의 선택 버튼과 BottomOptionPicker 하단 선택 시트로 선택함.
안내 모드는 음성 + 효과음, 음성 안내, 효과음, 진동, 전체 무음을 제공함.
실행 시작과 단계 전환 시 현재 단계 유형명 뒤에 시작을 붙인 음성 안내를 제공함.
실행 중 다음 단계 10초 전에는 10초 후 {다음 단계명}입니다. 음성 안내를 제공하고, 현재 단계 종료 3초 전에는 3, 2, 1 카운트다운을 제공함.
전체 완료는 운동 완료, 조기 종료는 운동 종료 음성 안내를 제공하며 화면 이탈은 해당 음성 안내 종료 뒤에 수행함.
백그라운드 또는 비활성 상태에서 지나간 10초 전 안내와 3초 카운트다운 안내는 복귀 시점에 몰아서 재생하지 않음.
백그라운드에서 단계가 진행된 뒤 화면이 복귀하면 현재 단계 기준 시작 안내만 필요한 경우 1회 수행함.
브라우저가 Screen Wake Lock API를 지원하면 러닝 시작 시 화면 유지 요청을 수행함.
화면 유지가 지원되지 않거나 활성화 실패 시 실행 화면 상단 상태 문구로 안내함.
wake lock release 이벤트를 감지해 화면 유지 상태 문구를 갱신함.
러닝 중 화면이 다시 visible 상태가 되면 wake lock을 다시 요청함.
화면 이탈 또는 완료, 종료 시 wake lock을 해제하고 speech synthesis를 정리함.
Chrome PWA 환경을 주요 실행 환경으로 전제하되, 브라우저 API 지원 여부에 따라 화면 유지와 진동은 제한될 수 있음.
9.9 PWA 진입
workout 프런트는 자체적으로 manifest를 만들지 않고, workout-api가 제공하는 manifest와 service worker를 소비함.
PWA manifest와 apple touch icon의 실제 아이콘 URL은 workout-api가 포털 메뉴의 APP_ICON + icon_val에서 해석한 shared-ui 앱 아이콘 slug를 기준으로 제공함.
브라우저가 standalone 설치형 진입을 할 수 있도록 base template의 관련 메타와 등록 스크립트를 그대로 사용함.
workout PWA는 Android Chrome 설치형 실행을 주요 모바일 실행 환경으로 전제함.
service worker는 설치형 진입 조건과 앱 셸 등록을 위한 최소 범위로 사용하며, 러닝 타이머, 음성 안내, 효과음, 진동을 백그라운드에서 지속 실행하는 주체로 사용하지 않음.
PWA 백그라운드 실행은 브라우저와 OS 정책의 영향을 받으므로, workout-ui는 백그라운드 지속 실행 보장이 아니라 visible 복귀 시 실제 시각 기준 상태 보정을 기본 전략으로 사용함.
10. 운동 계획 화면과 규칙 엔진 응답 계약
계획 생성 완료 응답은 저장된 운동 계획 DTO를 반환함.
계획 생성 완료 응답의 운동 계획 DTO는 routine_mode_code, 연결 세션 식별자, 운동별 세트 목록을 포함함.
운동 계획 화면은 SSE, graph trace, usage log id를 사용하지 않음.
운동 계획 화면은 AI 조언 영역을 렌더링하지 않음.
최근 계획 조회와 특정 계획 조회는 연결 세션의 활성 세트 레코드를 기준으로 루틴 카드를 구성한 응답을 표시함.
루틴 카드는 운동별 카드와 세트별 행으로 구성하며, 각 행은 세트 번호, 중량, 반복수를 사용함.
러닝 계획 화면은 RunningPlanSavePayload 계열 타입을 사용하며, 러닝 세션 저장 payload 타입을 사용하지 않음.
러닝 단계 유형은 RUNNING_STEP 공통코드 기반 API 응답을 소비하며, 화면 내부 하드코딩 상수로 단계 유형 목록을 관리하지 않음.
러닝 계획 저장 payload는 실행 상태나 진행 단계 번호를 포함하지 않음. 실행 상태 변경은 progress API와 performed API만 수행함.
러닝 실행 완료와 계획 편집 화면의 명시적 수행 완료 처리는 별도 기록 저장 payload 없이 POST /api/workout/running/plans/{planId}/performed를 호출함.
11. 관련 문서
프로젝트 전체 구조는 docs/junkbox/common/guide-jb-core.md를 따름.
workout 백엔드 구조는 docs/junkbox/apps/guide-apps-workout-api.md를 따름.
공통 마스터 데이터 규칙은 docs/junkbox/common/guide-jb-common-master-data.md를 따름.
공통 UI 자산 구조는 docs/junkbox/libs/guide-lib-shared-ui.md를 따름.