diff --git a/docs/90_current_state.md b/docs/90_current_state.md index 646d073..b2bb5fe 100644 --- a/docs/90_current_state.md +++ b/docs/90_current_state.md @@ -4,6 +4,10 @@ Last Updated: 2026-03-16 ## DONE +- `/app` session gate 제거: + - `/app`은 더 이상 running / paused / takeover UI를 보여주지 않는다 + - current session이 있으면 상태와 상관없이 즉시 `/space`로 이동한다 + - no-session일 때만 atmosphere entry shell이 열린다 - `/app` Atmosphere Entry Shell 1차 구현: - no-session `/app`을 `goal + duration + atmosphere` 중심의 premium entry shell로 교체했다 - `microStep` 입력은 entry에서 제거했고, `예상 시간(분)` 입력과 12개 dummy atmosphere grid를 추가했다 @@ -11,11 +15,8 @@ Last Updated: 2026-03-16 - custom duration server contract 전까지는 입력한 분 값을 가장 가까운 기본 리듬(`25/5`, `50/10`, `90/20`)으로 매핑한다 - weekly review entry는 main CTA를 먹지 않도록 no-session shell의 quiet secondary dock 위치로 이동했다 -- `Paused Session Takeover Flow` 구현: - - `/app` paused gate에 `새 목표로 전환` 진입점을 추가했다 - - takeover confirm sheet에서만 current paused session을 정리하고 single-goal start 상태로 넘어간다 - - silent abandon을 막기 위해 server `startSession()`도 current session 존재 시 direct start를 거절하도록 정리했다 - - explicit confirm 이후에만 `abandon -> 새 목표 입력` 흐름이 가능하다 +- current session direct start 차단: + - silent abandon을 막기 위해 server `startSession()`은 current session 존재 시 direct start를 거절한다 - `/app` 기존 single-goal commitment gate는 legacy로 내려갔다: - 2-step `goal -> ritual` flow를 제거하고, current session이 있으면 `Resume` UI를 우선 노출하도록 정리했다 - 현재 source-of-truth는 `goal + duration + atmosphere` 중심의 새 entry shell spec이다 @@ -126,7 +127,7 @@ Last Updated: 2026-03-16 - recovery는 서버의 `pause 뒤 복귀` 집계를 사용하고, `자리 비움 뒤 복귀`만 limited note로 남긴다 - `/app -> /stats` primary entry의 1차 연결: - current session이 없고 최근 7일 데이터가 충분할 때 `/app`의 quiet secondary review dock에서 `Weekly Review` entry를 노출한다 - - resume 상태에서는 paused gate 안의 조용한 secondary entry로 review를 연다 + - current session이 있으면 `/app` 자체가 `/space`로 이동하므로, `/app` review entry는 no-session entry shell 안에서만 다룬다 - `/stats -> /app` handoff의 2차 연결: - `/stats` 마지막 CTA는 `/app?review=weekly&carryHint=...&entryPreset=forest-50-10`으로 연결된다 - `/app`은 이 query를 받아 entry stage 위의 review-aware return hint를 노출한다 diff --git a/docs/flows/current/15_app_stats_entry_flow_spec.md b/docs/flows/current/15_app_stats_entry_flow_spec.md index b70ad9b..977611e 100644 --- a/docs/flows/current/15_app_stats_entry_flow_spec.md +++ b/docs/flows/current/15_app_stats_entry_flow_spec.md @@ -175,7 +175,7 @@ review가 가장 유의미한 순간도 바로 여기다. 이 경우: - `/app` no-session 상태에서는 goal/duration/atmosphere entry stage에 집중 -- resume 상태에서는 entry shell 대신 resume card 안의 조용한 secondary entry로 review를 연다 +- current session이 있으면 `/app` 대신 `/space`로 이동하므로, `/app` review dock은 no-session entry shell에서만 다룬다 --- diff --git a/docs/flows/current/18_paused_session_reentry_spec.md b/docs/flows/current/18_paused_session_reentry_spec.md index eeff9dd..e4d1787 100644 --- a/docs/flows/current/18_paused_session_reentry_spec.md +++ b/docs/flows/current/18_paused_session_reentry_spec.md @@ -1,157 +1,65 @@ -# 18. Paused Session Re-entry Spec +# 18. Session Routing Spec -Last Updated: 2026-03-15 +Last Updated: 2026-03-16 -이 문서는 VibeRoom에서 **진행 중인 세션 / 멈춘 세션 / 쉬는 시간**을 어떻게 다르게 취급할지, -그리고 사용자가 `/app`에 들어왔을 때 어떤 경로로 다시 `/space`에 진입해야 하는지를 정의한다. +이 문서는 VibeRoom에서 `/app`과 `/space`의 역할을 어떻게 나눌지 정의한다. -핵심 목적은 하나다. +핵심 원칙은 하나다. -> session state에 따라 `/app`과 `/space`의 역할을 정확히 나누고, -> 사용자가 “지금 내가 어떤 상태인지”를 설명할 수 있는 premium UX를 만든다. +> current session이 있으면 사용자를 `/app`에 세워두지 않고 바로 `/space`로 보낸다. +> current session이 없을 때만 `/app`에서 새 entry를 만든다. 관련 문서: -- `./19_app_atmosphere_entry_spec.md` -- `../space/10_refocus_system_spec.md` -- `../space/11_away_return_recovery_spec.md` +- `../../screens/app/current/19_app_atmosphere_entry_spec.md` +- `../../screens/space/current/13_space_intent_card_collapsed_expanded_spec.md` - `./15_app_stats_entry_flow_spec.md` -- `../../product/16_product_alignment_audit_plan.md` -- `../../product/17_product_alignment_findings.md` -- `../../product_principles.md` -- `../../current_context.md` +- `../../product/12_core_loop_execution_roadmap.md` --- -## 1. 문제 정의 +## 1. 왜 바꾸는가 -지금까지의 혼란은 대부분 여기서 시작됐다. +이전 구조는 `/app`이 -- `세션` -- `타이머` -- `pause` -- `break` -- `return` +- no-session entry shell +- paused resume gate +- takeover decision +- review secondary entry -을 충분히 분리하지 않고 써 왔다. +를 모두 안고 있었다. -그 결과: +문제: -- 사용자는 `타이머가 멈춰 있으면 다 paused인가?`를 헷갈린다 -- `/app`에서 `resume`이 primary인지, `새로 시작`이 가능한지 흐려진다 -- `잠시 비우기`와 `break`의 의미가 섞인다 +- `/app`의 정체성이 흐려진다 +- 사용자는 `들어가는 화면`인지 `멈춘 세션을 처리하는 화면`인지 헷갈린다 +- `/space`의 recovery UX와 `/app`의 resume UX가 중복된다 -이 spec은 그 상태 정의를 먼저 고정한다. +따라서 `/app`은 다시 단순해져야 한다. --- ## 2. 한 줄 정의 -> running session은 바로 `/space`로 복귀시키고, -> paused session은 `/app`에서 다시 이어갈지 정하게 하되, -> 사용자가 `이어가기`를 눌렀다면 `/space`에서는 다시 묻지 않고 바로 resume한다. +### `/app` + +새 session을 시작하기 위한 atmosphere entry surface + +### `/space` + +이미 존재하는 session을 계속 다루는 execution surface --- -## 3. 상태 정의 +## 3. 라우팅 규칙 -### Session - -사용자가 현재 책임지고 있는 하나의 focus block. - -조건: - -- goal이 있다 -- 아직 명시적으로 닫지 않았다 -- 다시 이어갈 수 있다 - -### Running Focus - -- 현재 focus 타이머가 진행 중 -- 사용자는 같은 goal 안에서 작업 중 - -### Paused Focus - -- 사용자가 의도적으로 pause를 눌렀다 -- 같은 goal은 아직 살아 있다 -- 기본값은 `resume` - -### Running Break - -- focus block은 끝났다 -- break 타이머가 진행 중이다 -- 이건 paused session이 아니다 - -### Return - -- 사용자가 pause를 누르지 않고 떠났다가 돌아온 상태 -- system이 recovery 선택지를 먼저 제안하는 상태 - -### Closed Session - -- `여기서 마무리하기`로 끝낸 상태 -- 더 이상 current session이 아니다 - ---- - -## 4. 제품 원칙 - -### 1. Running은 다시 결정하게 하지 않는다 - -이미 running이면 사용자의 다음 행동은 “다시 정하기”가 아니라 “복귀”다. - -즉: - -- `/app`에서 hero를 다시 보여주지 않는다 -- 바로 `/space`로 보낸다 - -### 2. Paused는 자동 재생하지 않는다 - -pause는 사용자의 명시적 멈춤이다. - -즉: - -- `/app`에 들어왔다고 자동 resume하지 않는다 -- 사용자가 직접 `이어가기`를 눌러야 한다 - -### 3. Explicit Continue 이후에는 다시 묻지 않는다 - -사용자가 `/app`에서 `이어가기`를 눌렀다면, -그건 이미 “다시 하겠다”는 결정을 내린 것이다. - -즉: - -- `/app -> /space -> 다시 start 클릭` 금지 -- `/space` 진입과 동시에 resume해야 한다 - -### 4. `/app`은 decision surface, `/space`는 execution surface - -- `/app`: 이어갈지 / 다시 정리할지 / 새 목표로 전환할지 -- `/space`: 실제로 일하는 곳 - -이 둘이 같은 결정을 두 번 요구하면 실패다. - -### 5. 새 시작은 current session이 없을 때만 direct - -paused session이 있는데 새 목표를 바로 시작하게 하면 -회피 루프와 상태 오염이 생긴다. - -즉: - -- current session이 없을 때만 direct start -- current session이 있을 때 새 start는 explicit takeover flow로만 허용 - ---- - -## 5. 라우팅 정책 - -### Rule A. Running Focus +### Rule A. current session이 있으면 `/space` 상태: -- current session 존재 -- `state = running` -- `phase = focus` +- `currentSession` 존재 +- `state = running` 또는 `paused` +- `phase = focus` 또는 `break` 처리: @@ -159,44 +67,10 @@ paused session이 있는데 새 목표를 바로 시작하게 하면 이유: -- 이미 실행 중인 일은 다시 commitment gate에 세우면 안 된다 +- session이 살아 있는 동안 사용자의 일은 이미 시작된 상태다 +- `/app`이 끼어들면 execution surface와 decision surface가 섞인다 -### Rule B. Running Break - -상태: - -- current session 존재 -- `state = running` -- `phase = break` - -처리: - -- `/app` 진입 시 즉시 `/space` - -이유: - -- break도 현재 세션의 일부다 -- `/app`에서 다시 decision을 시키면 break/return 의미가 흐려진다 - -### Rule C. Paused Focus - -상태: - -- current session 존재 -- `state = paused` -- `phase = focus` - -처리: - -- `/app` 진입 -- `resume gate` 노출 - -이유: - -- pause는 사용자의 의도적 멈춤이므로 존중해야 한다 -- 하지만 같은 goal을 다시 이어갈지 결정할 여지를 줘야 한다 - -### Rule D. No Session / Closed Session +### Rule B. current session이 없으면 `/app` 상태: @@ -204,257 +78,79 @@ paused session이 있는데 새 목표를 바로 시작하게 하면 처리: -- `/app` 진입 -- no-session entry shell 노출 +- `/app` no-session entry shell 노출 --- -## 6. `/app` paused state UX +## 4. `/app`의 역할 -### 목적 +`/app`은 아래 3가지 결정만 받는다. -사용자가 “멈춘 세션이 아직 살아 있다”는 것을 즉시 이해하고, -한 번의 결정으로 `/space`에 다시 들어가게 만드는 것. +1. goal +2. duration +3. atmosphere -### 정보 구조 +포함하지 않음: -resume card 안에는 아래만 둔다. - -- 현재 goal -- 마지막 microStep -- 현재 상태 문구 - - 예: `잠시 멈춘 세션이 있어요` -- primary CTA -- quiet secondary actions - -### Primary CTA - -- `이어서 몰입하기` - -동작: - -- 클릭 -- `/space`로 이동 -- 자동 resume - -### Secondary - -- `한 조각 다시 잡기` -- `주간 review 보기` - -### Tertiary - -- `새 목표로 전환` - -중요: - -- new start가 아니다 -- takeover flow 진입점이다 -- server도 current session이 남아 있으면 direct start를 거절해야 한다 +- paused resume gate +- takeover sheet +- current session review entry +- running / paused 상태별 CTA --- -## 7. `/space` 재진입 동작 +## 5. `/space`의 역할 -### Resume CTA 이후 +current session이 있는 동안의 모든 판단은 `/space`에서 이뤄진다. -`/app`에서 `이어서 몰입하기`를 눌렀다면: +예: -- `/space`로 이동 -- 별도의 start 버튼 재요구 금지 -- 부드러운 transition 후 자동 resume +- 이어가기 +- 잠시 멈춤 +- 다시 붙잡기 +- 다음 단계 정하기 +- 여기서 마무리하기 -추천: - -- 300~800ms 정도의 soft transition -- 필요하면 아주 짧은 re-entry settle animation - -금지: - -- `/space`에서 다시 `시작`을 누르게 하는 것 -- resume 직후 또 다른 decision tray를 띄우는 것 - -### Refocus CTA 이후 - -`한 조각 다시 잡기`를 눌렀다면: - -- refocus를 먼저 거친다 -- 그 후 `/space` 진입과 함께 자동 resume - -즉, refocus는 decision이고, `/space`는 execution이다. +즉 paused session도 `/space` 안에서 다시 다룬다. --- -## 8. Takeover Flow +## 6. Weekly Review entry 규칙 -paused session이 있을 때 새 goal direct start는 허용하지 않는다. +### `/app` -대신 아래 흐름으로만 간다. +- current session이 없을 때만 quiet secondary entry로 노출 -### Trigger +### `/space` -- `/app` paused state에서 `새 목표로 전환` +- complete 이후 setup 상태에서만 secondary teaser 허용 -### Confirm Sheet - -질문: - -- `현재 멈춘 세션을 어떻게 할까요?` - -선택지: - -- `이어서 하기` -- `이 세션은 여기서 정리하고 새로 시작` -- `취소` - -### 동작 원칙 - -- `이어서 하기` - - sheet 닫기 - - resume card 유지 - -- `이 세션은 여기서 정리하고 새로 시작` - - current session을 명시적으로 닫는다 - - 그 다음 `/app` single-goal start 상태로 전환한다 - -- `취소` - - sheet 닫기 - -중요: - -- silent abandon 금지 -- paused session 위에 새 session을 덮어쓰기 금지 +current session이 살아 있는 동안 `/app`에서 review를 여는 flow는 current가 아니다. --- -## 9. Weekly Review와의 관계 +## 7. 구현 규칙 -paused state에서도 review는 열 수 있어야 한다. - -하지만 위계는 아래처럼 고정한다. - -### paused state - -- primary: `이어서 몰입하기` -- secondary: `한 조각 다시 잡기` -- quiet secondary: `주간 review 보기` - -즉: - -- review를 숨기면 안 된다 -- resume보다 앞세우면 안 된다 +1. `/app`은 current session fetch 후 session이 있으면 바로 `/space` redirect +2. `/app` render tree에는 paused gate / takeover sheet를 남기지 않는다 +3. `/space`는 paused session이라고 `/app`으로 되돌리지 않는다 +4. `/space` recovery는 pause / return / break / complete 안에서 닫는다 --- -## 10. 금지사항 +## 8. 하지 말아야 할 것 -- running session인데 `/app` hero를 보여주는 것 -- paused 상태에서 `/app` 진입만으로 자동 resume -- `/app`에서 `이어가기`를 눌렀는데 `/space`에서 다시 start를 요구하는 것 -- paused session 위에서 direct new start 허용 -- break를 paused session처럼 취급하는 것 -- takeover 없이 silent abandon 하는 것 +- paused session만 `/app`에 남기기 +- `/app -> /space -> 다시 resume/start` 이중 결정 +- `/app`에서 current session goal을 편집하기 +- current session이 있는데 `/app`에서 새 entry를 겹쳐 띄우기 --- -## 11. 구현 순서 +## 9. QA 포인트 -### Slice 1. Session Routing Contract - -범위: - -- `/app` 진입 시 current session state에 따른 route policy 고정 - -포함: - -- `running focus -> /space` -- `running break -> /space` -- `paused focus -> /app` -- `no session -> /app` - -완료 조건: - -- 상태별 route policy가 코드와 문서에서 동일하다 - -### Slice 2. `/app` Paused Resume Gate - -범위: - -- paused state의 resume card UX 정리 - -포함: - -- primary `이어서 몰입하기` -- `한 조각 다시 잡기` -- quiet `주간 review 보기` -- state copy 정리 - -완료 조건: - -- paused 사용자가 다음 행동을 2초 안에 이해할 수 있다 - -### Slice 3. `/space` Auto-Resume Handoff - -범위: - -- `/app` resume CTA 이후 `/space` 진입 시 자동 resume - -포함: - -- explicit continue 이후 double-confirm 제거 -- transition quality 보정 - -완료 조건: - -- `/app -> /space -> start` 이중 클릭이 사라진다 - -### Slice 4. Takeover Flow - -범위: - -- paused session 위에서 new start를 하고 싶을 때의 명시적 처리 - -포함: - -- confirm sheet -- close-and-start-new 경로 -- silent abandon 방지 - -완료 조건: - -- paused session 상태에서 새 목표 전환이 상태 오염 없이 가능하다 - -### Slice 5. Browser QA - -반드시 확인할 시나리오: - -1. running focus 상태에서 `/app` 진입 -2. running break 상태에서 `/app` 진입 -3. paused focus 상태에서 `/app` 진입 -4. paused -> `이어서 몰입하기` -5. paused -> `한 조각 다시 잡기` -6. paused -> `주간 review` -7. paused -> `새 목표로 전환` - ---- - -## 12. 성공 기준 - -- 사용자가 현재 상태를 설명할 수 있다 -- running이면 `/space`, paused면 `/app`이라는 규칙이 일관된다 -- paused에서의 primary CTA는 항상 `resume`이다 -- explicit continue 이후에는 다시 start를 요구하지 않는다 -- new start는 current session이 없을 때만 direct다 - ---- - -## 13. 최종 판단 - -VibeRoom이 world-class가 되려면 -`멈춤`, `쉬기`, `복귀`, `새 시작`을 같은 것으로 취급하면 안 된다. - -가장 중요한 원칙은 이거다. - -> 이미 실행 중인 것은 바로 복귀시키고, -> 의도적으로 멈춘 것은 다시 결정하게 하되, -> 다시 하겠다고 결정한 뒤에는 한 번 더 묻지 않는다. +1. current session 없음 -> `/app` entry shell +2. current session running -> `/app` 진입 즉시 `/space` +3. current session paused -> `/app` 진입 즉시 `/space` +4. `/space`에서 pause 후 화면이 `/app`으로 튀지 않음 +5. `/space` complete 후 no-session이 되면 다시 `/app` entry shell 접근 가능 diff --git a/docs/screens/app/current/19_app_atmosphere_entry_spec.md b/docs/screens/app/current/19_app_atmosphere_entry_spec.md index 883bc80..ceb62c6 100644 --- a/docs/screens/app/current/19_app_atmosphere_entry_spec.md +++ b/docs/screens/app/current/19_app_atmosphere_entry_spec.md @@ -9,6 +9,12 @@ Last Updated: 2026-03-16 - `Slice 1` no-session shell 구현 완료 - `Custom Duration Contract`와 `Weekly Review Dock Reposition`은 다음 slice로 남아 있음 +핵심 정책 변경: + +- `/app`에는 더 이상 running / paused / resume 같은 session gate UI를 두지 않는다 +- current session이 있으면 상태와 상관없이 바로 `/space`로 보낸다 +- current session이 없을 때만 `/app` entry shell이 열린다 + 관련 문서: - `../../../../../product_principles.md` @@ -55,6 +61,12 @@ Last Updated: 2026-03-16 ## 3. 핵심 제품 원칙 +### 0. `/app`은 session 상태를 보여주지 않는다 + +- `/app`은 entry surface다 +- current session이 있으면 사용자를 붙잡아 두지 않고 바로 `/space`로 이동시킨다 +- 따라서 `/app` 안의 paused resume gate, takeover sheet, session review entry는 current가 아니다 + ### 1. Single Goal First는 유지한다 - goal은 1개만 입력한다 @@ -70,7 +82,7 @@ Last Updated: 2026-03-16 ### 3. Duration은 사용자가 직접 입력한다 -- 기존 preset `25/5`, `50/10` 중심 entry를 버린다 +- 기존 preset `25/5`, `50/10`, `90/20` 중심 entry를 버린다 - 사용자는 “이 목표를 끝내는 데 얼마나 걸릴지”를 분 단위로 적는다 - 예: `70분` @@ -130,6 +142,10 @@ Last Updated: 2026-03-16 - quiet weekly review entry - plan pill / account +전제: + +- current session이 있으면 이 화면 자체에 오래 머무르지 않고 `/space`로 넘어간다 + ### Layer 2. Primary Start Stage - goal input @@ -313,19 +329,15 @@ Last Updated: 2026-03-16 - 이미 실행 중인 세션은 다시 decision gate에 세우지 않는다 -### Flow C. Paused Session Exists +### Flow C. Any Current Session Exists 1. 사용자가 `/app` 진입 -2. paused resume gate 노출 -3. 선택: - - `이어서 몰입하기` - - `한 조각 다시 잡기` - - `새 목표로 전환` +2. 바로 `/space` redirect 중요: -- 이 상태에서는 atmosphere grid를 메인 UI로 먼저 보여주지 않는다 -- resume가 primary다 +- `/app`은 current session을 처리하는 장소가 아니다 +- resume / pause / break / complete 판단은 `/space` 안에서 이어진다 ### Flow D. Review Entry @@ -381,7 +393,7 @@ Last Updated: 2026-03-16 제외: -- paused resume gate 재설계 +- current session routing 재설계 - `/stats` IA 변경 - server custom duration contract @@ -489,7 +501,7 @@ Last Updated: 2026-03-16 ### Slice 3. Paused Gate Coexistence -- paused session일 때는 새로운 entry shell 대신 resume gate를 우선 유지 +- current session이 있으면 `/app`을 건너뛰고 `/space`로 보낸다 - no-session일 때만 새 shell 노출 ### Slice 4. Custom Duration Contract @@ -517,5 +529,5 @@ Last Updated: 2026-03-16 - atmosphere grid가 decorator가 아니라 실제 선택 surface로 읽힌다 - weekly review가 start보다 앞서지 않는다 - `/app`이 planner/dashboard처럼 보이지 않는다 -- paused session에서는 resume gate가 새 entry shell보다 우선한다 +- current session이 있을 때는 `/app`에 머무르지 않고 `/space`로 이동한다 - `70분` 같은 custom duration이 실제 세션 길이로 반영된다 diff --git a/docs/session_brief.md b/docs/session_brief.md index 7751360..86b442c 100644 --- a/docs/session_brief.md +++ b/docs/session_brief.md @@ -32,12 +32,10 @@ Last Updated: 2026-03-16 - 선택한 atmosphere는 `/app` 배경 preview와 `/space` start payload의 `scene/sound`에 같이 반영된다. - duration은 우선 가장 가까운 기본 리듬으로 매핑하는 임시 계약을 사용한다. - weekly review entry는 right-side quiet dock 위치로 옮겨 main CTA보다 낮은 위계를 유지한다. - -- `Paused Session Takeover Flow`를 구현했다. - - `/app` paused gate에 `새 목표로 전환` 액션이 추가됐다. - - takeover confirm sheet에서만 기존 paused session을 정리하고 새로 시작할 수 있다. - - server `startSession()`은 더 이상 silent abandon을 하지 않고, current session이 남아 있으면 direct start를 거절한다. - - takeover confirm 후에만 `abandon -> single-goal start` 순서로 넘어간다. +- `/app`은 이제 session gate를 보여주지 않는다. + - current session이 있으면 상태와 상관없이 즉시 `/space`로 이동한다. + - 따라서 paused resume gate와 takeover sheet는 current UX가 아니다. + - server `startSession()`은 여전히 current session 존재 시 direct start를 거절해 silent abandon을 막는다. - `/space` Refocus System 첫 slice를 구현했다. - pause 직후 바로 편집 시트가 아니라 작은 recovery prompt를 먼저 띄운다. - 여기서 `한 조각 다시 잡기`를 누르면 refocus tray로 들어간다. @@ -107,10 +105,7 @@ Last Updated: 2026-03-16 - recovery는 서버의 `pause 뒤 복귀` 집계를 사용하고, `away recovery`만 limited state로 남긴다. - `/app`에서 `/stats`로 들어가는 primary path 1차가 생겼다. - current session이 없을 때는 quiet review dock에서 `/stats`로 진입할 수 있다. - - paused session 상태에서도 resume gate 안에 조용한 secondary review entry가 남는다. - - review entry는 main start/resume CTA보다 항상 낮은 강조를 유지한다. -- `/app` Resume 상태에서도 weekly review entry가 보이게 정리했다. - - review primary entry가 active session 상태에서 사라지지 않도록, resume card 안에 조용한 secondary review link를 추가했다. + - review entry는 main start CTA보다 항상 낮은 강조를 유지한다. - `/stats` 마지막 CTA의 `/app` return handoff가 연결됐다. - carry-forward CTA는 `/app?review=weekly&carryHint=...`로 돌아온다. - `/app`은 review-aware return hint를 먼저 보여주되, goal은 사용자가 직접 입력하게 유지한다. @@ -124,20 +119,10 @@ Last Updated: 2026-03-16 - `Weekly Review` recovery의 서버 연결이 들어갔다. - server `focus-summary` 응답에 `recovery`가 추가됐다. - 현재는 `pause 뒤 복귀`만 실집계이며, `자리 비움 뒤 복귀`는 partial note로 남아 있다. -- paused session 재진입 정책을 별도 source of truth로 고정했다. - - `running focus -> /space` - - `running break -> /space` - - `paused focus -> /app` - - `/app`의 explicit continue 이후 `/space`에서는 다시 start를 묻지 않고 자동 resume해야 한다. - - paused session 위의 새 시작은 direct가 아니라 takeover flow로만 허용한다. -- `Paused Session Re-entry`의 Session Routing Contract를 1차 구현했다. - - `/app`은 running session을 감지하면 hero를 보여주지 않고 즉시 `/space`로 보낸다. - - `/space`는 paused session 상태에서 explicit handoff intent 없이 직접 열리면 `/app`으로 되돌린다. - - `/app`의 `이어서 들어가기`는 다음 slice를 위해 `/space?resume=continue` handoff를 사용한다. -- `Paused Resume Gate`와 `Auto-Resume Handoff`를 구현했다. - - paused 상태의 `/app`은 `이어서 몰입하기`, `한 조각 다시 잡기`, quiet `주간 review 보기`를 함께 보여준다. - - `이어서 몰입하기`는 `/space?resume=continue`로 들어간 뒤 자동 resume된다. - - `한 조각 다시 잡기`는 `/space?resume=refocus`로 들어간 뒤 refocus tray를 바로 연다. +- session routing 정책을 다시 단순화했다. + - current session이 있으면 `/app`에서 머무르지 않고 바로 `/space`로 이동한다. + - `/space`는 paused session이라고 `/app`으로 되돌리지 않는다. + - `/app`은 no-session entry surface로만 남는다. - `Product Alignment Audit` 운영을 시작했다. - `docs/product/16_product_alignment_audit_plan.md`를 기준 문서로 추가했다. - `docs/product/17_product_alignment_findings.md`에 core loop의 P1/P2 mismatch를 수집하기 시작했다. diff --git a/docs/work.md b/docs/work.md index 1d50bde..1ba9419 100644 --- a/docs/work.md +++ b/docs/work.md @@ -30,7 +30,7 @@ - 4x3 atmosphere grid - primary CTA - 제외 범위: - - paused resume gate 재설계 금지 + - `/space` recovery UX 재설계 금지 - weekly review 상세 IA 변경 금지 - server contract 변경 금지 - 완료 조건: @@ -78,14 +78,14 @@ - review return hint placement - 제외 범위: - `/stats` IA 변경 금지 - - paused resume gate 재설계 금지 + - `/space` recovery UX 재설계 금지 - 완료 조건: - review entry는 항상 발견 가능하지만 start보다 앞서지 않는다 - - no-session shell과 paused gate에서 위계가 일관된다 + - no-session shell 안에서만 quiet secondary dock로 읽힌다 - 진행 상태: - 대기 - 검증: - - `/app` no-session / paused browser QA + - `/app` no-session browser QA - 커밋 힌트: - fix(app): review dock 위치 재정렬 @@ -96,7 +96,7 @@ - 새 `/app` entry shell까지 포함한 핵심 흐름을 브라우저에서 실제로 검증한다. - 변경 범위: - `/app` no-session - - `/app` paused resume + - current session 상태에서 `/app -> /space` redirect - `/app -> /stats -> /app` - `/space` pause / return / next beat / complete - `/space` complete -> setup -> weekly review entry diff --git a/src/features/stats/model/useFocusStats.ts b/src/features/stats/model/useFocusStats.ts index a90ed72..85c9cbb 100644 --- a/src/features/stats/model/useFocusStats.ts +++ b/src/features/stats/model/useFocusStats.ts @@ -262,7 +262,7 @@ const buildCarryForward = (summary: FocusStatsSummary): WeeklyReviewViewModel['c return { hintKey, presetId: 'forest-50-10', - presetLabel: 'Forest · 50/10 · Forest Birds', + presetLabel: 'Forest · Forest Birds', keepDoing, tryNext, ctaLabel: copy.stats.reviewCarryCta, diff --git a/src/widgets/focus-dashboard/ui/FocusDashboardWidget.tsx b/src/widgets/focus-dashboard/ui/FocusDashboardWidget.tsx index 3ab4e49..3a01845 100644 --- a/src/widgets/focus-dashboard/ui/FocusDashboardWidget.tsx +++ b/src/widgets/focus-dashboard/ui/FocusDashboardWidget.tsx @@ -34,7 +34,7 @@ const REVIEW_ENTRY_PRESETS = { sceneId: DEFAULT_SCENE_ID, soundPresetId: DEFAULT_SOUND_ID, timerPresetId: DEFAULT_TIMER_ID, - label: '숲 · 50/10 · Forest Birds', + label: '숲 · Forest Birds', }, } as const; const DEFAULT_ATMOSPHERE = @@ -45,30 +45,12 @@ const entryCopy = { goalPlaceholder: '예: 제안서 첫 문단만 다듬기', durationLabel: '예상 시간(분)', durationPlaceholder: '예: 70', - durationHelper: '입력한 시간은 지금 가장 가까운 기본 리듬으로 먼저 맞춰서 들어가요.', + durationHelper: '이 목표를 끝내는 데 걸릴 것 같은 시간을 적어요.', startNow: '이 분위기로 들어가기', startLoading: '입장 준비 중...', atmosphereTitle: '어떤 분위기에서 들어갈까요?', atmosphereBody: '배경과 사운드는 같이 움직여요. 오늘 goal에 맞는 atmosphere 하나만 고르면 바로 들어갈 수 있어요.', - resumeEyebrow: 'Resume', - resumeRunning: '진행 중인 세션이 있어요.', - resumePaused: '잠시 멈춘 세션이 있어요.', - resumeCta: '이어서 몰입하기', - resumeRefocusCta: '한 조각 다시 잡기', - resumeRouting: '진행 중인 세션으로 돌아가는 중이에요.', - resumeMicroStepLabel: '마지막 한 조각', - resumePausedHint: '같은 목표를 바로 이어가거나, 다시 시작할 한 조각만 먼저 정리할 수 있어요.', - resumeNewGoalHint: '새 목표로 전환하려면 지금 멈춘 세션을 먼저 어떻게 정리할지 결정해야 해요.', - resumeTakeoverCta: '새 목표로 전환', - takeoverEyebrow: '새 목표로 전환', - takeoverTitle: '현재 멈춘 세션을 어떻게 할까요?', - takeoverBody: '지금 멈춘 흐름을 조용히 없애지 않고, 먼저 정리한 뒤 새 목표로 넘어가요.', - takeoverKeepCta: '이어서 하기', - takeoverConfirmCta: '이 세션은 여기서 정리하고 새로 시작', - takeoverCancelCta: '취소', - takeoverLoading: '세션을 정리하는 중...', - takeoverFailed: '멈춘 세션을 정리하지 못했어요. 잠시 후 다시 시도해 주세요.', loadFailed: '세션 상태를 불러오지 못했어요. 새로 시작은 계속 할 수 있어요.', reviewEyebrow: 'Weekly Review', reviewTitle: '이번 주 review를 잠깐 보고 갈까요?', @@ -77,9 +59,6 @@ const entryCopy = { reviewTitlePro: '나에게 잘 맞았던 흐름을 다시 보고 갈까요?', reviewCtaPro: '나에게 맞는 흐름 보기', reviewHelperPro: '가장 잘 맞았던 ritual과 carry-forward를 보고 돌아올 수 있어요.', - resumeReviewEyebrow: 'Weekly Review', - resumeReviewTitle: '잠깐 review를 보고 다시 들어갈 수 있어요.', - resumeReviewHelper: '현재 세션은 그대로 두고, 이번 주 흐름만 짧게 확인합니다.', reviewReturnEyebrow: '방금 본 review 기준', reviewReturnTitleSteady: '이번 주에 잘 맞았던 흐름을 그대로 가져가 보세요.', reviewReturnTitleSmaller: '이번엔 목표를 더 작게 잡아보세요.', @@ -89,25 +68,13 @@ const entryCopy = { reviewReturnBodySmaller: '길이를 늘리기보다, 더 작은 goal과 더 구체적인 첫 한 조각으로 시작하면 이어가기 쉬워져요.', reviewReturnBodyClosure: '큰 흐름보다 지금 블록을 어디서 마무리할지 먼저 떠올리면 끝까지 가져가기 쉬워져요.', reviewReturnBodyStart: '길이를 늘리기보다, 아주 작은 goal로 이번 주 첫 세션 하나를 더 여는 데 집중해 보세요.', - reviewReturnRitualLabel: '추천 ritual · 숲 · 50/10 · Forest Birds', + reviewReturnRitualLabel: '추천 atmosphere · 숲 · Forest Birds', paywallLead: 'Calm Session OS PRO', paywallBody: 'Pro는 더 빠른 ritual과 더 깊은 review로 시작과 복귀를 가볍게 만듭니다.', }; const goalCardClass = 'w-full rounded-[2rem] border border-white/12 bg-[#0f1115]/26 px-6 py-6 shadow-[0_24px_60px_rgba(3,7,18,0.32)] backdrop-blur-xl md:px-8 md:py-8'; -const primaryButtonClass = - 'inline-flex items-center justify-center rounded-full border border-white/16 bg-white/[0.14] px-6 py-3 text-sm font-medium text-white transition hover:bg-white/[0.18] active:scale-[0.99] disabled:cursor-not-allowed disabled:opacity-48'; -const secondaryButtonClass = - 'inline-flex items-center justify-center rounded-full border border-white/10 bg-white/[0.06] px-5 py-3 text-sm font-medium text-white/84 transition hover:bg-white/[0.1] hover:text-white active:scale-[0.99]'; - -const resolveSoundLabel = (soundPresetId?: string | null) => { - if (!soundPresetId) { - return 'Silent'; - } - - return SOUND_PRESETS.find((preset) => preset.id === soundPresetId)?.label ?? 'Silent'; -}; const reviewCarryCopyByHint: Record< ReviewCarryHint, @@ -171,10 +138,6 @@ export const FocusDashboardWidget = () => { const [currentSession, setCurrentSession] = useState(null); const [isCheckingSession, setIsCheckingSession] = useState(true); const [sessionLookupError, setSessionLookupError] = useState(null); - const [isTakeoverSheetOpen, setIsTakeoverSheetOpen] = useState(false); - const [isResolvingTakeover, setIsResolvingTakeover] = useState(false); - const [takeoverError, setTakeoverError] = useState(null); - const [focusGoalAfterTakeover, setFocusGoalAfterTakeover] = useState(false); const goalInputRef = useRef(null); const selectedAtmosphere = useMemo( @@ -192,14 +155,6 @@ export const FocusDashboardWidget = () => { return getSceneById(currentSession?.sceneId ?? selectedAtmosphere.sceneId) ?? SCENE_THEMES[0]; }, [currentSession?.sceneId, selectedAtmosphere.sceneId]); - const activeRitualMeta = useMemo(() => { - const timerLabel = - getTimerPresetMetaById(currentSession?.timerPresetId ?? DEFAULT_TIMER_ID).label; - const soundLabel = resolveSoundLabel(currentSession?.soundPresetId ?? DEFAULT_SOUND_ID); - - return `${activeScene.name} · ${timerLabel} · ${soundLabel}`; - }, [activeScene.name, currentSession?.soundPresetId, currentSession?.timerPresetId]); - const trimmedGoal = goalDraft.trim(); const canStart = trimmedGoal.length > 0 && @@ -232,11 +187,8 @@ export const FocusDashboardWidget = () => { const durationHelper = parsedDurationMinutes === null ? '이 목표를 끝내는 데 걸릴 것 같은 시간을 분 단위로 적어주세요.' - : parsedDurationMinutes === resolvedTimerPreset.focusMinutes - ? `${entryCopy.durationHelper} 지금은 ${resolvedTimerPreset.label} 리듬으로 바로 들어가요.` - : `${entryCopy.durationHelper} ${parsedDurationMinutes}분은 지금 ${resolvedTimerPreset.label} 리듬으로 먼저 들어가요.`; - const isRunningSession = currentSession?.state === 'running'; - const isPausedSession = currentSession?.state === 'paused'; + : entryCopy.durationHelper; + const hasCurrentSession = Boolean(currentSession); useEffect(() => { let cancelled = false; @@ -272,25 +224,10 @@ export const FocusDashboardWidget = () => { }, []); useEffect(() => { - if (!isCheckingSession && isRunningSession) { + if (!isCheckingSession && hasCurrentSession) { router.replace('/space'); } - }, [isCheckingSession, isRunningSession, router]); - - useEffect(() => { - if (!focusGoalAfterTakeover || currentSession) { - return; - } - - const frameId = window.requestAnimationFrame(() => { - goalInputRef.current?.focus(); - setFocusGoalAfterTakeover(false); - }); - - return () => { - window.cancelAnimationFrame(frameId); - }; - }, [currentSession, focusGoalAfterTakeover]); + }, [hasCurrentSession, isCheckingSession, router]); const openPaywall = () => { if (!isPro) { @@ -298,13 +235,6 @@ export const FocusDashboardWidget = () => { } }; - const resetEntryDrafts = () => { - setGoalDraft(''); - setSelectedAtmosphereId(initialAtmosphere.id); - setDurationDraft(String(initialDurationMinutes)); - setHasEditedDuration(false); - }; - const handleDurationChange = (value: string) => { setDurationDraft(sanitizeDurationDraft(value)); setHasEditedDuration(true); @@ -369,56 +299,8 @@ export const FocusDashboardWidget = () => { setIsStartingSession(false); }; - const handleResumeSession = () => { - router.push('/space?resume=continue'); - }; - - const handleResumeRefocus = () => { - router.push('/space?resume=refocus'); - }; - - const handleOpenTakeoverSheet = () => { - setTakeoverError(null); - setIsTakeoverSheetOpen(true); - }; - - const handleCloseTakeoverSheet = () => { - if (isResolvingTakeover) { - return; - } - - setIsTakeoverSheetOpen(false); - setTakeoverError(null); - }; - - const handleConfirmTakeover = async () => { - if (!currentSession || isResolvingTakeover) { - return; - } - - setIsResolvingTakeover(true); - setTakeoverError(null); - - try { - await focusSessionApi.abandonSession(); - setCurrentSession(null); - setIsTakeoverSheetOpen(false); - setSessionLookupError(null); - resetEntryDrafts(); - setFocusGoalAfterTakeover(true); - } catch (error) { - setTakeoverError( - error instanceof Error ? error.message : entryCopy.takeoverFailed, - ); - } finally { - setIsResolvingTakeover(false); - } - }; - const shouldShowWeeklyReviewTeaser = !isCheckingSession && !currentSession && hasEnoughWeeklyData && !isReviewReturn; - const shouldShowResumeReviewEntry = - !isCheckingSession && isPausedSession && hasEnoughWeeklyData; return (
@@ -443,9 +325,6 @@ export const FocusDashboardWidget = () => {
{isCheckingSession ? (
-

- {entryCopy.resumeEyebrow} -

세션 상태를 불러오는 중이에요.

) : ( @@ -467,135 +346,58 @@ export const FocusDashboardWidget = () => {
) : null} - {isRunningSession ? ( -
-

- {entryCopy.resumeEyebrow} -

-

{entryCopy.resumeRouting}

-
- ) : currentSession ? ( -
-
-

- {entryCopy.resumeEyebrow} -

-

- {currentSession.goal} -

-

- {entryCopy.resumePaused} -

- {currentSession.microStep ? ( -
-

- {entryCopy.resumeMicroStepLabel} -

-

{currentSession.microStep}

-
- ) : null} -
- -
-
- - -
-

{activeRitualMeta}

-
- -

{entryCopy.resumePausedHint}

-

{entryCopy.resumeNewGoalHint}

- - - {shouldShowResumeReviewEntry ? ( + -
+

- {entryCopy.resumeReviewEyebrow} + {entryCopy.reviewEyebrow}

-

- {entryCopy.resumeReviewTitle} +

+ {reviewTeaserTitle}

-

- {isPro ? review.carryForward.keepDoing : entryCopy.resumeReviewHelper} +

+ {reviewTeaserSummary}

+

{reviewTeaserHelper}

- + {reviewTeaserCta}
- ) : null} -
- ) : ( - -
-
-

- {entryCopy.reviewEyebrow} -

-

- {reviewTeaserTitle} -

-

- {reviewTeaserSummary} -

-

{reviewTeaserHelper}

-
- - {reviewTeaserCta} - -
- - ) : undefined - } - selectedAtmosphere={selectedAtmosphere} - sessionLookupError={sessionLookupError} - startButtonLabel={entryCopy.startNow} - startButtonLoadingLabel={entryCopy.startLoading} - atmosphereOptions={ATMOSPHERE_OPTIONS} - atmosphereTitle={entryCopy.atmosphereTitle} - atmosphereBody={entryCopy.atmosphereBody} - onDurationChange={handleDurationChange} - onGoalChange={setGoalDraft} - onSelectAtmosphere={handleSelectAtmosphere} - onSelectDuration={handleSelectDuration} - onStartSession={() => { - void handleStartSession(); - }} - /> - )} + ) : undefined + } + selectedAtmosphere={selectedAtmosphere} + sessionLookupError={sessionLookupError} + startButtonLabel={entryCopy.startNow} + startButtonLoadingLabel={entryCopy.startLoading} + atmosphereOptions={ATMOSPHERE_OPTIONS} + atmosphereTitle={entryCopy.atmosphereTitle} + atmosphereBody={entryCopy.atmosphereBody} + onDurationChange={handleDurationChange} + onGoalChange={setGoalDraft} + onSelectAtmosphere={handleSelectAtmosphere} + onSelectDuration={handleSelectDuration} + onStartSession={() => { + void handleStartSession(); + }} + />
)}
@@ -624,75 +426,6 @@ export const FocusDashboardWidget = () => { ) : null} - - {isTakeoverSheetOpen ? ( -
-
-
-

- {entryCopy.takeoverEyebrow} -

-

- {entryCopy.takeoverTitle} -

-

- {entryCopy.takeoverBody} -

- - {currentSession ? ( -
-

- {entryCopy.resumeEyebrow} -

-

- {currentSession.goal} -

- {currentSession.microStep ? ( -

- {entryCopy.resumeMicroStepLabel} · {currentSession.microStep} -

- ) : null} -
- ) : null} - - {takeoverError ? ( -

{takeoverError}

- ) : null} - -
- - - -
-
-
- ) : null}
); }; diff --git a/src/widgets/space-workspace/ui/SpaceWorkspaceWidget.tsx b/src/widgets/space-workspace/ui/SpaceWorkspaceWidget.tsx index e5ba7b3..069df17 100644 --- a/src/widgets/space-workspace/ui/SpaceWorkspaceWidget.tsx +++ b/src/widgets/space-workspace/ui/SpaceWorkspaceWidget.tsx @@ -39,7 +39,6 @@ import { FocusTopToast } from "./FocusTopToast"; export const SpaceWorkspaceWidget = () => { const searchParams = useSearchParams(); const router = useRouter(); - const resumeIntent = searchParams.get("resume"); const sceneQuery = searchParams.get("scene") ?? searchParams.get("room"); const goalQuery = searchParams.get("goal")?.trim() ?? ""; const focusPlanItemIdQuery = searchParams.get("planItemId"); @@ -104,7 +103,6 @@ export const SpaceWorkspaceWidget = () => { const [pendingSessionEntryPoint, setPendingSessionEntryPoint] = useState("space-setup"); const [showReviewTeaserAfterComplete, setShowReviewTeaserAfterComplete] = useState(false); - const [hasConsumedEntryOverlayIntent, setHasConsumedEntryOverlayIntent] = useState(false); const { selectedPresetId, @@ -225,16 +223,7 @@ export const SpaceWorkspaceWidget = () => { workspaceMode === "setup" && showReviewTeaserAfterComplete && hasEnoughWeeklyData; - const allowsPausedReentry = - resumeIntent === "continue" || resumeIntent === "refocus"; const didResolveEntryRouteRef = useRef(false); - const didHandleResumeIntentRef = useRef(false); - const entryOverlayIntent = - !hasConsumedEntryOverlayIntent && - resumeIntent === "refocus" && - currentSession?.state === "paused" - ? "resume-refocus" - : null; const secondaryReviewTeaser = shouldShowSecondaryReviewTeaser ? { title: isPro @@ -265,33 +254,7 @@ export const SpaceWorkspaceWidget = () => { if (!currentSession) { return; } - - if (currentSession.state === "paused" && !allowsPausedReentry) { - router.replace("/app"); - } - }, [allowsPausedReentry, currentSession, isBootstrapping, router]); - - useEffect(() => { - if ( - isBootstrapping || - !currentSession || - currentSession.state !== "paused" || - didHandleResumeIntentRef.current - ) { - return; - } - - if (resumeIntent === "continue") { - didHandleResumeIntentRef.current = true; - router.replace("/space"); - void handleStartRequested(); - return; - } - - if (resumeIntent === "refocus") { - return; - } - }, [currentSession, handleStartRequested, isBootstrapping, resumeIntent, router]); + }, [currentSession, isBootstrapping, router]); useEffect(() => { const preferMobile = @@ -390,12 +353,7 @@ export const SpaceWorkspaceWidget = () => { canStartSession={controls.canStartSession} canPauseSession={controls.canPauseSession} canRestartSession={controls.canRestartSession} - entryOverlayIntent={entryOverlayIntent} returnPromptMode={awayReturnRecovery.returnPromptMode} - onEntryOverlayIntentHandled={() => { - setHasConsumedEntryOverlayIntent(true); - router.replace("/space"); - }} onStartRequested={() => { void handleStartRequested(); }}