Skip to content

계획 반영(공유) v1 — 팔로우한 사람의 계획을 내 달력에 링크로 (2026-08-23)

오너 요청

"운동 계획 공유 기능 — 팔로우한 친구의 달력에 들어가서 그 계획을 내 달력에 반영. 받아온 사람은 수정 불가, 만든 사람만 수정. 그 계획으로 세션을 시작할 때 복사본이 만들어져서 운동 완료·저장하면 완전히 내 기록. 복사된 세트는 운동 시작 순간부터 별도 오브젝트로 확정되어 수행자가 자유롭게 수정. UI는 다음에, 기능만 먼저. 프라이버시는 이번엔 생각하지 않는다." — 친구 검색·팔로우 v1 · 팔로잉 포스트의 후속.

설계 결정

  • 복사가 아니라 링크(adoption): planned_session_adoptions(user_id, planned_session_id, owner_user_id, created_at, completed_session_id, completed_at). 원작자 계획 행은 하나뿐이라 수정이 그대로 전파되고, 원작자가 지우면 cascade로 링크도 사라진다.
  • 읽기 전용: 계획 상세는 가시성 helper(planned_session_visible_v1 = 본인·반영자·팔로워)로 읽히지만 save_plan_v3/delete_planned_session_v2는 여전히 소유자만(비소유자 P0002). 클라이언트는 plan_ownerplanOwner/readOnly로 옮겨 편집기 진입을 막고, 받아온 계획의 "삭제"는 반영 취소로 보낸다.
  • 복사는 운동 시작 순간: 기존 시작 경로(startSessionget_planned_session_detaillgSessionToWorkoutDraft)가 그대로 복사본을 만든다. 저장은 save_workout_v4 래퍼가 호출자 소유가 아닌 planned_session_id를 엔진에 넘기기 전에 떼고(엔진 해시는 재시도에도 안정), 성공 후 호출자의 반영 행만 완료 처리한다 — 원작자 계획 상태는 불변, 완료된 반영은 내 달력에서 빠진다.
  • 프라이버시 범위 밖: 팔로우한 사람의 계획은 전부 읽힌다(피드 팔로잉 포스트와 같은 게이트 profile_feed_can_view_v1).

변경 — 마이그레이션 20260821490000_plan_adoption_v1.sql

내용
DB 저장소planned_session_adoptions(PK user+plan, not-self check, RLS 본인 행 select, 쓰기는 RPC만)
DB 헬퍼planned_session_visible_v1 · plan_owner_json_v1 · planned_session_card_stats_v1 · adopted_planned_session_cards_v1(user, from, to, limit)→{items,total} · adopted_planned_session_day_counts_v1 · adopted_planned_session_revision_v1
DB RPCget_following_calendar_month_v1(p_user_id, p_from, p_to)(≤42일, 일별 카운트·완료 lite ≤64·계획 카드 ≤64 + plan_owner/호출자 adopted, 비팔로우 42501) · adopt_planned_session_v1(멱등; 자기 계획 22023·비팔로우 42501·없음 P0002) · unadopt_planned_session_v1
DB 재발행(활성=마지막 정의 그대로 + 합류)get_calendar_month_summary(평탄화 정의; 반영 카드 합류·64행 합산·days[].planned_session_count·source_version) · get_calendar_day_summary(12행·summary.planned_session_count) · get_home_dashboard(current_month.plans 64행·summary.planned_session_count·plans_truncated) · get_planned_session_detail(+_v1_engine: 가시성 helper, 비소유자 plan_owner·adopted·editable:false, 세트는 소유자 행) · save_workout_v4(반영 계획 완료 처리)
pgTAP plan_adoption_v1.test.sql44 assert — 가시성, 친구 달력(카드·adopted·일별 카운트·42501·42일 초과 22023), 반영 거부 3종·멱등, 월/일/홈 합류(카드·plan_owner·source_ref·카운트), 상세 읽기 전용·비팔로우 P0002·비소유자 save_plan_v3 P0002, 반영 계획으로 save_workout_v4 → 본인 세션·반영 완료·원작자 계획 planned 유지·월 요약에서 제거, unadopt·cascade
클라이언트types/supabase.ts RPC 3종 · barbelicRepository/socialDomain/BarbelicApi loadFollowingCalendarMonth·adoptPlannedSession·unadoptPlannedSession · controllers/planAdoptionStore.ts(프레임워크 무의존: 친구 달력 월 로드·늦은 응답 폐기·반영/취소 낙관 반영·SQLSTATE 문구·P0002 재로드·오너 리셋) + planAdoptionController.tsappController 렌더 컨텍스트 friendCalendar · friendCalendarLoading · friendCalendarError · friendPlanPendingId · loadFriendCalendar · adoptFriendPlan · unadoptFriendPlan(변경 뒤 reloadRemoteData + refreshCalendarDates) · services/planAdoptionProjection.ts(planOwnerLite·isReadOnlyPlan·planAdoptionFields) · 달력 카드 어댑터/타입/매퍼·홈/상세 매퍼가 planOwner/readOnly/adopted를 싣고 plan_owner는 있을 때만 검증 · appController.openWorkoutPlanEditor 읽기 전용 차단(토스트) · workoutWriteController.deleteSession 받아온 계획 = unadopt(토스트 "내 달력에서 계획을 뺐어요.")
테스트tests/react/planAdoptionStore.test.mjs 10건, tests/react/planAdoptionProjection.test.mjs 5건, screenRpcContracts.test.mjs 상세 RPC 앵커 교체(pending-changes 신고)
테스트 지원 수리tests/support/schemaSql.mjs plain()-- 주석 속 따옴표(20260821440000 주석의 session's)에 동기화가 깨져 그 뒤 정의가 소문자화되지 않던 결함 — 주석 줄을 토큰화 없이 건너뛰도록 수리
문서app-screen-rpc-contract.md(+ko) 월/일/상세 추가 키, rpc-catalog.md, PRD §5.3·§5.7·§8, calendar-read-model-architecture.md, 이 기록

재발행 함정(기록)

  • 달력 월/일 RPC는 5.1 양파 평탄화(20260821010200~010400) 뒤 단일 함수다. schema.sql에서 첫 정의(구 래퍼·_v3_core/_v4_engine 호출)를 잡아 재발행하면 없는 함수를 부른다 — 반드시 마지막 정의를 추출해 패치한다(이번엔 스크립트로 활성 정의를 읽어 합류 블록만 삽입).

UI 미배선(다음 단계)

  • 친구 달력 화면(월 그리드·계획 카드·반영 토글), 내 달력/홈의 반영 카드 표식(원작자 이름·읽기 전용 배지), 계획 상세의 "반영/반영 취소" 버튼. 데이터는 전부 렌더 컨텍스트와 카드 필드(planOwner·readOnly·adopted)에 있다.

검증

  • 로컬 스택 db reset + supabase test db 48파일·725 assert PASS(신규 44 포함).
  • npm run check 체인 전부 PASS(check:unused·check:dual-key 포함), npm test 1761 PASS, npm run build PASS.