diff --git a/docs/09_app_entry_detailed_spec.md b/docs/09_app_entry_detailed_spec.md new file mode 100644 index 0000000..d531bce --- /dev/null +++ b/docs/09_app_entry_detailed_spec.md @@ -0,0 +1,132 @@ +# 09. `/app` Entry Detailed Spec + +Last Updated: 2026-03-14 + +이 문서는 `/app`을 **single-goal commitment gate**로 구현할 때의 상세 기준 문서다. + +관련 상위 기준: + +- `../../product_principles.md` +- `../../current_context.md` +- `08_app_reframe_strategy.md` + +--- + +## 1. 한 줄 정의 + +`/app`은 planner나 setup wizard가 아니라, +**사용자가 지금 할 일 한 가지를 정하고 가장 낮은 마찰로 `/space`에 들어가게 만드는 입구**다. + +--- + +## 2. 화면 역할 + +### `/app`이 해야 하는 일 + +- 현재 세션이 있으면 `이어가기`를 제안한다 +- 현재 세션이 없으면 `goal 1개 + optional microStep 1개`만 받는다 +- 주 행동은 항상 `지금 시작` 하나다 +- 시작 전에 많은 설정을 요구하지 않는다 + +### `/app`이 하면 안 되는 일 + +- 여러 목표를 관리하게 하기 +- planner / to-do / list app처럼 보이게 하기 +- scene / sound / timer 선택을 메인 결정으로 끌어올리기 +- 진입 전에 `정리`, `관리`, `저장`을 강요하기 + +--- + +## 3. 정보 구조 + +### 상태 A. Current Session Exists + +- eyebrow: `Resume` +- 현재 goal +- optional microStep +- primary CTA: `이어서 들어가기` +- 보조 정보: 현재 ritual 요약 +- 설명: + - 새 목표는 현재 세션을 마무리한 뒤 시작할 수 있다고만 안내 + +### 상태 B. No Current Session + +- 제목 +- 설명 +- goal input 1개 +- optional microStep input 1개 +- suggestion chips 3~4개 +- primary CTA: `지금 시작` +- 하단 보조 정보: + - `기본 ritual · scene · timer · sound` + - `공간과 사운드는 들어간 뒤에도 바꿀 수 있어요.` + +--- + +## 4. 상호작용 원칙 + +### Goal + +- 필수 +- 한 줄 입력 +- enter로 바로 시작 가능 + +### MicroStep + +- 선택 +- checklist가 아니라 `지금 할 한 조각` +- 1개만 허용 + +### Suggestions + +- 계획 리스트가 아니라 start friction reducer 역할 +- 클릭 시 goal input을 빠르게 채움 + +### Start + +- 현재 세션이 없을 때만 가능 +- `goal + microStep + default ritual`로 세션 시작 +- 성공 시 `/space` 이동 + +### Resume + +- 현재 세션이 있으면 start UI 대신 resume UI를 우선 노출 +- 클릭 시 `/space`로 이동 + +--- + +## 5. UX 원칙 + +- `/app`은 start surface여야지 setup surface가 아니어야 한다 +- 한 화면 안에서 결정할 것은 최대 2개(goal, optional microStep) +- visual hierarchy는 `goal > CTA > microStep > suggestions > ritual helper` 순서 +- paywall은 메인 CTA가 아니라 Plan Pill에서만 여는 수준으로 제한 + +--- + +## 6. 구현 범위 + +### 이번 slice에 포함 + +- 2-step ritual flow 제거 +- manage/list UI를 메인 경로에서 제거 +- current session resume path 추가 +- single-goal + optional microStep direct start +- default ritual만 사용 + +### 이번 slice에 포함하지 않음 + +- multi-goal manage 복구 +- entry 단계 scene/sound/timer 직접 선택 +- planner/dashboard 요소 복구 +- goal 저장과 session start의 backend atomic endpoint 신규 도입 + +--- + +## 7. 검증 기준 + +- 사용자가 10초 안에 goal을 입력하고 `/space`로 들어갈 수 있다 +- `/app` 첫 인상이 planner나 setup wizard처럼 보이지 않는다 +- current session이 있으면 `resume`이 주 행동으로 읽힌다 +- 현재 세션이 없을 때는 `지금 시작`이 유일한 dominant CTA다 +- scene / sound / timer는 entry의 주 의사결정으로 느껴지지 않는다 diff --git a/docs/10_refocus_system_spec.md b/docs/10_refocus_system_spec.md new file mode 100644 index 0000000..732945b --- /dev/null +++ b/docs/10_refocus_system_spec.md @@ -0,0 +1,468 @@ +# 10. Refocus System Spec + +Last Updated: 2026-03-14 + +이 문서는 VibeRoom의 `Refocus System`을 제품 대표 경험으로 설계하기 위한 상세 기준 문서다. + +관련 상위 문서: + +- `../../product_principles.md` +- `../../current_context.md` +- `09_app_entry_detailed_spec.md` + +--- + +## 1. 왜 Refocus가 핵심인가 + +VibeRoom의 차별점은 `더 오래 계획하게 만드는 것`이 아니라, +`흔들린 뒤에도 다시 집중 위에 올라타게 만드는 것`이어야 한다. + +ADHD 성향 사용자와 프리랜서에게 진짜 어려운 순간은 대개 아래 셋 중 하나다. + +- 시작 직전 +- 잠깐 멈췄다가 다시 붙잡아야 할 때 +- 한 조각을 끝냈는데 다음 동작이 흐려졌을 때 + +`/app`이 시작 마찰을 줄이는 화면이라면, +`Refocus System`은 **세션 도중 무너진 흐름을 다시 복구하는 핵심 시스템**이다. + +--- + +## 2. 레퍼런스에서 가져올 것 / 버릴 것 + +이 문서는 2026-03-14 기준 공식 사이트를 기준으로 아래 레퍼런스를 참고했다. + +- [Portal](https://portal.app/) +- [LifeAt Pricing](https://lifeat.io/pricing) +- [Focusmate Pricing](https://www.focusmate.com/pricing/) +- [Focusmate Getting Started](https://support.focusmate.com/en/articles/9110188-getting-started) +- [Focusmate Focus Now](https://support.focusmate.com/en/articles/9994509-focus-now) + +### Portal에서 가져올 것 + +- 배경이 주인공이고 인터페이스는 조용히 뒤로 물러나야 한다 +- 감각 품질 자체가 제품 가치가 된다 +- “아름답다”가 장식이 아니라 사용 지속 이유가 된다 + +### Focusmate에서 가져올 것 + +- 행동을 시작시키는 명확한 구조 +- 사용자가 망설이지 않게 하는 단일 흐름 +- `지금 바로 들어간다`는 즉시성 + +### LifeAt에서 가져올 것 + +- 가볍게 시작할 수 있는 친화성 +- 공간과 집중을 연결하는 감성 + +### LifeAt에서 가져오면 안 되는 것 + +- planner / todo / calendar 중심 구조 +- 목표를 많이 다루게 하는 흐름 +- “집중 앱”보다 “정리 앱”처럼 읽히는 경험 + +--- + +## 3. 한 줄 정의 + +> Refocus는 사용자가 흔들렸을 때 죄책감 없이 다시 한 조각 위에 올라타게 만드는 조용한 복귀 의식이다. + +중요한 점: + +- `수정 기능`이 아니다 +- `체크리스트`가 아니다 +- `왜 못 했는지 묻는 평가 시스템`이 아니다 + +--- + +## 4. 제품 목표 + +Refocus System은 아래 4가지를 만족해야 한다. + +1. 사용자가 세션을 포기하지 않게 한다 +2. pause 이후 복귀 마찰을 줄인다 +3. microStep을 다시 잡아 실행을 재개하게 한다 +4. UI가 배경과 몰입을 방해하지 않는다 + +--- + +## 5. 시스템 원칙 + +### 1. Refocus는 한 번에 한 질문만 한다 + +질문을 여러 개 던지면 planner처럼 느껴진다. +항상 다음 하나만 물어야 한다. + +- 계속할까? +- 지금 할 한 조각은 무엇일까? +- 이 목표를 끝낼까? + +### 2. 사용자를 평가하지 않는다 + +금지: + +- 왜 못 했어요? +- 얼마나 산만했나요? +- 다시 집중하세요 + +허용: + +- 다음 한 조각이 있나요? +- 지금 다시 시작할 수 있게 한 줄만 남겨볼까요? +- 여기까지로 충분한가요? + +### 3. goal은 1개, microStep도 1개 + +Refocus는 절대 리스트 관리 UI가 되면 안 된다. + +- multi-step 금지 +- queue 금지 +- subtask list 금지 + +### 4. 배경은 항상 주인공이다 + +Refocus UI는 강한 모달이 아니라 **배경 위에 잠깐 생겼다가 사라지는 얇은 recovery layer**여야 한다. + +### 5. Premium은 절제에서 온다 + +좋은 Refocus는 화려한 카드나 모션이 아니라 아래에서 온다. + +- 올바른 정보 밀도 +- 1개의 명확한 행동 +- 자연스러운 등장과 퇴장 +- 배경과 어우러지는 재질감 + +--- + +## 6. Refocus가 필요한 순간들 + +Refocus는 아래 4개 진입점으로 고정한다. + +### A. Pause 직후 + +사용자가 세션을 멈춘 뒤 다시 돌아와야 하는 순간. + +### B. MicroStep 완료 직후 + +현재 한 조각은 끝났지만 다음 행동이 아직 흐린 순간. + +### C. 사용자가 의도를 직접 수정하고 싶을 때 + +goal이나 microStep을 다시 정리하고 싶은 수동 진입. + +### D. 세션에서 멀어졌다가 복귀했을 때 + +탭을 바꾸거나 잠깐 이탈한 후 다시 `/space`로 돌아온 상황. + +--- + +## 7. 화면 상태 모델 + +Refocus는 아래 5개 상태만 가진다. + +1. `Focused` +2. `Paused` +3. `Refocus` +4. `Next Beat` +5. `Complete` + +한 번에 활성화되는 확장 상태는 하나만 허용한다. + +- `Refocus` +- `Next Beat` +- `Complete` + +이 셋은 동시에 보이면 안 된다. + +--- + +## 8. 상태별 상세 UX + +### 8.1 Focused + +목표: + +- 사용자가 UI를 거의 의식하지 않고 일하는 상태 + +보여야 하는 것: + +- goal +- optional microStep +- timer HUD +- minimal controls + +보이면 안 되는 것: + +- 질문 +- 복구 유도 문구 +- task-like affordance + +### 8.2 Paused + +목표: + +- 세션이 끊긴 느낌이 아니라 잠시 호흡을 고르는 느낌 + +행동: + +- 바로 큰 sheet를 띄우지 않는다 +- HUD 상태가 paused로 바뀌고, +- `다시 시작할 준비가 되면 한 조각만 다시 잡는다`는 감각을 준다 + +### 8.3 Refocus + +목표: + +- 목표를 버리지 않고 다시 시작점을 만든다 + +UI: + +- anchored tray 또는 compact sheet +- 필드 2개 + - 이번 세션 목표 + - 지금 할 한 조각 +- CTA 2개 + - 적용 + - 취소 + +중요: + +- `다시 방향 잡기`는 메인 액션이 아니라 recovery action이어야 한다 +- button cluster처럼 보이면 안 된다 + +### 8.4 Next Beat + +목표: + +- microStep 완료 후 checklist가 아니라 “다음 한 조각”으로 연결 + +질문: + +> 다음 한 조각이 있나요? + +행동: + +- `한 조각 정하기` +- `없이 계속` + +금지: + +- 다음 step list 펼치기 +- 여러 개 제안 +- 정리형 UI + +### 8.5 Complete + +목표: + +- 목표가 끝났을 때 닫음과 다음 시작을 모두 부드럽게 연결 + +질문: + +- 여기까지 끝났나요? + +행동: + +- 여기까지 끝내기 +- 다음 목표로 이어가기 +- 잠깐 쉬기 + +완료는 celebration보다 **closure quality**가 중요하다. + +--- + +## 9. 상세 플로우 + +### Flow A. Pause -> Refocus -> Resume + +1. 사용자가 pause 한다 +2. UI는 즉시 평가하지 않는다 +3. 사용자가 resume을 누르거나 intent를 누르면 Refocus로 들어갈 수 있다 +4. 사용자는 goal / microStep 중 필요한 것만 조정한다 +5. `적용 후 이어가기` 또는 `적용` 뒤 resume + +목표: + +- pause 이후 바로 복귀하는 것이 아니라, +- 한 번 숨을 고르고 다시 붙잡게 만든다 + +### Flow B. MicroStep Complete -> Next Beat + +1. 사용자가 microStep completion mark를 누른다 +2. 기존 microStep은 조용히 처리된다 +3. `다음 한 조각이 있나요?`가 나타난다 +4. 사용자는 + - 새 microStep을 적거나 + - 없이 계속 간다 + +목표: + +- planner처럼 다음 목록을 만드는 것이 아니라 +- 지금 하나만 다시 붙잡게 한다 + +### Flow C. Manual Refocus + +1. 사용자가 goal 영역을 눌러 의도 수정 진입 +2. goal / microStep 수정 +3. 적용 +4. 조용히 복귀 + +목표: + +- 세션을 깨지 않는 범위에서 intent만 조정 + +### Flow D. Goal Complete -> Next Goal or Close + +1. 사용자가 goal complete 진입 +2. 현재 목표를 닫을지, 다음 목표로 이어갈지 선택 +3. 다음 목표는 여전히 1개만 다룬다 + +목표: + +- checklist가 아니라 clean transition + +--- + +## 10. UI 구조 + +### 레이어 구조 + +#### Layer 1. Background + +- 배경 이미지/영상 +- 주인공 + +#### Layer 2. Core HUD + +- timer +- sound / scene controls +- intent card + +#### Layer 3. Recovery Layer + +- refocus tray +- next beat prompt +- complete tray + +Recovery Layer는 Core HUD와 같은 material family를 가져야 한다. +다만 더 조용하고 얇아야 한다. + +### Material 방향 + +- iOS 계열의 refined glass 참고 +- 강한 탁도보다 `투명 + blur + 얕은 경계`를 우선 +- glow, heavy shadow, thick border 금지 +- chip 남발 금지 + +### 모션 방향 + +- 빠르게 튀어나오지 않는다 +- appear 220~280ms +- disappear 180~220ms +- scale보다는 opacity + position shift 위주 + +--- + +## 11. 카피라이팅 방향 + +### tone + +- 짧다 +- 저압력이다 +- 평가하지 않는다 +- 다시 시작할 수 있게 한다 + +### 좋은 예 + +- `지금 할 한 조각` +- `다음 한 조각이 있나요?` +- `한 줄만 다듬고 다시 시작해요.` +- `여기까지로 충분한가요?` + +### 피해야 할 예 + +- `할 일` +- `리스트` +- `관리` +- `다음 단계들` +- `왜 못 했나요?` + +--- + +## 12. Free / Pro에서의 역할 + +### Free + +- 기본 refocus 진입 +- goal / microStep 재설정 +- next beat prompt +- goal complete 기본 흐름 + +### Pro + +- 더 정교한 refocus guidance +- 세션 패턴 기반 microStep 제안 +- 어떤 ritual에서 복귀율이 높은지 review에 반영 + +중요: + +Pro는 `더 많은 목표`가 아니라 `더 높은 복귀 성공률`을 파는 쪽으로 가야 한다. + +--- + +## 13. 성공 지표 + +Refocus System은 아래 지표로 판단한다. + +- Pause 후 Resume 비율 +- Pause 후 Refocus 진입 비율 +- Refocus 후 2분 이상 유지 비율 +- MicroStep 완료 후 Next Beat 입력 비율 +- Goal complete 후 다음 세션 전환 비율 +- 세션 중 abandon 비율 감소 + +--- + +## 14. 구현 우선순위 + +### Slice 1 + +- pause 이후 Refocus 진입 구조 정리 +- goal / microStep 수정 tray 정교화 +- 한 번에 하나의 overlay만 뜨도록 상태 정리 + +### Slice 2 + +- microStep 완료 -> Next Beat 흐름 정리 +- checklist 느낌 제거 + +### Slice 3 + +- goal complete tray의 완성도 향상 +- closure / next goal / break 분기 정리 + +### Slice 4 + +- Pro용 adaptive refocus 기획 +- review와 refocus 연결 + +--- + +## 15. 절대 피해야 할 방향 + +- pause를 실패처럼 느끼게 하는 UX +- checklist UI +- multi-step planner화 +- 과한 그래프 / 통계 immediate 노출 +- 배경보다 더 앞에 나오는 recovery UI +- action chip 남발 +- 한 번에 여러 질문을 던지는 sheet + +--- + +## 16. 이 문서를 기준으로 다음 구현에서 꼭 지켜야 할 것 + +- Refocus는 `편집 기능`이 아니라 `recovery ritual`처럼 느껴져야 한다 +- 사용자는 한 번에 하나의 행동만 선택해야 한다 +- 배경은 절대 희생시키지 않는다 +- premium quality는 더 많은 glass가 아니라 더 적은 friction에서 나온다 diff --git a/docs/11_away_return_recovery_spec.md b/docs/11_away_return_recovery_spec.md new file mode 100644 index 0000000..79ae892 --- /dev/null +++ b/docs/11_away_return_recovery_spec.md @@ -0,0 +1,441 @@ +# 11. Away / Return Recovery Spec + +Last Updated: 2026-03-14 + +이 문서는 사용자가 `pause`를 누르지 않고 그냥 자리를 떠난 경우를 VibeRoom이 어떻게 감지하고, 어떻게 맞이하고, 어떻게 다시 복귀시킬지를 정의하는 상세 기준 문서다. + +관련 문서: + +- `../../product_principles.md` +- `../../current_context.md` +- `10_refocus_system_spec.md` + +--- + +## 1. 왜 이 기획이 중요한가 + +ADHD 성향 사용자와 프리랜서는 자주 아래처럼 이탈한다. + +- `pause`를 누를 생각도 못 한 채 자리에서 일어남 +- 잠깐 딴 탭을 보다가 세션 흐름을 잃음 +- focus가 끝났는지도 모른 채 돌아옴 + +이 상황을 제품이 다루지 않으면 아래 문제가 생긴다. + +- Refocus가 “사용자가 pause를 눌렀을 때만 작동하는 기능”으로 축소된다 +- 세션이 실제 흐름보다 더 기계적으로 느껴진다 +- `pause`와 `break`가 같은 “멈춘 상태”처럼 읽힌다 +- 돌아온 사용자를 잘못된 상태로 맞이하게 된다 + +VibeRoom은 감시 앱이 되어서는 안 되지만, +**돌아왔을 때의 복귀 품질**은 반드시 설계해야 한다. + +--- + +## 2. 한 줄 정의 + +> Away / Return은 사용자의 무의식적 이탈을 실패로 다루지 않고, 돌아왔을 때 가장 자연스럽게 다시 몰입 위에 올려놓는 복귀 레이어다. + +중요한 점: + +- 사용자를 통제하거나 막는 기능이 아니다 +- “왜 떠났는가”를 추궁하지 않는다 +- 핵심은 detection보다 **return UX**다 + +--- + +## 3. 이 시스템이 해결해야 하는 문제 + +### 문제 A. 사용자는 `pause`를 누르지 않는다 + +사용자는 실제로는 쉬고 싶어서 일어난 것이어도, +제품 안에서는 아무 액션도 하지 않은 채 세션이 흘러간다. + +### 문제 B. focus가 끝났는데 break처럼 보인다 + +사용자가 없던 사이 focus가 끝났다면, 돌아왔을 때 단순 `Break`로 맞이하면 안 된다. +그건 제품이 사용자의 실제 맥락을 오해한 것이다. + +### 문제 C. pause와 break가 겹쳐 보인다 + +현재 감정적으로는 이렇게 읽히기 쉽다. + +- `Pause`: 내가 멈춤 +- `Break`: focus가 끝나서 쉬는 중 + +둘은 의미가 다른데, UI와 상태가 충분히 분리되지 않으면 같은 “멈춤”처럼 느껴진다. + +--- + +## 4. 핵심 원칙 + +### 1. 감시는 하지 않는다 + +아래는 금지한다. + +- webcam / face tracking +- 키보드/마우스 무입력만으로 강제 이탈 판정 +- 사용자를 혼내는 알림 +- “집중하지 않았네요” 류의 카피 + +### 2. 확실한 신호만 사용한다 + +웹에서 “자리 비움”은 정확히 알 수 없다. +따라서 v1은 아래처럼 비교적 강한 신호만 쓴다. + +- `visibilitychange` +- `pagehide` +- 브라우저/기기 sleep 이후 큰 시간 점프 +- 창 복귀 시점 + +### 3. detection보다 return이 중요하다 + +중요한 것은 “네가 떠났다는 걸 알아냈다”가 아니라, +**“돌아온 지금 무엇을 제안할 것인가”**다. + +### 4. Pause와 Break는 명확히 다르게 느껴져야 한다 + +- `Pause`는 recovery tone +- `Break`는 release / reset tone +- `Return`은 re-entry tone + +### 5. focus ended while away는 Break가 아니라 Return이다 + +사용자가 없는 동안 focus가 끝났다면, +돌아온 순간의 경험은 `쉬는 중`이 아니라 `복귀 결정`이어야 한다. + +--- + +## 5. 상태 모델 + +VibeRoom의 세션 상태를 제품적으로는 아래처럼 다룬다. + +### Core States + +- `Focus` +- `Pause` +- `Break` + +### Recovery States + +- `AwayCandidate` +- `Return` + +### 의미 + +#### Focus + +사용자가 현재 세션 안에서 일하고 있음 + +#### Pause + +사용자가 의도적으로 멈춤 + +#### Break + +focus 블록을 끝내고 의도적으로 쉬는 상태 + +#### AwayCandidate + +사용자가 실제로 자리를 떴을 가능성이 높은 내부 판단 상태 + +#### Return + +이탈 후 다시 돌아온 순간, 제품이 복귀를 제안하는 상태 + +--- + +## 6. 웹에서의 감지 전략 + +### v1에서 사용하는 신호 + +#### 1. visibility hidden + +- 사용자가 탭을 떠남 +- 브라우저가 background로 감 + +#### 2. pagehide / tab background + +- 페이지가 전환되거나 숨겨짐 + +#### 3. sleep / wake 시간 점프 + +- 사용자가 기기를 잠그거나 화면이 꺼졌다가 돌아왔을 가능성 +- `Date.now()` 기준 큰 delta로 감지 + +### v1에서 사용하지 않는 신호 + +#### blur / focus 단독 + +너무 오탐이 많다. + +#### 무입력 시간만으로 Away 판단 + +읽고 생각하는 사용자를 잘못 판정할 수 있다. + +#### pointer / keyboard tracking 강제화 + +감시처럼 느껴질 수 있어 금지 + +--- + +## 7. 판단 규칙 + +### Rule 1. focus 중 hidden/wake gap 발생 + +상태: + +- `Focus` 중 +- `visibility hidden` 또는 `sleep/wake delta` 발생 + +처리: + +- 내부적으로 `AwayCandidate` + +### Rule 2. 돌아왔을 때 focus가 아직 안 끝남 + +상태: + +- 사용자가 복귀 +- 남은 focus 시간이 남아 있음 + +처리: + +- `Return` tray 노출 +- 질문: + - `이어서 할까요?` + - `한 조각 다시 잡을까요?` + +### Rule 3. 돌아왔을 때 focus가 이미 끝남 + +상태: + +- 사용자가 복귀 +- focus phase는 끝났음 +- 하지만 사용자는 그 종료를 경험하지 못했음 + +처리: + +- `Break` 직접 진입 금지 +- `Return` tray 노출 +- 질문: + - `자리를 비운 사이 이 블록이 끝났어요. 지금 어떻게 이어갈까요?` + +행동: + +- `지금부터 쉬기` +- `다음 목표로 이어가기` +- `한 조각 다시 잡기` + +### Rule 4. pause 상태에서 복귀 + +상태: + +- 이미 사용자가 직접 `Pause`한 상태 + +처리: + +- 이건 Away보다 Pause 흐름이 우선 +- 기존 pause recovery prompt 유지 + +즉, `Away / Return`은 manual pause를 덮어쓰지 않는다. + +--- + +## 8. Return UX 설계 + +### Return의 목적 + +- 죄책감 없이 다시 올려놓기 +- `지금 어떤 상태인지`를 간단히 알려주기 +- 선택지를 2~3개만 제시하기 + +### Return의 기본 구조 + +- eyebrow: `다시 돌아왔어요` +- 짧은 현재 맥락 설명 +- primary action 1개 +- secondary action 1개 +- tertiary는 최대 1개 + +### Case A. focus still running + +카피 예: + +- 제목: `이어서 갈까요?` +- 설명: `흐름은 그대로 남아 있어요. 바로 이어가거나 한 조각만 다시 잡을 수 있어요.` + +행동: + +- primary: `이어서 하기` +- secondary: `한 조각 다시 잡기` + +### Case B. focus ended while away + +카피 예: + +- 제목: `자리를 비운 사이 이 블록이 끝났어요.` +- 설명: `지금부터 쉬거나, 다음으로 이어갈 수 있어요.` + +행동: + +- primary: `지금부터 쉬기` +- secondary: `다음 목표 이어가기` +- tertiary: `한 조각 다시 잡기` + +### Case C. break ended while away + +이건 v1에서는 단순화한다. + +- break가 끝났고 사용자가 돌아왔다면 +- `다음 블록으로 이어갈까요?` 정도의 단순 return prompt만 둔다 +- 이 상태는 후속 slice에서 다룬다 + +--- + +## 9. Pause / Break / Return의 차이 + +### Pause + +- 사용자가 직접 멈춘 상태 +- 톤: recovery +- 질문: + - `한 조각 다시 잡기` + - `이대로 이어가기` + +### Break + +- 사용자가 focus 블록을 끝낸 뒤 쉬는 상태 +- 톤: release / reset +- 질문: + - `쉬기 계속` + - `다음으로 가기` + +### Return + +- 사용자가 떠났다가 돌아온 상태 +- 톤: re-entry +- 질문: + - `지금 어디서 다시 시작할까?` + +핵심: + +`Pause`와 `Return`은 비슷하지만 다르다. + +- Pause는 사용자가 멈춘 것 +- Return은 제품이 사용자의 이탈을 감지하고 다시 맞이하는 것 + +--- + +## 10. UI / Layer 구조 + +### Layer 원칙 + +- Away / Return도 기존 recovery family 안에 들어간다 +- 한 번에 하나의 recovery layer만 열 수 있다 + +우선순위: + +1. `Complete` +2. `Return` +3. `Pause` +4. `Refocus` +5. `Next Beat` + +### 이유 + +- Complete는 가장 명시적이고 종료 의미가 강함 +- Return은 환경 변화에 대한 복귀 진입점 +- Pause는 사용자의 수동 멈춤 +- Refocus와 Next Beat는 더 세부적인 recovery 단계 + +--- + +## 11. 카피 원칙 + +### 좋은 톤 + +- `다시 돌아왔어요` +- `흐름은 그대로 남아 있어요` +- `한 조각만 다시 잡을까요?` +- `지금부터 쉬거나, 다음으로 이어갈 수 있어요` + +### 금지 톤 + +- `집중을 놓쳤네요` +- `왜 자리를 비우셨나요` +- `세션이 중단되었습니다` +- `복귀하세요` + +--- + +## 12. 구현 우선순위 + +### Slice A. Spec 정리 + +- 상태 모델 확정 +- detection 전략 확정 +- Return UX 문구 확정 + +### Slice B. Detection 도입 + +- `visibilitychange` +- `pagehide` +- sleep/wake delta + +### Slice C. Return Tray 구현 + +- focus still running case +- focus ended while away case + +### Slice D. Pause / Break 분리 강화 + +- copy +- visual tone +- action hierarchy + +--- + +## 13. 측정 지표 + +- hidden -> return 이후 resume 비율 +- away detected 후 abandon 비율 +- focus ended while away 후 다음 행동 선택률 +- return tray에서 primary 선택 비율 +- return 후 2분 이상 유지 비율 + +--- + +## 14. 이번 기획이 기존 계획과 어떻게 연결되는가 + +이 문서는 `중간에 끼어든 기획`이 맞지만, 방향을 흐리는 끼어듦이 아니다. +오히려 `Refocus System`을 완성하기 위해 필요한 핵심 보강이다. + +따라서 순서는 이렇게 재정렬한다. + +1. `Refocus System` 구현 +2. `Away / Return Recovery` 구현 +3. 그 다음에 originally planned visual polish +4. 그 다음에 Break 품질과 Review 확장 + +즉, 원래 예정했던 다음 기획은 사라지지 않는다. +**Away / Return이 먼저 들어오고, visual polish와 break refinement가 그 뒤로 밀리는 것**이다. + +--- + +## 15. 절대 피해야 할 방향 + +- 감시처럼 느껴지는 detection +- 무입력 시간을 벌점처럼 해석하는 것 +- hidden -> 자동 pause 강제 +- 돌아왔을 때 죄책감 유발 카피 +- focus ended while away인데 그냥 break로 보내는 것 + +--- + +## 16. 다음 구현에서 꼭 지켜야 할 것 + +- detection은 조심스럽게, return UX는 분명하게 +- pause와 return을 섞지 말 것 +- break는 reward / reset tone으로 유지할 것 +- away는 “실패”가 아니라 “복귀가 필요한 순간”으로 다룰 것 diff --git a/docs/12_core_loop_execution_roadmap.md b/docs/12_core_loop_execution_roadmap.md new file mode 100644 index 0000000..4091e56 --- /dev/null +++ b/docs/12_core_loop_execution_roadmap.md @@ -0,0 +1,285 @@ +# 12. Core Loop Execution Roadmap + +Last Updated: 2026-03-14 + +이 문서는 VibeRoom의 핵심 제품 기획을 **어떤 순서로 구현까지 연결할지**를 정의하는 실행 로드맵이다. + +중요한 목적은 두 가지다. + +- 중간에 끼어드는 기획이 전체 방향을 흔들지 않게 한다 +- 어떤 세션, 어떤 에이전트가 들어와도 **다음으로 무엇을 해야 하는지** 즉시 이해하게 한다 + +관련 문서: + +- `../../product_principles.md` +- `../../current_context.md` +- `09_app_entry_detailed_spec.md` +- `10_refocus_system_spec.md` +- `11_away_return_recovery_spec.md` + +--- + +## 1. 기본 원칙 + +VibeRoom은 아래 방식으로 진행한다. + +1. 바뀌면 안 되는 제품 원칙을 먼저 고정한다 +2. 코어 루프를 정의한다 +3. 코어 루프를 **slice 단위**로 상세 기획한다 +4. 각 slice는 `기획 -> 구현 -> 브라우저 QA -> 문서 업데이트`까지 닫고 간다 +5. 다음 slice는 이전 slice가 제품적으로 안정된 뒤에 들어간다 + +즉: + +- `모든 기획을 끝낸 뒤 한 번에 개발`하지 않는다 +- `생각나는 기능을 바로 구현`하지도 않는다 +- **큰 방향은 먼저 고정하고, 세부는 vertical slice로 기획 후 바로 구현**한다 + +--- + +## 2. 현재까지 완료된 것 + +### 완료 1. Product Principles + +- 루트 문서 `../../product_principles.md` +- single-goal, premium focus, anti-to-do 방향 고정 + +### 완료 2. `/app` Entry Reframe + +- 문서: `09_app_entry_detailed_spec.md` +- 구현 상태: + - `/app`은 single-goal commitment gate + - planner/list-first 구조 제거 + - current session이 있으면 resume 우선 + +### 완료 3. Refocus System 기본 구조 + +- 문서: `10_refocus_system_spec.md` +- 구현 상태: + - pause -> refocus 흐름의 기본 skeleton 존재 + - next beat / goal complete의 상태 분리 시작 + +--- + +## 3. 지금 끼어든 기획의 위치 + +### 끼어든 기획 + +- `11_away_return_recovery_spec.md` + +이 기획은 원래 흐름을 덮어쓴 것이 아니다. +이 문서는 **Refocus System을 실제 사용자 행동에 맞게 완성하기 위해 중간에 추가된 필수 slice**다. + +왜 끼어들었는가: + +- 사용자는 `pause`를 누르지 않고 자리를 뜨는 경우가 많다 +- 이 케이스를 다루지 않으면 Refocus System이 반쪽짜리가 된다 +- `pause`와 `break`가 겹쳐 보이는 문제도 여기서 정리해야 한다 + +즉, 이 기획은 “옆길”이 아니라 **Refocus System의 확장 파트**다. + +--- + +## 4. 현재 기준의 구현 순서 + +아래 순서를 공식 순서로 고정한다. + +### Phase 1. `/app` Entry Polish + +목적: + +- 시작 마찰을 최소화 +- `/app`이 planner가 아니라 commitment gate로 읽히게 고정 + +상태: + +- 기획 완료 +- 구현 대부분 완료 +- 남은 것은 브라우저 QA와 polish + +### Phase 2. Refocus System + +문서: + +- `10_refocus_system_spec.md` + +목적: + +- pause 이후 복귀 +- microStep 완료 후 다음 한 조각 연결 +- goal complete의 자연스러운 closure + +상태: + +- 기획 완료 +- 구현 진행 중 + +### Phase 3. Away / Return Recovery + +문서: + +- `11_away_return_recovery_spec.md` + +목적: + +- 사용자가 `pause` 없이 떠난 경우를 감지 +- 돌아왔을 때 Break가 아니라 Return UX로 맞이 +- `pause`, `break`, `return`의 감정적 의미를 분리 + +상태: + +- 기획 완료 +- 아직 구현 전 + +중요: + +- 이것은 `Refocus System` 다음으로 이어지는 것이 아니라 +- **Refocus System 구현의 후반부**로 바로 이어진다 + +### Phase 4. Complete / Break Separation Polish + +목적: + +- `goal complete`, `break`, `return`의 의미와 재질감 분리 +- “쉬는 중”과 “복귀 결정”이 섞이지 않게 정리 + +상태: + +- 아직 상세 기획 일부만 존재 +- Away / Return 구현 후 들어간다 + +### Phase 5. Weekly Review Reframe + +목적: + +- total time보다 시작 성공률, 복귀율, 유지 패턴 중심으로 재설계 + +상태: + +- 아직 본격 기획 전 + +### Phase 6. Premium Ambience System + +목적: + +- Portal급 감각 품질 확보 +- 배경, 사운드, 전환, material의 art direction 통일 + +상태: + +- 방향만 존재 +- review 전후 어느 시점에 들어갈지 조정 가능 + +--- + +## 5. “그 다음에 하려던 기획”은 무엇인가 + +Away / Return이 끼어들기 전, 다음으로 예정된 축은 아래 두 가지였다. + +1. `Refocus / Next Beat / Goal Complete`의 premium polish +2. `Pause`와 `Break`의 감정/구조 분리 + +즉, 원래 예정된 기획은 사라진 것이 아니다. +단지 순서가 아래처럼 정리된 것이다. + +이전 예상 순서: + +1. Refocus polish +2. Break refinement +3. Review + +현재 확정 순서: + +1. Refocus polish +2. Away / Return Recovery +3. Break refinement +4. Review + +정리하면: + +- **Away / Return은 중간에 끼어든 임시 아이디어가 아니라** +- `Break refinement`보다 먼저 처리해야 하는 선행 조건이다 + +--- + +## 6. 다음 세션에서의 실제 작업 순서 + +다음 작업은 아래 순서로 진행한다. + +### Step 1. `/space` Refocus visual/behavior polish 마무리 + +포함: + +- pause tray +- refocus tray +- next beat tray +- goal complete tray + +완료 기준: + +- recovery flow가 checklist나 planner처럼 보이지 않는다 +- overlay는 한 번에 하나만 보인다 + +### Step 2. Away / Return 구현 + +포함: + +- `visibilitychange` +- `pagehide` +- sleep/wake delta 감지 +- `Return` tray 추가 + +완료 기준: + +- focus ended while away 시 standard break로 바로 가지 않는다 +- return 상태에서 `지금부터 쉬기 / 이어가기 / 한 조각 다시 잡기`가 가능하다 + +### Step 3. Break 분리 + +포함: + +- `Pause`, `Break`, `Return` 카피와 material 분리 +- break를 recovery tone이 아니라 release tone으로 재설계 + +### Step 4. Review 상세 기획 + +포함: + +- started +- resumed +- completed +- recovery rate +- ritual success correlation + +--- + +## 7. 의사결정 규칙 + +새 기능이나 새 기획이 들어올 때는 아래 규칙으로 판단한다. + +### 바로 진행해도 되는 경우 + +- 기존 코어 루프의 빈칸을 메우는 경우 +- `start / immerse / refocus / return / complete` 중 하나를 선명하게 하는 경우 + +### 뒤로 미뤄야 하는 경우 + +- list/planner/task manager 느낌을 강하게 만드는 경우 +- social, buddy, coworking 쪽으로 제품 무게를 옮기는 경우 +- 화면에 오브젝트와 설정을 더 많이 추가하는 경우 + +--- + +## 8. 한 줄 결론 + +현재 끼어든 `Away / Return` 기획은 옆길이 아니다. + +> 이것은 `Refocus System`을 실제 사용자 행동에 맞게 완성하기 위해 반드시 먼저 처리해야 하는 다음 단계다. + +즉, 다음에 원래 하려던 기획은 사라지지 않았고, +순서는 아래처럼 재정렬되었다. + +1. Refocus polish +2. Away / Return Recovery +3. Break refinement +4. Weekly Review diff --git a/docs/90_current_state.md b/docs/90_current_state.md index 764c36c..1741d39 100644 --- a/docs/90_current_state.md +++ b/docs/90_current_state.md @@ -1,18 +1,53 @@ # 90. Current State -Last Updated: 2026-03-12 +Last Updated: 2026-03-14 ## DONE +- `/app` single-goal commitment gate 재구성: + - 2-step `goal -> ritual` flow 제거 + - current session이 있으면 `Resume` UI를 우선 노출하고, `/space`로 바로 이어가기만 제안 + - current session이 없으면 `goal 1개 + optional microStep 1개 + primary CTA`만 남긴 direct start 구조로 단순화 + - `환경 세팅`, `블록 정리`, scene/sound/timer 선택을 메인 진입 경로에서 제거 + - suggestion chip은 planner가 아니라 입력 마찰을 줄이는 용도로만 유지 + - 시작 시에는 기본 ritual(`forest · 50/10 · forest-birds`)로 세션을 열고, 세부 조정은 `/space`에서 하도록 역할을 분리 + - `/app`이 planner home이 아니라 commitment gate라는 역할을 문서와 구현 모두에서 다시 고정 +- `/space` Refocus System slice 1 구현: + - HUD recovery layer를 `paused / refocus / next-beat / complete` 단일 overlay 상태로 정리 + - pause 직후 바로 편집 시트를 열지 않고, 작은 recovery prompt를 먼저 노출 + - `한 조각 다시 잡기`로 refocus에 들어가고, paused 상태에서는 `적용하고 이어가기`로 바로 resume 연결 + - microStep 완료 후 `다음 한 조각이 있나요?` next-beat prompt로만 이어지게 정리 + - 한 번에 하나의 recovery tray만 열리도록 hierarchy를 고정 +- `/space` Refocus System slice 2 구현: + - pause prompt의 `이대로 이어가기`가 실제 resume 동작으로 연결 + - goal complete tray에 `여기까지 끝내기` 경로 추가 + - 현재 세션을 다음 목표 입력 없이도 정상 완료 처리할 수 있게 연결 + - goal complete / rest / next-goal의 세 분기가 UI와 동작 모두에서 분리됨 +- `/space` Refocus System slice 3 구현: + - goal complete tray가 초기부터 input form을 강요하지 않도록 progressive disclosure 구조로 변경 + - `여기까지 끝내기 / 잠깐 쉬기 / 다음 목표 이어가기`를 먼저 제안하고, 다음 목표 입력은 선택 시에만 펼쳐지게 정리 + - next-beat prompt에 현재 goal 문맥을 함께 보여주도록 보강 +- `/space` Refocus System slice 4 구현: + - pause / next-beat / complete / refocus tray의 glass material, hairline, spacing을 공통 규칙으로 정리 + - 선택 액션을 단순 inline 링크에서 quiet option row로 바꿔 recovery decision hierarchy를 선명하게 만듦 + - `goal complete`의 세 분기를 같은 tray 안의 선택 행으로 통합해 planner/form 느낌을 약화 + - `refocus` form field와 footer action 톤을 다른 recovery tray와 같은 제품군으로 맞춤 +- `/space` Away / Return Recovery slice 구현: + - `visibilitychange`, `pagehide`, sleep/wake gap 기반 AwayCandidate 감지 추가 + - 짧은 탭 전환에는 반응하지 않도록 hidden threshold를 둠 + - 돌아왔을 때 focus가 아직 running이면 `Return` tray에서 `이어서 하기 / 한 조각 다시 잡기`를 제안 + - 자리를 비운 사이 focus가 끝나 break phase가 되었으면 standard break 대신 `Return` tray를 먼저 띄움 + - 이 경우 `지금부터 쉬기 / 다음 목표 이어가기 / 한 조각 다시 잡기`를 선택할 수 있음 + - `다음 목표 이어가기`는 `Goal Complete` next view로 바로 연결됨 + - Focus Entry Surface / Execution Surface 재정의: - `/app`을 planning home이 아니라 hero-first focus entry surface로 재구성 - 상단 카피를 `Planning Home` 톤에서 `지금 시작할 첫 블록` 진입 톤으로 교체 - 메인 hero에 one-line goal input + 단일 primary CTA `지금 시작`만 남기고, 첫 진입의 주 행동을 고정 - empty state에서는 starter draft를 자동 주입하지 않고 placeholder 입력으로 시작한다 - suggestion chip으로 draft를 빠르게 교체할 수 있게 하고, 직접 타이핑 시에는 ad-hoc start를 허용 - - block CRUD는 메인 화면에서 제거하고 `블록 정리` manage sheet 안으로 내렸다 - - `FocusPlan` current item은 hero prefill로 이어지고, preview row는 최대 2개까지만 보조적으로 노출 - - Free는 1개, Pro는 최대 5개 블록까지 관리하도록 프론트 제한을 유지 + - 이후 `/app`은 current session이 있으면 `Resume`, 없으면 single-goal direct start만 남기는 commitment gate로 더 줄였다 + - block CRUD, preview row, list-first 구조는 메인 진입 경로에서 제거했다 - `/space`는 planning overview 없이 goal/scene/sound/timer + HUD 실행 화면으로 정리 - focus-plan / focus-session 서버 계약 연결: - `GET /api/v1/focus-plan/today` @@ -36,8 +71,8 @@ Last Updated: 2026-03-12 - `/app` route를 Session OS focus entry surface로 복구: - `/app` route가 `/space` redirect 대신 `FocusDashboardWidget`을 렌더링 - current/next summary card와 list-first 구조를 제거하고, entry hero가 above-the-fold를 차지한다 - - `start`를 `plan persistence`와 분리해 goal only 쿼리로도 `/space` 진입 가능하게 정리했다 - - Free에서 두 번째 블록 추가 시도 시 manage sheet 내부에서 paywall로 진입 + - 현재는 current session이 있으면 resume, 없으면 `goal + microStep + start`만 제안하는 single-goal entry로 정리됐다 + - scene/sound/timer는 기본 ritual로 시작하고, 세부 조정은 `/space`에서 담당한다 - 플랜 tier 공유 store 추가: - `entities/plan/model/usePlanTier.ts` 추가 - localStorage 기반 Free/Pro 상태를 `/app`, `/space`, `/stats`, dock paywall에서 공통 사용 @@ -226,19 +261,17 @@ Last Updated: 2026-03-12 ## NEXT -1. `/app` focus entry surface start/manage 브라우저 스모크 -2. `/space` goal-complete -> next goal immediate start 흐름 QA +1. `/app` single-goal commitment gate 브라우저 스모크 +2. `/space` intent HUD / refocus / goal-complete hierarchy QA 3. `/stats` factual summary / trend / refresh 브라우저 QA ## RISKS -- `/app` manage sheet의 리스트는 append-only라 drag/drop reorder는 아직 없다 - Free/Pro 제한은 클라이언트 local tier 기준이므로 서버에서 직접 막지 않는다 - Free/Pro gating은 localStorage mock tier 기반이라 실제 구독 상태와 연결되지 않았다 - `advance-goal`은 atomic endpoint 기준으로 동작하지만, 네트워크 실패 시 사용자는 현재 시트에서 재시도해야 한다 - Session OS 도메인은 mock 기반이므로 실제 저장/복구 API 없이도 화면만 먼저 완성된 상태다 - empty state에서 CTA는 살아 있지만 실제 시작 전에 입력 포커스가 먼저 필요하므로, 첫 진입 사용성은 브라우저 확인이 필요하다 -- current item이 아닌 preview row 선택은 ad-hoc start로 처리되므로, 큐 재정렬을 기대하는 사용자와 정신 모델 차이가 생길 수 있다 - `/space` paywall 전환 진입점은 `/app` / `/stats` 중심이라 execution 화면만 본 사용자에게는 업그레이드 맥락이 약할 수 있다 - `/admin` 업로드 콘솔은 구조 복구가 끝났지만, 실제 파일 업로드 경로는 브라우저 수동 검증 전까지 확정할 수 없다 - stage background overscan으로 좁은 화면에서 배경 crop이 조금 더 강하게 느껴질 수 있어 실기기 확인이 필요하다 diff --git a/docs/session_brief.md b/docs/session_brief.md index 568e9bb..04de9bc 100644 --- a/docs/session_brief.md +++ b/docs/session_brief.md @@ -1,6 +1,6 @@ # Session Brief -Last Updated: 2026-03-12 +Last Updated: 2026-03-14 세션 시작 시 항상 읽는 초소형 스냅샷 문서. @@ -14,19 +14,42 @@ Last Updated: 2026-03-12 ## 현재 우선순위 -1. `/app` focus entry surface start/manage 브라우저 QA -2. `/space` goal-complete -> next goal immediate start 흐름 QA -3. `/stats` factual summary / trend / refresh QA +1. `Pause / Break / Return` 분리 polish +2. `/space` Refocus + Return 브라우저 QA +3. `Weekly Review` 상세 기획 ## 최근 세션 상태 -- `/app`을 planning home이 아니라 focus entry surface로 다시 재구성했다. - - hero에 one-line goal input과 단일 CTA `지금 시작`을 두고, 첫 블록 진입을 화면의 주 행동으로 올렸다. - - empty state에서는 값을 미리 채우지 않고 placeholder만 두며, 입력 후 바로 `/space`로 들어간다. - - suggestion chip으로 draft를 빠르게 바꿀 수 있고, 직접 수정하면 ad-hoc start가 가능하다. - - plan CRUD는 메인 화면에서 제거하고 `블록 정리` manage sheet 안으로 내렸다. - - current item은 hero를 prefill하고, next item은 최대 2개까지만 얕은 preview로 남긴다. - - Free는 1개, Pro는 최대 5개까지 관리한다. +- `/space` Refocus System 첫 slice를 구현했다. + - pause 직후 바로 편집 시트가 아니라 작은 recovery prompt를 먼저 띄운다. + - 여기서 `한 조각 다시 잡기`를 누르면 refocus tray로 들어간다. + - paused 상태의 refocus는 `적용하고 이어가기`로 바로 resume까지 연결된다. + - microStep 완료 후에는 checklist가 아니라 `다음 한 조각이 있나요?` prompt로만 이어진다. + - recovery UI는 `paused / refocus / next-beat / complete` 중 하나만 열리도록 단일 overlay 상태로 묶였다. +- `/space` goal complete 종료 경로를 복구했다. + - `여기까지 끝내기`로 현재 목표를 다음 목표 입력 없이도 정상 완료 처리할 수 있다. + - pause prompt의 `이대로 이어가기`는 단순 닫기가 아니라 실제 resume으로 연결된다. +- `/space` goal complete / next beat를 덜 form스럽게 정리했다. + - goal complete는 처음부터 input을 요구하지 않고, 선택지를 먼저 보여준 뒤 `다음 목표 이어가기`를 선택했을 때만 입력이 열린다. + - next-beat prompt는 현재 goal 문맥을 함께 보여줘서 사용자가 어떤 목표를 이어가는지 잃지 않게 했다. +- `/space` recovery tray material과 선택 위계를 같은 패밀리로 맞추기 시작했다. + - pause / next-beat / complete tray가 공통 dark-glass shell을 공유한다. + - inline 링크 중심이던 선택지를 quiet option row 구조로 바꿔, checklist보다 recovery decision처럼 읽히게 정리했다. + - `Goal Complete`는 `여기까지 끝내기 / 잠깐 쉬기 / 다음 목표 이어가기`를 같은 tray 안의 선택 행으로 제시한다. + - `Refocus`는 같은 shell 안에서 field / action 톤을 통일해 다른 tray와 같은 제품군처럼 보이게 맞추는 중이다. +- `/space` Away / Return Recovery를 구현했다. + - `visibilitychange`, `pagehide`, sleep/wake gap 기반 감지를 추가했다. + - 짧은 탭 전환에는 반응하지 않도록 hidden threshold를 둬 오탐을 줄였다. + - 돌아왔을 때 focus가 계속 running이면 `Return` tray가 `이어서 하기 / 한 조각 다시 잡기`를 제안한다. + - 자리를 비운 사이 focus가 끝나 break phase가 되었으면 standard break 대신 `Return` tray가 먼저 뜬다. + - 이 경우 `지금부터 쉬기 / 다음 목표 이어가기 / 한 조각 다시 잡기` 중 하나를 고를 수 있다. + - `다음 목표 이어가기`는 goal complete next view로 바로 연결된다. +- `/app`을 single-goal commitment gate로 다시 줄였다. + - 2-step ritual setup을 제거했다. + - current session이 있으면 `Resume` UI만 보여주고, `/space`로 이어가기만 제안한다. + - current session이 없으면 `goal 1개 + optional microStep 1개 + 바로 시작`만 남긴다. + - scene/sound/timer는 기본 ritual로 시작하고 `/space`에서 바꾸게 둔다. + - manage/list 성격의 affordance는 메인 진입 경로에서 제거했다. - `/space`는 execution-only surface로 정리됐다. - setup drawer에서 Daily Plan / Ritual Library 섹션을 제거했다. - goal, scene, sound, timer만 확인하고 focus HUD로 진입한다. diff --git a/docs/work.md b/docs/work.md index 7d84627..2d4dc2f 100644 --- a/docs/work.md +++ b/docs/work.md @@ -17,62 +17,89 @@ ## 작업 1 -- 제목: Focus Entry Surface /space linked flow 브라우저 QA +- 제목: `/space` Refocus polish 마무리 - 목적: - - `/app`이 planning home이 아니라 focus entry surface로 보이는지 확인한다. - - hero input + primary CTA만으로 `/space` 진입이 가능한지 검증한다. + - `10_refocus_system_spec.md` 기준으로 pause / refocus / next beat / goal complete를 premium recovery flow로 정리한다. + - recovery overlay가 planner/checklist처럼 보이지 않게 한다. - 변경 범위: - - `/app` hero input / placeholder / suggestion chip / start CTA 확인 - - `블록 정리` sheet 열기, row 선택, row 수정, row 삭제 확인 - - Free 1개 / Pro 5개 제한 확인 + - pause tray + - refocus tray + - next beat tray + - goal complete tray + - copy / hierarchy / material / motion polish - 제외 범위: - - 실제 결제 연동 금지 - - calendar/task 외부 연동 금지 + - multi-goal / list affordance 추가 금지 + - social/accountability 확장 금지 + - review 통계 확장 금지 - 완료 조건: - - `/app`이 리스트 CRUD보다 `지금 시작` hero가 먼저 읽힌다. - - empty state에서도 disabled primary CTA 없이 `/space` 진입 경로가 살아 있다. - - manage sheet 안에서만 add/edit/delete가 보이고, 메인 화면에는 row-level 관리 버튼이 없다. - - start link에 담긴 goal/plan item이 `/space` 세션 시작까지 유지된다. + - refocus가 `편집 기능`이 아니라 `recovery ritual`처럼 읽힌다. + - pause / next beat / complete가 한 번에 하나만 보인다. + - bright/dark scene 모두에서 안정적으로 읽힌다. - 검증: - 브라우저 수동 확인 - 커밋 힌트: - - chore(qa): focus-entry-surface smoke + - feat(space): refocus-system polish ## 작업 2 -- 제목: 목표 완료 후 다음 목표 즉시 실행 QA +- 제목: `Away / Return Recovery` 구현 - 목적: - - `/space`가 execution-only surface로 보이는지 확인한다. - - goal complete sheet에서 다음 목표를 입력하면 setup으로 돌아가지 않고 즉시 다음 세션이 시작되는지 검증한다. + - `11_away_return_recovery_spec.md` 기준으로 pause 없이 떠난 사용자의 복귀 흐름을 구현한다. + - `pause`, `break`, `return`이 같은 멈춤 상태처럼 읽히지 않게 한다. - 변경 범위: - - `/space?goal=...&planItemId=...` 진입 확인 - - goal complete -> next goal -> running session 전환 확인 - - scene/sound/timer 유지 여부 확인 + - `visibilitychange` + - `pagehide` + - sleep/wake delta 감지 + - return tray + - focus ended while away 처리 - 제외 범위: - - timer 종료 자동 전환 추가 금지 - - ritual/template persistence 추가 금지 + - webcam / idle tracking / 감시성 기능 금지 + - planner/list affordance 추가 금지 + - break를 standard pause처럼 재사용 금지 - 완료 조건: - - 현재 목표 완료 시 linked plan item이 완료 처리되고, 새 목표가 즉시 running session으로 이어진다. - - `/space` setup drawer에 planning/ritual 섹션이 남아 있지 않다. + - focus가 끝난 뒤 복귀하면 바로 standard break로 가지 않는다. + - return 상태에서 `이어가기 / 한 조각 다시 잡기 / 지금부터 쉬기` 중 적절한 제안이 나온다. - 검증: - - 브라우저 수동 확인 + - 브라우저 수동 확인 + 상태 전이 점검 - 커밋 힌트: - - chore(qa): advance-goal linked session smoke + - feat(space): away-return-recovery ## 작업 3 -- 제목: `/stats` factual summary QA +- 제목: `Pause / Break / Return` 분리 polish - 목적: - - `/stats`가 해석형 insight 없이 factual summary만 보여주는지 확인한다. + - 세 상태가 감정적으로도 구조적으로도 다르게 읽히도록 정리한다. - 변경 범위: - - today / last7Days factual cards 확인 - - trend 그래프와 refresh/source 상태 확인 + - 카피 + - material + - tray hierarchy + - timer/HUD와의 연결 - 제외 범위: - - 해석형 패턴 추천 추가 금지 - - social/accountability mock 복구 금지 + - review 통계 확장 금지 + - social/accountability 확장 금지 - 완료 조건: - - `/stats`에서 started/completed/carried over/focus minutes만 일관되게 보인다. + - pause는 recovery tone + - break는 release tone + - return은 re-entry tone으로 분리된다 - 검증: - 브라우저 수동 확인 - 커밋 힌트: - - chore(qa): stats factual summary smoke + - feat(space): separate-pause-break-return + +## 작업 4 + +- 제목: `Weekly Review` 상세 기획 +- 목적: + - total time 중심이 아니라 행동 변화 중심의 review를 설계한다. +- 변경 범위: + - started / resumed / completed / recovery rate / ritual fit 정의 + - free / pro review 가치 구분 +- 제외 범위: + - planner/dashboard 확장 금지 + - 해석 과잉 카피 금지 +- 완료 조건: + - review가 다음 세션 성공률을 높이는 역할로 정의된다. +- 검증: + - 기획 문서 작성 +- 커밋 힌트: + - docs(product): weekly-review-spec diff --git a/src/shared/i18n/messages/space.ts b/src/shared/i18n/messages/space.ts index e5f3f2d..8d4c5ae 100644 --- a/src/shared/i18n/messages/space.ts +++ b/src/shared/i18n/messages/space.ts @@ -42,14 +42,37 @@ export const space = { refocusTitle: '다시 방향 잡기', refocusDescription: '딱 한 줄만 다듬고 다시 시작해요.', refocusApply: '적용', + refocusApplyAndResume: '적용하고 이어가기', refocusApplying: '적용 중…', refocusSaved: '이번 세션 방향을 다듬었어요.', refocusOpenOnPause: '잠시 멈춘 김에 다음 한 조각을 다시 맞춰볼까요?', + pausePromptEyebrow: '잠깐 멈춤', + pausePromptTitle: '다시 붙잡을 한 조각만 정하면 돼요.', + pausePromptDescription: '왜 멈췄는지는 묻지 않아요. 다시 시작할 한 조각만 남겨요.', + pausePromptRefocus: '한 조각 다시 잡기', + pausePromptRefocusHint: '목표는 그대로 두고, 지금 다시 시작할 한 줄만 정리해요.', + pausePromptKeep: '이대로 이어가기', + pausePromptKeepHint: '지금 방향을 그대로 유지한 채 바로 이어서 시작해요.', + returnPromptEyebrow: '다시 돌아왔어요', + returnPromptFocusTitle: '이어서 갈까요?', + returnPromptFocusDescription: '흐름은 그대로 남아 있어요. 바로 이어가거나 한 조각만 다시 잡을 수 있어요.', + returnPromptBreakTitle: '자리를 비운 사이 이 블록이 끝났어요.', + returnPromptBreakDescription: '지금부터 쉬거나, 다음으로 이어갈 수 있어요.', + returnPromptContinue: '이어서 하기', + returnPromptContinueHint: '타이머와 흐름을 그대로 둔 채 다시 집중으로 돌아갑니다.', + returnPromptRest: '지금부터 쉬기', + returnPromptRestHint: '지금부터 break를 시작한 것처럼 천천히 숨을 고릅니다.', + returnPromptNext: '다음 목표 이어가기', + returnPromptNextHint: '다음 한 조각을 바로 정하고 흐름을 끊지 않고 잇습니다.', + returnPromptRefocus: '한 조각 다시 잡기', + returnPromptRefocusHint: '왜 멈췄는지는 건너뛰고, 지금 다시 시작할 한 줄만 남깁니다.', microStepCompleteAriaLabel: '현재 한 조각 완료', microStepPromptTitle: '다음 한 조각이 있나요?', microStepPromptDescription: '리스트를 만들지 않고, 지금 다시 시작할 한 조각만 정해요.', microStepPromptKeep: '이 목표만 유지', + microStepPromptKeepHint: '다음 한 조각은 비워두고, 같은 목표만 유지해요.', microStepPromptDefine: '한 조각 정하기', + microStepPromptDefineHint: '바로 손을 움직일 수 있는 다음 한 줄만 정해요.', microStepCleared: '지금 할 한 조각을 비우고 목표만 유지해요.', completeAction: '이번 목표 완료', }, @@ -59,10 +82,19 @@ export const space = { placeholderExample: (goal: string) => `예: ${goal}`, title: '좋아요. 다음 한 조각은?', description: '너무 크게 잡지 말고, 바로 다음 한 조각만.', + currentGoalLabel: '끝낸 목표', + nextGoalLabel: '다음 목표', + chooseNextButton: '다음 목표 이어가기', + chooseNextDescription: '바로 이어갈 다음 한 조각을 정하고 계속 갑니다.', + backButton: '돌아가기', closeAriaLabel: '닫기', + finishButton: '여기까지 끝내기', + finishDescription: '이 블록은 여기서 닫고, 다음 진입은 가볍게 남겨둡니다.', restButton: '잠깐 쉬기', + restDescription: '지금은 멈추고 숨을 고른 뒤 돌아올 여지를 남겨둡니다.', confirmButton: '다음 목표로 바로 시작', confirmPending: '시작 중…', + finishPending: '마무리 중…', }, controlCenter: { sectionTitles: { diff --git a/src/widgets/focus-dashboard/ui/FocusDashboardWidget.tsx b/src/widgets/focus-dashboard/ui/FocusDashboardWidget.tsx index aa8393f..e51707b 100644 --- a/src/widgets/focus-dashboard/ui/FocusDashboardWidget.tsx +++ b/src/widgets/focus-dashboard/ui/FocusDashboardWidget.tsx @@ -1,566 +1,346 @@ 'use client'; -import { useRouter } from 'next/navigation'; import { useEffect, useMemo, useRef, useState } from 'react'; -import { - type FocusPlanItem, - type FocusPlanToday, - useFocusPlan, -} from '@/entities/focus-plan'; -import { usePlanTier } from '@/entities/plan'; -import { SCENE_THEMES, getSceneById } from '@/entities/scene'; +import { useRouter } from 'next/navigation'; import { useMediaCatalog, getSceneStageBackgroundStyle } from '@/entities/media'; +import { usePlanTier } from '@/entities/plan'; +import { getSceneById, SCENE_THEMES } from '@/entities/scene'; import { SOUND_PRESETS } from '@/entities/session'; import { PaywallSheetContent } from '@/features/paywall-sheet'; import { PlanPill } from '@/features/plan-pill'; -import { focusSessionApi } from '@/features/focus-session/api/focusSessionApi'; +import { focusSessionApi, type FocusSession } from '@/features/focus-session/api/focusSessionApi'; import { copy } from '@/shared/i18n'; import { cn } from '@/shared/lib/cn'; -import { useDragScroll } from '@/shared/lib/useDragScroll'; -import { FocusPlanManageSheet, type FocusPlanEditingState } from './FocusPlanManageSheet'; -const FREE_MAX_ITEMS = 1; -const PRO_MAX_ITEMS = 5; +const DEFAULT_SCENE_ID = getSceneById('forest')?.id ?? SCENE_THEMES[0].id; +const DEFAULT_SOUND_ID = SOUND_PRESETS.find((preset) => preset.id === 'forest-birds')?.id ?? SOUND_PRESETS[0].id; +const DEFAULT_TIMER_ID = '50-10'; +const GOAL_SUGGESTIONS = copy.session.goalChips.slice(0, 4); -const focusEntryCopy = { +const entryCopy = { eyebrow: 'VibeRoom', - title: '오늘의 깊은 몰입을 위한 단 하나의 목표', - description: '지금 당장 시작할 딱 하나만 남겨두세요.', - inputLabel: '첫 블록', - inputPlaceholder: '예: 제안서 첫 문단만 다듬기', - helper: '아주 작게 잡아도 괜찮아요.', - startNow: '바로 몰입하기', - nextStep: '환경 세팅', - manageBlocks: '내 계획에서 가져오기', - previewTitle: '이어갈 블록', - previewDescription: '다음 후보는 가볍게만 두고, 시작은 위 버튼 하나로 끝냅니다.', - reviewLinkLabel: 'stats', - reviewFallback: '최근 7일 흐름을 불러오는 중이에요.', - ritualMeta: '기본 설정으로 들어갑니다. 공간 안에서 언제든 바꿀 수 있어요.', - apiUnavailableNote: '계획 연결이 잠시 느려요. 지금은 첫 블록부터 바로 시작할 수 있어요.', - freeUpgradeLabel: '두 번째 블록부터는 PRO', - paywallSource: 'focus-entry-manage-sheet', + title: '지금 붙잡을 한 가지', + description: '길게 정리하지 말고, 한 줄만 남기고 바로 들어가요.', + goalPlaceholder: '예: 제안서 첫 문단만 다듬기', + microStepLabel: '지금 할 한 조각', + microStepPlaceholder: '예: 파일 열고 첫 문장만 정리하기', + microStepHelper: '선택 사항이에요. 바로 손이 가게 만드는 한 조각이면 충분해요.', + startNow: '지금 시작', + startLoading: '몰입 준비 중...', + ritualHint: '기본 ritual · 숲 · 50/10 · Forest Birds', + ritualHelper: '공간과 사운드는 들어간 뒤에도 바꿀 수 있어요.', + resumeEyebrow: 'Resume', + resumeRunning: '진행 중인 세션이 있어요.', + resumePaused: '잠시 멈춘 세션이 있어요.', + resumeCta: '이어서 들어가기', + resumeMicroStepLabel: '마지막 한 조각', + resumeNewGoalHint: '새 목표는 현재 세션을 마무리한 뒤 시작할 수 있어요.', + loadFailed: '세션 상태를 불러오지 못했어요. 새로 시작은 계속 할 수 있어요.', paywallLead: 'Calm Session OS PRO', - paywallBody: '여러 블록을 이어서 정리하는 manage sheet는 PRO에서 열립니다.', - microStepTitle: '가장 작은 첫 단계 (선택)', - microStepHelper: '이 목표를 위해 당장 할 수 있는 5분짜리 행동은 무엇인가요?', - microStepPlaceholder: '예: 폴더 열기, 노션 켜기', - ritualTitle: '어떤 환경에서 몰입하시겠어요?', - ritualHelper: '오늘의 무드를 선택하세요.', + paywallBody: 'Pro는 더 빠른 ritual과 더 깊은 review로 시작과 복귀를 가볍게 만듭니다.', }; -const ENTRY_SUGGESTIONS = [ - { id: 'tidy-10m', label: '정리 10분', goal: '정리 10분만 하기' }, - { id: 'mail-3', label: '메일 3개', goal: '메일 3개 정리' }, - { id: 'doc-1p', label: '문서 1p', goal: '문서 1p 다듬기' }, -] as const; +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 inputShellClass = + 'w-full rounded-[1.75rem] border border-white/14 bg-white/[0.06] px-5 py-4 text-white outline-none transition focus:border-white/24 focus:bg-white/[0.09]'; +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'; -type EntrySource = 'starter' | 'plan' | 'custom'; -type DashboardStep = 'goal' | 'ritual'; - -const getVisiblePlanItems = ( - currentItem: FocusPlanItem | null, - nextItems: FocusPlanItem[], - limit: number, -) => { - return [currentItem, ...nextItems] - .filter((item): item is FocusPlanItem => Boolean(item)) - .slice(0, limit); +const timerLabelById: Record = { + '25-5': '25/5', + '50-10': '50/10', + '90-20': '90/20', }; -const resolveVisiblePlanItems = (nextPlan: FocusPlanToday | null, limit: number) => { - return getVisiblePlanItems(nextPlan?.currentItem ?? null, nextPlan?.nextItems ?? [], limit); -}; +const resolveSoundLabel = (soundPresetId?: string | null) => { + if (!soundPresetId) { + return 'Silent'; + } -// Premium Glassmorphism UI Classes -const glassInputClass = 'w-full rounded-full border border-white/20 bg-black/20 px-8 py-5 text-center text-lg md:text-xl font-light tracking-wide text-white placeholder:text-white/40 shadow-2xl backdrop-blur-xl outline-none transition-all focus:border-white/40 focus:bg-black/30 focus:ring-4 focus:ring-white/10'; -const primaryGlassBtnClass = 'inline-flex items-center justify-center rounded-full border border-white/20 bg-white/20 px-8 py-4 text-base font-medium text-white shadow-xl backdrop-blur-xl transition-all hover:bg-white/30 hover:scale-[1.02] active:scale-95 disabled:opacity-50 disabled:cursor-not-allowed'; -const secondaryGlassBtnClass = 'inline-flex items-center justify-center rounded-full border border-white/10 bg-transparent px-8 py-4 text-base font-medium text-white/80 transition-all hover:bg-white/10 hover:text-white active:scale-95 disabled:opacity-50 disabled:cursor-not-allowed'; -const panelGlassClass = 'rounded-[2rem] border border-white/10 bg-black/40 p-6 md:p-8 shadow-2xl backdrop-blur-2xl'; -const itemCardGlassClass = 'relative flex flex-col items-center justify-center overflow-hidden rounded-2xl border border-white/10 bg-white/5 p-4 text-white transition-all hover:bg-white/10 active:scale-95 cursor-pointer'; -const itemCardGlassSelectedClass = 'border-white/40 bg-white/20 shadow-[0_0_20px_rgba(255,255,255,0.1)]'; + return SOUND_PRESETS.find((preset) => preset.id === soundPresetId)?.label ?? 'Silent'; +}; export const FocusDashboardWidget = () => { const router = useRouter(); - const { plan: planTier, isPro, setPlan } = usePlanTier(); - const { plan, isLoading, isSaving, source, createItem, updateItem, deleteItem } = useFocusPlan(); + const { plan, isPro, setPlan } = usePlanTier(); const { sceneAssetMap } = useMediaCatalog(); - const [step, setStep] = useState('goal'); - const [paywallSource, setPaywallSource] = useState(null); - const [manageSheetOpen, setManageSheetOpen] = useState(false); - const [editingState, setEditingState] = useState(null); - - const [entryDraft, setEntryDraft] = useState(''); - const [selectedPlanItemId, setSelectedPlanItemId] = useState(null); - const [entrySource, setEntrySource] = useState('starter'); - + const [goalDraft, setGoalDraft] = useState(''); const [microStepDraft, setMicroStepDraft] = useState(''); - // Use user's last preference or default to first - const [selectedSceneId, setSelectedSceneId] = useState(SCENE_THEMES[0].id); - const [selectedSoundId, setSelectedSoundId] = useState(SOUND_PRESETS[0].id); - const [selectedTimerId, setSelectedTimerId] = useState('50-10'); - const [isStartingSession, setIsStartingSession] = useState(false); + const [paywallSource, setPaywallSource] = useState(null); + const [currentSession, setCurrentSession] = useState(null); + const [isCheckingSession, setIsCheckingSession] = useState(true); + const [sessionLookupError, setSessionLookupError] = useState(null); - const entryInputRef = useRef(null); - const microStepInputRef = useRef(null); - const inputRef = useRef(null); + const goalInputRef = useRef(null); - const { - containerRef: sceneContainerRef, - events: sceneDragEvents, - isDragging: isSceneDragging, - shouldSuppressClick: shouldSuppressSceneClick, - } = useDragScroll(); + const activeScene = useMemo(() => { + return getSceneById(currentSession?.sceneId ?? DEFAULT_SCENE_ID) ?? SCENE_THEMES[0]; + }, [currentSession?.sceneId]); - const selectedScene = useMemo(() => getSceneById(selectedSceneId) ?? SCENE_THEMES[0], [selectedSceneId]); + const activeRitualMeta = useMemo(() => { + const timerLabel = timerLabelById[currentSession?.timerPresetId ?? DEFAULT_TIMER_ID] ?? '50/10'; + const soundLabel = resolveSoundLabel(currentSession?.soundPresetId ?? DEFAULT_SOUND_ID); - const maxItems = isPro ? PRO_MAX_ITEMS : FREE_MAX_ITEMS; - const planItems = useMemo(() => { - return getVisiblePlanItems(plan.currentItem, plan.nextItems, maxItems); - }, [maxItems, plan.currentItem, plan.nextItems]); + return `${activeScene.name} · ${timerLabel} · ${soundLabel}`; + }, [activeScene.name, currentSession?.soundPresetId, currentSession?.timerPresetId]); - const currentItem = planItems[0] ?? null; - const shouldUseCurrentPlanDefaults = - Boolean(currentItem) && (entrySource === 'starter' || (entrySource === 'plan' && !selectedPlanItemId)); - const resolvedEntryDraft = shouldUseCurrentPlanDefaults && currentItem ? currentItem.title : entryDraft; - const resolvedSelectedPlanItemId = - shouldUseCurrentPlanDefaults && currentItem ? currentItem.id : selectedPlanItemId; - - const hasPendingEdit = editingState !== null; - const canAddMore = planItems.length < maxItems; - const canManagePlan = source === 'api' && !isLoading; - const trimmedEntryGoal = resolvedEntryDraft.trim(); - const isGoalReady = trimmedEntryGoal.length > 0; + const trimmedGoal = goalDraft.trim(); + const canStart = trimmedGoal.length > 0 && !isStartingSession && !currentSession; useEffect(() => { - if (!editingState) return; - const rafId = window.requestAnimationFrame(() => { - inputRef.current?.focus(); - inputRef.current?.select(); - }); - return () => window.cancelAnimationFrame(rafId); - }, [editingState]); + let cancelled = false; - const openPaywall = () => setPaywallSource(focusEntryCopy.paywallSource); + const loadCurrentSession = async () => { + setIsCheckingSession(true); - const handleSelectPlanItem = (item: FocusPlanItem) => { - const isCurrentSelection = currentItem?.id === item.id; - setEntryDraft(item.title); - setSelectedPlanItemId(isCurrentSelection ? item.id : null); - setEntrySource(isCurrentSelection ? 'plan' : 'custom'); - setManageSheetOpen(false); - }; - - const handleSelectSuggestion = (goal: string) => { - setEntryDraft(goal); - setSelectedPlanItemId(null); - setEntrySource('custom'); - }; - - const handleEntryDraftChange = (value: string) => { - setEntryDraft(value); - setEntrySource('custom'); - setSelectedPlanItemId(null); - }; - - const handleAddBlock = () => { - if (hasPendingEdit || isSaving || !canManagePlan) return; - if (!canAddMore) { - if (!isPro) openPaywall(); - return; - } - setEditingState({ mode: 'new', value: '' }); - }; - - const handleEditRow = (item: FocusPlanItem) => { - if (hasPendingEdit || isSaving) return; - setEditingState({ mode: 'edit', itemId: item.id, value: item.title }); - }; - - const handleManageDraftChange = (value: string) => { - setEditingState((current) => current ? { ...current, value } : current); - }; - - const handleCancelEdit = () => { - if (!isSaving) setEditingState(null); - }; - - const handleSaveEdit = async () => { - if (!editingState) return; - const trimmedTitle = editingState.value.trim(); - if (!trimmedTitle) return; - - if (editingState.mode === 'new') { - const nextPlan = await createItem({ title: trimmedTitle }); - if (!nextPlan) return; - setEditingState(null); - if (!currentItem) { - const nextVisiblePlanItems = resolveVisiblePlanItems(nextPlan, maxItems); - const nextCurrentItem = nextVisiblePlanItems[0] ?? null; - if (nextCurrentItem) { - setEntryDraft(nextCurrentItem.title); - setSelectedPlanItemId(nextCurrentItem.id); - setEntrySource('plan'); + try { + const session = await focusSessionApi.getCurrentSession(); + if (cancelled) { + return; + } + setCurrentSession(session); + setSessionLookupError(null); + } catch (error) { + if (cancelled) { + return; + } + setCurrentSession(null); + setSessionLookupError(error instanceof Error ? error.message : entryCopy.loadFailed); + } finally { + if (!cancelled) { + setIsCheckingSession(false); } } - return; - } + }; - const currentRow = planItems.find((item) => item.id === editingState.itemId); - if (!currentRow) return; - if (currentRow.title === trimmedTitle) { - setEditingState(null); - return; - } + void loadCurrentSession(); - const nextPlan = await updateItem(editingState.itemId, { title: trimmedTitle }); - if (!nextPlan) return; - setEditingState(null); - if (resolvedSelectedPlanItemId === editingState.itemId) { - setEntryDraft(trimmedTitle); - setEntrySource('plan'); + return () => { + cancelled = true; + }; + }, []); + + const openPaywall = () => { + if (!isPro) { + setPaywallSource('app-entry-plan-pill'); } }; - const handleDeleteRow = async (itemId: string) => { - const nextPlan = await deleteItem(itemId); - if (!nextPlan) return; - if (editingState?.mode === 'edit' && editingState.itemId === itemId) { - setEditingState(null); - } - if (resolvedSelectedPlanItemId === itemId) { - const nextVisiblePlanItems = resolveVisiblePlanItems(nextPlan, maxItems); - const nextCurrentItem = nextVisiblePlanItems[0] ?? null; - if (nextCurrentItem) { - setEntryDraft(nextCurrentItem.title); - setSelectedPlanItemId(nextCurrentItem.id); - setEntrySource('plan'); - return; - } - setEntryDraft(''); - setSelectedPlanItemId(null); - setEntrySource('custom'); - } - }; - - const handleNextStep = () => { - if (!isGoalReady) { - entryInputRef.current?.focus(); - return; - } - if (step === 'goal') setStep('ritual'); + const handleSelectSuggestion = (label: string) => { + setGoalDraft(label); + goalInputRef.current?.focus(); }; const handleStartSession = async () => { - if (isStartingSession) return; + if (!trimmedGoal || isStartingSession || currentSession) { + if (!trimmedGoal) { + goalInputRef.current?.focus(); + } + return; + } + setIsStartingSession(true); try { await focusSessionApi.startSession({ - goal: trimmedEntryGoal, + goal: trimmedGoal, microStep: microStepDraft.trim() || null, - sceneId: selectedSceneId, - soundPresetId: selectedSoundId, - timerPresetId: selectedTimerId, - focusPlanItemId: resolvedSelectedPlanItemId || undefined, - entryPoint: 'space-setup' + sceneId: DEFAULT_SCENE_ID, + soundPresetId: DEFAULT_SOUND_ID, + timerPresetId: DEFAULT_TIMER_ID, + entryPoint: 'space-setup', }); router.push('/space'); - } catch (err) { - console.error('Failed to start session', err); - setIsStartingSession(false); + return; + } catch (error) { + console.error('Failed to start focus session from /app', error); } + + setIsStartingSession(false); + }; + + const handleResumeSession = () => { + router.push('/space'); }; return ( -
- {/* Premium Cinematic Background */} +
- {/* Global Gradient Overlay for text readability */} -
+
+
- {/* Header */} -
-

