fix(flow): app entry를 no-session 전용으로 단순화
This commit is contained in:
@@ -4,6 +4,10 @@ Last Updated: 2026-03-16
|
|||||||
|
|
||||||
## DONE
|
## DONE
|
||||||
|
|
||||||
|
- `/app` session gate 제거:
|
||||||
|
- `/app`은 더 이상 running / paused / takeover UI를 보여주지 않는다
|
||||||
|
- current session이 있으면 상태와 상관없이 즉시 `/space`로 이동한다
|
||||||
|
- no-session일 때만 atmosphere entry shell이 열린다
|
||||||
- `/app` Atmosphere Entry Shell 1차 구현:
|
- `/app` Atmosphere Entry Shell 1차 구현:
|
||||||
- no-session `/app`을 `goal + duration + atmosphere` 중심의 premium entry shell로 교체했다
|
- no-session `/app`을 `goal + duration + atmosphere` 중심의 premium entry shell로 교체했다
|
||||||
- `microStep` 입력은 entry에서 제거했고, `예상 시간(분)` 입력과 12개 dummy atmosphere grid를 추가했다
|
- `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`)으로 매핑한다
|
- custom duration server contract 전까지는 입력한 분 값을 가장 가까운 기본 리듬(`25/5`, `50/10`, `90/20`)으로 매핑한다
|
||||||
- weekly review entry는 main CTA를 먹지 않도록 no-session shell의 quiet secondary dock 위치로 이동했다
|
- weekly review entry는 main CTA를 먹지 않도록 no-session shell의 quiet secondary dock 위치로 이동했다
|
||||||
|
|
||||||
- `Paused Session Takeover Flow` 구현:
|
- current session direct start 차단:
|
||||||
- `/app` paused gate에 `새 목표로 전환` 진입점을 추가했다
|
- silent abandon을 막기 위해 server `startSession()`은 current session 존재 시 direct start를 거절한다
|
||||||
- takeover confirm sheet에서만 current paused session을 정리하고 single-goal start 상태로 넘어간다
|
|
||||||
- silent abandon을 막기 위해 server `startSession()`도 current session 존재 시 direct start를 거절하도록 정리했다
|
|
||||||
- explicit confirm 이후에만 `abandon -> 새 목표 입력` 흐름이 가능하다
|
|
||||||
- `/app` 기존 single-goal commitment gate는 legacy로 내려갔다:
|
- `/app` 기존 single-goal commitment gate는 legacy로 내려갔다:
|
||||||
- 2-step `goal -> ritual` flow를 제거하고, current session이 있으면 `Resume` UI를 우선 노출하도록 정리했다
|
- 2-step `goal -> ritual` flow를 제거하고, current session이 있으면 `Resume` UI를 우선 노출하도록 정리했다
|
||||||
- 현재 source-of-truth는 `goal + duration + atmosphere` 중심의 새 entry shell spec이다
|
- 현재 source-of-truth는 `goal + duration + atmosphere` 중심의 새 entry shell spec이다
|
||||||
@@ -126,7 +127,7 @@ Last Updated: 2026-03-16
|
|||||||
- recovery는 서버의 `pause 뒤 복귀` 집계를 사용하고, `자리 비움 뒤 복귀`만 limited note로 남긴다
|
- recovery는 서버의 `pause 뒤 복귀` 집계를 사용하고, `자리 비움 뒤 복귀`만 limited note로 남긴다
|
||||||
- `/app -> /stats` primary entry의 1차 연결:
|
- `/app -> /stats` primary entry의 1차 연결:
|
||||||
- current session이 없고 최근 7일 데이터가 충분할 때 `/app`의 quiet secondary review dock에서 `Weekly Review` entry를 노출한다
|
- 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 -> /app` handoff의 2차 연결:
|
||||||
- `/stats` 마지막 CTA는 `/app?review=weekly&carryHint=...&entryPreset=forest-50-10`으로 연결된다
|
- `/stats` 마지막 CTA는 `/app?review=weekly&carryHint=...&entryPreset=forest-50-10`으로 연결된다
|
||||||
- `/app`은 이 query를 받아 entry stage 위의 review-aware return hint를 노출한다
|
- `/app`은 이 query를 받아 entry stage 위의 review-aware return hint를 노출한다
|
||||||
|
|||||||
@@ -175,7 +175,7 @@ review가 가장 유의미한 순간도 바로 여기다.
|
|||||||
이 경우:
|
이 경우:
|
||||||
|
|
||||||
- `/app` no-session 상태에서는 goal/duration/atmosphere entry stage에 집중
|
- `/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에서만 다룬다
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -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에서 **진행 중인 세션 / 멈춘 세션 / 쉬는 시간**을 어떻게 다르게 취급할지,
|
이 문서는 VibeRoom에서 `/app`과 `/space`의 역할을 어떻게 나눌지 정의한다.
|
||||||
그리고 사용자가 `/app`에 들어왔을 때 어떤 경로로 다시 `/space`에 진입해야 하는지를 정의한다.
|
|
||||||
|
|
||||||
핵심 목적은 하나다.
|
핵심 원칙은 하나다.
|
||||||
|
|
||||||
> session state에 따라 `/app`과 `/space`의 역할을 정확히 나누고,
|
> current session이 있으면 사용자를 `/app`에 세워두지 않고 바로 `/space`로 보낸다.
|
||||||
> 사용자가 “지금 내가 어떤 상태인지”를 설명할 수 있는 premium UX를 만든다.
|
> current session이 없을 때만 `/app`에서 새 entry를 만든다.
|
||||||
|
|
||||||
관련 문서:
|
관련 문서:
|
||||||
|
|
||||||
- `./19_app_atmosphere_entry_spec.md`
|
- `../../screens/app/current/19_app_atmosphere_entry_spec.md`
|
||||||
- `../space/10_refocus_system_spec.md`
|
- `../../screens/space/current/13_space_intent_card_collapsed_expanded_spec.md`
|
||||||
- `../space/11_away_return_recovery_spec.md`
|
|
||||||
- `./15_app_stats_entry_flow_spec.md`
|
- `./15_app_stats_entry_flow_spec.md`
|
||||||
- `../../product/16_product_alignment_audit_plan.md`
|
- `../../product/12_core_loop_execution_roadmap.md`
|
||||||
- `../../product/17_product_alignment_findings.md`
|
|
||||||
- `../../product_principles.md`
|
|
||||||
- `../../current_context.md`
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 1. 문제 정의
|
## 1. 왜 바꾸는가
|
||||||
|
|
||||||
지금까지의 혼란은 대부분 여기서 시작됐다.
|
이전 구조는 `/app`이
|
||||||
|
|
||||||
- `세션`
|
- no-session entry shell
|
||||||
- `타이머`
|
- paused resume gate
|
||||||
- `pause`
|
- takeover decision
|
||||||
- `break`
|
- review secondary entry
|
||||||
- `return`
|
|
||||||
|
|
||||||
을 충분히 분리하지 않고 써 왔다.
|
를 모두 안고 있었다.
|
||||||
|
|
||||||
그 결과:
|
문제:
|
||||||
|
|
||||||
- 사용자는 `타이머가 멈춰 있으면 다 paused인가?`를 헷갈린다
|
- `/app`의 정체성이 흐려진다
|
||||||
- `/app`에서 `resume`이 primary인지, `새로 시작`이 가능한지 흐려진다
|
- 사용자는 `들어가는 화면`인지 `멈춘 세션을 처리하는 화면`인지 헷갈린다
|
||||||
- `잠시 비우기`와 `break`의 의미가 섞인다
|
- `/space`의 recovery UX와 `/app`의 resume UX가 중복된다
|
||||||
|
|
||||||
이 spec은 그 상태 정의를 먼저 고정한다.
|
따라서 `/app`은 다시 단순해져야 한다.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 2. 한 줄 정의
|
## 2. 한 줄 정의
|
||||||
|
|
||||||
> running session은 바로 `/space`로 복귀시키고,
|
### `/app`
|
||||||
> paused session은 `/app`에서 다시 이어갈지 정하게 하되,
|
|
||||||
> 사용자가 `이어가기`를 눌렀다면 `/space`에서는 다시 묻지 않고 바로 resume한다.
|
새 session을 시작하기 위한 atmosphere entry surface
|
||||||
|
|
||||||
|
### `/space`
|
||||||
|
|
||||||
|
이미 존재하는 session을 계속 다루는 execution surface
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 3. 상태 정의
|
## 3. 라우팅 규칙
|
||||||
|
|
||||||
### Session
|
### Rule A. current session이 있으면 `/space`
|
||||||
|
|
||||||
사용자가 현재 책임지고 있는 하나의 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
|
|
||||||
|
|
||||||
상태:
|
상태:
|
||||||
|
|
||||||
- current session 존재
|
- `currentSession` 존재
|
||||||
- `state = running`
|
- `state = running` 또는 `paused`
|
||||||
- `phase = focus`
|
- `phase = focus` 또는 `break`
|
||||||
|
|
||||||
처리:
|
처리:
|
||||||
|
|
||||||
@@ -159,44 +67,10 @@ paused session이 있는데 새 목표를 바로 시작하게 하면
|
|||||||
|
|
||||||
이유:
|
이유:
|
||||||
|
|
||||||
- 이미 실행 중인 일은 다시 commitment gate에 세우면 안 된다
|
- session이 살아 있는 동안 사용자의 일은 이미 시작된 상태다
|
||||||
|
- `/app`이 끼어들면 execution surface와 decision surface가 섞인다
|
||||||
|
|
||||||
### Rule B. Running Break
|
### Rule B. current session이 없으면 `/app`
|
||||||
|
|
||||||
상태:
|
|
||||||
|
|
||||||
- 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
|
|
||||||
|
|
||||||
상태:
|
상태:
|
||||||
|
|
||||||
@@ -204,257 +78,79 @@ paused session이 있는데 새 목표를 바로 시작하게 하면
|
|||||||
|
|
||||||
처리:
|
처리:
|
||||||
|
|
||||||
- `/app` 진입
|
- `/app` no-session entry shell 노출
|
||||||
- no-session entry shell 노출
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 6. `/app` paused state UX
|
## 4. `/app`의 역할
|
||||||
|
|
||||||
### 목적
|
`/app`은 아래 3가지 결정만 받는다.
|
||||||
|
|
||||||
사용자가 “멈춘 세션이 아직 살아 있다”는 것을 즉시 이해하고,
|
1. goal
|
||||||
한 번의 결정으로 `/space`에 다시 들어가게 만드는 것.
|
2. duration
|
||||||
|
3. atmosphere
|
||||||
|
|
||||||
### 정보 구조
|
포함하지 않음:
|
||||||
|
|
||||||
resume card 안에는 아래만 둔다.
|
- paused resume gate
|
||||||
|
- takeover sheet
|
||||||
- 현재 goal
|
- current session review entry
|
||||||
- 마지막 microStep
|
- running / paused 상태별 CTA
|
||||||
- 현재 상태 문구
|
|
||||||
- 예: `잠시 멈춘 세션이 있어요`
|
|
||||||
- primary CTA
|
|
||||||
- quiet secondary actions
|
|
||||||
|
|
||||||
### Primary CTA
|
|
||||||
|
|
||||||
- `이어서 몰입하기`
|
|
||||||
|
|
||||||
동작:
|
|
||||||
|
|
||||||
- 클릭
|
|
||||||
- `/space`로 이동
|
|
||||||
- 자동 resume
|
|
||||||
|
|
||||||
### Secondary
|
|
||||||
|
|
||||||
- `한 조각 다시 잡기`
|
|
||||||
- `주간 review 보기`
|
|
||||||
|
|
||||||
### Tertiary
|
|
||||||
|
|
||||||
- `새 목표로 전환`
|
|
||||||
|
|
||||||
중요:
|
|
||||||
|
|
||||||
- new start가 아니다
|
|
||||||
- takeover flow 진입점이다
|
|
||||||
- server도 current session이 남아 있으면 direct start를 거절해야 한다
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 7. `/space` 재진입 동작
|
## 5. `/space`의 역할
|
||||||
|
|
||||||
### Resume CTA 이후
|
current session이 있는 동안의 모든 판단은 `/space`에서 이뤄진다.
|
||||||
|
|
||||||
`/app`에서 `이어서 몰입하기`를 눌렀다면:
|
예:
|
||||||
|
|
||||||
- `/space`로 이동
|
- 이어가기
|
||||||
- 별도의 start 버튼 재요구 금지
|
- 잠시 멈춤
|
||||||
- 부드러운 transition 후 자동 resume
|
- 다시 붙잡기
|
||||||
|
- 다음 단계 정하기
|
||||||
|
- 여기서 마무리하기
|
||||||
|
|
||||||
추천:
|
즉 paused session도 `/space` 안에서 다시 다룬다.
|
||||||
|
|
||||||
- 300~800ms 정도의 soft transition
|
|
||||||
- 필요하면 아주 짧은 re-entry settle animation
|
|
||||||
|
|
||||||
금지:
|
|
||||||
|
|
||||||
- `/space`에서 다시 `시작`을 누르게 하는 것
|
|
||||||
- resume 직후 또 다른 decision tray를 띄우는 것
|
|
||||||
|
|
||||||
### Refocus CTA 이후
|
|
||||||
|
|
||||||
`한 조각 다시 잡기`를 눌렀다면:
|
|
||||||
|
|
||||||
- refocus를 먼저 거친다
|
|
||||||
- 그 후 `/space` 진입과 함께 자동 resume
|
|
||||||
|
|
||||||
즉, refocus는 decision이고, `/space`는 execution이다.
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 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
|
current session이 살아 있는 동안 `/app`에서 review를 여는 flow는 current가 아니다.
|
||||||
|
|
||||||
질문:
|
|
||||||
|
|
||||||
- `현재 멈춘 세션을 어떻게 할까요?`
|
|
||||||
|
|
||||||
선택지:
|
|
||||||
|
|
||||||
- `이어서 하기`
|
|
||||||
- `이 세션은 여기서 정리하고 새로 시작`
|
|
||||||
- `취소`
|
|
||||||
|
|
||||||
### 동작 원칙
|
|
||||||
|
|
||||||
- `이어서 하기`
|
|
||||||
- sheet 닫기
|
|
||||||
- resume card 유지
|
|
||||||
|
|
||||||
- `이 세션은 여기서 정리하고 새로 시작`
|
|
||||||
- current session을 명시적으로 닫는다
|
|
||||||
- 그 다음 `/app` single-goal start 상태로 전환한다
|
|
||||||
|
|
||||||
- `취소`
|
|
||||||
- sheet 닫기
|
|
||||||
|
|
||||||
중요:
|
|
||||||
|
|
||||||
- silent abandon 금지
|
|
||||||
- paused session 위에 새 session을 덮어쓰기 금지
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 9. Weekly Review와의 관계
|
## 7. 구현 규칙
|
||||||
|
|
||||||
paused state에서도 review는 열 수 있어야 한다.
|
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 안에서 닫는다
|
||||||
### paused state
|
|
||||||
|
|
||||||
- primary: `이어서 몰입하기`
|
|
||||||
- secondary: `한 조각 다시 잡기`
|
|
||||||
- quiet secondary: `주간 review 보기`
|
|
||||||
|
|
||||||
즉:
|
|
||||||
|
|
||||||
- review를 숨기면 안 된다
|
|
||||||
- resume보다 앞세우면 안 된다
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 10. 금지사항
|
## 8. 하지 말아야 할 것
|
||||||
|
|
||||||
- running session인데 `/app` hero를 보여주는 것
|
- paused session만 `/app`에 남기기
|
||||||
- paused 상태에서 `/app` 진입만으로 자동 resume
|
- `/app -> /space -> 다시 resume/start` 이중 결정
|
||||||
- `/app`에서 `이어가기`를 눌렀는데 `/space`에서 다시 start를 요구하는 것
|
- `/app`에서 current session goal을 편집하기
|
||||||
- paused session 위에서 direct new start 허용
|
- current session이 있는데 `/app`에서 새 entry를 겹쳐 띄우기
|
||||||
- break를 paused session처럼 취급하는 것
|
|
||||||
- takeover 없이 silent abandon 하는 것
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 11. 구현 순서
|
## 9. QA 포인트
|
||||||
|
|
||||||
### Slice 1. Session Routing Contract
|
1. current session 없음 -> `/app` entry shell
|
||||||
|
2. current session running -> `/app` 진입 즉시 `/space`
|
||||||
범위:
|
3. current session paused -> `/app` 진입 즉시 `/space`
|
||||||
|
4. `/space`에서 pause 후 화면이 `/app`으로 튀지 않음
|
||||||
- `/app` 진입 시 current session state에 따른 route policy 고정
|
5. `/space` complete 후 no-session이 되면 다시 `/app` entry shell 접근 가능
|
||||||
|
|
||||||
포함:
|
|
||||||
|
|
||||||
- `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가 되려면
|
|
||||||
`멈춤`, `쉬기`, `복귀`, `새 시작`을 같은 것으로 취급하면 안 된다.
|
|
||||||
|
|
||||||
가장 중요한 원칙은 이거다.
|
|
||||||
|
|
||||||
> 이미 실행 중인 것은 바로 복귀시키고,
|
|
||||||
> 의도적으로 멈춘 것은 다시 결정하게 하되,
|
|
||||||
> 다시 하겠다고 결정한 뒤에는 한 번 더 묻지 않는다.
|
|
||||||
|
|||||||
@@ -9,6 +9,12 @@ Last Updated: 2026-03-16
|
|||||||
- `Slice 1` no-session shell 구현 완료
|
- `Slice 1` no-session shell 구현 완료
|
||||||
- `Custom Duration Contract`와 `Weekly Review Dock Reposition`은 다음 slice로 남아 있음
|
- `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`
|
- `../../../../../product_principles.md`
|
||||||
@@ -55,6 +61,12 @@ Last Updated: 2026-03-16
|
|||||||
|
|
||||||
## 3. 핵심 제품 원칙
|
## 3. 핵심 제품 원칙
|
||||||
|
|
||||||
|
### 0. `/app`은 session 상태를 보여주지 않는다
|
||||||
|
|
||||||
|
- `/app`은 entry surface다
|
||||||
|
- current session이 있으면 사용자를 붙잡아 두지 않고 바로 `/space`로 이동시킨다
|
||||||
|
- 따라서 `/app` 안의 paused resume gate, takeover sheet, session review entry는 current가 아니다
|
||||||
|
|
||||||
### 1. Single Goal First는 유지한다
|
### 1. Single Goal First는 유지한다
|
||||||
|
|
||||||
- goal은 1개만 입력한다
|
- goal은 1개만 입력한다
|
||||||
@@ -70,7 +82,7 @@ Last Updated: 2026-03-16
|
|||||||
|
|
||||||
### 3. Duration은 사용자가 직접 입력한다
|
### 3. Duration은 사용자가 직접 입력한다
|
||||||
|
|
||||||
- 기존 preset `25/5`, `50/10` 중심 entry를 버린다
|
- 기존 preset `25/5`, `50/10`, `90/20` 중심 entry를 버린다
|
||||||
- 사용자는 “이 목표를 끝내는 데 얼마나 걸릴지”를 분 단위로 적는다
|
- 사용자는 “이 목표를 끝내는 데 얼마나 걸릴지”를 분 단위로 적는다
|
||||||
- 예: `70분`
|
- 예: `70분`
|
||||||
|
|
||||||
@@ -130,6 +142,10 @@ Last Updated: 2026-03-16
|
|||||||
- quiet weekly review entry
|
- quiet weekly review entry
|
||||||
- plan pill / account
|
- plan pill / account
|
||||||
|
|
||||||
|
전제:
|
||||||
|
|
||||||
|
- current session이 있으면 이 화면 자체에 오래 머무르지 않고 `/space`로 넘어간다
|
||||||
|
|
||||||
### Layer 2. Primary Start Stage
|
### Layer 2. Primary Start Stage
|
||||||
|
|
||||||
- goal input
|
- goal input
|
||||||
@@ -313,19 +329,15 @@ Last Updated: 2026-03-16
|
|||||||
|
|
||||||
- 이미 실행 중인 세션은 다시 decision gate에 세우지 않는다
|
- 이미 실행 중인 세션은 다시 decision gate에 세우지 않는다
|
||||||
|
|
||||||
### Flow C. Paused Session Exists
|
### Flow C. Any Current Session Exists
|
||||||
|
|
||||||
1. 사용자가 `/app` 진입
|
1. 사용자가 `/app` 진입
|
||||||
2. paused resume gate 노출
|
2. 바로 `/space` redirect
|
||||||
3. 선택:
|
|
||||||
- `이어서 몰입하기`
|
|
||||||
- `한 조각 다시 잡기`
|
|
||||||
- `새 목표로 전환`
|
|
||||||
|
|
||||||
중요:
|
중요:
|
||||||
|
|
||||||
- 이 상태에서는 atmosphere grid를 메인 UI로 먼저 보여주지 않는다
|
- `/app`은 current session을 처리하는 장소가 아니다
|
||||||
- resume가 primary다
|
- resume / pause / break / complete 판단은 `/space` 안에서 이어진다
|
||||||
|
|
||||||
### Flow D. Review Entry
|
### Flow D. Review Entry
|
||||||
|
|
||||||
@@ -381,7 +393,7 @@ Last Updated: 2026-03-16
|
|||||||
|
|
||||||
제외:
|
제외:
|
||||||
|
|
||||||
- paused resume gate 재설계
|
- current session routing 재설계
|
||||||
- `/stats` IA 변경
|
- `/stats` IA 변경
|
||||||
- server custom duration contract
|
- server custom duration contract
|
||||||
|
|
||||||
@@ -489,7 +501,7 @@ Last Updated: 2026-03-16
|
|||||||
|
|
||||||
### Slice 3. Paused Gate Coexistence
|
### Slice 3. Paused Gate Coexistence
|
||||||
|
|
||||||
- paused session일 때는 새로운 entry shell 대신 resume gate를 우선 유지
|
- current session이 있으면 `/app`을 건너뛰고 `/space`로 보낸다
|
||||||
- no-session일 때만 새 shell 노출
|
- no-session일 때만 새 shell 노출
|
||||||
|
|
||||||
### Slice 4. Custom Duration Contract
|
### Slice 4. Custom Duration Contract
|
||||||
@@ -517,5 +529,5 @@ Last Updated: 2026-03-16
|
|||||||
- atmosphere grid가 decorator가 아니라 실제 선택 surface로 읽힌다
|
- atmosphere grid가 decorator가 아니라 실제 선택 surface로 읽힌다
|
||||||
- weekly review가 start보다 앞서지 않는다
|
- weekly review가 start보다 앞서지 않는다
|
||||||
- `/app`이 planner/dashboard처럼 보이지 않는다
|
- `/app`이 planner/dashboard처럼 보이지 않는다
|
||||||
- paused session에서는 resume gate가 새 entry shell보다 우선한다
|
- current session이 있을 때는 `/app`에 머무르지 않고 `/space`로 이동한다
|
||||||
- `70분` 같은 custom duration이 실제 세션 길이로 반영된다
|
- `70분` 같은 custom duration이 실제 세션 길이로 반영된다
|
||||||
|
|||||||
@@ -32,12 +32,10 @@ Last Updated: 2026-03-16
|
|||||||
- 선택한 atmosphere는 `/app` 배경 preview와 `/space` start payload의 `scene/sound`에 같이 반영된다.
|
- 선택한 atmosphere는 `/app` 배경 preview와 `/space` start payload의 `scene/sound`에 같이 반영된다.
|
||||||
- duration은 우선 가장 가까운 기본 리듬으로 매핑하는 임시 계약을 사용한다.
|
- duration은 우선 가장 가까운 기본 리듬으로 매핑하는 임시 계약을 사용한다.
|
||||||
- weekly review entry는 right-side quiet dock 위치로 옮겨 main CTA보다 낮은 위계를 유지한다.
|
- weekly review entry는 right-side quiet dock 위치로 옮겨 main CTA보다 낮은 위계를 유지한다.
|
||||||
|
- `/app`은 이제 session gate를 보여주지 않는다.
|
||||||
- `Paused Session Takeover Flow`를 구현했다.
|
- current session이 있으면 상태와 상관없이 즉시 `/space`로 이동한다.
|
||||||
- `/app` paused gate에 `새 목표로 전환` 액션이 추가됐다.
|
- 따라서 paused resume gate와 takeover sheet는 current UX가 아니다.
|
||||||
- takeover confirm sheet에서만 기존 paused session을 정리하고 새로 시작할 수 있다.
|
- server `startSession()`은 여전히 current session 존재 시 direct start를 거절해 silent abandon을 막는다.
|
||||||
- server `startSession()`은 더 이상 silent abandon을 하지 않고, current session이 남아 있으면 direct start를 거절한다.
|
|
||||||
- takeover confirm 후에만 `abandon -> single-goal start` 순서로 넘어간다.
|
|
||||||
- `/space` Refocus System 첫 slice를 구현했다.
|
- `/space` Refocus System 첫 slice를 구현했다.
|
||||||
- pause 직후 바로 편집 시트가 아니라 작은 recovery prompt를 먼저 띄운다.
|
- pause 직후 바로 편집 시트가 아니라 작은 recovery prompt를 먼저 띄운다.
|
||||||
- 여기서 `한 조각 다시 잡기`를 누르면 refocus tray로 들어간다.
|
- 여기서 `한 조각 다시 잡기`를 누르면 refocus tray로 들어간다.
|
||||||
@@ -107,10 +105,7 @@ Last Updated: 2026-03-16
|
|||||||
- recovery는 서버의 `pause 뒤 복귀` 집계를 사용하고, `away recovery`만 limited state로 남긴다.
|
- recovery는 서버의 `pause 뒤 복귀` 집계를 사용하고, `away recovery`만 limited state로 남긴다.
|
||||||
- `/app`에서 `/stats`로 들어가는 primary path 1차가 생겼다.
|
- `/app`에서 `/stats`로 들어가는 primary path 1차가 생겼다.
|
||||||
- current session이 없을 때는 quiet review dock에서 `/stats`로 진입할 수 있다.
|
- current session이 없을 때는 quiet review dock에서 `/stats`로 진입할 수 있다.
|
||||||
- paused session 상태에서도 resume gate 안에 조용한 secondary review entry가 남는다.
|
- review entry는 main start CTA보다 항상 낮은 강조를 유지한다.
|
||||||
- review entry는 main start/resume CTA보다 항상 낮은 강조를 유지한다.
|
|
||||||
- `/app` Resume 상태에서도 weekly review entry가 보이게 정리했다.
|
|
||||||
- review primary entry가 active session 상태에서 사라지지 않도록, resume card 안에 조용한 secondary review link를 추가했다.
|
|
||||||
- `/stats` 마지막 CTA의 `/app` return handoff가 연결됐다.
|
- `/stats` 마지막 CTA의 `/app` return handoff가 연결됐다.
|
||||||
- carry-forward CTA는 `/app?review=weekly&carryHint=...`로 돌아온다.
|
- carry-forward CTA는 `/app?review=weekly&carryHint=...`로 돌아온다.
|
||||||
- `/app`은 review-aware return hint를 먼저 보여주되, goal은 사용자가 직접 입력하게 유지한다.
|
- `/app`은 review-aware return hint를 먼저 보여주되, goal은 사용자가 직접 입력하게 유지한다.
|
||||||
@@ -124,20 +119,10 @@ Last Updated: 2026-03-16
|
|||||||
- `Weekly Review` recovery의 서버 연결이 들어갔다.
|
- `Weekly Review` recovery의 서버 연결이 들어갔다.
|
||||||
- server `focus-summary` 응답에 `recovery`가 추가됐다.
|
- server `focus-summary` 응답에 `recovery`가 추가됐다.
|
||||||
- 현재는 `pause 뒤 복귀`만 실집계이며, `자리 비움 뒤 복귀`는 partial note로 남아 있다.
|
- 현재는 `pause 뒤 복귀`만 실집계이며, `자리 비움 뒤 복귀`는 partial note로 남아 있다.
|
||||||
- paused session 재진입 정책을 별도 source of truth로 고정했다.
|
- session routing 정책을 다시 단순화했다.
|
||||||
- `running focus -> /space`
|
- current session이 있으면 `/app`에서 머무르지 않고 바로 `/space`로 이동한다.
|
||||||
- `running break -> /space`
|
- `/space`는 paused session이라고 `/app`으로 되돌리지 않는다.
|
||||||
- `paused focus -> /app`
|
- `/app`은 no-session entry surface로만 남는다.
|
||||||
- `/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를 바로 연다.
|
|
||||||
- `Product Alignment Audit` 운영을 시작했다.
|
- `Product Alignment Audit` 운영을 시작했다.
|
||||||
- `docs/product/16_product_alignment_audit_plan.md`를 기준 문서로 추가했다.
|
- `docs/product/16_product_alignment_audit_plan.md`를 기준 문서로 추가했다.
|
||||||
- `docs/product/17_product_alignment_findings.md`에 core loop의 P1/P2 mismatch를 수집하기 시작했다.
|
- `docs/product/17_product_alignment_findings.md`에 core loop의 P1/P2 mismatch를 수집하기 시작했다.
|
||||||
|
|||||||
10
docs/work.md
10
docs/work.md
@@ -30,7 +30,7 @@
|
|||||||
- 4x3 atmosphere grid
|
- 4x3 atmosphere grid
|
||||||
- primary CTA
|
- primary CTA
|
||||||
- 제외 범위:
|
- 제외 범위:
|
||||||
- paused resume gate 재설계 금지
|
- `/space` recovery UX 재설계 금지
|
||||||
- weekly review 상세 IA 변경 금지
|
- weekly review 상세 IA 변경 금지
|
||||||
- server contract 변경 금지
|
- server contract 변경 금지
|
||||||
- 완료 조건:
|
- 완료 조건:
|
||||||
@@ -78,14 +78,14 @@
|
|||||||
- review return hint placement
|
- review return hint placement
|
||||||
- 제외 범위:
|
- 제외 범위:
|
||||||
- `/stats` IA 변경 금지
|
- `/stats` IA 변경 금지
|
||||||
- paused resume gate 재설계 금지
|
- `/space` recovery UX 재설계 금지
|
||||||
- 완료 조건:
|
- 완료 조건:
|
||||||
- review entry는 항상 발견 가능하지만 start보다 앞서지 않는다
|
- 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 위치 재정렬
|
- fix(app): review dock 위치 재정렬
|
||||||
|
|
||||||
@@ -96,7 +96,7 @@
|
|||||||
- 새 `/app` entry shell까지 포함한 핵심 흐름을 브라우저에서 실제로 검증한다.
|
- 새 `/app` entry shell까지 포함한 핵심 흐름을 브라우저에서 실제로 검증한다.
|
||||||
- 변경 범위:
|
- 변경 범위:
|
||||||
- `/app` no-session
|
- `/app` no-session
|
||||||
- `/app` paused resume
|
- current session 상태에서 `/app -> /space` redirect
|
||||||
- `/app -> /stats -> /app`
|
- `/app -> /stats -> /app`
|
||||||
- `/space` pause / return / next beat / complete
|
- `/space` pause / return / next beat / complete
|
||||||
- `/space` complete -> setup -> weekly review entry
|
- `/space` complete -> setup -> weekly review entry
|
||||||
|
|||||||
@@ -262,7 +262,7 @@ const buildCarryForward = (summary: FocusStatsSummary): WeeklyReviewViewModel['c
|
|||||||
return {
|
return {
|
||||||
hintKey,
|
hintKey,
|
||||||
presetId: 'forest-50-10',
|
presetId: 'forest-50-10',
|
||||||
presetLabel: 'Forest · 50/10 · Forest Birds',
|
presetLabel: 'Forest · Forest Birds',
|
||||||
keepDoing,
|
keepDoing,
|
||||||
tryNext,
|
tryNext,
|
||||||
ctaLabel: copy.stats.reviewCarryCta,
|
ctaLabel: copy.stats.reviewCarryCta,
|
||||||
|
|||||||
@@ -34,7 +34,7 @@ const REVIEW_ENTRY_PRESETS = {
|
|||||||
sceneId: DEFAULT_SCENE_ID,
|
sceneId: DEFAULT_SCENE_ID,
|
||||||
soundPresetId: DEFAULT_SOUND_ID,
|
soundPresetId: DEFAULT_SOUND_ID,
|
||||||
timerPresetId: DEFAULT_TIMER_ID,
|
timerPresetId: DEFAULT_TIMER_ID,
|
||||||
label: '숲 · 50/10 · Forest Birds',
|
label: '숲 · Forest Birds',
|
||||||
},
|
},
|
||||||
} as const;
|
} as const;
|
||||||
const DEFAULT_ATMOSPHERE =
|
const DEFAULT_ATMOSPHERE =
|
||||||
@@ -45,30 +45,12 @@ const entryCopy = {
|
|||||||
goalPlaceholder: '예: 제안서 첫 문단만 다듬기',
|
goalPlaceholder: '예: 제안서 첫 문단만 다듬기',
|
||||||
durationLabel: '예상 시간(분)',
|
durationLabel: '예상 시간(분)',
|
||||||
durationPlaceholder: '예: 70',
|
durationPlaceholder: '예: 70',
|
||||||
durationHelper: '입력한 시간은 지금 가장 가까운 기본 리듬으로 먼저 맞춰서 들어가요.',
|
durationHelper: '이 목표를 끝내는 데 걸릴 것 같은 시간을 적어요.',
|
||||||
startNow: '이 분위기로 들어가기',
|
startNow: '이 분위기로 들어가기',
|
||||||
startLoading: '입장 준비 중...',
|
startLoading: '입장 준비 중...',
|
||||||
atmosphereTitle: '어떤 분위기에서 들어갈까요?',
|
atmosphereTitle: '어떤 분위기에서 들어갈까요?',
|
||||||
atmosphereBody:
|
atmosphereBody:
|
||||||
'배경과 사운드는 같이 움직여요. 오늘 goal에 맞는 atmosphere 하나만 고르면 바로 들어갈 수 있어요.',
|
'배경과 사운드는 같이 움직여요. 오늘 goal에 맞는 atmosphere 하나만 고르면 바로 들어갈 수 있어요.',
|
||||||
resumeEyebrow: 'Resume',
|
|
||||||
resumeRunning: '진행 중인 세션이 있어요.',
|
|
||||||
resumePaused: '잠시 멈춘 세션이 있어요.',
|
|
||||||
resumeCta: '이어서 몰입하기',
|
|
||||||
resumeRefocusCta: '한 조각 다시 잡기',
|
|
||||||
resumeRouting: '진행 중인 세션으로 돌아가는 중이에요.',
|
|
||||||
resumeMicroStepLabel: '마지막 한 조각',
|
|
||||||
resumePausedHint: '같은 목표를 바로 이어가거나, 다시 시작할 한 조각만 먼저 정리할 수 있어요.',
|
|
||||||
resumeNewGoalHint: '새 목표로 전환하려면 지금 멈춘 세션을 먼저 어떻게 정리할지 결정해야 해요.',
|
|
||||||
resumeTakeoverCta: '새 목표로 전환',
|
|
||||||
takeoverEyebrow: '새 목표로 전환',
|
|
||||||
takeoverTitle: '현재 멈춘 세션을 어떻게 할까요?',
|
|
||||||
takeoverBody: '지금 멈춘 흐름을 조용히 없애지 않고, 먼저 정리한 뒤 새 목표로 넘어가요.',
|
|
||||||
takeoverKeepCta: '이어서 하기',
|
|
||||||
takeoverConfirmCta: '이 세션은 여기서 정리하고 새로 시작',
|
|
||||||
takeoverCancelCta: '취소',
|
|
||||||
takeoverLoading: '세션을 정리하는 중...',
|
|
||||||
takeoverFailed: '멈춘 세션을 정리하지 못했어요. 잠시 후 다시 시도해 주세요.',
|
|
||||||
loadFailed: '세션 상태를 불러오지 못했어요. 새로 시작은 계속 할 수 있어요.',
|
loadFailed: '세션 상태를 불러오지 못했어요. 새로 시작은 계속 할 수 있어요.',
|
||||||
reviewEyebrow: 'Weekly Review',
|
reviewEyebrow: 'Weekly Review',
|
||||||
reviewTitle: '이번 주 review를 잠깐 보고 갈까요?',
|
reviewTitle: '이번 주 review를 잠깐 보고 갈까요?',
|
||||||
@@ -77,9 +59,6 @@ const entryCopy = {
|
|||||||
reviewTitlePro: '나에게 잘 맞았던 흐름을 다시 보고 갈까요?',
|
reviewTitlePro: '나에게 잘 맞았던 흐름을 다시 보고 갈까요?',
|
||||||
reviewCtaPro: '나에게 맞는 흐름 보기',
|
reviewCtaPro: '나에게 맞는 흐름 보기',
|
||||||
reviewHelperPro: '가장 잘 맞았던 ritual과 carry-forward를 보고 돌아올 수 있어요.',
|
reviewHelperPro: '가장 잘 맞았던 ritual과 carry-forward를 보고 돌아올 수 있어요.',
|
||||||
resumeReviewEyebrow: 'Weekly Review',
|
|
||||||
resumeReviewTitle: '잠깐 review를 보고 다시 들어갈 수 있어요.',
|
|
||||||
resumeReviewHelper: '현재 세션은 그대로 두고, 이번 주 흐름만 짧게 확인합니다.',
|
|
||||||
reviewReturnEyebrow: '방금 본 review 기준',
|
reviewReturnEyebrow: '방금 본 review 기준',
|
||||||
reviewReturnTitleSteady: '이번 주에 잘 맞았던 흐름을 그대로 가져가 보세요.',
|
reviewReturnTitleSteady: '이번 주에 잘 맞았던 흐름을 그대로 가져가 보세요.',
|
||||||
reviewReturnTitleSmaller: '이번엔 목표를 더 작게 잡아보세요.',
|
reviewReturnTitleSmaller: '이번엔 목표를 더 작게 잡아보세요.',
|
||||||
@@ -89,25 +68,13 @@ const entryCopy = {
|
|||||||
reviewReturnBodySmaller: '길이를 늘리기보다, 더 작은 goal과 더 구체적인 첫 한 조각으로 시작하면 이어가기 쉬워져요.',
|
reviewReturnBodySmaller: '길이를 늘리기보다, 더 작은 goal과 더 구체적인 첫 한 조각으로 시작하면 이어가기 쉬워져요.',
|
||||||
reviewReturnBodyClosure: '큰 흐름보다 지금 블록을 어디서 마무리할지 먼저 떠올리면 끝까지 가져가기 쉬워져요.',
|
reviewReturnBodyClosure: '큰 흐름보다 지금 블록을 어디서 마무리할지 먼저 떠올리면 끝까지 가져가기 쉬워져요.',
|
||||||
reviewReturnBodyStart: '길이를 늘리기보다, 아주 작은 goal로 이번 주 첫 세션 하나를 더 여는 데 집중해 보세요.',
|
reviewReturnBodyStart: '길이를 늘리기보다, 아주 작은 goal로 이번 주 첫 세션 하나를 더 여는 데 집중해 보세요.',
|
||||||
reviewReturnRitualLabel: '추천 ritual · 숲 · 50/10 · Forest Birds',
|
reviewReturnRitualLabel: '추천 atmosphere · 숲 · Forest Birds',
|
||||||
paywallLead: 'Calm Session OS PRO',
|
paywallLead: 'Calm Session OS PRO',
|
||||||
paywallBody: 'Pro는 더 빠른 ritual과 더 깊은 review로 시작과 복귀를 가볍게 만듭니다.',
|
paywallBody: 'Pro는 더 빠른 ritual과 더 깊은 review로 시작과 복귀를 가볍게 만듭니다.',
|
||||||
};
|
};
|
||||||
|
|
||||||
const goalCardClass =
|
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';
|
'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<
|
const reviewCarryCopyByHint: Record<
|
||||||
ReviewCarryHint,
|
ReviewCarryHint,
|
||||||
@@ -171,10 +138,6 @@ export const FocusDashboardWidget = () => {
|
|||||||
const [currentSession, setCurrentSession] = useState<FocusSession | null>(null);
|
const [currentSession, setCurrentSession] = useState<FocusSession | null>(null);
|
||||||
const [isCheckingSession, setIsCheckingSession] = useState(true);
|
const [isCheckingSession, setIsCheckingSession] = useState(true);
|
||||||
const [sessionLookupError, setSessionLookupError] = useState<string | null>(null);
|
const [sessionLookupError, setSessionLookupError] = useState<string | null>(null);
|
||||||
const [isTakeoverSheetOpen, setIsTakeoverSheetOpen] = useState(false);
|
|
||||||
const [isResolvingTakeover, setIsResolvingTakeover] = useState(false);
|
|
||||||
const [takeoverError, setTakeoverError] = useState<string | null>(null);
|
|
||||||
const [focusGoalAfterTakeover, setFocusGoalAfterTakeover] = useState(false);
|
|
||||||
|
|
||||||
const goalInputRef = useRef<HTMLInputElement | null>(null);
|
const goalInputRef = useRef<HTMLInputElement | null>(null);
|
||||||
const selectedAtmosphere = useMemo(
|
const selectedAtmosphere = useMemo(
|
||||||
@@ -192,14 +155,6 @@ export const FocusDashboardWidget = () => {
|
|||||||
return getSceneById(currentSession?.sceneId ?? selectedAtmosphere.sceneId) ?? SCENE_THEMES[0];
|
return getSceneById(currentSession?.sceneId ?? selectedAtmosphere.sceneId) ?? SCENE_THEMES[0];
|
||||||
}, [currentSession?.sceneId, selectedAtmosphere.sceneId]);
|
}, [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 trimmedGoal = goalDraft.trim();
|
||||||
const canStart =
|
const canStart =
|
||||||
trimmedGoal.length > 0 &&
|
trimmedGoal.length > 0 &&
|
||||||
@@ -232,11 +187,8 @@ export const FocusDashboardWidget = () => {
|
|||||||
const durationHelper =
|
const durationHelper =
|
||||||
parsedDurationMinutes === null
|
parsedDurationMinutes === null
|
||||||
? '이 목표를 끝내는 데 걸릴 것 같은 시간을 분 단위로 적어주세요.'
|
? '이 목표를 끝내는 데 걸릴 것 같은 시간을 분 단위로 적어주세요.'
|
||||||
: parsedDurationMinutes === resolvedTimerPreset.focusMinutes
|
: entryCopy.durationHelper;
|
||||||
? `${entryCopy.durationHelper} 지금은 ${resolvedTimerPreset.label} 리듬으로 바로 들어가요.`
|
const hasCurrentSession = Boolean(currentSession);
|
||||||
: `${entryCopy.durationHelper} ${parsedDurationMinutes}분은 지금 ${resolvedTimerPreset.label} 리듬으로 먼저 들어가요.`;
|
|
||||||
const isRunningSession = currentSession?.state === 'running';
|
|
||||||
const isPausedSession = currentSession?.state === 'paused';
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
@@ -272,25 +224,10 @@ export const FocusDashboardWidget = () => {
|
|||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!isCheckingSession && isRunningSession) {
|
if (!isCheckingSession && hasCurrentSession) {
|
||||||
router.replace('/space');
|
router.replace('/space');
|
||||||
}
|
}
|
||||||
}, [isCheckingSession, isRunningSession, router]);
|
}, [hasCurrentSession, isCheckingSession, router]);
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!focusGoalAfterTakeover || currentSession) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const frameId = window.requestAnimationFrame(() => {
|
|
||||||
goalInputRef.current?.focus();
|
|
||||||
setFocusGoalAfterTakeover(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
window.cancelAnimationFrame(frameId);
|
|
||||||
};
|
|
||||||
}, [currentSession, focusGoalAfterTakeover]);
|
|
||||||
|
|
||||||
const openPaywall = () => {
|
const openPaywall = () => {
|
||||||
if (!isPro) {
|
if (!isPro) {
|
||||||
@@ -298,13 +235,6 @@ export const FocusDashboardWidget = () => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const resetEntryDrafts = () => {
|
|
||||||
setGoalDraft('');
|
|
||||||
setSelectedAtmosphereId(initialAtmosphere.id);
|
|
||||||
setDurationDraft(String(initialDurationMinutes));
|
|
||||||
setHasEditedDuration(false);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleDurationChange = (value: string) => {
|
const handleDurationChange = (value: string) => {
|
||||||
setDurationDraft(sanitizeDurationDraft(value));
|
setDurationDraft(sanitizeDurationDraft(value));
|
||||||
setHasEditedDuration(true);
|
setHasEditedDuration(true);
|
||||||
@@ -369,56 +299,8 @@ export const FocusDashboardWidget = () => {
|
|||||||
setIsStartingSession(false);
|
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 =
|
const shouldShowWeeklyReviewTeaser =
|
||||||
!isCheckingSession && !currentSession && hasEnoughWeeklyData && !isReviewReturn;
|
!isCheckingSession && !currentSession && hasEnoughWeeklyData && !isReviewReturn;
|
||||||
const shouldShowResumeReviewEntry =
|
|
||||||
!isCheckingSession && isPausedSession && hasEnoughWeeklyData;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="relative min-h-dvh overflow-hidden bg-slate-950 text-white selection:bg-white/20">
|
<div className="relative min-h-dvh overflow-hidden bg-slate-950 text-white selection:bg-white/20">
|
||||||
@@ -443,9 +325,6 @@ export const FocusDashboardWidget = () => {
|
|||||||
<div className="w-full max-w-[42rem]">
|
<div className="w-full max-w-[42rem]">
|
||||||
{isCheckingSession ? (
|
{isCheckingSession ? (
|
||||||
<div className={cn(goalCardClass, 'space-y-4 text-center')}>
|
<div className={cn(goalCardClass, 'space-y-4 text-center')}>
|
||||||
<p className="text-[11px] font-medium uppercase tracking-[0.18em] text-white/46">
|
|
||||||
{entryCopy.resumeEyebrow}
|
|
||||||
</p>
|
|
||||||
<p className="text-[15px] text-white/72">세션 상태를 불러오는 중이에요.</p>
|
<p className="text-[15px] text-white/72">세션 상태를 불러오는 중이에요.</p>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
@@ -467,82 +346,6 @@ export const FocusDashboardWidget = () => {
|
|||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
{isRunningSession ? (
|
|
||||||
<div className={cn(goalCardClass, 'space-y-4 text-center')}>
|
|
||||||
<p className="text-[11px] font-medium uppercase tracking-[0.18em] text-white/46">
|
|
||||||
{entryCopy.resumeEyebrow}
|
|
||||||
</p>
|
|
||||||
<p className="text-[15px] text-white/72">{entryCopy.resumeRouting}</p>
|
|
||||||
</div>
|
|
||||||
) : currentSession ? (
|
|
||||||
<div className={cn(goalCardClass, 'space-y-5')}>
|
|
||||||
<div className="space-y-3">
|
|
||||||
<p className="text-[11px] font-medium uppercase tracking-[0.18em] text-white/46">
|
|
||||||
{entryCopy.resumeEyebrow}
|
|
||||||
</p>
|
|
||||||
<h1 className="text-[1.8rem] font-light leading-[1.14] tracking-[-0.03em] text-white md:text-[2.2rem]">
|
|
||||||
{currentSession.goal}
|
|
||||||
</h1>
|
|
||||||
<p className="text-sm text-white/68">
|
|
||||||
{entryCopy.resumePaused}
|
|
||||||
</p>
|
|
||||||
{currentSession.microStep ? (
|
|
||||||
<div className="rounded-[1.1rem] border border-white/10 bg-white/[0.04] px-4 py-3">
|
|
||||||
<p className="mb-1 text-[11px] font-medium uppercase tracking-[0.16em] text-white/44">
|
|
||||||
{entryCopy.resumeMicroStepLabel}
|
|
||||||
</p>
|
|
||||||
<p className="text-[15px] text-white/82">{currentSession.microStep}</p>
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
|
||||||
<div className="flex flex-col gap-2.5 sm:flex-row sm:flex-wrap sm:items-center">
|
|
||||||
<button type="button" onClick={handleResumeSession} className={primaryButtonClass}>
|
|
||||||
{entryCopy.resumeCta}
|
|
||||||
</button>
|
|
||||||
<button type="button" onClick={handleResumeRefocus} className={secondaryButtonClass}>
|
|
||||||
{entryCopy.resumeRefocusCta}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<p className="text-xs text-white/48 sm:max-w-[15rem] sm:text-right">{activeRitualMeta}</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<p className="text-sm text-white/56">{entryCopy.resumePausedHint}</p>
|
|
||||||
<p className="text-sm text-white/46">{entryCopy.resumeNewGoalHint}</p>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={handleOpenTakeoverSheet}
|
|
||||||
className="inline-flex w-fit items-center text-sm font-medium text-white/62 transition hover:text-white/84"
|
|
||||||
>
|
|
||||||
{entryCopy.resumeTakeoverCta}
|
|
||||||
</button>
|
|
||||||
|
|
||||||
{shouldShowResumeReviewEntry ? (
|
|
||||||
<Link
|
|
||||||
href="/stats"
|
|
||||||
className="block rounded-[1.35rem] border border-white/10 bg-[#0f1115]/12 px-4 py-3 backdrop-blur-lg transition hover:bg-[#0f1115]/18"
|
|
||||||
>
|
|
||||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-end sm:justify-between">
|
|
||||||
<div className="min-w-0">
|
|
||||||
<p className="text-[11px] font-medium uppercase tracking-[0.16em] text-white/42">
|
|
||||||
{entryCopy.resumeReviewEyebrow}
|
|
||||||
</p>
|
|
||||||
<p className="mt-2 text-[0.96rem] font-medium tracking-[-0.02em] text-white/88">
|
|
||||||
{entryCopy.resumeReviewTitle}
|
|
||||||
</p>
|
|
||||||
<p className="mt-2 max-w-[30rem] text-[12px] leading-[1.6] text-white/60">
|
|
||||||
{isPro ? review.carryForward.keepDoing : entryCopy.resumeReviewHelper}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<span className="inline-flex shrink-0 items-center text-[12px] font-medium tracking-[0.04em] text-white/72">
|
|
||||||
{reviewTeaserCta}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</Link>
|
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<AppAtmosphereEntryShell
|
<AppAtmosphereEntryShell
|
||||||
canStart={canStart}
|
canStart={canStart}
|
||||||
durationDraft={durationDraft}
|
durationDraft={durationDraft}
|
||||||
@@ -595,7 +398,6 @@ export const FocusDashboardWidget = () => {
|
|||||||
void handleStartSession();
|
void handleStartSession();
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -624,75 +426,6 @@ export const FocusDashboardWidget = () => {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
{isTakeoverSheetOpen ? (
|
|
||||||
<div className="fixed inset-0 z-50 flex items-end justify-center p-4 sm:items-center">
|
|
||||||
<div className="absolute inset-0 bg-slate-950/52 backdrop-blur-[3px]" />
|
|
||||||
<div className="relative z-10 w-full max-w-[30rem] rounded-[2rem] border border-white/12 bg-[linear-gradient(165deg,rgba(15,17,21,0.92)_0%,rgba(7,10,14,0.98)_100%)] p-6 shadow-[0_24px_70px_rgba(2,6,23,0.38)] backdrop-blur-2xl">
|
|
||||||
<p className="text-[11px] font-medium uppercase tracking-[0.16em] text-white/42">
|
|
||||||
{entryCopy.takeoverEyebrow}
|
|
||||||
</p>
|
|
||||||
<h2 className="mt-3 text-[1.45rem] font-light leading-[1.18] tracking-[-0.03em] text-white">
|
|
||||||
{entryCopy.takeoverTitle}
|
|
||||||
</h2>
|
|
||||||
<p className="mt-3 text-[14px] leading-[1.7] text-white/62">
|
|
||||||
{entryCopy.takeoverBody}
|
|
||||||
</p>
|
|
||||||
|
|
||||||
{currentSession ? (
|
|
||||||
<div className="mt-5 rounded-[1.35rem] border border-white/10 bg-white/[0.05] px-4 py-4">
|
|
||||||
<p className="text-[11px] font-medium uppercase tracking-[0.16em] text-white/40">
|
|
||||||
{entryCopy.resumeEyebrow}
|
|
||||||
</p>
|
|
||||||
<p className="mt-2 text-[1rem] font-medium tracking-[-0.02em] text-white/88">
|
|
||||||
{currentSession.goal}
|
|
||||||
</p>
|
|
||||||
{currentSession.microStep ? (
|
|
||||||
<p className="mt-2 text-[13px] leading-[1.6] text-white/58">
|
|
||||||
{entryCopy.resumeMicroStepLabel} · {currentSession.microStep}
|
|
||||||
</p>
|
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
|
|
||||||
{takeoverError ? (
|
|
||||||
<p className="mt-4 text-sm text-amber-100/82">{takeoverError}</p>
|
|
||||||
) : null}
|
|
||||||
|
|
||||||
<div className="mt-6 flex flex-col gap-2.5">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={handleResumeSession}
|
|
||||||
disabled={isResolvingTakeover}
|
|
||||||
className={primaryButtonClass}
|
|
||||||
>
|
|
||||||
{entryCopy.takeoverKeepCta}
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => {
|
|
||||||
void handleConfirmTakeover();
|
|
||||||
}}
|
|
||||||
disabled={isResolvingTakeover}
|
|
||||||
className={cn(
|
|
||||||
secondaryButtonClass,
|
|
||||||
'border-white/14 bg-white/[0.05] text-white/86 hover:bg-white/[0.1]',
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{isResolvingTakeover ? entryCopy.takeoverLoading : entryCopy.takeoverConfirmCta}
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={handleCloseTakeoverSheet}
|
|
||||||
disabled={isResolvingTakeover}
|
|
||||||
className="inline-flex items-center justify-center rounded-full px-4 py-2.5 text-sm font-medium text-white/54 transition hover:text-white/78 disabled:opacity-45"
|
|
||||||
>
|
|
||||||
{entryCopy.takeoverCancelCta}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -39,7 +39,6 @@ import { FocusTopToast } from "./FocusTopToast";
|
|||||||
export const SpaceWorkspaceWidget = () => {
|
export const SpaceWorkspaceWidget = () => {
|
||||||
const searchParams = useSearchParams();
|
const searchParams = useSearchParams();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const resumeIntent = searchParams.get("resume");
|
|
||||||
const sceneQuery = searchParams.get("scene") ?? searchParams.get("room");
|
const sceneQuery = searchParams.get("scene") ?? searchParams.get("room");
|
||||||
const goalQuery = searchParams.get("goal")?.trim() ?? "";
|
const goalQuery = searchParams.get("goal")?.trim() ?? "";
|
||||||
const focusPlanItemIdQuery = searchParams.get("planItemId");
|
const focusPlanItemIdQuery = searchParams.get("planItemId");
|
||||||
@@ -104,7 +103,6 @@ export const SpaceWorkspaceWidget = () => {
|
|||||||
const [pendingSessionEntryPoint, setPendingSessionEntryPoint] =
|
const [pendingSessionEntryPoint, setPendingSessionEntryPoint] =
|
||||||
useState<SessionEntryPoint>("space-setup");
|
useState<SessionEntryPoint>("space-setup");
|
||||||
const [showReviewTeaserAfterComplete, setShowReviewTeaserAfterComplete] = useState(false);
|
const [showReviewTeaserAfterComplete, setShowReviewTeaserAfterComplete] = useState(false);
|
||||||
const [hasConsumedEntryOverlayIntent, setHasConsumedEntryOverlayIntent] = useState(false);
|
|
||||||
|
|
||||||
const {
|
const {
|
||||||
selectedPresetId,
|
selectedPresetId,
|
||||||
@@ -225,16 +223,7 @@ export const SpaceWorkspaceWidget = () => {
|
|||||||
workspaceMode === "setup" &&
|
workspaceMode === "setup" &&
|
||||||
showReviewTeaserAfterComplete &&
|
showReviewTeaserAfterComplete &&
|
||||||
hasEnoughWeeklyData;
|
hasEnoughWeeklyData;
|
||||||
const allowsPausedReentry =
|
|
||||||
resumeIntent === "continue" || resumeIntent === "refocus";
|
|
||||||
const didResolveEntryRouteRef = useRef(false);
|
const didResolveEntryRouteRef = useRef(false);
|
||||||
const didHandleResumeIntentRef = useRef(false);
|
|
||||||
const entryOverlayIntent =
|
|
||||||
!hasConsumedEntryOverlayIntent &&
|
|
||||||
resumeIntent === "refocus" &&
|
|
||||||
currentSession?.state === "paused"
|
|
||||||
? "resume-refocus"
|
|
||||||
: null;
|
|
||||||
const secondaryReviewTeaser = shouldShowSecondaryReviewTeaser
|
const secondaryReviewTeaser = shouldShowSecondaryReviewTeaser
|
||||||
? {
|
? {
|
||||||
title: isPro
|
title: isPro
|
||||||
@@ -265,33 +254,7 @@ export const SpaceWorkspaceWidget = () => {
|
|||||||
if (!currentSession) {
|
if (!currentSession) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
}, [currentSession, isBootstrapping, router]);
|
||||||
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]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const preferMobile =
|
const preferMobile =
|
||||||
@@ -390,12 +353,7 @@ export const SpaceWorkspaceWidget = () => {
|
|||||||
canStartSession={controls.canStartSession}
|
canStartSession={controls.canStartSession}
|
||||||
canPauseSession={controls.canPauseSession}
|
canPauseSession={controls.canPauseSession}
|
||||||
canRestartSession={controls.canRestartSession}
|
canRestartSession={controls.canRestartSession}
|
||||||
entryOverlayIntent={entryOverlayIntent}
|
|
||||||
returnPromptMode={awayReturnRecovery.returnPromptMode}
|
returnPromptMode={awayReturnRecovery.returnPromptMode}
|
||||||
onEntryOverlayIntentHandled={() => {
|
|
||||||
setHasConsumedEntryOverlayIntent(true);
|
|
||||||
router.replace("/space");
|
|
||||||
}}
|
|
||||||
onStartRequested={() => {
|
onStartRequested={() => {
|
||||||
void handleStartRequested();
|
void handleStartRequested();
|
||||||
}}
|
}}
|
||||||
|
|||||||
Reference in New Issue
Block a user