- {focusEntryCopy.eyebrow} +

+

+ {entryCopy.eyebrow}

- { - if (!isPro) openPaywall(); - }} - /> +
- {/* Main Content Area */} -
- - {/* Step 1: Goal Setup */} -
-
-

- {focusEntryCopy.title} -

- -
- handleEntryDraftChange(event.target.value)} - onKeyDown={(e) => e.key === 'Enter' && handleNextStep()} - placeholder={focusEntryCopy.inputPlaceholder} - className={glassInputClass} - autoFocus - /> - -
- - -
- - {/* Suggestions / Manage - very minimal */} -
-
- {ENTRY_SUGGESTIONS.map((suggestion) => { - const isActive = resolvedSelectedPlanItemId === null && trimmedEntryGoal === suggestion.goal; - return ( - - ); - })} -
- -
+
+
+ {isCheckingSession ? ( +
+

+ {entryCopy.resumeEyebrow} +

+

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

-
-
- - {/* Step 2: Ritual Setup */} -
-
-
-
-

Today's Focus

-

{trimmedEntryGoal}

-
- -
- -
-
- {/* Microstep */} -
- -

{focusEntryCopy.microStepHelper}

-
- - {/* Timer */} -
-

몰입 리듬

-
- {[{id: '25-5', label: '25 / 5'}, {id: '50-10', label: '50 / 10'}, {id: '90-20', label: '90 / 20'}].map(timer => ( - - ))} + ) : currentSession ? ( +
+
+

+ {entryCopy.resumeEyebrow} +

+

+ {currentSession.goal} +

+

+ {currentSession.state === 'paused' ? entryCopy.resumePaused : entryCopy.resumeRunning} +

+ {currentSession.microStep ? ( +
+

+ {entryCopy.resumeMicroStepLabel} +

+

{currentSession.microStep}

-
+ ) : null}
-
- {/* Scene */} -
-

배경 공간

-
+ +

{activeRitualMeta}

+
+ +

{entryCopy.resumeNewGoalHint}

+
+ ) : ( +
+
+

+ {entryCopy.title} +

+

+ {entryCopy.description} +

+
+ +
+
-
+ autoFocus + /> + - {/* Sound */} -
-

사운드

-
- {SOUND_PRESETS.map(sound => ( - - ))} -
+ + +

{entryCopy.microStepHelper}

+
+ +
+ {GOAL_SUGGESTIONS.map((suggestion) => { + const isActive = trimmedGoal === suggestion.label; + + return ( + + ); + })} +
+ +
+ +
+

+ {entryCopy.ritualHint} +

+

{entryCopy.ritualHelper}

-
-
- + {sessionLookupError ? ( +

{entryCopy.loadFailed}

+ ) : null}
-
+ )}
- {/* Plan Sheet & Paywall */} - { - if (!isSaving) { - setManageSheetOpen(false); - setEditingState(null); - } - }} - onAddBlock={handleAddBlock} - onDraftChange={handleManageDraftChange} - onSelect={handleSelectPlanItem} - onEdit={handleEditRow} - onDelete={(itemId) => { - void handleDeleteRow(itemId); - }} - onSave={() => { - void handleSaveEdit(); - }} - onCancel={handleCancelEdit} - /> - {paywallSource ? (
-
- setDraft(event.target.value)} - placeholder={placeholder} - className="h-11 w-full rounded-[18px] border border-white/10 bg-black/14 px-3.5 text-[0.98rem] tracking-tight text-white placeholder:text-white/30 focus:border-white/20 focus:bg-black/20 focus:outline-none focus:ring-2 focus:ring-white/8" - /> -
- - -
-
+ {view === 'choice' ? ( +
+ {trimmedCurrentGoal ? ( +
+

+ {copy.space.goalComplete.currentGoalLabel} +

+

{trimmedCurrentGoal}

+
+ ) : null} + +
+ + + +
+
+ ) : ( +
+ {trimmedCurrentGoal ? ( +
+

+ {copy.space.goalComplete.currentGoalLabel} +

+

{trimmedCurrentGoal}

+
+ ) : null} + + + +
+ + +
+
+ )}
); diff --git a/src/widgets/space-focus-hud/ui/NextMicroStepPrompt.tsx b/src/widgets/space-focus-hud/ui/NextMicroStepPrompt.tsx index b72f6ce..52cd48f 100644 --- a/src/widgets/space-focus-hud/ui/NextMicroStepPrompt.tsx +++ b/src/widgets/space-focus-hud/ui/NextMicroStepPrompt.tsx @@ -2,9 +2,18 @@ import { copy } from '@/shared/i18n'; import { cn } from '@/shared/lib/cn'; +import { + HUD_OPTION_CHEVRON, + HUD_OPTION_ROW, + HUD_OPTION_ROW_PRIMARY, + HUD_TRAY_HAIRLINE, + HUD_TRAY_LAYER, + HUD_TRAY_SHELL, +} from './overlayStyles'; interface NextMicroStepPromptProps { open: boolean; + goal: string; isSubmitting: boolean; error: string | null; onKeepGoalOnly: () => void; @@ -13,11 +22,14 @@ interface NextMicroStepPromptProps { export const NextMicroStepPrompt = ({ open, + goal, isSubmitting, error, onKeepGoalOnly, onDefineNext, }: NextMicroStepPromptProps) => { + const trimmedGoal = goal.trim(); + return (
-
-
-
+
+
+

다음 한 조각

@@ -44,28 +53,53 @@ export const NextMicroStepPrompt = ({ {copy.space.focusHud.microStepPromptDescription}

+ {trimmedGoal ? ( +
+

+ {copy.space.focusHud.intentLabel} +

+

{trimmedGoal}

+
+ ) : null} + {error ? (

{error}

) : null} -
+
diff --git a/src/widgets/space-focus-hud/ui/PauseRefocusPrompt.tsx b/src/widgets/space-focus-hud/ui/PauseRefocusPrompt.tsx new file mode 100644 index 0000000..d4dde44 --- /dev/null +++ b/src/widgets/space-focus-hud/ui/PauseRefocusPrompt.tsx @@ -0,0 +1,93 @@ +'use client'; + +import { copy } from '@/shared/i18n'; +import { cn } from '@/shared/lib/cn'; +import { + HUD_OPTION_CHEVRON, + HUD_OPTION_ROW, + HUD_OPTION_ROW_PRIMARY, + HUD_PAUSE_BODY, + HUD_PAUSE_EYEBROW, + HUD_PAUSE_TITLE, + HUD_TRAY_HAIRLINE, + HUD_TRAY_LAYER, + HUD_TRAY_SHELL, +} from './overlayStyles'; + +interface PauseRefocusPromptProps { + open: boolean; + isBusy: boolean; + onRefocus: () => void; + onKeepCurrent: () => void; +} + +export const PauseRefocusPrompt = ({ + open, + isBusy, + onRefocus, + onKeepCurrent, +}: PauseRefocusPromptProps) => { + return ( +
+
+
+
+ +
+

+ {copy.space.focusHud.pausePromptEyebrow} +

+

+ {copy.space.focusHud.pausePromptTitle} +

+

+ {copy.space.focusHud.pausePromptDescription} +

+ +
+ + +
+
+
+
+ ); +}; diff --git a/src/widgets/space-focus-hud/ui/RefocusSheet.tsx b/src/widgets/space-focus-hud/ui/RefocusSheet.tsx index 01c6dea..fe347b9 100644 --- a/src/widgets/space-focus-hud/ui/RefocusSheet.tsx +++ b/src/widgets/space-focus-hud/ui/RefocusSheet.tsx @@ -4,12 +4,21 @@ import type { FormEvent } from 'react'; import { useEffect, useRef } from 'react'; import { copy } from '@/shared/i18n'; import { cn } from '@/shared/lib/cn'; +import { + HUD_FIELD, + HUD_TEXT_LINK, + HUD_TEXT_LINK_STRONG, + HUD_TRAY_HAIRLINE, + HUD_TRAY_LAYER, + HUD_TRAY_SHELL, +} from './overlayStyles'; interface RefocusSheetProps { open: boolean; goalDraft: string; microStepDraft: string; autoFocusField: 'goal' | 'microStep'; + submitLabel?: string; isSaving: boolean; error: string | null; onGoalChange: (value: string) => void; @@ -23,6 +32,7 @@ export const RefocusSheet = ({ goalDraft, microStepDraft, autoFocusField, + submitLabel, isSaving, error, onGoalChange, @@ -91,12 +101,9 @@ export const RefocusSheet = ({ )} aria-hidden={!open} > -
-
-
+
+
+
@@ -118,7 +125,7 @@ export const RefocusSheet = ({ value={goalDraft} onChange={(event) => onGoalChange(event.target.value)} placeholder={copy.space.sessionGoal.placeholder} - className="h-11 w-full rounded-[18px] border border-white/10 bg-black/14 px-3.5 text-[1rem] tracking-tight text-white placeholder:text-white/28 focus:border-white/20 focus:bg-black/20 focus:outline-none focus:ring-2 focus:ring-white/8" + className={HUD_FIELD} /> @@ -131,7 +138,7 @@ export const RefocusSheet = ({ value={microStepDraft} onChange={(event) => onMicroStepChange(event.target.value)} placeholder={copy.space.sessionGoal.hint} - className="h-11 w-full rounded-[18px] border border-white/10 bg-black/12 px-3.5 text-[0.98rem] tracking-tight text-white placeholder:text-white/26 focus:border-white/20 focus:bg-black/18 focus:outline-none focus:ring-2 focus:ring-white/8" + className={HUD_FIELD} /> @@ -146,16 +153,16 @@ export const RefocusSheet = ({ type="button" onClick={onClose} disabled={isSaving} - className="inline-flex h-8 items-center justify-center rounded-full border border-white/10 bg-black/14 px-3 text-[11px] font-medium tracking-[0.14em] text-white/62 backdrop-blur-md transition-all hover:bg-black/20 hover:text-white/84 disabled:cursor-default disabled:border-white/6 disabled:bg-black/10 disabled:text-white/26" + className={HUD_TEXT_LINK} > {copy.common.cancel} diff --git a/src/widgets/space-focus-hud/ui/ReturnPrompt.tsx b/src/widgets/space-focus-hud/ui/ReturnPrompt.tsx new file mode 100644 index 0000000..5b0f2ed --- /dev/null +++ b/src/widgets/space-focus-hud/ui/ReturnPrompt.tsx @@ -0,0 +1,157 @@ +'use client'; + +import { copy } from '@/shared/i18n'; +import { cn } from '@/shared/lib/cn'; +import { + HUD_OPTION_CHEVRON, + HUD_OPTION_ROW, + HUD_OPTION_ROW_PRIMARY, + HUD_TRAY_HAIRLINE, + HUD_TRAY_LAYER, + HUD_TRAY_SHELL, +} from './overlayStyles'; + +interface ReturnPromptProps { + open: boolean; + mode: 'focus' | 'break'; + isBusy: boolean; + onContinue: () => void; + onRefocus: () => void; + onRest?: () => void; + onNextGoal?: () => void; +} + +export const ReturnPrompt = ({ + open, + mode, + isBusy, + onContinue, + onRefocus, + onRest, + onNextGoal, +}: ReturnPromptProps) => { + const isBreakReturn = mode === 'break'; + + return ( +
+
+
+
+ +
+

+ {copy.space.focusHud.returnPromptEyebrow} +

+

+ {isBreakReturn + ? copy.space.focusHud.returnPromptBreakTitle + : copy.space.focusHud.returnPromptFocusTitle} +

+

+ {isBreakReturn + ? copy.space.focusHud.returnPromptBreakDescription + : copy.space.focusHud.returnPromptFocusDescription} +

+ +
+ {isBreakReturn ? ( + <> + + + + + ) : ( + <> + + + + )} +
+
+
+
+ ); +}; diff --git a/src/widgets/space-focus-hud/ui/SpaceFocusHudWidget.tsx b/src/widgets/space-focus-hud/ui/SpaceFocusHudWidget.tsx index c69b101..be0c175 100644 --- a/src/widgets/space-focus-hud/ui/SpaceFocusHudWidget.tsx +++ b/src/widgets/space-focus-hud/ui/SpaceFocusHudWidget.tsx @@ -5,7 +5,9 @@ import { SpaceTimerHudWidget } from '@/widgets/space-timer-hud'; import { GoalCompleteSheet } from './GoalCompleteSheet'; import { IntentCapsule } from './IntentCapsule'; import { NextMicroStepPrompt } from './NextMicroStepPrompt'; +import { PauseRefocusPrompt } from './PauseRefocusPrompt'; import { RefocusSheet } from './RefocusSheet'; +import { ReturnPrompt } from './ReturnPrompt'; interface SpaceFocusHudWidgetProps { goal: string; @@ -18,11 +20,14 @@ interface SpaceFocusHudWidgetProps { canStartSession?: boolean; canPauseSession?: boolean; canRestartSession?: boolean; + returnPromptMode?: 'focus' | 'break' | null; onStartRequested?: () => void; onPauseRequested?: () => void; onRestartRequested?: () => void; + onDismissReturnPrompt?: () => void; onIntentUpdate: (payload: { goal?: string; microStep?: string | null }) => boolean | Promise; onGoalUpdate: (nextGoal: string) => boolean | Promise; + onGoalFinish: () => boolean | Promise; onStatusMessage: (payload: HudStatusLinePayload) => void; } @@ -37,16 +42,19 @@ export const SpaceFocusHudWidget = ({ canStartSession = false, canPauseSession = false, canRestartSession = false, + returnPromptMode = null, onStartRequested, onPauseRequested, onRestartRequested, + onDismissReturnPrompt, onIntentUpdate, onGoalUpdate, + onGoalFinish, onStatusMessage, }: SpaceFocusHudWidgetProps) => { - const [sheetOpen, setSheetOpen] = useState(false); - const [isRefocusOpen, setRefocusOpen] = useState(false); - const [isMicroStepPromptOpen, setMicroStepPromptOpen] = useState(false); + const [overlay, setOverlay] = useState<'none' | 'paused' | 'return' | 'refocus' | 'next-beat' | 'complete'>('none'); + const [refocusOrigin, setRefocusOrigin] = useState<'manual' | 'pause' | 'next-beat' | 'return'>('manual'); + const [completePreferredView, setCompletePreferredView] = useState<'choice' | 'next'>('choice'); const [draftGoal, setDraftGoal] = useState(''); const [draftMicroStep, setDraftMicroStep] = useState(''); const [autoFocusField, setAutoFocusField] = useState<'goal' | 'microStep'>('goal'); @@ -58,7 +66,12 @@ export const SpaceFocusHudWidget = ({ const restReminderTimerRef = useRef(null); const normalizedGoal = goal.trim().length > 0 ? goal.trim() : copy.space.focusHud.goalFallback; const normalizedMicroStep = microStep?.trim() ? microStep.trim() : null; - const isIntentOverlayOpen = isRefocusOpen || isMicroStepPromptOpen || sheetOpen; + const isPausedPromptOpen = overlay === 'paused'; + const isReturnPromptOpen = overlay === 'return'; + const isRefocusOpen = overlay === 'refocus'; + const isMicroStepPromptOpen = overlay === 'next-beat'; + const isCompleteOpen = overlay === 'complete'; + const isIntentOverlayOpen = overlay !== 'none'; useEffect(() => { return () => { @@ -69,6 +82,32 @@ export const SpaceFocusHudWidget = ({ }; }, []); + useEffect(() => { + if (!hasActiveSession) { + setOverlay('none'); + setIntentError(null); + setSavingIntent(false); + setRefocusOrigin('manual'); + setCompletePreferredView('choice'); + } + }, [hasActiveSession]); + + useEffect(() => { + if (!returnPromptMode) { + if (overlay === 'return') { + setOverlay('none'); + } + return; + } + + if (overlay === 'complete') { + return; + } + + setIntentError(null); + setOverlay('return'); + }, [overlay, returnPromptMode]); + useEffect(() => { if (!visibleRef.current && playbackState === 'running') { onStatusMessage({ @@ -89,13 +128,16 @@ export const SpaceFocusHudWidget = ({ resumePlaybackStateRef.current = playbackState; }, [normalizedGoal, onStatusMessage, playbackState]); - const openRefocus = useCallback((field: 'goal' | 'microStep' = 'goal') => { + const openRefocus = useCallback(( + field: 'goal' | 'microStep' = 'goal', + origin: 'manual' | 'pause' | 'next-beat' | 'return' = 'manual', + ) => { setDraftGoal(goal.trim()); setDraftMicroStep(normalizedMicroStep ?? ''); setAutoFocusField(field); setIntentError(null); - setMicroStepPromptOpen(false); - setRefocusOpen(true); + setRefocusOrigin(origin); + setOverlay('refocus'); }, [goal, normalizedMicroStep]); useEffect(() => { @@ -103,31 +145,42 @@ export const SpaceFocusHudWidget = ({ pausePlaybackStateRef.current === 'running' && playbackState === 'paused' && hasActiveSession && - !isRefocusOpen && - !sheetOpen + overlay === 'none' ) { - openRefocus('microStep'); + setIntentError(null); + setOverlay('paused'); onStatusMessage({ message: copy.space.focusHud.refocusOpenOnPause, }); } pausePlaybackStateRef.current = playbackState; - }, [hasActiveSession, isRefocusOpen, onStatusMessage, openRefocus, playbackState, sheetOpen]); + }, [hasActiveSession, onStatusMessage, overlay, playbackState]); useEffect(() => { - if (normalizedMicroStep) { - return; + if (playbackState === 'running' && overlay === 'paused') { + setOverlay('none'); } + }, [overlay, playbackState]); - setMicroStepPromptOpen(false); - }, [normalizedMicroStep]); + useEffect(() => { + if (!normalizedMicroStep && overlay === 'next-beat') { + setOverlay('none'); + } + }, [normalizedMicroStep, overlay]); - const handleOpenCompleteSheet = () => { + const handleOpenCompleteSheet = (preferredView: 'choice' | 'next' = 'choice') => { setIntentError(null); - setRefocusOpen(false); - setMicroStepPromptOpen(false); - setSheetOpen(true); + setCompletePreferredView(preferredView); + setOverlay('complete'); + }; + + const handleDismissReturnPrompt = () => { + onDismissReturnPrompt?.(); + + if (overlay === 'return') { + setOverlay('none'); + } }; const handleRefocusSubmit = async () => { @@ -151,10 +204,18 @@ export const SpaceFocusHudWidget = ({ return; } - setRefocusOpen(false); + setOverlay('none'); onStatusMessage({ message: copy.space.focusHud.refocusSaved, }); + + if (refocusOrigin === 'return') { + onDismissReturnPrompt?.(); + } + + if (refocusOrigin === 'pause' && playbackState === 'paused') { + onStartRequested?.(); + } } finally { setSavingIntent(false); } @@ -178,7 +239,7 @@ export const SpaceFocusHudWidget = ({ return; } - setMicroStepPromptOpen(false); + setOverlay('none'); onStatusMessage({ message: copy.space.focusHud.microStepCleared, }); @@ -192,37 +253,70 @@ export const SpaceFocusHudWidget = ({ setDraftMicroStep(''); setAutoFocusField('microStep'); setIntentError(null); - setMicroStepPromptOpen(false); - setRefocusOpen(true); + setRefocusOrigin('next-beat'); + setOverlay('refocus'); }; return ( <> -
+
openRefocus()} + onOpenRefocus={() => openRefocus('goal', 'manual')} onMicroStepDone={() => { if (!normalizedMicroStep) { - openRefocus('microStep'); + openRefocus('microStep', 'next-beat'); return; } setIntentError(null); - setRefocusOpen(false); - setMicroStepPromptOpen(true); + setOverlay('next-beat'); }} onGoalCompleteRequest={handleOpenCompleteSheet} /> + { + handleDismissReturnPrompt(); + }} + onRefocus={() => { + handleDismissReturnPrompt(); + openRefocus('microStep', 'return'); + }} + onRest={() => { + handleDismissReturnPrompt(); + onStatusMessage({ message: copy.space.focusHud.restReminder }); + }} + onNextGoal={() => { + handleDismissReturnPrompt(); + handleOpenCompleteSheet('next'); + }} + /> + openRefocus('microStep', 'pause')} + onKeepCurrent={() => { + setOverlay('none'); + onStartRequested?.(); + }} + /> { void handleRefocusSubmit(); @@ -241,6 +335,7 @@ export const SpaceFocusHudWidget = ({ /> { @@ -249,11 +344,13 @@ export const SpaceFocusHudWidget = ({ onDefineNext={handleDefineNextMicroStep} /> setSheetOpen(false)} + preferredView={completePreferredView} + onClose={() => setOverlay('none')} + onFinish={() => Promise.resolve(onGoalFinish())} onRest={() => { - setSheetOpen(false); + setOverlay('none'); if (restReminderTimerRef.current) { window.clearTimeout(restReminderTimerRef.current); diff --git a/src/widgets/space-focus-hud/ui/overlayStyles.ts b/src/widgets/space-focus-hud/ui/overlayStyles.ts new file mode 100644 index 0000000..964c3e0 --- /dev/null +++ b/src/widgets/space-focus-hud/ui/overlayStyles.ts @@ -0,0 +1,34 @@ +export const HUD_TRAY_SHELL = + 'pointer-events-auto relative mt-3 w-full overflow-hidden rounded-[22px] border border-white/10 bg-[#101318]/30 text-white shadow-[0_12px_28px_rgba(2,6,23,0.14)] backdrop-blur-[8px] backdrop-saturate-125'; + +export const HUD_TRAY_LAYER = + 'pointer-events-none absolute inset-0 rounded-[22px] bg-[linear-gradient(180deg,rgba(255,255,255,0.08)_0%,rgba(255,255,255,0.025)_24%,rgba(255,255,255,0.01)_100%)]'; + +export const HUD_TRAY_HAIRLINE = 'pointer-events-none absolute inset-x-0 top-0 h-px bg-white/16'; + +export const HUD_FIELD = + 'h-11 w-full rounded-[18px] border border-white/10 bg-black/14 px-3.5 text-[0.98rem] tracking-tight text-white placeholder:text-white/30 focus:border-white/20 focus:bg-black/20 focus:outline-none focus:ring-2 focus:ring-white/8'; + +export const HUD_OPTION_ROW = + 'group flex w-full items-start justify-between gap-4 rounded-[20px] border border-white/8 bg-black/10 px-4 py-3.5 text-left transition-all duration-200 hover:border-white/14 hover:bg-black/14 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-white/10 disabled:cursor-not-allowed disabled:border-white/6 disabled:bg-black/8 disabled:text-white/30'; + +export const HUD_OPTION_ROW_PRIMARY = + 'border-white/12 bg-black/14 hover:border-white/18 hover:bg-black/18'; + +export const HUD_OPTION_CHEVRON = + 'mt-0.5 shrink-0 text-[13px] text-white/28 transition-colors duration-200 group-hover:text-white/52'; + +export const HUD_PAUSE_EYEBROW = + 'text-[11px] font-medium tracking-[0.12em] text-white/42'; + +export const HUD_PAUSE_TITLE = + 'mt-2 max-w-[24rem] text-[1.18rem] font-medium leading-[1.34] tracking-[-0.02em] text-white/95 md:text-[1.28rem]'; + +export const HUD_PAUSE_BODY = + 'mt-2 max-w-[23rem] text-[13px] leading-[1.6] text-white/58 md:text-[13.5px]'; + +export const HUD_TEXT_LINK = + 'text-[12px] font-medium tracking-[0.08em] text-white/62 underline decoration-white/16 underline-offset-4 transition-all duration-200 hover:text-white/84 hover:decoration-white/28 disabled:cursor-default disabled:text-white/26 disabled:no-underline'; + +export const HUD_TEXT_LINK_STRONG = + 'text-[12px] font-semibold tracking-[0.08em] text-white/86 underline decoration-white/22 underline-offset-4 transition-all duration-200 hover:text-white hover:decoration-white/36 disabled:cursor-default disabled:text-white/30 disabled:no-underline'; diff --git a/src/widgets/space-workspace/model/useAwayReturnRecovery.ts b/src/widgets/space-workspace/model/useAwayReturnRecovery.ts new file mode 100644 index 0000000..f5efe4e --- /dev/null +++ b/src/widgets/space-workspace/model/useAwayReturnRecovery.ts @@ -0,0 +1,184 @@ +'use client'; + +import { useCallback, useEffect, useRef, useState } from 'react'; +import type { FocusSession } from '@/features/focus-session'; + +const AWAY_HIDDEN_THRESHOLD_MS = 20_000; +const AWAY_SLEEP_GAP_THRESHOLD_MS = 90_000; +const HEARTBEAT_INTERVAL_MS = 15_000; + +export type ReturnPromptMode = 'focus' | 'break'; + +interface UseAwayReturnRecoveryParams { + currentSession: FocusSession | null; + isBootstrapping: boolean; + syncCurrentSession: () => Promise; +} + +interface UseAwayReturnRecoveryResult { + returnPromptMode: ReturnPromptMode | null; + dismissReturnPrompt: () => void; +} + +export const useAwayReturnRecovery = ({ + currentSession, + isBootstrapping, + syncCurrentSession, +}: UseAwayReturnRecoveryParams): UseAwayReturnRecoveryResult => { + const [returnPromptMode, setReturnPromptMode] = useState(null); + const hiddenAtRef = useRef(null); + const awayCandidateRef = useRef(false); + const heartbeatAtRef = useRef(Date.now()); + const isHandlingReturnRef = useRef(false); + + const isRunningFocusSession = + currentSession?.state === 'running' && currentSession.phase === 'focus'; + + const clearAwayCandidate = useCallback(() => { + hiddenAtRef.current = null; + awayCandidateRef.current = false; + }, []); + + const dismissReturnPrompt = useCallback(() => { + setReturnPromptMode(null); + clearAwayCandidate(); + }, [clearAwayCandidate]); + + useEffect(() => { + heartbeatAtRef.current = Date.now(); + }, [currentSession?.id]); + + useEffect(() => { + if (!isRunningFocusSession) { + clearAwayCandidate(); + return; + } + + const intervalId = window.setInterval(() => { + if (document.visibilityState === 'visible') { + heartbeatAtRef.current = Date.now(); + } + }, HEARTBEAT_INTERVAL_MS); + + return () => { + window.clearInterval(intervalId); + }; + }, [clearAwayCandidate, isRunningFocusSession]); + + useEffect(() => { + if (currentSession?.state !== 'running') { + if (returnPromptMode === 'focus') { + setReturnPromptMode(null); + } + return; + } + + if (currentSession.phase !== 'break' && returnPromptMode === 'break') { + setReturnPromptMode(null); + } + }, [currentSession?.phase, currentSession?.state, returnPromptMode]); + + useEffect(() => { + if (isBootstrapping) { + return; + } + + const maybeHandleReturn = async () => { + if (isHandlingReturnRef.current) { + return; + } + + const hiddenDuration = + hiddenAtRef.current == null ? 0 : Date.now() - hiddenAtRef.current; + const sleepGap = Date.now() - heartbeatAtRef.current; + + if (!awayCandidateRef.current) { + if ( + isRunningFocusSession && + document.visibilityState === 'visible' && + sleepGap >= AWAY_SLEEP_GAP_THRESHOLD_MS + ) { + awayCandidateRef.current = true; + } else { + return; + } + } + + if (hiddenAtRef.current != null && hiddenDuration < AWAY_HIDDEN_THRESHOLD_MS) { + clearAwayCandidate(); + heartbeatAtRef.current = Date.now(); + return; + } + + isHandlingReturnRef.current = true; + + try { + const syncedSession = await syncCurrentSession(); + const resolvedSession = syncedSession ?? currentSession; + + clearAwayCandidate(); + heartbeatAtRef.current = Date.now(); + + if (!resolvedSession || resolvedSession.state !== 'running') { + setReturnPromptMode(null); + return; + } + + if (resolvedSession.phase === 'focus') { + setReturnPromptMode('focus'); + return; + } + + if (resolvedSession.phase === 'break') { + setReturnPromptMode('break'); + return; + } + + setReturnPromptMode(null); + } finally { + isHandlingReturnRef.current = false; + } + }; + + const handleVisibilityChange = () => { + if (document.visibilityState === 'hidden') { + if (isRunningFocusSession) { + hiddenAtRef.current = Date.now(); + awayCandidateRef.current = true; + } + + return; + } + + void maybeHandleReturn(); + }; + + const handlePageHide = () => { + if (isRunningFocusSession) { + hiddenAtRef.current = Date.now(); + awayCandidateRef.current = true; + } + }; + + const handleWindowFocus = () => { + void maybeHandleReturn(); + }; + + document.addEventListener('visibilitychange', handleVisibilityChange); + window.addEventListener('pagehide', handlePageHide); + window.addEventListener('focus', handleWindowFocus); + window.addEventListener('pageshow', handleWindowFocus); + + return () => { + document.removeEventListener('visibilitychange', handleVisibilityChange); + window.removeEventListener('pagehide', handlePageHide); + window.removeEventListener('focus', handleWindowFocus); + window.removeEventListener('pageshow', handleWindowFocus); + }; + }, [clearAwayCandidate, currentSession, isBootstrapping, isRunningFocusSession, syncCurrentSession]); + + return { + returnPromptMode, + dismissReturnPrompt, + }; +}; diff --git a/src/widgets/space-workspace/model/useSpaceWorkspaceSessionControls.ts b/src/widgets/space-workspace/model/useSpaceWorkspaceSessionControls.ts index ca297d9..bec6e02 100644 --- a/src/widgets/space-workspace/model/useSpaceWorkspaceSessionControls.ts +++ b/src/widgets/space-workspace/model/useSpaceWorkspaceSessionControls.ts @@ -40,6 +40,12 @@ interface UseSpaceWorkspaceSessionControlsParams { goal?: string; microStep?: string | null; }) => Promise; + completeSession: (payload: { + completionType: 'goal-complete' | 'timer-complete'; + completedGoal?: string; + focusScore?: number; + distractionCount?: number; + }) => Promise; advanceGoal: (input: { completedGoal: string; nextGoal: string; @@ -78,6 +84,7 @@ export const useSpaceWorkspaceSessionControls = ({ resumeSession, restartCurrentPhase, updateCurrentIntent, + completeSession, advanceGoal, abandonSession, setGoalInput, @@ -294,6 +301,47 @@ export const useSpaceWorkspaceSessionControls = ({ unlockPlayback, ]); + const handleGoalComplete = useCallback(async () => { + const trimmedCurrentGoal = goalInput.trim(); + + if (!currentSession) { + return false; + } + + const completedSession = await completeSession({ + completionType: 'goal-complete', + completedGoal: trimmedCurrentGoal || undefined, + }); + + if (!completedSession) { + pushStatusLine({ + message: copy.space.workspace.goalCompleteSyncFailed, + }); + return false; + } + + setGoalInput(''); + setLinkedFocusPlanItemId(null); + setSelectedGoalId(null); + setShowResumePrompt(false); + setPendingSessionEntryPoint('space-setup'); + setPreviewPlaybackState('paused'); + setWorkspaceMode('setup'); + return true; + }, [ + completeSession, + currentSession, + goalInput, + pushStatusLine, + setGoalInput, + setLinkedFocusPlanItemId, + setPendingSessionEntryPoint, + setPreviewPlaybackState, + setSelectedGoalId, + setShowResumePrompt, + setWorkspaceMode, + ]); + const handleIntentUpdate = useCallback(async (input: { goal?: string; microStep?: string | null; @@ -407,6 +455,7 @@ export const useSpaceWorkspaceSessionControls = ({ handlePauseRequested, handleRestartRequested, handleIntentUpdate, + handleGoalComplete, handleGoalAdvance, }; }; diff --git a/src/widgets/space-workspace/ui/SpaceWorkspaceWidget.tsx b/src/widgets/space-workspace/ui/SpaceWorkspaceWidget.tsx index 458aabf..82f389b 100644 --- a/src/widgets/space-workspace/ui/SpaceWorkspaceWidget.tsx +++ b/src/widgets/space-workspace/ui/SpaceWorkspaceWidget.tsx @@ -20,6 +20,7 @@ import { SpaceToolsDockWidget } from "@/widgets/space-tools-dock"; import { useRouter, useSearchParams } from "next/navigation"; import { useEffect, useMemo, useState } from "react"; import type { SessionEntryPoint, WorkspaceMode } from "../model/types"; +import { useAwayReturnRecovery } from "../model/useAwayReturnRecovery"; import { useSpaceWorkspaceSelection } from "../model/useSpaceWorkspaceSelection"; import { useSpaceWorkspaceSessionControls } from "../model/useSpaceWorkspaceSessionControls"; import { @@ -117,8 +118,10 @@ export const SpaceWorkspaceWidget = () => { restartCurrentPhase, updateCurrentIntent, updateCurrentSelection, + completeSession, advanceGoal, abandonSession, + syncCurrentSession, } = useFocusSessionEngine(); const isFocusMode = workspaceMode === "focus"; @@ -191,6 +194,7 @@ export const SpaceWorkspaceWidget = () => { resumeSession, restartCurrentPhase, updateCurrentIntent, + completeSession, advanceGoal, abandonSession, setGoalInput: selection.setGoalInput, @@ -199,6 +203,12 @@ export const SpaceWorkspaceWidget = () => { setShowResumePrompt: selection.setShowResumePrompt, }); + const awayReturnRecovery = useAwayReturnRecovery({ + currentSession, + isBootstrapping, + syncCurrentSession, + }); + useEffect(() => { if (!isBootstrapping && !currentSession && !hasQueryOverrides) { router.replace("/app"); @@ -296,6 +306,7 @@ export const SpaceWorkspaceWidget = () => { canStartSession={controls.canStartSession} canPauseSession={controls.canPauseSession} canRestartSession={controls.canRestartSession} + returnPromptMode={awayReturnRecovery.returnPromptMode} onStartRequested={() => { void controls.handleStartRequested(); }} @@ -305,8 +316,10 @@ export const SpaceWorkspaceWidget = () => { onRestartRequested={() => { void controls.handleRestartRequested(); }} + onDismissReturnPrompt={awayReturnRecovery.dismissReturnPrompt} onStatusMessage={pushStatusLine} onIntentUpdate={controls.handleIntentUpdate} + onGoalFinish={controls.handleGoalComplete} onGoalUpdate={controls.handleGoalAdvance} /> ) : null}