feat(flow): focus session api v2 웹 계약 전환

This commit is contained in:
2026-03-16 17:30:52 +09:00
parent f4910238a0
commit 38abc1e0c7
30 changed files with 390 additions and 702 deletions

View File

@@ -12,7 +12,7 @@ Last Updated: 2026-03-16
- 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를 추가했다
- atmosphere는 `scene + sound`가 함께 묶인 선택 단위로 동작하며, 선택한 atmosphere가 `/app` 배경과 `/space` start payload에 같이 반영된다 - atmosphere는 `scene + sound`가 함께 묶인 선택 단위로 동작하며, 선택한 atmosphere가 `/app` 배경과 `/space` start payload에 같이 반영된다
- custom duration server contract 전까지는 입력한 분 값을 가장 가까운 기본 리듬(`25/5`, `50/10`, `90/20`)으로 매핑한 - 입력한 duration은 raw `focusDurationMinutes`로 server에 전달된
- weekly review entry는 main CTA를 먹지 않도록 no-session shell의 quiet secondary dock 위치로 이동했다 - weekly review entry는 main CTA를 먹지 않도록 no-session shell의 quiet secondary dock 위치로 이동했다
- `/app` Atmosphere Entry Shell visual premium polish: - `/app` Atmosphere Entry Shell visual premium polish:
- no-session shell을 `decision rail + selected atmosphere stage + curated atmosphere library` 구조로 다시 짰다 - no-session shell을 `decision rail + selected atmosphere stage + curated atmosphere library` 구조로 다시 짰다
@@ -142,17 +142,13 @@ Last Updated: 2026-03-16
- current session이 없고 최근 7일 데이터가 충분할 때 `/app`의 quiet secondary review dock에서 `Weekly Review` entry를 노출한다 - current session이 없고 최근 7일 데이터가 충분할 때 `/app`의 quiet secondary review dock에서 `Weekly Review` entry를 노출한다
- current session이 있으면 `/app` 자체가 `/space`로 이동하므로, `/app` review entry는 no-session entry shell 안에서만 다룬다 - 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=...&entryAtmosphereId=forest-draft&entryDurationMinutes=50`으로 연결된다
- `/app`은 이 query를 받아 entry stage 위의 review-aware return hint를 노출한다 - `/app`은 이 query를 받아 entry stage 위의 review-aware return hint를 노출한다
- goal과 duration은 자동 입력하지 않고, 방향만 가볍게 제안한다 - goal과 duration은 자동 입력하지 않고, 방향만 가볍게 제안한다
- Pro personalized handoff 3차 연결: - Pro personalized handoff 3차 연결:
- Pro에서는 `/stats` carry-forward 섹션에 추천 ritual을 함께 보여준다 - Pro에서는 `/stats` carry-forward 섹션에 추천 ritual을 함께 보여준다
- `/stats` 마지막 CTA 카피가 generic start가 아니라 `가장 잘 맞은 ritual로 /app 돌아가기`로 바뀐다 - `/stats` 마지막 CTA 카피가 generic start가 아니라 `가장 잘 맞은 atmosphere로 /app 돌아가기`로 바뀐다
- `/app` teaser와 review return hint도 Pro에서 더 구체적인 next-session handoff 톤으로 표시된다 - `/app` teaser와 review return hint도 Pro에서 더 구체적인 next-session handoff 톤으로 표시된다
- `/space` secondary review teaser 4차 연결:
- goal complete로 setup 상태로 돌아왔을 때만 setup drawer 아래에 low-emphasis review teaser가 보인다
- teaser는 `주간 review 보기``/stats`를 열고, 방금 끝낸 흐름 반영을 과장하지 않는 카피만 사용한다
- 다시 시작하거나 dismiss하면 사라지며, live execution 중에는 보이지 않는다
- `Weekly Review` recovery의 서버 연결: - `Weekly Review` recovery의 서버 연결:
- server `focus-summary` 응답에 `recovery`가 추가됐다 - server `focus-summary` 응답에 `recovery`가 추가됐다
- `pause_count / resume_count` 기반 `pause 뒤 복귀`를 실제 수치로 보여준다 - `pause_count / resume_count` 기반 `pause 뒤 복귀`를 실제 수치로 보여준다
@@ -184,7 +180,6 @@ Last Updated: 2026-03-16
- HUD 내부 status line을 제거하고 상단 중앙 고정 토스트로 통합 - HUD 내부 status line을 제거하고 상단 중앙 고정 토스트로 통합
- Notes 저장/Undo, Goal 전환, 잠금 안내 피드백이 동일 위치에서 노출 - Notes 저장/Undo, Goal 전환, 잠금 안내 피드백이 동일 위치에서 노출
- Free 코어 루프 개방: - Free 코어 루프 개방:
- Quick Controls Time의 `90/20` 잠금을 제거
- 기본 Sound 잠금 제거로 Free에서도 기본 3~6 프리셋 선택 가능 - 기본 Sound 잠금 제거로 Free에서도 기본 3~6 프리셋 선택 가능
- Pro 가치 재배치: - Pro 가치 재배치:
- Pro 잠금 대상을 `Daily Focus Plan / Rituals / Weekly Review`로 재정의 - Pro 잠금 대상을 `Daily Focus Plan / Rituals / Weekly Review`로 재정의
@@ -210,11 +205,11 @@ Last Updated: 2026-03-16
- 모드 설명 1줄(기본: 모든 컨트롤 표시, 몰입: 필수만 남기고 숨김) 추가 - 모드 설명 1줄(기본: 모든 컨트롤 표시, 몰입: 필수만 남기고 숨김) 추가
- 모드 상태를 workspace -> tools-dock -> focus-hud 경로로 연결해 HUD 톤 반영 유지 - 모드 상태를 workspace -> tools-dock -> focus-hud 경로로 연결해 HUD 톤 반영 유지
- `/space` Scene 기반 자동 추천 적용: - `/space` Scene 기반 자동 추천 적용:
- `RoomTheme``recommendedSoundPresetId`, `recommendedTimerPresetId` 필드 추가 - `RoomTheme``recommendedSoundPresetId` 필드 추가
- 첫 진입/시작 시 Scene 추천 타이머/사운드가 자동 반영되도록 초기화 로직 정리 - 첫 진입/시작 시 Scene 추천 사운드와 atmosphere 기반 duration이 자동 반영되도록 초기화 로직 정리
- Scene 변경 시 `override.sound/timer``false`인 항목만 자동 동기화 - Scene 변경 시 `override.sound/duration``false`인 항목만 자동 동기화
- `/space` 사용자 override 존중 규칙 도입: - `/space` 사용자 override 존중 규칙 도입:
- `override.sound`, `override.timer` UI 상태 추가 - `override.sound`, `override.duration` UI 상태 추가
- 사용자가 직접 고른 항목은 이후 Scene 변경에도 자동 덮어쓰기되지 않도록 반영 - 사용자가 직접 고른 항목은 이후 Scene 변경에도 자동 덮어쓰기되지 않도록 반영
- `추천으로 되돌리기(더미)` 액션으로 override 초기화 + 추천값 즉시 복원 지원 - `추천으로 되돌리기(더미)` 액션으로 override 초기화 + 추천값 즉시 복원 지원
- `Control Center`를 Scene/Time 중심으로 단순화: - `Control Center`를 Scene/Time 중심으로 단순화:
@@ -224,8 +219,8 @@ Last Updated: 2026-03-16
- 우하단 Sound Quick 경로를 override 적용의 명시적 경로로 분리: - 우하단 Sound Quick 경로를 override 적용의 명시적 경로로 분리:
- `onQuickSoundSelect` 콜백으로 연결해 `override.sound` 규칙을 코드 레벨에서 고정 - `onQuickSoundSelect` 콜백으로 연결해 `override.sound` 규칙을 코드 레벨에서 고정
- 세션 상태 더미 저장/복원 추가: - 세션 상태 더미 저장/복원 추가:
- `sceneId`, `timerPresetId`, `soundPresetId`, `goal`, `override(sound/timer)`를 localStorage에 저장 - `sceneId`, `durationMinutes`, `soundPresetId`, `goal`, `override(sound/duration)`를 localStorage에 저장
- 복원 우선순위: 쿼리 파라미터 > 저장 상태 > Scene 추천 - 복원 우선순위: 저장 상태 > 사용자 기본 설정 > atmosphere 추천
- `/space` 진입 Resume CTA 추가: - `/space` 진입 Resume CTA 추가:
- 저장된 목표가 있고 쿼리 오버라이드가 없을 때 `지난 한 조각 이어서` 블록 1회 노출 - 저장된 목표가 있고 쿼리 오버라이드가 없을 때 `지난 한 조각 이어서` 블록 1회 노출
- `이어서 시작`: 저장 목표로 즉시 Focus 진입 - `이어서 시작`: 저장 목표로 즉시 Focus 진입

View File

@@ -10,9 +10,9 @@ Last Updated: 2026-03-15
관련 문서: 관련 문서:
- `./19_app_atmosphere_entry_spec.md` - `../../screens/app/current/19_app_atmosphere_entry_spec.md`
- `../../product/12_core_loop_execution_roadmap.md` - `../../product/12_core_loop_execution_roadmap.md`
- `../stats/14_weekly_review_reframe_spec.md` - `../../screens/stats/current/14_weekly_review_reframe_spec.md`
- `../../product_principles.md` - `../../product_principles.md`
- `../../current_context.md` - `../../current_context.md`
@@ -237,7 +237,7 @@ review의 마지막 CTA는 아래 중 하나여야 한다.
추천: 추천:
- CTA를 `/app?review=weekly&preset=...` 같은 방식으로 연결 - CTA를 `/app?review=weekly&entryAtmosphereId=...&entryDurationMinutes=...` 같은 방식으로 연결
- 최소한 query 또는 client state로 ritual/context를 넘긴다 - 최소한 query 또는 client state로 ritual/context를 넘긴다
중요: 중요:
@@ -267,7 +267,7 @@ review-aware state가 된다.
예시: 예시:
- `이번 주에 가장 잘 맞았던 흐름 · Forest · 50/10` - `이번 주에 가장 잘 맞았던 흐름 · Forest Draft · 50`
- `이번엔 시작을 더 작게 잡아보세요` - `이번엔 시작을 더 작게 잡아보세요`
### 유지할 것 ### 유지할 것
@@ -300,9 +300,9 @@ review-aware state가 된다.
### Pro ### Pro
- teaser가 더 구체적일 수 있다 - teaser가 더 구체적일 수 있다
- `Forest · 50/10에서 pause 뒤 복귀율이 가장 높았어요` - `Forest Draft · 50에서 pause 뒤 복귀율이 가장 높았어요`
- `/stats` 마지막 CTA가 더 개인화된다 - `/stats` 마지막 CTA가 더 개인화된다
- `가장 잘 맞은 ritual로 /app 돌아가기` - `가장 잘 맞은 atmosphere로 /app 돌아가기`
- `/app` 복귀 후 ritual prefill / carry-forward hint가 더 정교하다 - `/app` 복귀 후 ritual prefill / carry-forward hint가 더 정교하다
핵심: 핵심:
@@ -399,7 +399,8 @@ review-aware state가 된다.
추천 query: 추천 query:
- `review=weekly` - `review=weekly`
- `entryPreset=forest-50-10` - `entryAtmosphereId=forest-draft`
- `entryDurationMinutes=50`
- `carryHint=start-smaller` - `carryHint=start-smaller`
주의: 주의:
@@ -412,7 +413,8 @@ review-aware state가 된다.
로컬 상태: 로컬 상태:
- `reviewSource` - `reviewSource`
- `suggestedEntryPreset` - `suggestedEntryAtmosphereId`
- `suggestedEntryDurationMinutes`
- `carryForwardHint` - `carryForwardHint`
--- ---

View File

@@ -61,7 +61,7 @@ Last Updated: 2026-03-15
| ALN-001 | P1 | `/space` Goal Complete / Break semantics | `잠깐 쉬기`는 블록을 닫고 break로 넘어가는 것처럼 읽힘 | 실제로는 overlay만 닫고 reminder만 예약되어 break 의미가 깨졌음 | `src/widgets/space-focus-hud/ui/SpaceFocusHudWidget.tsx`, `src/shared/i18n/messages/space.ts` | fixed-awaiting-browser | `잠시 비우기 -> pause + reminder`가 실제 체감상도 맞는지 브라우저 확인 | | ALN-001 | P1 | `/space` Goal Complete / Break semantics | `잠깐 쉬기`는 블록을 닫고 break로 넘어가는 것처럼 읽힘 | 실제로는 overlay만 닫고 reminder만 예약되어 break 의미가 깨졌음 | `src/widgets/space-focus-hud/ui/SpaceFocusHudWidget.tsx`, `src/shared/i18n/messages/space.ts` | fixed-awaiting-browser | `잠시 비우기 -> pause + reminder`가 실제 체감상도 맞는지 브라우저 확인 |
| ALN-002 | P1 | `/app` Weekly Review primary entry | `/app`이 Weekly Review의 primary entry라고 정의됨 | current session이 있으면 review entry가 완전히 사라져 primary entry가 끊겼음 | `src/widgets/focus-dashboard/ui/FocusDashboardWidget.tsx`, `docs/flows/current/15_app_stats_entry_flow_spec.md` | fixed-awaiting-browser | resume 상태에서 review entry 발견성과 우선순위 확인 | | ALN-002 | P1 | `/app` Weekly Review primary entry | `/app`이 Weekly Review의 primary entry라고 정의됨 | current session이 있으면 review entry가 완전히 사라져 primary entry가 끊겼음 | `src/widgets/focus-dashboard/ui/FocusDashboardWidget.tsx`, `docs/flows/current/15_app_stats_entry_flow_spec.md` | fixed-awaiting-browser | resume 상태에서 review entry 발견성과 우선순위 확인 |
| ALN-003 | P2 | `/space` secondary review teaser | `방금 끝낸 흐름까지 review에 담아둘까요?`처럼 read-after-write를 약속했음 | 실제로는 generic `/stats`만 열고, 방금 끝낸 흐름을 별도 handoff하지 않았음 | `src/shared/i18n/messages/space.ts`, `src/widgets/space-workspace/ui/SpaceWorkspaceWidget.tsx` | fixed-awaiting-browser | setup drawer teaser가 과장 없이 자연스럽게 읽히는지 확인 | | ALN-003 | P2 | `/space` secondary review teaser | `방금 끝낸 흐름까지 review에 담아둘까요?`처럼 read-after-write를 약속했음 | 실제로는 generic `/stats`만 열고, 방금 끝낸 흐름을 별도 handoff하지 않았음 | `src/shared/i18n/messages/space.ts`, `src/widgets/space-workspace/ui/SpaceWorkspaceWidget.tsx` | fixed-awaiting-browser | setup drawer teaser가 과장 없이 자연스럽게 읽히는지 확인 |
| ALN-004 | P1 | Pro personalized handoff | `/stats`에서 추천 ritual로 돌아간다고 말했음 | 실제로는 `/app` 문구만 바뀌고 start behavior는 기본 ritual 그대로였음 | `src/widgets/focus-dashboard/ui/FocusDashboardWidget.tsx`, `src/shared/i18n/messages/app.ts` | fixed-awaiting-browser | `entryPreset` 실제 scene/sound/timer 시작값으로 적용되는지 검증 | | ALN-004 | P1 | Pro personalized handoff | `/stats`에서 추천 atmosphere/duration으로 돌아간다고 말했음 | 실제로는 `/app` 문구만 바뀌고 start behavior는 기본 그대로였음 | `src/widgets/focus-dashboard/ui/FocusDashboardWidget.tsx`, `src/shared/i18n/messages/app.ts` | fixed-awaiting-browser | `entryAtmosphereId + entryDurationMinutes` 실제 start 값으로 적용되는지 검증 |
| ALN-005 | P2 | `/space` intent card interaction | rail, edit, expand/collapse의 역할이 분리돼야 함 | goal 클릭과 edit 진입이 섞여 예측 가능성이 낮았음 | `src/widgets/space-focus-hud/ui/IntentCapsule.tsx`, `docs/screens/space/current/13_space_intent_card_collapsed_expanded_spec.md` | fixed-awaiting-browser | desktop/mobile에서 expand와 edit 구분이 분명한지 확인 | | ALN-005 | P2 | `/space` intent card interaction | rail, edit, expand/collapse의 역할이 분리돼야 함 | goal 클릭과 edit 진입이 섞여 예측 가능성이 낮았음 | `src/widgets/space-focus-hud/ui/IntentCapsule.tsx`, `docs/screens/space/current/13_space_intent_card_collapsed_expanded_spec.md` | fixed-awaiting-browser | desktop/mobile에서 expand와 edit 구분이 분명한지 확인 |
| ALN-006 | P2 | `/space` Goal Complete 2단계 인지 | 1단계 choice와 2단계 next 입력이 명확히 구분돼야 함 | 사용자 입장에서는 `돌아가기 / 다음 목표로 바로 시작` 화면이 top-level 분기처럼 읽히기 쉬움 | `src/widgets/space-focus-hud/ui/GoalCompleteSheet.tsx`, `src/shared/i18n/messages/space.ts` | open | choice view와 next view의 제목, 구조, motion, context label을 더 분리하는 기획 필요 | | ALN-006 | P2 | `/space` Goal Complete 2단계 인지 | 1단계 choice와 2단계 next 입력이 명확히 구분돼야 함 | 사용자 입장에서는 `돌아가기 / 다음 목표로 바로 시작` 화면이 top-level 분기처럼 읽히기 쉬움 | `src/widgets/space-focus-hud/ui/GoalCompleteSheet.tsx`, `src/shared/i18n/messages/space.ts` | open | choice view와 next view의 제목, 구조, motion, context label을 더 분리하는 기획 필요 |
| ALN-007 | P2 | Weekly Review discoverability | review는 `/app`의 primary ritual이어야 함 | 데이터 gate와 currentSession 조건에 따라 사용자에게 “아예 없는 기능”처럼 느껴질 수 있음 | `src/widgets/focus-dashboard/ui/FocusDashboardWidget.tsx`, `docs/flows/current/15_app_stats_entry_flow_spec.md` | open | low-data 상태와 resume 상태를 포함한 discoverability 정책 재정의 | | ALN-007 | P2 | Weekly Review discoverability | review는 `/app`의 primary ritual이어야 함 | 데이터 gate와 currentSession 조건에 따라 사용자에게 “아예 없는 기능”처럼 느껴질 수 있음 | `src/widgets/focus-dashboard/ui/FocusDashboardWidget.tsx`, `docs/flows/current/15_app_stats_entry_flow_spec.md` | open | low-data 상태와 resume 상태를 포함한 discoverability 정책 재정의 |

View File

@@ -8,7 +8,9 @@ Last Updated: 2026-03-16
- `Slice 1` no-session shell 구현 완료 - `Slice 1` no-session shell 구현 완료
- `Slice 1-2` visual premium polish 구현 완료 - `Slice 1-2` visual premium polish 구현 완료
- `Custom Duration Contract``Weekly Review Dock Reposition`은 다음 slice로 남아 있음 - `Custom Duration Contract` 구현 완료
- `Weekly Review Dock Reposition` 구현 완료
- 다음 slice는 `/space` current-session-only cleanup과 browser QA다
핵심 정책 변경: 핵심 정책 변경:
@@ -256,7 +258,7 @@ Last Updated: 2026-03-16
### E. Selection Rules ### E. Selection Rules
- no-session 상태에서는 atmosphere 1개가 기본 선택된 상태로 시작한다 - no-session 상태에서는 atmosphere 1개가 기본 선택된 상태로 시작한다
- review handoff로 들어온 경우에는 handoff preset과 가장 가까운 atmosphere를 preselect한다 - review handoff로 들어온 경우에는 handoff `entryAtmosphereId + entryDurationMinutes`를 preselect한다
- 사용자가 duration을 직접 수정하기 전까지는 선택된 atmosphere의 기본 duration suggestion을 보여줄 수 있다 - 사용자가 duration을 직접 수정하기 전까지는 선택된 atmosphere의 기본 duration suggestion을 보여줄 수 있다
- 사용자가 duration을 직접 수정한 뒤에는 atmosphere를 바꿔도 duration 값을 덮어쓰지 않는다 - 사용자가 duration을 직접 수정한 뒤에는 atmosphere를 바꿔도 duration 값을 덮어쓰지 않는다
@@ -397,31 +399,31 @@ Last Updated: 2026-03-16
--- ---
## 10. Slice 1 구현 원칙 ## 10. 구현 상태와 남은 작업
이번 구현 slice는 `/app`**no-session shell**을 먼저 바꾸는 단계다. 현재 `/app`의 no-session shell은 이미 구현되어 있고, focus-session API V2도 함께 연결됐다.
포함: 현재 포함된 범위:
- goal input - goal input
- duration input - duration input
- 12개 atmosphere grid - 12개 atmosphere grid
- selected atmosphere 기반 background 반영 - selected atmosphere 기반 background 반영
- quiet review dock의 기본 위치 - quiet review dock의 기본 위치
- raw `focusDurationMinutes` start payload
- review handoff의 `entryAtmosphereId + entryDurationMinutes`
제외: 이번 라운드에서 제외된 것:
- current session routing 재설계
- `/stats` IA 변경 - `/stats` IA 변경
- server custom duration contract - `/space` current-session-only dead path cleanup
### Slice 1 임시 계약 ### 현재 계약
- 사용자는 분 단위 duration을 입력한다 - 사용자는 분 단위 duration을 입력한다
- 하지만 server contract가 아직 preset 기반이면, 이번 slice에서는 **입력 시간에 가장 가까운 기존 focus preset**으로 임시 매핑한 - server`focusDurationMinutes`를 공식 start 계약으로 받는
- 이 임시 매핑은 다음 slice(`Custom Duration Contract`)에서 실제 duration 연동으로 대체한 - selected atmosphere는 `atmosphereId + sceneId + soundPresetId`로 함께 저장된
- UI에는 이 상태가 과장 없이 드러나야 한 - `/space`는 current session 응답의 `focusDurationSeconds`, `atmosphereId`, `sceneId`, `soundPresetId`를 source of truth로 읽는
- 예: `지금은 가장 가까운 기본 리듬으로 먼저 들어가요.`
--- ---
@@ -486,19 +488,10 @@ Last Updated: 2026-03-16
### 필요한 변화 ### 필요한 변화
1. `startSession`custom duration minutes를 받도록 확장 1. `startSession``focusDurationMinutes`를 공식 필드로 받는다
2. session에 `focusDurationMinutes` 저장 2. session에 `focusDurationSeconds``atmosphereId` 저장한다
3. break duration 계산 정책 정의 3. `GET /focus-sessions/current``/space` hydration에 필요한 goal / duration / atmosphere / scene / sound를 모두 반환한다
4. atmosphere 선택 단위를 위한 `sceneId + soundPresetId` 조합 저장 4. `/stats -> /app` handoff는 preset이 아니라 atmosphere / duration 힌트를 사용한다
### break duration 정책 제안
- 10~30분: 5분
- 31~60분: 10분
- 61~120분: 15분
- 121분 이상: 20분
이건 사용자가 break를 직접 고르지 않아도 자연스럽게 이어지게 하기 위한 기본 정책이다.
--- ---

View File

@@ -266,7 +266,7 @@ Weekly Review는 `/stats` 안에서 아래 5개 구역으로 재구성한다.
예시: 예시:
- `Forest · 50/10에서 가장 오래 이어졌어요.` - `Forest Draft · 50에서 가장 오래 이어졌어요.`
- `Rain 계열에서는 pause 후 복귀가 더 높았어요.` - `Rain 계열에서는 pause 후 복귀가 더 높았어요.`
중요: 중요:
@@ -400,7 +400,7 @@ Free에서 하지 않는 것:
- multi-week trend - multi-week trend
- ritual 비교표 - ritual 비교표
- scene/sound/timer 조합 비교 - atmosphere/duration 조합 비교
- archive 기반 long-term pattern - archive 기반 long-term pattern
Free 가치: Free 가치:
@@ -471,7 +471,7 @@ trigger:
teaser 예시: teaser 예시:
- `이번 주 pause 뒤 복귀가 3번 있었어요` - `이번 주 pause 뒤 복귀가 3번 있었어요`
- `Forest 50/10에서 가장 잘 이어졌어요` - `Forest Draft 50분에서 가장 잘 이어졌어요`
CTA: CTA:
@@ -482,7 +482,7 @@ CTA:
weekly review 마지막 CTA: weekly review 마지막 CTA:
- `이 조합으로 다음 세션 시작` - `이 조합으로 다음 세션 시작`
- `/app`으로 연결하되 ritual preset을 prefill - `/app`으로 연결하되 atmosphere와 duration을 prefill
즉 review는 읽고 끝나는 화면이 아니라 다음 entry에 연결돼야 한다. 즉 review는 읽고 끝나는 화면이 아니라 다음 entry에 연결돼야 한다.

View File

@@ -14,10 +14,10 @@ Last Updated: 2026-03-16
## 현재 우선순위 ## 현재 우선순위
1. `/app` Atmosphere Entry Shell 1. `/space` current-session-only cleanup
2. `Custom Duration Contract` 2. `Core Loop Alignment` browser audit
3. `Weekly Review Dock Reposition` 3. `Weekly Review` carry-forward 고도화
4. `Core Loop Alignment` browser audit 4. `Premium Ambience` polish
## 최근 세션 상태 ## 최근 세션 상태
@@ -30,7 +30,7 @@ Last Updated: 2026-03-16
- no-session 상태는 더 이상 legacy `goal + microStep + fixed ritual` 화면을 쓰지 않는다. - no-session 상태는 더 이상 legacy `goal + microStep + fixed ritual` 화면을 쓰지 않는다.
- 현재는 `goal 1개 + 예상 시간(분) + atmosphere 12개 grid + start CTA`로 들어간다. - 현재는 `goal 1개 + 예상 시간(분) + atmosphere 12개 grid + start CTA`로 들어간다.
- 선택한 atmosphere는 `/app` 배경 preview와 `/space` start payload의 `scene/sound`에 같이 반영된다. - 선택한 atmosphere는 `/app` 배경 preview와 `/space` start payload의 `scene/sound`에 같이 반영된다.
- duration은 우선 가장 가까운 기본 리듬으로 매핑하는 임시 계약을 사용한다. - duration은 raw `focusDurationMinutes`로 server에 전달한다.
- weekly review entry는 right-side quiet dock 위치로 옮겨 main CTA보다 낮은 위계를 유지한다. - weekly review entry는 right-side quiet dock 위치로 옮겨 main CTA보다 낮은 위계를 유지한다.
- `/app` Atmosphere Entry Shell visual premium polish를 반영했다. - `/app` Atmosphere Entry Shell visual premium polish를 반영했다.
- utility card 묶음 대신 `decision rail + selected atmosphere stage + curated library` 구조로 재구성했다. - utility card 묶음 대신 `decision rail + selected atmosphere stage + curated library` 구조로 재구성했다.
@@ -122,10 +122,6 @@ Last Updated: 2026-03-16
- `Weekly Review Entry Flow`의 Pro personalized handoff까지 연결됐다. - `Weekly Review Entry Flow`의 Pro personalized handoff까지 연결됐다.
- Pro에서는 `/stats` carry-forward에 추천 ritual을 함께 보여준다. - Pro에서는 `/stats` carry-forward에 추천 ritual을 함께 보여준다.
- `/stats` 마지막 CTA와 `/app` teaser / return hint가 더 구체적인 handoff 톤으로 바뀐다. - `/stats` 마지막 CTA와 `/app` teaser / return hint가 더 구체적인 handoff 톤으로 바뀐다.
- `/space` complete 이후 secondary review teaser까지 연결됐다.
- goal complete로 setup 상태로 돌아왔을 때만 setup drawer 아래에 작은 review teaser가 보인다.
- full review 강제 이동 없이 `/stats`를 여는 secondary entry로만 동작한다.
- 방금 끝낸 흐름을 반영한다고 과장하지 않는 카피로 정리했다.
- `Weekly Review` recovery의 서버 연결이 들어갔다. - `Weekly Review` recovery의 서버 연결이 들어갔다.
- server `focus-summary` 응답에 `recovery`가 추가됐다. - server `focus-summary` 응답에 `recovery`가 추가됐다.
- 현재는 `pause 뒤 복귀`만 실집계이며, `자리 비움 뒤 복귀`는 partial note로 남아 있다. - 현재는 `pause 뒤 복귀`만 실집계이며, `자리 비움 뒤 복귀`는 partial note로 남아 있다.
@@ -166,7 +162,6 @@ Last Updated: 2026-03-16
- HUD 내부 status line 제거 - HUD 내부 status line 제거
- Notes/Goal/잠금 피드백이 동일 위치 토스트로 표시 - Notes/Goal/잠금 피드백이 동일 위치 토스트로 표시
- 기본 기능 잠금을 해소했다. - 기본 기능 잠금을 해소했다.
- Time `90/20`을 Free로 개방
- 기본 Sound 잠금 제거 - 기본 Sound 잠금 제거
- Pro 잠금 구조를 Session OS 중심으로 재구성했다. - Pro 잠금 구조를 Session OS 중심으로 재구성했다.
- `Daily Focus Plan / Rituals / Weekly Review` 요약 카드 추가 - `Daily Focus Plan / Rituals / Weekly Review` 요약 카드 추가
@@ -182,17 +177,17 @@ Last Updated: 2026-03-16
- 옵션: `컨트롤 자동 숨김` - 옵션: `컨트롤 자동 숨김`
- ON 상태에서 Control Center가 8초 무입력이면 자동 닫힘 처리 - ON 상태에서 Control Center가 8초 무입력이면 자동 닫힘 처리
- `/space`에 Scene 추천 자동 적용 규칙을 도입했다. - `/space`에 Scene 추천 자동 적용 규칙을 도입했다.
- Room 데이터에 `recommendedSoundPresetId`, `recommendedTimerPresetId`를 추가했다. - Room 데이터에 `recommendedSoundPresetId`를 추가했다.
- 초기 진입/Scene 변경 시 override가 없는 항목만 추천값으로 자동 반영된다. - 초기 진입/Scene 변경 시 override가 없는 항목만 추천값으로 자동 반영된다.
- `/space`에 override 상태(`sound`, `timer`)를 추가했다. - `/space`에 override 상태(`sound`, `duration`)를 추가했다.
- 사용자가 직접 고른 사운드/타이머는 Scene 변경에도 자동 덮어쓰지 않는다. - 사용자가 직접 고른 사운드/duration은 Scene 변경에도 자동 덮어쓰지 않는다.
- `추천으로 되돌리기(더미)` 액션으로 override 초기화 + 추천값 즉시 복원이 가능하다. - `추천으로 되돌리기(더미)` 액션으로 override 초기화 + 추천값 즉시 복원이 가능하다.
- Control Center를 Scene/Time 중심으로 단순화했다. - Control Center를 Scene/Time 중심으로 단순화했다.
- Sound/Preset Packs 섹션 제거 - Sound/Preset Packs 섹션 제거
- 추천 정보 1줄 + `추천으로 되돌리기`만 유지 - 추천 정보 1줄 + `추천으로 되돌리기`만 유지
- 우하단 Sound Quick 선택 경로를 `onQuickSoundSelect`로 분리해 override.sound 규칙을 명시했다. - 우하단 Sound Quick 선택 경로를 `onQuickSoundSelect`로 분리해 override.sound 규칙을 명시했다.
- `/space` 선택 상태 로컬 저장/복원을 추가했다. - `/space` 선택 상태 로컬 저장/복원을 추가했다.
- 저장: `sceneId`, `timerPresetId`, `soundPresetId`, `goal`, `override(sound/timer)` - 저장: `sceneId`, `durationMinutes`, `soundPresetId`, `goal`, `override(sound/duration)`
- 복원 우선순위: 쿼리 파라미터 > 저장 상태 > Scene 추천 - 복원 우선순위: 쿼리 파라미터 > 저장 상태 > Scene 추천
- `/space` 진입 시 Resume CTA를 추가했다. - `/space` 진입 시 Resume CTA를 추가했다.
- 저장된 목표가 있고 쿼리 오버라이드가 없으면 `지난 한 조각 이어서`를 1회 노출 - 저장된 목표가 있고 쿼리 오버라이드가 없으면 `지난 한 조각 이어서`를 1회 노출

View File

@@ -23,7 +23,6 @@ export const SCENE_THEMES: SceneTheme[] = [
tags: [...copy.scenes[0].tags], tags: [...copy.scenes[0].tags],
recommendedSound: copy.scenes[0].recommendedSound, recommendedSound: copy.scenes[0].recommendedSound,
recommendedSoundPresetId: 'rain-focus', recommendedSoundPresetId: 'rain-focus',
recommendedTimerPresetId: '25-5',
recommendedTime: copy.scenes[0].recommendedTime, recommendedTime: copy.scenes[0].recommendedTime,
vibeLabel: copy.scenes[0].vibeLabel, vibeLabel: copy.scenes[0].vibeLabel,
hubColor: '#D6E6F7', hubColor: '#D6E6F7',
@@ -45,7 +44,6 @@ export const SCENE_THEMES: SceneTheme[] = [
tags: [...copy.scenes[1].tags], tags: [...copy.scenes[1].tags],
recommendedSound: copy.scenes[1].recommendedSound, recommendedSound: copy.scenes[1].recommendedSound,
recommendedSoundPresetId: 'cafe-work', recommendedSoundPresetId: 'cafe-work',
recommendedTimerPresetId: '25-5',
recommendedTime: copy.scenes[1].recommendedTime, recommendedTime: copy.scenes[1].recommendedTime,
vibeLabel: copy.scenes[1].vibeLabel, vibeLabel: copy.scenes[1].vibeLabel,
hubColor: '#F5DDCB', hubColor: '#F5DDCB',
@@ -67,7 +65,6 @@ export const SCENE_THEMES: SceneTheme[] = [
tags: [...copy.scenes[2].tags], tags: [...copy.scenes[2].tags],
recommendedSound: copy.scenes[2].recommendedSound, recommendedSound: copy.scenes[2].recommendedSound,
recommendedSoundPresetId: 'deep-white', recommendedSoundPresetId: 'deep-white',
recommendedTimerPresetId: '50-10',
recommendedTime: copy.scenes[2].recommendedTime, recommendedTime: copy.scenes[2].recommendedTime,
vibeLabel: copy.scenes[2].vibeLabel, vibeLabel: copy.scenes[2].vibeLabel,
hubColor: '#DCE4D1', hubColor: '#DCE4D1',
@@ -89,7 +86,6 @@ export const SCENE_THEMES: SceneTheme[] = [
tags: [...copy.scenes[3].tags], tags: [...copy.scenes[3].tags],
recommendedSound: copy.scenes[3].recommendedSound, recommendedSound: copy.scenes[3].recommendedSound,
recommendedSoundPresetId: 'ocean-calm', recommendedSoundPresetId: 'ocean-calm',
recommendedTimerPresetId: '25-5',
recommendedTime: copy.scenes[3].recommendedTime, recommendedTime: copy.scenes[3].recommendedTime,
vibeLabel: copy.scenes[3].vibeLabel, vibeLabel: copy.scenes[3].vibeLabel,
hubColor: '#CFE9EA', hubColor: '#CFE9EA',
@@ -111,7 +107,6 @@ export const SCENE_THEMES: SceneTheme[] = [
tags: [...copy.scenes[4].tags], tags: [...copy.scenes[4].tags],
recommendedSound: copy.scenes[4].recommendedSound, recommendedSound: copy.scenes[4].recommendedSound,
recommendedSoundPresetId: 'forest-birds', recommendedSoundPresetId: 'forest-birds',
recommendedTimerPresetId: '50-10',
recommendedTime: copy.scenes[4].recommendedTime, recommendedTime: copy.scenes[4].recommendedTime,
vibeLabel: copy.scenes[4].vibeLabel, vibeLabel: copy.scenes[4].vibeLabel,
hubColor: '#D1E7C9', hubColor: '#D1E7C9',
@@ -133,7 +128,6 @@ export const SCENE_THEMES: SceneTheme[] = [
tags: [...copy.scenes[5].tags], tags: [...copy.scenes[5].tags],
recommendedSound: copy.scenes[5].recommendedSound, recommendedSound: copy.scenes[5].recommendedSound,
recommendedSoundPresetId: 'fireplace', recommendedSoundPresetId: 'fireplace',
recommendedTimerPresetId: '25-5',
recommendedTime: copy.scenes[5].recommendedTime, recommendedTime: copy.scenes[5].recommendedTime,
vibeLabel: copy.scenes[5].vibeLabel, vibeLabel: copy.scenes[5].vibeLabel,
hubColor: '#F2D4C0', hubColor: '#F2D4C0',
@@ -155,7 +149,6 @@ export const SCENE_THEMES: SceneTheme[] = [
tags: [...copy.scenes[6].tags], tags: [...copy.scenes[6].tags],
recommendedSound: copy.scenes[6].recommendedSound, recommendedSound: copy.scenes[6].recommendedSound,
recommendedSoundPresetId: 'deep-white', recommendedSoundPresetId: 'deep-white',
recommendedTimerPresetId: '50-10',
recommendedTime: copy.scenes[6].recommendedTime, recommendedTime: copy.scenes[6].recommendedTime,
vibeLabel: copy.scenes[6].vibeLabel, vibeLabel: copy.scenes[6].vibeLabel,
hubColor: '#D9D3ED', hubColor: '#D9D3ED',
@@ -177,7 +170,6 @@ export const SCENE_THEMES: SceneTheme[] = [
tags: [...copy.scenes[7].tags], tags: [...copy.scenes[7].tags],
recommendedSound: copy.scenes[7].recommendedSound, recommendedSound: copy.scenes[7].recommendedSound,
recommendedSoundPresetId: 'deep-white', recommendedSoundPresetId: 'deep-white',
recommendedTimerPresetId: '50-10',
recommendedTime: copy.scenes[7].recommendedTime, recommendedTime: copy.scenes[7].recommendedTime,
vibeLabel: copy.scenes[7].vibeLabel, vibeLabel: copy.scenes[7].vibeLabel,
hubColor: '#D8E7F3', hubColor: '#D8E7F3',
@@ -199,7 +191,6 @@ export const SCENE_THEMES: SceneTheme[] = [
tags: [...copy.scenes[8].tags], tags: [...copy.scenes[8].tags],
recommendedSound: copy.scenes[8].recommendedSound, recommendedSound: copy.scenes[8].recommendedSound,
recommendedSoundPresetId: 'silent', recommendedSoundPresetId: 'silent',
recommendedTimerPresetId: '25-5',
recommendedTime: copy.scenes[8].recommendedTime, recommendedTime: copy.scenes[8].recommendedTime,
vibeLabel: copy.scenes[8].vibeLabel, vibeLabel: copy.scenes[8].vibeLabel,
hubColor: '#F6EDC7', hubColor: '#F6EDC7',
@@ -221,7 +212,6 @@ export const SCENE_THEMES: SceneTheme[] = [
tags: [...copy.scenes[9].tags], tags: [...copy.scenes[9].tags],
recommendedSound: copy.scenes[9].recommendedSound, recommendedSound: copy.scenes[9].recommendedSound,
recommendedSoundPresetId: 'deep-white', recommendedSoundPresetId: 'deep-white',
recommendedTimerPresetId: '90-20',
recommendedTime: copy.scenes[9].recommendedTime, recommendedTime: copy.scenes[9].recommendedTime,
vibeLabel: copy.scenes[9].vibeLabel, vibeLabel: copy.scenes[9].vibeLabel,
hubColor: '#D4DCF4', hubColor: '#D4DCF4',

View File

@@ -13,7 +13,6 @@ export interface SceneTheme {
tags: SceneTag[]; tags: SceneTag[];
recommendedSound: string; recommendedSound: string;
recommendedSoundPresetId: string; recommendedSoundPresetId: string;
recommendedTimerPresetId: string;
recommendedTime: string; recommendedTime: string;
vibeLabel: string; vibeLabel: string;
hubColor: string; hubColor: string;

View File

@@ -1,4 +1,3 @@
export * from './model/mockSession'; export * from './model/mockSession';
export * from './model/focusSystem';
export * from './model/types'; export * from './model/types';
export * from './model/useThoughtInbox'; export * from './model/useThoughtInbox';

View File

@@ -1,306 +0,0 @@
export interface FocusPlanItem {
id: string;
title: string;
goal: string;
blockLabel: string;
ritualLabel: string;
energyLabel: string;
successSignal: string;
sceneId: string;
soundPresetId: string;
timerLabel: string;
proOnly?: boolean;
locked?: boolean;
}
export interface SessionTemplate {
id: string;
name: string;
description: string;
goalPrompt: string;
cadenceLabel: string;
notificationTone: string;
sceneId: string;
soundPresetId: string;
timerLabel: string;
proOnly?: boolean;
locked?: boolean;
}
export interface SessionOutcome {
id: string;
title: string;
description: string;
nextSuggestion: string;
}
export interface WeeklyReviewMetric {
id: string;
label: string;
value: string;
delta: string;
locked?: boolean;
}
export interface WeeklyReviewInsight {
id: string;
label: string;
value: string;
description: string;
proOnly?: boolean;
locked?: boolean;
}
export interface WeeklyReview {
headline: string;
summary: string;
metrics: WeeklyReviewMetric[];
insights: WeeklyReviewInsight[];
}
export interface AsyncCheckIn {
id: string;
name: string;
role: string;
status: string;
message: string;
timeLabel: string;
reactionSummary: string;
proOnly?: boolean;
locked?: boolean;
}
const FOCUS_PLAN_ITEMS_BASE: FocusPlanItem[] = [
{
id: 'design-qa',
title: '디자인 QA 요청 3개 정리',
goal: '디자인 QA 요청 3개 우선순위 정리',
blockLabel: '15분 triage + 10분 결정',
ritualLabel: 'Soft Landing 25/5',
energyLabel: '낮은 진입 장벽',
successSignal: '버릴 것 1개, 오늘 처리 1개만 확정',
sceneId: 'quiet-library',
soundPresetId: 'deep-white',
timerLabel: '25/5',
},
{
id: 'proposal-intro',
title: '제안서 첫 문단 다듬기',
goal: '제안서 첫 문단 다듬기',
blockLabel: '30분 초안 + 20분 정리',
ritualLabel: 'Rain Draft 50/10',
energyLabel: '깊은 사고',
successSignal: '첫 문단을 소리 내어 읽었을 때 어색함이 없어질 것',
sceneId: 'rain-window',
soundPresetId: 'rain-focus',
timerLabel: '50/10',
proOnly: true,
},
{
id: 'ship-one-function',
title: '핵심 함수 1개 마무리',
goal: '핵심 함수 1개 마무리',
blockLabel: '40분 구현 + 10분 정리',
ritualLabel: 'Forest Ship 50/10',
energyLabel: '중간 몰입',
successSignal: '리뷰 전에 스스로 설명 가능한 상태 만들기',
sceneId: 'forest',
soundPresetId: 'forest-birds',
timerLabel: '50/10',
proOnly: true,
},
];
const SESSION_TEMPLATES_BASE: SessionTemplate[] = [
{
id: 'soft-landing',
name: 'Soft Landing',
description: '몸을 풀듯 천천히 진입하는 시작 ritual',
goalPrompt: '메일 3개 또는 작은 정리 1개',
cadenceLabel: '25/5 · 낮은 압박',
notificationTone: '조용함',
sceneId: 'sun-window',
soundPresetId: 'rain-focus',
timerLabel: '25/5',
},
{
id: 'rain-draft',
name: 'Rain Draft',
description: '문서, 글쓰기, 제안서 초안용 깊은 진입 ritual',
goalPrompt: '문단 1개 또는 초안 1개',
cadenceLabel: '50/10 · 깊은 몰입',
notificationTone: '기본',
sceneId: 'rain-window',
soundPresetId: 'rain-focus',
timerLabel: '50/10',
proOnly: true,
},
{
id: 'forest-ship',
name: 'Forest Ship',
description: '코드, QA, 실행 정리용 차분한 마감 ritual',
goalPrompt: '함수 1개 또는 리뷰 2개',
cadenceLabel: '50/10 · 차분한 추진',
notificationTone: '강함',
sceneId: 'forest',
soundPresetId: 'forest-birds',
timerLabel: '50/10',
proOnly: true,
},
];
export const SESSION_OUTCOMES: SessionOutcome[] = [
{
id: 'carry-forward',
title: '다음 한 조각으로 이어가기',
description: '세션이 끝난 뒤 바로 다음 블록을 붙여서 흐름을 지킵니다.',
nextSuggestion: '방금 한 작업의 다음 문단 1개',
},
{
id: 'promote-inbox',
title: '주차한 생각 끌어올리기',
description: 'distraction dump에 넣어둔 항목을 오늘 큐로 승격합니다.',
nextSuggestion: '인박스에서 가장 가벼운 메모 1개',
},
{
id: 'reset-with-smaller-goal',
title: '더 작게 다시 시작하기',
description: '무너진 날에는 범위를 절반으로 줄여 다시 시작합니다.',
nextSuggestion: '5분 안에 끝낼 수 있는 아주 작은 조각',
},
];
export const CALM_WEEKLY_REVIEW: WeeklyReview = {
headline: '이번 주는 짧은 진입 ritual이 시작 성공률을 끌어올렸어요.',
summary:
'길게 버티는 것보다, 짧게 시작하고 이어가는 패턴이 가장 안정적이었어요. 점심 직전에는 목표를 더 작게 쪼개는 편이 좋았습니다.',
metrics: [
{ id: 'start-success', label: '시작 성공률', value: '78%', delta: '+12%' },
{ id: 'completion-rate', label: '완료율', value: '64%', delta: '+9%' },
{ id: 'recovery-rate', label: '중단 후 복귀율', value: '71%', delta: '+6%', locked: true },
{ id: 'ritual-fit', label: 'ritual 적합도', value: 'Soft Landing', delta: '가장 안정적', locked: true },
],
insights: [
{
id: 'fragile-window',
label: '흔들리는 시간대',
value: '11:30 - 12:30',
description: '점심 직전엔 25/5와 작은 목표가 더 잘 맞았어요.',
},
{
id: 'best-window',
label: '잘 풀리는 시간대',
value: '14:00 - 16:00',
description: '오후 초반엔 50/10 깊은 몰입 블록이 유지됐어요.',
},
{
id: 'best-ritual',
label: '가장 잘 맞는 ritual',
value: 'Soft Landing',
description: '짧은 준비와 낮은 압박이 시작 지연을 줄였습니다.',
proOnly: true,
},
{
id: 'recovery-cue',
label: '복귀 신호',
value: '문장 1개만 고치기',
description: '큰 목표보다 작은 복귀 문장이 재시작을 쉽게 만들었어요.',
proOnly: true,
},
],
};
const ASYNC_CHECK_INS_BASE: AsyncCheckIn[] = [
{
id: 'buddy-mina',
name: 'Mina',
role: '브랜드 디자이너',
status: '오늘은 천천히',
message: '오후에는 QA만 정리하고 끝내려 해요.',
timeLabel: '12분 전',
reactionSummary: '👍 3 · 🫶 1',
},
{
id: 'buddy-jisoo',
name: 'Jisoo',
role: '프리랜서 라이터',
status: '25분만 달릴게요',
message: '첫 문단만 정리해두면 오늘은 충분해요.',
timeLabel: '31분 전',
reactionSummary: '👏 2 · 🔥 1',
},
{
id: 'digest-repeat-buddy',
name: '반복 파트너 digest',
role: 'PRO',
status: '이번 주 리듬 요약',
message: '같은 시간대에 서로 시작한 날이 4일이었어요.',
timeLabel: '금요일 오전',
reactionSummary: '주간 digest',
proOnly: true,
},
];
const markLocked = <T extends { proOnly?: boolean }>(items: T[], isPro: boolean) => {
return items.map((item) => ({
...item,
locked: Boolean(!isPro && item.proOnly),
}));
};
export const getFocusPlanItems = (isPro: boolean) => {
return markLocked(FOCUS_PLAN_ITEMS_BASE, isPro);
};
export const getSessionTemplates = (isPro: boolean) => {
return markLocked(SESSION_TEMPLATES_BASE, isPro);
};
export const getWeeklyReview = (isPro: boolean): WeeklyReview => {
return {
...CALM_WEEKLY_REVIEW,
metrics: CALM_WEEKLY_REVIEW.metrics.map((metric) => ({
...metric,
locked: Boolean(!isPro && metric.locked),
})),
insights: markLocked(CALM_WEEKLY_REVIEW.insights, isPro),
};
};
export const getAsyncCheckIns = (isPro: boolean) => {
return markLocked(ASYNC_CHECK_INS_BASE, isPro);
};
export const buildSessionStartHref = (params: {
goal: string;
sceneId: string;
soundPresetId: string;
timerLabel: string;
}) => {
const query = new URLSearchParams({
goal: params.goal,
scene: params.sceneId,
sound: params.soundPresetId,
timer: params.timerLabel,
});
return `/space?${query.toString()}`;
};
export const getFocusPlanStartHref = (item: FocusPlanItem) => {
return buildSessionStartHref({
goal: item.goal,
sceneId: item.sceneId,
soundPresetId: item.soundPresetId,
timerLabel: item.timerLabel,
});
};
export const getSessionTemplateStartHref = (template: SessionTemplate) => {
return buildSessionStartHref({
goal: template.goalPrompt,
sceneId: template.sceneId,
soundPresetId: template.soundPresetId,
timerLabel: template.timerLabel,
});
};

View File

@@ -5,7 +5,6 @@ import type {
RecentThought, RecentThought,
ReactionOption, ReactionOption,
SoundPreset, SoundPreset,
TimerPreset,
} from './types'; } from './types';
import { copy } from '@/shared/i18n'; import { copy } from '@/shared/i18n';
@@ -19,8 +18,6 @@ export const REACTION_OPTIONS: ReactionOption[] = [...copy.session.reactionOptio
export const SOUND_PRESETS: SoundPreset[] = [...copy.session.soundPresets]; export const SOUND_PRESETS: SoundPreset[] = [...copy.session.soundPresets];
export const TIMER_PRESETS: TimerPreset[] = [...copy.session.timerPresets];
export const DISTRACTION_DUMP_PLACEHOLDER = [...copy.session.distractionDumpPlaceholder]; export const DISTRACTION_DUMP_PLACEHOLDER = [...copy.session.distractionDumpPlaceholder];
export const TODAY_STATS: FocusStatCard[] = [...copy.session.todayStats]; export const TODAY_STATS: FocusStatCard[] = [...copy.session.todayStats];

View File

@@ -19,13 +19,6 @@ export interface SoundPreset {
label: string; label: string;
} }
export interface TimerPreset {
id: string;
label: string;
focusMinutes?: number;
breakMinutes?: number;
}
export interface FocusStatCard { export interface FocusStatCard {
id: string; id: string;
label: string; label: string;

View File

@@ -9,7 +9,7 @@ interface RawFocusSession {
id: number | string; id: number | string;
sceneId: string; sceneId: string;
goal: string; goal: string;
timerPresetId: string; atmosphereId?: string | null;
soundPresetId: string | null; soundPresetId: string | null;
focusPlanItemId?: number | null; focusPlanItemId?: number | null;
microStep?: string | null; microStep?: string | null;
@@ -35,7 +35,7 @@ export interface FocusSession {
id: string; id: string;
sceneId: string; sceneId: string;
goal: string; goal: string;
timerPresetId: string; atmosphereId?: string | null;
soundPresetId: string | null; soundPresetId: string | null;
focusPlanItemId?: string | null; focusPlanItemId?: string | null;
microStep?: string | null; microStep?: string | null;
@@ -55,7 +55,8 @@ export interface FocusSession {
export interface StartFocusSessionRequest { export interface StartFocusSessionRequest {
sceneId: string; sceneId: string;
goal: string; goal: string;
timerPresetId: string; focusDurationMinutes: number;
atmosphereId: string;
soundPresetId?: string | null; soundPresetId?: string | null;
focusPlanItemId?: string; focusPlanItemId?: string;
microStep?: string | null; microStep?: string | null;
@@ -71,6 +72,7 @@ export interface CompleteFocusSessionRequest {
export interface UpdateCurrentFocusSessionSelectionRequest { export interface UpdateCurrentFocusSessionSelectionRequest {
sceneId?: string; sceneId?: string;
atmosphereId?: string | null;
soundPresetId?: string | null; soundPresetId?: string | null;
} }
@@ -86,8 +88,9 @@ export interface ExtendCurrentPhaseRequest {
export interface AdvanceCurrentGoalRequest { export interface AdvanceCurrentGoalRequest {
completedGoal: string; completedGoal: string;
nextGoal: string; nextGoal: string;
sceneId: string; sceneId?: string;
timerPresetId: string; focusDurationMinutes?: number;
atmosphereId?: string | null;
soundPresetId?: string | null; soundPresetId?: string | null;
focusPlanItemId?: string; focusPlanItemId?: string;
} }

View File

@@ -1,17 +1,16 @@
import { import {
DEFAULT_PRESET_OPTIONS,
NOTIFICATION_INTENSITY_OPTIONS, NOTIFICATION_INTENSITY_OPTIONS,
} from '@/shared/config/settingsOptions'; } from '@/shared/config/settingsOptions';
import { copy } from '@/shared/i18n'; import { copy } from '@/shared/i18n';
import { apiClient } from '@/shared/lib/apiClient'; import { apiClient } from '@/shared/lib/apiClient';
export type NotificationIntensity = (typeof NOTIFICATION_INTENSITY_OPTIONS)[number]; export type NotificationIntensity = (typeof NOTIFICATION_INTENSITY_OPTIONS)[number];
export type DefaultPresetId = (typeof DEFAULT_PRESET_OPTIONS)[number]['id'];
export interface UserFocusPreferences { export interface UserFocusPreferences {
reduceMotion: boolean; reduceMotion: boolean;
notificationIntensity: NotificationIntensity; notificationIntensity: NotificationIntensity;
defaultPresetId: DefaultPresetId; defaultAtmosphereId?: string | null;
defaultDurationMinutes?: number | null;
defaultSceneId: string | null; defaultSceneId: string | null;
defaultSoundPresetId: string | null; defaultSoundPresetId: string | null;
} }
@@ -21,7 +20,8 @@ export type UpdateUserFocusPreferencesRequest = Partial<UserFocusPreferences>;
export const DEFAULT_USER_FOCUS_PREFERENCES: UserFocusPreferences = { export const DEFAULT_USER_FOCUS_PREFERENCES: UserFocusPreferences = {
reduceMotion: false, reduceMotion: false,
notificationIntensity: copy.preferences.defaultNotificationIntensity, notificationIntensity: copy.preferences.defaultNotificationIntensity,
defaultPresetId: DEFAULT_PRESET_OPTIONS[0].id, defaultAtmosphereId: null,
defaultDurationMinutes: null,
defaultSceneId: null, defaultSceneId: null,
defaultSoundPresetId: null, defaultSoundPresetId: null,
}; };
@@ -30,7 +30,7 @@ export const preferencesApi = {
/** /**
* Backend Codex: * Backend Codex:
* - 로그인한 사용자의 집중 관련 개인 설정을 반환한다. * - 로그인한 사용자의 집중 관련 개인 설정을 반환한다.
* - 최소 reduceMotion, notificationIntensity, defaultPresetId를 포함한다. * - 최소 reduceMotion, notificationIntensity, defaultAtmosphereId, defaultDurationMinutes를 포함한다.
* - 아직 저장된 값이 없으면 서버 기본값을 내려주거나 null 필드 없이 기본 스키마로 응답한다. * - 아직 저장된 값이 없으면 서버 기본값을 내려주거나 null 필드 없이 기본 스키마로 응답한다.
*/ */
getFocusPreferences: async (): Promise<UserFocusPreferences> => { getFocusPreferences: async (): Promise<UserFocusPreferences> => {

View File

@@ -86,8 +86,11 @@ export interface WeeklyReviewViewModel {
completionQuality: WeeklyReviewSection; completionQuality: WeeklyReviewSection;
carryForward: { carryForward: {
hintKey: ReviewCarryHint; hintKey: ReviewCarryHint;
presetId: string; atmosphereId: string;
presetLabel: string; atmosphereLabel: string;
sceneId: string;
soundPresetId: string | null;
durationMinutes: number;
keepDoing: string; keepDoing: string;
tryNext: string; tryNext: string;
ctaLabel: string; ctaLabel: string;
@@ -256,13 +259,17 @@ const buildCarryForward = (summary: FocusStatsSummary): WeeklyReviewViewModel['c
const params = new URLSearchParams({ const params = new URLSearchParams({
review: 'weekly', review: 'weekly',
carryHint: hintKey, carryHint: hintKey,
entryPreset: 'forest-50-10', entryAtmosphereId: 'forest-draft',
entryDurationMinutes: '50',
}); });
return { return {
hintKey, hintKey,
presetId: 'forest-50-10', atmosphereId: 'forest-draft',
presetLabel: 'Forest · Forest Birds', atmosphereLabel: 'Forest Draft',
sceneId: 'forest',
soundPresetId: 'forest-birds',
durationMinutes: 50,
keepDoing, keepDoing,
tryNext, tryNext,
ctaLabel: copy.stats.reviewCarryCta, ctaLabel: copy.stats.reviewCarryCta,

View File

@@ -1,5 +1,3 @@
import { copy } from '@/shared/i18n'; import { copy } from '@/shared/i18n';
export const NOTIFICATION_INTENSITY_OPTIONS = copy.settings.notificationIntensityOptions; export const NOTIFICATION_INTENSITY_OPTIONS = copy.settings.notificationIntensityOptions;
export const DEFAULT_PRESET_OPTIONS = copy.settings.defaultPresetOptions;

View File

@@ -9,14 +9,11 @@ export const app = {
reduceMotionDescription: '전환 애니메이션을 최소화합니다. (UI 토글 목업)', reduceMotionDescription: '전환 애니메이션을 최소화합니다. (UI 토글 목업)',
notificationIntensityTitle: '알림 강도', notificationIntensityTitle: '알림 강도',
notificationIntensityDescription: '집중 시작/종료 신호의 존재감을 선택합니다.', notificationIntensityDescription: '집중 시작/종료 신호의 존재감을 선택합니다.',
defaultPresetTitle: '기본 프리셋', defaultDurationTitle: '기본 집중 시간',
defaultPresetDescription: '입장 시 자동 선택될 추천 세트를 고릅니다.', defaultDurationDescription: '새 세션에 기본으로 채울 시간을 정합니다.',
defaultAtmosphereTitle: '기본 Atmosphere',
defaultAtmosphereDescription: '새 세션에 기본으로 제안할 분위기를 정합니다.',
notificationIntensityOptions: ['조용함', '기본', '강함'], notificationIntensityOptions: ['조용함', '기본', '강함'],
defaultPresetOptions: [
{ id: 'balanced', label: 'Balanced 25/5 + Rain Focus' },
{ id: 'deep-work', label: 'Deep Work 50/10 + Deep White' },
{ id: 'gentle', label: 'Gentle 25/5 + Silent' },
],
}, },
stats: { stats: {
title: 'Weekly Review', title: 'Weekly Review',
@@ -79,10 +76,10 @@ export const app = {
reviewCarryTryClosure: '시작은 있었지만 마무리가 약했어요. 다음 주에는 완료 직전에 다른 블록으로 넘어가지 않는 흐름을 한 번 만들어 보세요.', reviewCarryTryClosure: '시작은 있었지만 마무리가 약했어요. 다음 주에는 완료 직전에 다른 블록으로 넘어가지 않는 흐름을 한 번 만들어 보세요.',
reviewCarryTryStart: '시작 횟수가 적었어요. 다음 주에는 길이를 늘리기보다 첫 세션을 한 번 더 여는 것에 집중해 보세요.', reviewCarryTryStart: '시작 횟수가 적었어요. 다음 주에는 길이를 늘리기보다 첫 세션을 한 번 더 여는 것에 집중해 보세요.',
reviewCarryCta: '이 흐름으로 다음 세션 시작', reviewCarryCta: '이 흐름으로 다음 세션 시작',
reviewCarryCtaPro: '추천 ritual과 함께 /app 돌아가기', reviewCarryCtaPro: '추천 atmosphere와 함께 /app 돌아가기',
reviewCarryKeepTitle: '다음 주에 유지할 것', reviewCarryKeepTitle: '다음 주에 유지할 것',
reviewCarryTryTitle: '다음 주에 바꿔볼 것', reviewCarryTryTitle: '다음 주에 바꿔볼 것',
reviewCarryPresetLabel: '추천 ritual', reviewCarryPresetLabel: '추천 atmosphere',
today: '오늘', today: '오늘',
last7Days: '최근 7일', last7Days: '최근 7일',
chartTitle: '집중 흐름 그래프', chartTitle: '집중 흐름 그래프',
@@ -155,12 +152,6 @@ export const app = {
{ id: 'fireplace', label: 'Fireplace' }, { id: 'fireplace', label: 'Fireplace' },
{ id: 'silent', label: 'Silent' }, { id: 'silent', label: 'Silent' },
], ],
timerPresets: [
{ id: '25-5', label: '25/5', focusMinutes: 25, breakMinutes: 5 },
{ id: '50-10', label: '50/10', focusMinutes: 50, breakMinutes: 10 },
{ id: '90-20', label: '90/20', focusMinutes: 90, breakMinutes: 20 },
{ id: 'custom', label: '커스텀' },
],
distractionDumpPlaceholder: ['디자인 QA 요청 확인', '세금계산서 발행 메모', '오후 미팅 질문 1개 정리'], distractionDumpPlaceholder: ['디자인 QA 요청 확인', '세금계산서 발행 메모', '오후 미팅 질문 1개 정리'],
todayStats: [ todayStats: [
{ id: 'today-focus', label: '오늘 집중 시간', value: '2h 40m', delta: '+35m' }, { id: 'today-focus', label: '오늘 집중 시간', value: '2h 40m', delta: '+35m' },

View File

@@ -8,14 +8,11 @@ export const settings = {
reduceMotionDescription: '전환 애니메이션을 최소화합니다. (UI 토글 목업)', reduceMotionDescription: '전환 애니메이션을 최소화합니다. (UI 토글 목업)',
notificationIntensityTitle: '알림 강도', notificationIntensityTitle: '알림 강도',
notificationIntensityDescription: '집중 시작/종료 신호의 존재감을 선택합니다.', notificationIntensityDescription: '집중 시작/종료 신호의 존재감을 선택합니다.',
defaultPresetTitle: '기본 프리셋', defaultDurationTitle: '기본 집중 시간',
defaultPresetDescription: '입장 시 자동 선택될 추천 세트를 고릅니다.', defaultDurationDescription: '새 세션에 기본으로 채울 시간을 정합니다.',
defaultAtmosphereTitle: '기본 Atmosphere',
defaultAtmosphereDescription: '새 세션에 기본으로 제안할 분위기를 정합니다.',
notificationIntensityOptions: ['조용함', '기본', '강함'], notificationIntensityOptions: ['조용함', '기본', '강함'],
defaultPresetOptions: [
{ id: 'balanced', label: 'Balanced 25/5 + Rain Focus' },
{ id: 'deep-work', label: 'Deep Work 50/10 + Deep White' },
{ id: 'gentle', label: 'Gentle 25/5 + Silent' },
],
} as const; } as const;
export const stats = { export const stats = {
@@ -114,7 +111,7 @@ export const plan = {
{ {
id: 'rituals', id: 'rituals',
name: 'Rituals', name: 'Rituals',
description: 'scene + sound + timer 조합을 반복 가능한 시작 방식으로 저장합니다.', description: 'atmosphere와 duration 조합을 반복 가능한 시작 방식으로 저장합니다.',
}, },
{ {
id: 'weekly-review', id: 'weekly-review',

View File

@@ -30,12 +30,6 @@ export const session = {
{ id: 'fireplace', label: 'Fireplace' }, { id: 'fireplace', label: 'Fireplace' },
{ id: 'silent', label: 'Silent' }, { id: 'silent', label: 'Silent' },
], ],
timerPresets: [
{ id: '25-5', label: '25/5', focusMinutes: 25, breakMinutes: 5 },
{ id: '50-10', label: '50/10', focusMinutes: 50, breakMinutes: 10 },
{ id: '90-20', label: '90/20', focusMinutes: 90, breakMinutes: 20 },
{ id: 'custom', label: '커스텀' },
],
distractionDumpPlaceholder: ['디자인 QA 요청 확인', '세금계산서 발행 메모', '오후 미팅 질문 1개 정리'], distractionDumpPlaceholder: ['디자인 QA 요청 확인', '세금계산서 발행 메모', '오후 미팅 질문 1개 정리'],
todayStats: [ todayStats: [
{ id: 'today-focus', label: '오늘 집중 시간', value: '2h 40m', delta: '+35m' }, { id: 'today-focus', label: '오늘 집중 시간', value: '2h 40m', delta: '+35m' },

View File

@@ -1,12 +1,6 @@
import { getSceneById, type SceneTheme } from '@/entities/scene'; import { getSceneById, type SceneTheme } from '@/entities/scene';
import { SOUND_PRESETS } from '@/entities/session'; import { SOUND_PRESETS } from '@/entities/session';
const TIMER_PRESETS = [
{ id: '25-5', label: '25/5', focusMinutes: 25 },
{ id: '50-10', label: '50/10', focusMinutes: 50 },
{ id: '90-20', label: '90/20', focusMinutes: 90 },
] as const;
const DURATION_SUGGESTIONS = [25, 45, 70, 90] as const; const DURATION_SUGGESTIONS = [25, 45, 70, 90] as const;
export interface AtmosphereOption { export interface AtmosphereOption {
@@ -14,6 +8,7 @@ export interface AtmosphereOption {
name: string; name: string;
sceneId: string; sceneId: string;
soundPresetId: string | null; soundPresetId: string | null;
recommendedDurationMinutes: number;
description: string; description: string;
caption: string; caption: string;
scene: SceneTheme; scene: SceneTheme;
@@ -25,6 +20,7 @@ const createAtmosphereOption = (
name: string, name: string,
sceneId: string, sceneId: string,
soundPresetId: string | null, soundPresetId: string | null,
recommendedDurationMinutes: number,
description: string, description: string,
caption: string, caption: string,
): AtmosphereOption => { ): AtmosphereOption => {
@@ -38,6 +34,7 @@ const createAtmosphereOption = (
name, name,
sceneId, sceneId,
soundPresetId, soundPresetId,
recommendedDurationMinutes,
description, description,
caption, caption,
scene, scene,
@@ -52,6 +49,7 @@ export const ATMOSPHERE_OPTIONS: AtmosphereOption[] = [
'Rain Window', 'Rain Window',
'rain-window', 'rain-window',
'rain-focus', 'rain-focus',
45,
'비 소리 위로 조용히 문장을 붙잡기 좋은 흐름.', '비 소리 위로 조용히 문장을 붙잡기 좋은 흐름.',
'조용한 시작', '조용한 시작',
), ),
@@ -60,6 +58,7 @@ export const ATMOSPHERE_OPTIONS: AtmosphereOption[] = [
'Quiet Library', 'Quiet Library',
'quiet-library', 'quiet-library',
'deep-white', 'deep-white',
70,
'소음 없이 길게 읽고 정리할 때 안정적인 조합.', '소음 없이 길게 읽고 정리할 때 안정적인 조합.',
'길게 읽는 날', '길게 읽는 날',
), ),
@@ -68,6 +67,7 @@ export const ATMOSPHERE_OPTIONS: AtmosphereOption[] = [
'Dawn Cafe', 'Dawn Cafe',
'dawn-cafe', 'dawn-cafe',
'cafe-work', 'cafe-work',
25,
'가볍게 손을 움직이며 초안을 시작하기 좋은 온도.', '가볍게 손을 움직이며 초안을 시작하기 좋은 온도.',
'워밍업용', '워밍업용',
), ),
@@ -76,6 +76,7 @@ export const ATMOSPHERE_OPTIONS: AtmosphereOption[] = [
'Forest Draft', 'Forest Draft',
'forest', 'forest',
'forest-birds', 'forest-birds',
50,
'딥워크 진입 전에 숨을 고르게 만드는 기본 조합.', '딥워크 진입 전에 숨을 고르게 만드는 기본 조합.',
'기본 리듬', '기본 리듬',
), ),
@@ -84,6 +85,7 @@ export const ATMOSPHERE_OPTIONS: AtmosphereOption[] = [
'Fireplace Glow', 'Fireplace Glow',
'fireplace', 'fireplace',
'fireplace', 'fireplace',
70,
'밤에 닫히지 않는 일 하나를 끝까지 가져가고 싶을 때.', '밤에 닫히지 않는 일 하나를 끝까지 가져가고 싶을 때.',
'늦은 시간용', '늦은 시간용',
), ),
@@ -92,6 +94,7 @@ export const ATMOSPHERE_OPTIONS: AtmosphereOption[] = [
'Deep Night Desk', 'Deep Night Desk',
'city-night', 'city-night',
'deep-white', 'deep-white',
90,
'도시의 불빛은 멀리 두고 화면 안의 일만 남기는 조합.', '도시의 불빛은 멀리 두고 화면 안의 일만 남기는 조합.',
'몰입 유지', '몰입 유지',
), ),
@@ -100,6 +103,7 @@ export const ATMOSPHERE_OPTIONS: AtmosphereOption[] = [
'Snow Light', 'Snow Light',
'snow-mountain', 'snow-mountain',
'deep-white', 'deep-white',
45,
'머리를 식히면서 구조를 정리해야 할 때 선명한 공기.', '머리를 식히면서 구조를 정리해야 할 때 선명한 공기.',
'정리용', '정리용',
), ),
@@ -108,6 +112,7 @@ export const ATMOSPHERE_OPTIONS: AtmosphereOption[] = [
'Sun Window', 'Sun Window',
'sun-window', 'sun-window',
'silent', 'silent',
25,
'과하게 자극적이지 않게 아침 에너지를 가져오는 장면.', '과하게 자극적이지 않게 아침 에너지를 가져오는 장면.',
'낮 시간용', '낮 시간용',
), ),
@@ -116,6 +121,7 @@ export const ATMOSPHERE_OPTIONS: AtmosphereOption[] = [
'Ocean Still', 'Ocean Still',
'wave-sound', 'wave-sound',
'ocean-calm', 'ocean-calm',
70,
'넓은 생각이 필요한 기획이나 리서치에 어울리는 흐름.', '넓은 생각이 필요한 기획이나 리서치에 어울리는 흐름.',
'넓게 생각하기', '넓게 생각하기',
), ),
@@ -124,6 +130,7 @@ export const ATMOSPHERE_OPTIONS: AtmosphereOption[] = [
'Orbit Night', 'Orbit Night',
'outer-space', 'outer-space',
'deep-white', 'deep-white',
90,
'길고 깊은 블록에 들어갈 때 외부 자극을 멀리 밀어낸다.', '길고 깊은 블록에 들어갈 때 외부 자극을 멀리 밀어낸다.',
'장시간 집중', '장시간 집중',
), ),
@@ -132,6 +139,7 @@ export const ATMOSPHERE_OPTIONS: AtmosphereOption[] = [
'Rain Notes', 'Rain Notes',
'rain-window', 'rain-window',
'deep-white', 'deep-white',
50,
'빗소리 대신 더 조용한 백색 소음으로 문장만 남긴 버전.', '빗소리 대신 더 조용한 백색 소음으로 문장만 남긴 버전.',
'더 낮은 자극', '더 낮은 자극',
), ),
@@ -140,6 +148,7 @@ export const ATMOSPHERE_OPTIONS: AtmosphereOption[] = [
'Quiet Pages', 'Quiet Pages',
'quiet-library', 'quiet-library',
'silent', 'silent',
45,
'읽기와 쓰기 사이를 오갈 때 가장 얇은 존재감으로 머문다.', '읽기와 쓰기 사이를 오갈 때 가장 얇은 존재감으로 머문다.',
'완전 조용함', '완전 조용함',
), ),
@@ -179,29 +188,12 @@ export const sanitizeDurationDraft = (value: string) => {
return String(Math.min(180, parsed)); return String(Math.min(180, parsed));
}; };
export const getTimerPresetMetaById = (timerPresetId: string) => { export const formatDurationMinutesLabel = (minutes: number) => {
return TIMER_PRESETS.find((preset) => preset.id === timerPresetId) ?? TIMER_PRESETS[1]; return `${minutes}m`;
};
export const resolveNearestTimerPreset = (minutes: number) => {
return TIMER_PRESETS.reduce((best, candidate) => {
const bestDiff = Math.abs(best.focusMinutes - minutes);
const candidateDiff = Math.abs(candidate.focusMinutes - minutes);
if (candidateDiff < bestDiff) {
return candidate;
}
if (candidateDiff === bestDiff && candidate.focusMinutes > best.focusMinutes) {
return candidate;
}
return best;
});
}; };
export const getRecommendedDurationMinutes = (option: AtmosphereOption) => { export const getRecommendedDurationMinutes = (option: AtmosphereOption) => {
return getTimerPresetMetaById(option.scene.recommendedTimerPresetId).focusMinutes; return option.recommendedDurationMinutes;
}; };
export const getAtmosphereOptionById = (id: string) => { export const getAtmosphereOptionById = (id: string) => {

View File

@@ -7,6 +7,7 @@ import { useMediaCatalog, getSceneStageBackgroundStyle } from '@/entities/media'
import { usePlanTier } from '@/entities/plan'; import { usePlanTier } from '@/entities/plan';
import { getSceneById, SCENE_THEMES } from '@/entities/scene'; import { getSceneById, SCENE_THEMES } from '@/entities/scene';
import { SOUND_PRESETS } from '@/entities/session'; import { SOUND_PRESETS } from '@/entities/session';
import { preferencesApi } from '@/features/preferences/api/preferencesApi';
import { PaywallSheetContent } from '@/features/paywall-sheet'; import { PaywallSheetContent } from '@/features/paywall-sheet';
import { focusSessionApi, type FocusSession } from '@/features/focus-session/api/focusSessionApi'; import { focusSessionApi, type FocusSession } from '@/features/focus-session/api/focusSessionApi';
import { useFocusStats, type ReviewCarryHint } from '@/features/stats'; import { useFocusStats, type ReviewCarryHint } from '@/features/stats';
@@ -18,24 +19,13 @@ import {
findAtmosphereOptionForSelection, findAtmosphereOptionForSelection,
getAtmosphereOptionById, getAtmosphereOptionById,
getRecommendedDurationMinutes, getRecommendedDurationMinutes,
getTimerPresetMetaById,
parseDurationMinutes, parseDurationMinutes,
resolveNearestTimerPreset,
sanitizeDurationDraft, sanitizeDurationDraft,
} from '../model/atmosphereEntry'; } from '../model/atmosphereEntry';
import { AppAtmosphereEntryShell } from './AppAtmosphereEntryShell'; import { AppAtmosphereEntryShell } from './AppAtmosphereEntryShell';
const DEFAULT_SCENE_ID = getSceneById('forest')?.id ?? SCENE_THEMES[0].id; 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_SOUND_ID = SOUND_PRESETS.find((preset) => preset.id === 'forest-birds')?.id ?? SOUND_PRESETS[0].id;
const DEFAULT_TIMER_ID = '50-10';
const REVIEW_ENTRY_PRESETS = {
'forest-50-10': {
sceneId: DEFAULT_SCENE_ID,
soundPresetId: DEFAULT_SOUND_ID,
timerPresetId: DEFAULT_TIMER_ID,
label: '숲 · Forest Birds',
},
} as const;
const DEFAULT_ATMOSPHERE = const DEFAULT_ATMOSPHERE =
findAtmosphereOptionForSelection(DEFAULT_SCENE_ID, DEFAULT_SOUND_ID) ?? ATMOSPHERE_OPTIONS[0]; findAtmosphereOptionForSelection(DEFAULT_SCENE_ID, DEFAULT_SOUND_ID) ?? ATMOSPHERE_OPTIONS[0];
@@ -67,7 +57,7 @@ const entryCopy = {
reviewReturnBodySmaller: 'Instead of extending time, a smaller goal makes it easier to keep going.', reviewReturnBodySmaller: 'Instead of extending time, a smaller goal makes it easier to keep going.',
reviewReturnBodyClosure: 'Think about where to close the block first to finish strong.', reviewReturnBodyClosure: 'Think about where to close the block first to finish strong.',
reviewReturnBodyStart: 'Just aim to open one more short session to build momentum.', reviewReturnBodyStart: 'Just aim to open one more short session to build momentum.',
reviewReturnRitualLabel: 'Recommended Ritual · Forest · Forest Birds', reviewReturnRitualLabel: 'Recommended Atmosphere',
paywallLead: 'Calm Session OS PRO', paywallLead: 'Calm Session OS PRO',
paywallBody: 'Pro enables faster rituals and deeper reviews for seamless entry and return.', paywallBody: 'Pro enables faster rituals and deeper reviews for seamless entry and return.',
}; };
@@ -101,29 +91,22 @@ export const FocusDashboardWidget = () => {
const { sceneAssetMap } = useMediaCatalog(); const { sceneAssetMap } = useMediaCatalog();
const { summary: weeklySummary } = useFocusStats(); const { summary: weeklySummary } = useFocusStats();
const reviewEntryPreset = searchParams.get('entryPreset'); const reviewEntryAtmosphereId = searchParams.get('entryAtmosphereId');
const reviewEntryPresetConfig = useMemo(() => { const reviewEntryDurationMinutes = searchParams.get('entryDurationMinutes');
if (!reviewEntryPreset) {
return null;
}
return REVIEW_ENTRY_PRESETS[reviewEntryPreset as keyof typeof REVIEW_ENTRY_PRESETS] ?? null;
}, [reviewEntryPreset]);
const initialAtmosphere = useMemo(() => { const initialAtmosphere = useMemo(() => {
return ( return (
findAtmosphereOptionForSelection( (reviewEntryAtmosphereId ? ATMOSPHERE_OPTIONS.find((option) => option.id === reviewEntryAtmosphereId) : null) ??
reviewEntryPresetConfig?.sceneId ?? DEFAULT_SCENE_ID, DEFAULT_ATMOSPHERE
reviewEntryPresetConfig?.soundPresetId ?? DEFAULT_SOUND_ID,
) ?? DEFAULT_ATMOSPHERE
); );
}, [reviewEntryPresetConfig]); }, [reviewEntryAtmosphereId]);
const initialDurationMinutes = useMemo(() => { const initialDurationMinutes = useMemo(() => {
if (reviewEntryPresetConfig) { const parsed = Number(reviewEntryDurationMinutes);
return getTimerPresetMetaById(reviewEntryPresetConfig.timerPresetId).focusMinutes; if (Number.isFinite(parsed) && parsed >= 5 && parsed <= 180) {
return parsed;
} }
return getRecommendedDurationMinutes(initialAtmosphere); return getRecommendedDurationMinutes(initialAtmosphere);
}, [initialAtmosphere, reviewEntryPresetConfig]); }, [initialAtmosphere, reviewEntryDurationMinutes]);
const [goalDraft, setGoalDraft] = useState(''); const [goalDraft, setGoalDraft] = useState('');
const [durationDraft, setDurationDraft] = useState(() => String(initialDurationMinutes)); const [durationDraft, setDurationDraft] = useState(() => String(initialDurationMinutes));
@@ -150,11 +133,6 @@ export const FocusDashboardWidget = () => {
return Number.isFinite(parsed) ? parsed : null; return Number.isFinite(parsed) ? parsed : null;
}, [durationDraft]); }, [durationDraft]);
const parsedDurationMinutes = parseDurationMinutes(durationDraft); const parsedDurationMinutes = parseDurationMinutes(durationDraft);
const resolvedTimerPreset = useMemo(() => {
const targetMinutes =
parsedDurationMinutes ?? getRecommendedDurationMinutes(selectedAtmosphere);
return resolveNearestTimerPreset(targetMinutes);
}, [parsedDurationMinutes, selectedAtmosphere]);
const activeScene = useMemo(() => { const activeScene = useMemo(() => {
return getSceneById(currentSession?.sceneId ?? selectedAtmosphere.sceneId) ?? SCENE_THEMES[0]; return getSceneById(currentSession?.sceneId ?? selectedAtmosphere.sceneId) ?? SCENE_THEMES[0];
@@ -184,7 +162,9 @@ export const FocusDashboardWidget = () => {
const reviewReturnCopy = const reviewReturnCopy =
normalizedReviewCarryHint !== null ? reviewCarryCopyByHint[normalizedReviewCarryHint] : null; normalizedReviewCarryHint !== null ? reviewCarryCopyByHint[normalizedReviewCarryHint] : null;
const reviewReturnRitualLabel = const reviewReturnRitualLabel =
isPro && reviewEntryPresetConfig ? `추천 ritual · ${reviewEntryPresetConfig.label}` : null; isPro && reviewEntryAtmosphereId
? `${entryCopy.reviewReturnRitualLabel} · ${initialAtmosphere.name} · ${initialDurationMinutes}m`
: null;
const reviewTeaserTitle = isPro ? entryCopy.reviewTitlePro : entryCopy.reviewTitle; const reviewTeaserTitle = isPro ? entryCopy.reviewTitlePro : entryCopy.reviewTitle;
const durationHelper = const durationHelper =
rawDurationValue !== null && rawDurationValue < 5 rawDurationValue !== null && rawDurationValue < 5
@@ -227,6 +207,43 @@ export const FocusDashboardWidget = () => {
}; };
}, []); }, []);
useEffect(() => {
if (isReviewReturn) {
return;
}
let cancelled = false;
void preferencesApi
.getFocusPreferences()
.then((preferences) => {
if (cancelled) {
return;
}
if (preferences.defaultAtmosphereId) {
const preferredAtmosphere = ATMOSPHERE_OPTIONS.find(
(option) => option.id === preferences.defaultAtmosphereId,
);
if (preferredAtmosphere) {
setSelectedAtmosphereId(preferredAtmosphere.id);
}
}
if (!hasEditedDuration && preferences.defaultDurationMinutes) {
setDurationDraft(String(preferences.defaultDurationMinutes));
}
})
.catch(() => {
// Preference hydration should not block entry.
});
return () => {
cancelled = true;
};
}, [hasEditedDuration, isReviewReturn]);
useEffect(() => { useEffect(() => {
if (!isCheckingSession && hasCurrentSession) { if (!isCheckingSession && hasCurrentSession) {
router.replace('/space'); router.replace('/space');
@@ -276,9 +293,10 @@ export const FocusDashboardWidget = () => {
await focusSessionApi.startSession({ await focusSessionApi.startSession({
goal: trimmedGoal, goal: trimmedGoal,
microStep: null, microStep: null,
atmosphereId: selectedAtmosphere.id,
focusDurationMinutes: parsedDurationMinutes,
sceneId: selectedAtmosphere.sceneId, sceneId: selectedAtmosphere.sceneId,
soundPresetId: selectedAtmosphere.soundPresetId, soundPresetId: selectedAtmosphere.soundPresetId,
timerPresetId: resolvedTimerPreset.id,
entryPoint: 'space-setup', entryPoint: 'space-setup',
}); });
router.push('/space'); router.push('/space');

View File

@@ -2,9 +2,12 @@
import Link from 'next/link'; import Link from 'next/link';
import { import {
DEFAULT_PRESET_OPTIONS,
NOTIFICATION_INTENSITY_OPTIONS, NOTIFICATION_INTENSITY_OPTIONS,
} from '@/shared/config/settingsOptions'; } from '@/shared/config/settingsOptions';
import {
ATMOSPHERE_OPTIONS,
ENTRY_DURATION_SUGGESTIONS,
} from '@/widgets/focus-dashboard/model/atmosphereEntry';
import { copy } from '@/shared/i18n'; import { copy } from '@/shared/i18n';
import { useUserFocusPreferences } from '@/features/preferences'; import { useUserFocusPreferences } from '@/features/preferences';
import { cn } from '@/shared/lib/cn'; import { cn } from '@/shared/lib/cn';
@@ -114,24 +117,55 @@ export const SettingsPanelWidget = () => {
</section> </section>
<section className="rounded-xl border border-brand-dark/12 bg-white/78 p-4 backdrop-blur-sm"> <section className="rounded-xl border border-brand-dark/12 bg-white/78 p-4 backdrop-blur-sm">
<h2 className="text-base font-semibold text-brand-dark">{settings.defaultPresetTitle}</h2> <h2 className="text-base font-semibold text-brand-dark">{settings.defaultDurationTitle}</h2>
<p className="mt-1 text-sm text-brand-dark/64">{settings.defaultPresetDescription}</p> <p className="mt-1 text-sm text-brand-dark/64">{settings.defaultDurationDescription}</p>
<div className="mt-3 space-y-2"> <div className="mt-3 space-y-2">
{DEFAULT_PRESET_OPTIONS.map((preset) => ( <div className="flex flex-wrap gap-2">
{ENTRY_DURATION_SUGGESTIONS.map((minutes) => (
<button <button
key={preset.id} key={minutes}
type="button" type="button"
onClick={() => { onClick={() => {
void updatePreferences({ defaultPresetId: preset.id }); void updatePreferences({ defaultDurationMinutes: minutes });
}}
className={cn(
'rounded-full border px-3 py-1.5 text-xs transition-colors',
preferences.defaultDurationMinutes === minutes
? 'border-brand-primary/45 bg-brand-soft/60 text-brand-dark'
: 'border-brand-dark/18 bg-white/75 text-brand-dark/78 hover:bg-white',
)}
>
{minutes}
</button>
))}
</div>
</div>
</section>
<section className="rounded-xl border border-brand-dark/12 bg-white/78 p-4 backdrop-blur-sm">
<h2 className="text-base font-semibold text-brand-dark">{settings.defaultAtmosphereTitle}</h2>
<p className="mt-1 text-sm text-brand-dark/64">{settings.defaultAtmosphereDescription}</p>
<div className="mt-3 grid gap-2 sm:grid-cols-2">
{ATMOSPHERE_OPTIONS.slice(0, 8).map((atmosphere) => (
<button
key={atmosphere.id}
type="button"
onClick={() => {
void updatePreferences({
defaultAtmosphereId: atmosphere.id,
defaultSceneId: atmosphere.sceneId,
defaultSoundPresetId: atmosphere.soundPresetId,
});
}} }}
className={cn( className={cn(
'w-full rounded-lg border px-3 py-2 text-left text-sm transition-colors', 'w-full rounded-lg border px-3 py-2 text-left text-sm transition-colors',
preferences.defaultPresetId === preset.id preferences.defaultAtmosphereId === atmosphere.id
? 'border-brand-primary/45 bg-brand-soft/58 text-brand-dark' ? 'border-brand-primary/45 bg-brand-soft/58 text-brand-dark'
: 'border-brand-dark/16 bg-white/72 text-brand-dark/82 hover:bg-white', : 'border-brand-dark/16 bg-white/72 text-brand-dark/82 hover:bg-white',
)} )}
> >
{preset.label} <p className="font-medium">{atmosphere.name}</p>
<p className="mt-1 text-xs text-brand-dark/60">{atmosphere.caption}</p>
</button> </button>
))} ))}
</div> </div>

View File

@@ -7,7 +7,6 @@ import type { SceneTheme } from '@/entities/scene';
import type { import type {
GoalChip, GoalChip,
SoundPreset, SoundPreset,
TimerPreset,
} from '@/entities/session'; } from '@/entities/session';
import { copy } from '@/shared/i18n'; import { copy } from '@/shared/i18n';
import { SceneSelectCarousel } from '@/features/scene-select'; import { SceneSelectCarousel } from '@/features/scene-select';
@@ -22,16 +21,16 @@ interface SpaceSetupDrawerWidgetProps {
scenes: SceneTheme[]; scenes: SceneTheme[];
sceneAssetMap?: SceneAssetMap; sceneAssetMap?: SceneAssetMap;
selectedSceneId: string; selectedSceneId: string;
selectedTimerLabel: string; selectedDurationLabel: string;
selectedSoundPresetId: string; selectedSoundPresetId: string;
goalInput: string; goalInput: string;
selectedGoalId: string | null; selectedGoalId: string | null;
goalChips: GoalChip[]; goalChips: GoalChip[];
soundPresets: SoundPreset[]; soundPresets: SoundPreset[];
timerPresets: TimerPreset[]; durationOptions: readonly number[];
canStart: boolean; canStart: boolean;
onSceneSelect: (sceneId: string) => void; onSceneSelect: (sceneId: string) => void;
onTimerSelect: (timerLabel: string) => void; onDurationSelect: (durationMinutes: number) => void;
onSoundSelect: (soundPresetId: string) => void; onSoundSelect: (soundPresetId: string) => void;
onGoalChange: (value: string) => void; onGoalChange: (value: string) => void;
onGoalChipSelect: (chip: GoalChip) => void; onGoalChipSelect: (chip: GoalChip) => void;
@@ -81,16 +80,16 @@ export const SpaceSetupDrawerWidget = ({
scenes, scenes,
sceneAssetMap, sceneAssetMap,
selectedSceneId, selectedSceneId,
selectedTimerLabel, selectedDurationLabel,
selectedSoundPresetId, selectedSoundPresetId,
goalInput, goalInput,
selectedGoalId, selectedGoalId,
goalChips, goalChips,
soundPresets, soundPresets,
timerPresets, durationOptions,
canStart, canStart,
onSceneSelect, onSceneSelect,
onTimerSelect, onDurationSelect,
onSoundSelect, onSoundSelect,
onGoalChange, onGoalChange,
onGoalChipSelect, onGoalChipSelect,
@@ -208,7 +207,7 @@ export const SpaceSetupDrawerWidget = ({
/> />
<SummaryChip <SummaryChip
label={setup.timerLabel} label={setup.timerLabel}
value={selectedTimerLabel} value={selectedDurationLabel}
open={openPopover === 'timer'} open={openPopover === 'timer'}
onClick={() => togglePopover('timer')} onClick={() => togglePopover('timer')}
/> />
@@ -237,15 +236,15 @@ export const SpaceSetupDrawerWidget = ({
{openPopover === 'timer' ? ( {openPopover === 'timer' ? (
<div className="absolute left-0 top-[calc(100%+0.5rem)] z-20 rounded-2xl border border-white/14 bg-slate-950/80 p-3 shadow-[0_18px_44px_rgba(2,6,23,0.4)] backdrop-blur-xl animate-[popover-rise_220ms_ease-out] motion-reduce:animate-none"> <div className="absolute left-0 top-[calc(100%+0.5rem)] z-20 rounded-2xl border border-white/14 bg-slate-950/80 p-3 shadow-[0_18px_44px_rgba(2,6,23,0.4)] backdrop-blur-xl animate-[popover-rise_220ms_ease-out] motion-reduce:animate-none">
<div className="flex flex-wrap gap-1.5"> <div className="flex flex-wrap gap-1.5">
{timerPresets.slice(0, 3).map((preset) => { {durationOptions.map((minutes) => {
const selected = preset.label === selectedTimerLabel; const selected = `${minutes}m` === selectedDurationLabel;
return ( return (
<button <button
key={preset.id} key={minutes}
type="button" type="button"
onClick={() => { onClick={() => {
onTimerSelect(preset.label); onDurationSelect(minutes);
setOpenPopover(null); setOpenPopover(null);
}} }}
className={cn( className={cn(
@@ -255,7 +254,7 @@ export const SpaceSetupDrawerWidget = ({
: 'border-white/12 bg-white/[0.03] text-white/66 hover:bg-white/8', : 'border-white/12 bg-white/[0.03] text-white/66 hover:bg-white/8',
)} )}
> >
{preset.label} {minutes}m
</button> </button>
); );
})} })}

View File

@@ -3,12 +3,12 @@ export type SessionEntryPoint = 'space-setup' | 'goal-complete' | 'resume-restor
export type SelectionOverride = { export type SelectionOverride = {
sound: boolean; sound: boolean;
timer: boolean; duration: boolean;
}; };
export interface StoredWorkspaceSelection { export interface StoredWorkspaceSelection {
sceneId?: string; sceneId?: string;
timerPresetId?: string; durationMinutes?: number;
soundPresetId?: string; soundPresetId?: string;
goal?: string; goal?: string;
override?: Partial<SelectionOverride>; override?: Partial<SelectionOverride>;

View File

@@ -16,11 +16,16 @@ import type { FocusSession } from '@/features/focus-session';
import { preferencesApi } from '@/features/preferences/api/preferencesApi'; import { preferencesApi } from '@/features/preferences/api/preferencesApi';
import { copy } from '@/shared/i18n'; import { copy } from '@/shared/i18n';
import type { HudStatusLinePayload } from '@/shared/lib/useHudStatusLine'; import type { HudStatusLinePayload } from '@/shared/lib/useHudStatusLine';
import {
findAtmosphereOptionForSelection,
getAtmosphereOptionById,
getRecommendedDurationMinutes,
} from '@/widgets/focus-dashboard/model/atmosphereEntry';
import type { SelectionOverride } from './types'; import type { SelectionOverride } from './types';
import { import {
formatDurationMinutesLabel,
readStoredWorkspaceSelection, readStoredWorkspaceSelection,
resolveTimerLabelFromPresetId, resolveInitialDurationMinutes,
resolveTimerPresetIdFromLabel,
} from './workspaceSelection'; } from './workspaceSelection';
import { useWorkspacePersistence } from './useWorkspacePersistence'; import { useWorkspacePersistence } from './useWorkspacePersistence';
import { useWorkspaceMediaDiagnostics } from './useWorkspaceMediaDiagnostics'; import { useWorkspaceMediaDiagnostics } from './useWorkspaceMediaDiagnostics';
@@ -29,12 +34,7 @@ interface UseSpaceWorkspaceSelectionParams {
initialSceneId: string; initialSceneId: string;
initialGoal: string; initialGoal: string;
initialFocusPlanItemId: string | null; initialFocusPlanItemId: string | null;
initialTimerLabel: string; initialDurationMinutes: number;
sceneQuery: string | null;
goalQuery: string;
soundQuery: string | null;
timerQuery: string | null;
hasQueryOverrides: boolean;
currentSession: FocusSession | null; currentSession: FocusSession | null;
sceneAssetMap: SceneAssetMap; sceneAssetMap: SceneAssetMap;
selectedPresetId: string; selectedPresetId: string;
@@ -46,6 +46,7 @@ interface UseSpaceWorkspaceSelectionParams {
updateCurrentSelection: (payload: { updateCurrentSelection: (payload: {
sceneId?: string; sceneId?: string;
soundPresetId?: string | null; soundPresetId?: string | null;
atmosphereId?: string | null;
}) => Promise<FocusSession | null>; }) => Promise<FocusSession | null>;
mediaCatalogError: string | null; mediaCatalogError: string | null;
usedFallbackManifest: boolean; usedFallbackManifest: boolean;
@@ -66,12 +67,7 @@ export const useSpaceWorkspaceSelection = ({
initialSceneId, initialSceneId,
initialGoal, initialGoal,
initialFocusPlanItemId, initialFocusPlanItemId,
initialTimerLabel, initialDurationMinutes,
sceneQuery,
goalQuery,
soundQuery,
timerQuery,
hasQueryOverrides,
currentSession, currentSession,
sceneAssetMap, sceneAssetMap,
selectedPresetId, selectedPresetId,
@@ -86,7 +82,7 @@ export const useSpaceWorkspaceSelection = ({
hasResolvedManifest, hasResolvedManifest,
}: UseSpaceWorkspaceSelectionParams) => { }: UseSpaceWorkspaceSelectionParams) => {
const [selectedSceneId, setSelectedSceneId] = useState(initialSceneId); const [selectedSceneId, setSelectedSceneId] = useState(initialSceneId);
const [selectedTimerLabel, setSelectedTimerLabel] = useState(initialTimerLabel); const [selectedDurationMinutes, setSelectedDurationMinutes] = useState(initialDurationMinutes);
const [goalInput, setGoalInput] = useState(initialGoal); const [goalInput, setGoalInput] = useState(initialGoal);
const [linkedFocusPlanItemId, setLinkedFocusPlanItemId] = useState<string | null>(initialFocusPlanItemId); const [linkedFocusPlanItemId, setLinkedFocusPlanItemId] = useState<string | null>(initialFocusPlanItemId);
const [selectedGoalId, setSelectedGoalId] = useState<string | null>(null); const [selectedGoalId, setSelectedGoalId] = useState<string | null>(null);
@@ -95,7 +91,7 @@ export const useSpaceWorkspaceSelection = ({
const [hasHydratedSelection, setHasHydratedSelection] = useState(false); const [hasHydratedSelection, setHasHydratedSelection] = useState(false);
const [selectionOverride, setSelectionOverride] = useState<SelectionOverride>({ const [selectionOverride, setSelectionOverride] = useState<SelectionOverride>({
sound: false, sound: false,
timer: false, duration: false,
}); });
const didHydrateServerPreferencesRef = useRef(false); const didHydrateServerPreferencesRef = useRef(false);
@@ -110,6 +106,10 @@ export const useSpaceWorkspaceSelection = ({
}, [selectedScene]); }, [selectedScene]);
const canStart = goalInput.trim().length > 0; const canStart = goalInput.trim().length > 0;
const selectedDurationLabel = useMemo(
() => formatDurationMinutesLabel(selectedDurationMinutes),
[selectedDurationMinutes],
);
const applyRecommendedSelections = useCallback(( const applyRecommendedSelections = useCallback((
sceneId: string, sceneId: string,
@@ -121,12 +121,17 @@ export const useSpaceWorkspaceSelection = ({
return; return;
} }
if (!overrideState.timer) { if (!overrideState.duration) {
const recommendedTimerLabel = resolveTimerLabelFromPresetId(scene.recommendedTimerPresetId); const recommendedAtmosphere =
findAtmosphereOptionForSelection(sceneId, scene.recommendedSoundPresetId) ??
findAtmosphereOptionForSelection(sceneId);
if (recommendedTimerLabel) { setSelectedDurationMinutes(
setSelectedTimerLabel(recommendedTimerLabel); resolveInitialDurationMinutes(
} undefined,
recommendedAtmosphere ? getRecommendedDurationMinutes(recommendedAtmosphere) : undefined,
),
);
} }
if ( if (
@@ -140,12 +145,16 @@ export const useSpaceWorkspaceSelection = ({
const persistSpaceSelection = useCallback((selection: { const persistSpaceSelection = useCallback((selection: {
sceneId?: string; sceneId?: string;
soundPresetId?: string | null; soundPresetId?: string | null;
durationMinutes?: number;
}) => { }) => {
const preferencePayload: { const preferencePayload: {
defaultAtmosphereId?: string | null;
defaultDurationMinutes?: number | null;
defaultSceneId?: string | null; defaultSceneId?: string | null;
defaultSoundPresetId?: string | null; defaultSoundPresetId?: string | null;
} = {}; } = {};
const currentSessionPayload: { const currentSessionPayload: {
atmosphereId?: string | null;
sceneId?: string; sceneId?: string;
soundPresetId?: string | null; soundPresetId?: string | null;
} = {}; } = {};
@@ -160,6 +169,18 @@ export const useSpaceWorkspaceSelection = ({
currentSessionPayload.soundPresetId = selection.soundPresetId; currentSessionPayload.soundPresetId = selection.soundPresetId;
} }
if (selection.durationMinutes !== undefined) {
preferencePayload.defaultDurationMinutes = selection.durationMinutes;
}
const nextSceneId = selection.sceneId ?? selectedSceneId;
const nextSoundPresetId =
selection.soundPresetId === undefined ? selectedPresetId : selection.soundPresetId;
const nextAtmosphere = findAtmosphereOptionForSelection(nextSceneId, nextSoundPresetId);
preferencePayload.defaultAtmosphereId = nextAtmosphere?.id ?? null;
currentSessionPayload.atmosphereId = nextAtmosphere?.id ?? null;
void (async () => { void (async () => {
const [preferencesResult, sessionResult] = await Promise.allSettled([ const [preferencesResult, sessionResult] = await Promise.allSettled([
preferencesApi.updateFocusPreferences(preferencePayload), preferencesApi.updateFocusPreferences(preferencePayload),
@@ -181,7 +202,7 @@ export const useSpaceWorkspaceSelection = ({
}); });
} }
})(); })();
}, [currentSession, pushStatusLine, updateCurrentSelection]); }, [currentSession, pushStatusLine, selectedPresetId, selectedSceneId, updateCurrentSelection]);
const handleSelectScene = useCallback((sceneId: string) => { const handleSelectScene = useCallback((sceneId: string) => {
void (async () => { void (async () => {
@@ -215,21 +236,26 @@ export const useSpaceWorkspaceSelection = ({
unlockPlayback, unlockPlayback,
]); ]);
const handleSelectTimer = useCallback((timerLabel: string, markOverride = false) => { const handleSelectDuration = useCallback((durationMinutes: number, markOverride = false) => {
setSelectedTimerLabel(timerLabel); if (!Number.isFinite(durationMinutes) || durationMinutes < 5) {
return;
}
setSelectedDurationMinutes(durationMinutes);
persistSpaceSelection({ durationMinutes });
if (!markOverride) { if (!markOverride) {
return; return;
} }
setSelectionOverride((current) => { setSelectionOverride((current) => {
if (current.timer) { if (current.duration) {
return current; return current;
} }
return { ...current, timer: true }; return { ...current, duration: true };
}); });
}, []); }, [persistSpaceSelection]);
const handleSelectSound = useCallback((presetId: string, markOverride = false) => { const handleSelectSound = useCallback((presetId: string, markOverride = false) => {
void (async () => { void (async () => {
@@ -287,17 +313,16 @@ export const useSpaceWorkspaceSelection = ({
const storedSelection = readStoredWorkspaceSelection(); const storedSelection = readStoredWorkspaceSelection();
const restoredSelectionOverride: SelectionOverride = { const restoredSelectionOverride: SelectionOverride = {
sound: Boolean(storedSelection.override?.sound), sound: Boolean(storedSelection.override?.sound),
timer: Boolean(storedSelection.override?.timer), duration: Boolean(storedSelection.override?.duration),
}; };
const restoredSceneId = const restoredSceneId =
!sceneQuery && storedSelection.sceneId && getSceneById(storedSelection.sceneId) storedSelection.sceneId && getSceneById(storedSelection.sceneId)
? normalizeSceneId(storedSelection.sceneId) ? normalizeSceneId(storedSelection.sceneId)
: null; : null;
const restoredTimerLabel = !timerQuery const restoredDurationMinutes =
? resolveTimerLabelFromPresetId(storedSelection.timerPresetId) typeof storedSelection.durationMinutes === 'number' ? storedSelection.durationMinutes : null;
: null;
const restoredSoundPresetId = const restoredSoundPresetId =
!soundQuery && storedSelection.soundPresetId && SOUND_PRESETS.some((preset) => preset.id === storedSelection.soundPresetId) storedSelection.soundPresetId && SOUND_PRESETS.some((preset) => preset.id === storedSelection.soundPresetId)
? storedSelection.soundPresetId ? storedSelection.soundPresetId
: null; : null;
const restoredGoal = storedSelection.goal?.trim() ?? ''; const restoredGoal = storedSelection.goal?.trim() ?? '';
@@ -308,15 +333,15 @@ export const useSpaceWorkspaceSelection = ({
setSelectedSceneId(restoredSceneId); setSelectedSceneId(restoredSceneId);
} }
if (restoredTimerLabel) { if (restoredDurationMinutes) {
setSelectedTimerLabel(restoredTimerLabel); setSelectedDurationMinutes(restoredDurationMinutes);
} }
if (restoredSoundPresetId) { if (restoredSoundPresetId) {
setSelectedPresetId(restoredSoundPresetId); setSelectedPresetId(restoredSoundPresetId);
} }
if (!goalQuery && restoredGoal.length > 0 && !hasQueryOverrides) { if (restoredGoal.length > 0) {
setResumeGoal(restoredGoal); setResumeGoal(restoredGoal);
setShowResumePrompt(true); setShowResumePrompt(true);
} }
@@ -327,7 +352,7 @@ export const useSpaceWorkspaceSelection = ({
return () => { return () => {
window.cancelAnimationFrame(rafId); window.cancelAnimationFrame(rafId);
}; };
}, [goalQuery, hasQueryOverrides, sceneQuery, setSelectedPresetId, soundQuery, timerQuery]); }, [setSelectedPresetId]);
useEffect(() => { useEffect(() => {
if (!hasHydratedSelection || didHydrateServerPreferencesRef.current) { if (!hasHydratedSelection || didHydrateServerPreferencesRef.current) {
@@ -340,19 +365,27 @@ export const useSpaceWorkspaceSelection = ({
void preferencesApi void preferencesApi
.getFocusPreferences() .getFocusPreferences()
.then((preferences) => { .then((preferences) => {
if (cancelled || currentSession || hasQueryOverrides) { if (cancelled || currentSession) {
return; return;
} }
const normalizedPreferredSceneId = normalizeSceneId(preferences.defaultSceneId); const preferredAtmosphere =
preferences.defaultAtmosphereId
? getAtmosphereOptionById(preferences.defaultAtmosphereId)
: null;
const normalizedPreferredSceneId = normalizeSceneId(
preferredAtmosphere?.sceneId ?? preferences.defaultSceneId,
);
const nextSceneId = const nextSceneId =
normalizedPreferredSceneId && getSceneById(normalizedPreferredSceneId) normalizedPreferredSceneId && getSceneById(normalizedPreferredSceneId)
? normalizedPreferredSceneId ? normalizedPreferredSceneId
: null; : null;
const nextSoundPresetId = const nextSoundPresetId =
preferences.defaultSoundPresetId && (preferredAtmosphere?.soundPresetId ?? preferences.defaultSoundPresetId) &&
SOUND_PRESETS.some((preset) => preset.id === preferences.defaultSoundPresetId) SOUND_PRESETS.some(
? preferences.defaultSoundPresetId (preset) => preset.id === (preferredAtmosphere?.soundPresetId ?? preferences.defaultSoundPresetId),
)
? (preferredAtmosphere?.soundPresetId ?? preferences.defaultSoundPresetId)
: null; : null;
if (nextSceneId) { if (nextSceneId) {
@@ -363,6 +396,11 @@ export const useSpaceWorkspaceSelection = ({
setSelectedPresetId(nextSoundPresetId); setSelectedPresetId(nextSoundPresetId);
setSelectionOverride((current) => ({ ...current, sound: true })); setSelectionOverride((current) => ({ ...current, sound: true }));
} }
if (preferences.defaultDurationMinutes) {
setSelectedDurationMinutes(preferences.defaultDurationMinutes);
setSelectionOverride((current) => ({ ...current, duration: true }));
}
}) })
.catch(() => { .catch(() => {
// Focus preference load failure should not block entering the space. // Focus preference load failure should not block entering the space.
@@ -371,15 +409,13 @@ export const useSpaceWorkspaceSelection = ({
return () => { return () => {
cancelled = true; cancelled = true;
}; };
}, [currentSession, hasHydratedSelection, hasQueryOverrides, setSelectedPresetId]); }, [currentSession, hasHydratedSelection, setSelectedPresetId]);
useEffect(() => { useEffect(() => {
if (!currentSession) { if (!currentSession) {
return; return;
} }
const nextTimerLabel =
resolveTimerLabelFromPresetId(currentSession.timerPresetId) ?? selectedTimerLabel;
const nextSoundPresetId = const nextSoundPresetId =
currentSession.soundPresetId && currentSession.soundPresetId &&
SOUND_PRESETS.some((preset) => preset.id === currentSession.soundPresetId) SOUND_PRESETS.some((preset) => preset.id === currentSession.soundPresetId)
@@ -387,7 +423,7 @@ export const useSpaceWorkspaceSelection = ({
: selectedPresetId; : selectedPresetId;
const rafId = window.requestAnimationFrame(() => { const rafId = window.requestAnimationFrame(() => {
setSelectedSceneId(normalizeSceneId(currentSession.sceneId) ?? currentSession.sceneId); setSelectedSceneId(normalizeSceneId(currentSession.sceneId) ?? currentSession.sceneId);
setSelectedTimerLabel(nextTimerLabel); setSelectedDurationMinutes(Math.max(5, Math.round(currentSession.focusDurationSeconds / 60)));
setSelectedPresetId(nextSoundPresetId); setSelectedPresetId(nextSoundPresetId);
setGoalInput(currentSession.goal); setGoalInput(currentSession.goal);
setLinkedFocusPlanItemId(currentSession.focusPlanItemId ?? null); setLinkedFocusPlanItemId(currentSession.focusPlanItemId ?? null);
@@ -398,12 +434,12 @@ export const useSpaceWorkspaceSelection = ({
return () => { return () => {
window.cancelAnimationFrame(rafId); window.cancelAnimationFrame(rafId);
}; };
}, [currentSession, selectedPresetId, selectedTimerLabel, setSelectedPresetId]); }, [currentSession, selectedPresetId, setSelectedPresetId]);
useWorkspacePersistence({ useWorkspacePersistence({
hasHydratedSelection, hasHydratedSelection,
selectedScene, selectedScene,
selectedTimerPresetId: resolveTimerPresetIdFromLabel(selectedTimerLabel), selectedDurationMinutes,
selectedPresetId, selectedPresetId,
goalInput, goalInput,
showResumePrompt, showResumePrompt,
@@ -422,7 +458,8 @@ export const useSpaceWorkspaceSelection = ({
return { return {
selectedSceneId, selectedSceneId,
selectedTimerLabel, selectedDurationMinutes,
selectedDurationLabel,
goalInput, goalInput,
linkedFocusPlanItemId, linkedFocusPlanItemId,
selectedGoalId, selectedGoalId,
@@ -440,7 +477,7 @@ export const useSpaceWorkspaceSelection = ({
setShowResumePrompt, setShowResumePrompt,
setResumeGoal, setResumeGoal,
handleSelectScene, handleSelectScene,
handleSelectTimer, handleSelectDuration,
handleSelectSound, handleSelectSound,
handleGoalChipSelect, handleGoalChipSelect,
handleGoalChange, handleGoalChange,

View File

@@ -4,7 +4,7 @@ import { useCallback, useEffect, useRef } from 'react';
import type { FocusSession } from '@/features/focus-session'; import type { FocusSession } from '@/features/focus-session';
import { copy } from '@/shared/i18n'; import { copy } from '@/shared/i18n';
import type { HudStatusLinePayload } from '@/shared/lib/useHudStatusLine'; import type { HudStatusLinePayload } from '@/shared/lib/useHudStatusLine';
import { resolveTimerPresetIdFromLabel } from './workspaceSelection'; import { findAtmosphereOptionForSelection } from '@/widgets/focus-dashboard/model/atmosphereEntry';
import type { SessionEntryPoint, WorkspaceMode } from './types'; import type { SessionEntryPoint, WorkspaceMode } from './types';
interface UseSpaceWorkspaceSessionControlsParams { interface UseSpaceWorkspaceSessionControlsParams {
@@ -19,16 +19,17 @@ interface UseSpaceWorkspaceSessionControlsParams {
goalInput: string; goalInput: string;
linkedFocusPlanItemId: string | null; linkedFocusPlanItemId: string | null;
selectedSceneId: string; selectedSceneId: string;
selectedTimerLabel: string; selectedDurationMinutes: number;
selectedPresetId: string; selectedPresetId: string;
soundPlaybackError: string | null; soundPlaybackError: string | null;
pushStatusLine: (payload: HudStatusLinePayload) => void; pushStatusLine: (payload: HudStatusLinePayload) => void;
unlockPlayback: (requestedUrl?: string | null) => Promise<boolean>; unlockPlayback: (requestedUrl?: string | null) => Promise<boolean>;
resolveSoundPlaybackUrl: (presetId: string) => string | null; resolveSoundPlaybackUrl: (presetId: string) => string | null;
startSession: (input: { startSession: (input: {
atmosphereId: string;
sceneId: string; sceneId: string;
goal: string; goal: string;
timerPresetId: string; focusDurationMinutes: number;
soundPresetId: string | null; soundPresetId: string | null;
focusPlanItemId?: string; focusPlanItemId?: string;
entryPoint: SessionEntryPoint; entryPoint: SessionEntryPoint;
@@ -50,8 +51,9 @@ interface UseSpaceWorkspaceSessionControlsParams {
advanceGoal: (input: { advanceGoal: (input: {
completedGoal: string; completedGoal: string;
nextGoal: string; nextGoal: string;
sceneId: string; sceneId?: string;
timerPresetId: string; atmosphereId?: string | null;
focusDurationMinutes?: number;
soundPresetId: string; soundPresetId: string;
focusPlanItemId?: string; focusPlanItemId?: string;
}) => Promise<{ nextSession: FocusSession } | null>; }) => Promise<{ nextSession: FocusSession } | null>;
@@ -74,7 +76,7 @@ export const useSpaceWorkspaceSessionControls = ({
goalInput, goalInput,
linkedFocusPlanItemId, linkedFocusPlanItemId,
selectedSceneId, selectedSceneId,
selectedTimerLabel, selectedDurationMinutes,
selectedPresetId, selectedPresetId,
soundPlaybackError, soundPlaybackError,
pushStatusLine, pushStatusLine,
@@ -122,16 +124,17 @@ export const useSpaceWorkspaceSessionControls = ({
const startFocusFlow = useCallback(async () => { const startFocusFlow = useCallback(async () => {
const trimmedGoal = goalInput.trim(); const trimmedGoal = goalInput.trim();
const timerPresetId = resolveTimerPresetIdFromLabel(selectedTimerLabel); const selectedAtmosphere = findAtmosphereOptionForSelection(selectedSceneId, selectedPresetId);
if (!trimmedGoal || !timerPresetId) { if (!trimmedGoal) {
return; return;
} }
const startedSession = await startSession({ const startedSession = await startSession({
atmosphereId: selectedAtmosphere?.id ?? selectedSceneId,
sceneId: selectedSceneId, sceneId: selectedSceneId,
goal: trimmedGoal, goal: trimmedGoal,
timerPresetId, focusDurationMinutes: selectedDurationMinutes,
soundPresetId: selectedPresetId, soundPresetId: selectedPresetId,
focusPlanItemId: linkedFocusPlanItemId ?? undefined, focusPlanItemId: linkedFocusPlanItemId ?? undefined,
entryPoint: pendingSessionEntryPoint, entryPoint: pendingSessionEntryPoint,
@@ -150,9 +153,9 @@ export const useSpaceWorkspaceSessionControls = ({
goalInput, goalInput,
pendingSessionEntryPoint, pendingSessionEntryPoint,
pushStatusLine, pushStatusLine,
selectedDurationMinutes,
selectedPresetId, selectedPresetId,
selectedSceneId, selectedSceneId,
selectedTimerLabel,
linkedFocusPlanItemId, linkedFocusPlanItemId,
setPreviewPlaybackState, setPreviewPlaybackState,
startSession, startSession,
@@ -248,9 +251,11 @@ export const useSpaceWorkspaceSessionControls = ({
const handleGoalAdvance = useCallback(async (nextGoal: string) => { const handleGoalAdvance = useCallback(async (nextGoal: string) => {
const trimmedNextGoal = nextGoal.trim(); const trimmedNextGoal = nextGoal.trim();
const trimmedCurrentGoal = goalInput.trim(); const trimmedCurrentGoal = goalInput.trim();
const timerPresetId = resolveTimerPresetIdFromLabel(selectedTimerLabel); const selectedAtmosphere =
findAtmosphereOptionForSelection(selectedSceneId, selectedPresetId)
?? (currentSession?.atmosphereId ? { id: currentSession.atmosphereId } : null);
if (!trimmedNextGoal || !trimmedCurrentGoal || !timerPresetId || !currentSession) { if (!trimmedNextGoal || !trimmedCurrentGoal || !currentSession) {
return false; return false;
} }
@@ -259,8 +264,9 @@ export const useSpaceWorkspaceSessionControls = ({
const nextState = await advanceGoal({ const nextState = await advanceGoal({
completedGoal: trimmedCurrentGoal, completedGoal: trimmedCurrentGoal,
nextGoal: trimmedNextGoal, nextGoal: trimmedNextGoal,
atmosphereId: selectedAtmosphere?.id ?? null,
focusDurationMinutes: selectedDurationMinutes,
sceneId: selectedSceneId, sceneId: selectedSceneId,
timerPresetId,
soundPresetId: selectedPresetId, soundPresetId: selectedPresetId,
focusPlanItemId: linkedFocusPlanItemId ?? undefined, focusPlanItemId: linkedFocusPlanItemId ?? undefined,
}); });
@@ -290,9 +296,9 @@ export const useSpaceWorkspaceSessionControls = ({
linkedFocusPlanItemId, linkedFocusPlanItemId,
pushStatusLine, pushStatusLine,
resolveSoundPlaybackUrl, resolveSoundPlaybackUrl,
selectedDurationMinutes,
selectedPresetId, selectedPresetId,
selectedSceneId, selectedSceneId,
selectedTimerLabel,
setGoalInput, setGoalInput,
setLinkedFocusPlanItemId, setLinkedFocusPlanItemId,
setPendingSessionEntryPoint, setPendingSessionEntryPoint,

View File

@@ -8,7 +8,7 @@ import type { SceneTheme } from '@/entities/scene';
interface UseWorkspacePersistenceParams { interface UseWorkspacePersistenceParams {
hasHydratedSelection: boolean; hasHydratedSelection: boolean;
selectedScene: SceneTheme; selectedScene: SceneTheme;
selectedTimerPresetId: string | undefined; selectedDurationMinutes: number;
selectedPresetId: string; selectedPresetId: string;
goalInput: string; goalInput: string;
showResumePrompt: boolean; showResumePrompt: boolean;
@@ -19,7 +19,7 @@ interface UseWorkspacePersistenceParams {
export const useWorkspacePersistence = ({ export const useWorkspacePersistence = ({
hasHydratedSelection, hasHydratedSelection,
selectedScene, selectedScene,
selectedTimerPresetId, selectedDurationMinutes,
selectedPresetId, selectedPresetId,
goalInput, goalInput,
showResumePrompt, showResumePrompt,
@@ -41,7 +41,7 @@ export const useWorkspacePersistence = ({
WORKSPACE_SELECTION_STORAGE_KEY, WORKSPACE_SELECTION_STORAGE_KEY,
JSON.stringify({ JSON.stringify({
sceneId: selectedScene.id, sceneId: selectedScene.id,
timerPresetId: selectedTimerPresetId, durationMinutes: selectedDurationMinutes,
soundPresetId: selectedPresetId, soundPresetId: selectedPresetId,
goal: normalizedGoal, goal: normalizedGoal,
override: selectionOverride, override: selectionOverride,
@@ -53,7 +53,7 @@ export const useWorkspacePersistence = ({
resumeGoal, resumeGoal,
selectedPresetId, selectedPresetId,
selectedScene.id, selectedScene.id,
selectedTimerPresetId, selectedDurationMinutes,
selectionOverride, selectionOverride,
showResumePrompt, showResumePrompt,
]); ]);

View File

@@ -3,20 +3,16 @@ import {
normalizeSceneId, normalizeSceneId,
SCENE_THEMES, SCENE_THEMES,
} from '@/entities/scene'; } from '@/entities/scene';
import { import { SOUND_PRESETS } from '@/entities/session';
SOUND_PRESETS,
TIMER_PRESETS,
type TimerPreset,
} from '@/entities/session';
export type SelectionOverride = { export type SelectionOverride = {
sound: boolean; sound: boolean;
timer: boolean; duration: boolean;
}; };
export interface StoredWorkspaceSelection { export interface StoredWorkspaceSelection {
sceneId?: string; sceneId?: string;
timerPresetId?: string; durationMinutes?: number;
soundPresetId?: string; soundPresetId?: string;
goal?: string; goal?: string;
override?: Partial<SelectionOverride>; override?: Partial<SelectionOverride>;
@@ -85,56 +81,32 @@ export const resolveInitialSoundPreset = (
return SOUND_PRESETS[0].id; return SOUND_PRESETS[0].id;
}; };
export const TIMER_SELECTION_PRESETS = TIMER_PRESETS.filter( export const DURATION_SELECTION_OPTIONS = [25, 45, 70, 90] as const;
(preset): preset is TimerPreset & { focusMinutes: number; breakMinutes: number } =>
typeof preset.focusMinutes === 'number' && typeof preset.breakMinutes === 'number',
).slice(0, 3);
export const resolveTimerLabelFromPresetId = (presetId?: string) => { export const resolveInitialDurationMinutes = (
if (!presetId) { storedDurationMinutes?: number,
return null; recommendedDurationMinutes?: number,
}
const preset = TIMER_SELECTION_PRESETS.find((candidate) => candidate.id === presetId);
if (!preset) {
return null;
}
return preset.label;
};
export const resolveTimerPresetIdFromLabel = (timerLabel: string) => {
const preset = TIMER_SELECTION_PRESETS.find((candidate) => candidate.label === timerLabel);
return preset?.id;
};
export const resolveInitialTimerLabel = (
timerLabelFromQuery: string | null,
storedPresetId?: string,
recommendedPresetId?: string,
) => { ) => {
if (timerLabelFromQuery && TIMER_SELECTION_PRESETS.some((preset) => preset.label === timerLabelFromQuery)) { if (typeof storedDurationMinutes === 'number' && storedDurationMinutes >= 5 && storedDurationMinutes <= 180) {
return timerLabelFromQuery; return storedDurationMinutes;
} }
const storedLabel = resolveTimerLabelFromPresetId(storedPresetId); if (
typeof recommendedDurationMinutes === 'number' &&
if (storedLabel) { recommendedDurationMinutes >= 5 &&
return storedLabel; recommendedDurationMinutes <= 180
) {
return recommendedDurationMinutes;
} }
const recommendedLabel = resolveTimerLabelFromPresetId(recommendedPresetId); return 50;
if (recommendedLabel) {
return recommendedLabel;
}
return TIMER_SELECTION_PRESETS[0]?.label ?? '25/5';
}; };
export const resolveFocusTimeDisplayFromTimerLabel = (timerLabel: string) => { export const formatDurationMinutesLabel = (minutes: number) => {
const preset = TIMER_SELECTION_PRESETS.find((candidate) => candidate.label === timerLabel); return `${minutes}m`;
const focusMinutes = preset?.focusMinutes ?? 25; };
return `${String(focusMinutes).padStart(2, '0')}:00`;
export const resolveFocusTimeDisplayFromDurationMinutes = (durationMinutes: number) => {
const safeMinutes = Number.isFinite(durationMinutes) ? Math.max(5, Math.min(180, durationMinutes)) : 25;
return `${String(safeMinutes).padStart(2, '0')}:00`;
}; };

View File

@@ -19,31 +19,24 @@ import { useHudStatusLine } from "@/shared/lib/useHudStatusLine";
import { copy } from "@/shared/i18n"; import { copy } from "@/shared/i18n";
import { SpaceFocusHudWidget } from "@/widgets/space-focus-hud"; import { SpaceFocusHudWidget } from "@/widgets/space-focus-hud";
import { SpaceSetupDrawerWidget } from "@/widgets/space-setup-drawer"; import { SpaceSetupDrawerWidget } from "@/widgets/space-setup-drawer";
import { useRouter, useSearchParams } from "next/navigation"; import {
findAtmosphereOptionForSelection,
getRecommendedDurationMinutes,
} from "@/widgets/focus-dashboard/model/atmosphereEntry";
import { useRouter } from "next/navigation";
import { useEffect, useMemo, useRef, useState } from "react"; import { useEffect, useMemo, useRef, useState } from "react";
import type { SessionEntryPoint, WorkspaceMode } from "../model/types"; import type { SessionEntryPoint, WorkspaceMode } from "../model/types";
import { useSpaceWorkspaceSelection } from "../model/useSpaceWorkspaceSelection"; import { useSpaceWorkspaceSelection } from "../model/useSpaceWorkspaceSelection";
import { useSpaceWorkspaceSessionControls } from "../model/useSpaceWorkspaceSessionControls"; import { useSpaceWorkspaceSessionControls } from "../model/useSpaceWorkspaceSessionControls";
import { import {
resolveFocusTimeDisplayFromTimerLabel, DURATION_SELECTION_OPTIONS,
resolveInitialSceneId, resolveFocusTimeDisplayFromDurationMinutes,
resolveInitialSoundPreset, resolveInitialDurationMinutes,
resolveInitialTimerLabel,
TIMER_SELECTION_PRESETS,
} from "../model/workspaceSelection"; } from "../model/workspaceSelection";
import { FocusTopToast } from "./FocusTopToast"; import { FocusTopToast } from "./FocusTopToast";
export const SpaceWorkspaceWidget = () => { export const SpaceWorkspaceWidget = () => {
const searchParams = useSearchParams();
const router = useRouter(); const router = useRouter();
const sceneQuery = searchParams.get("scene") ?? searchParams.get("room");
const goalQuery = searchParams.get("goal")?.trim() ?? "";
const focusPlanItemIdQuery = searchParams.get("planItemId");
const soundQuery = searchParams.get("sound");
const timerQuery = searchParams.get("timer");
const hasQueryOverrides = Boolean(
sceneQuery || goalQuery || focusPlanItemIdQuery || soundQuery || timerQuery,
);
const { const {
addThought, addThought,
@@ -59,31 +52,28 @@ export const SpaceWorkspaceWidget = () => {
const { isPro } = usePlanTier(); const { isPro } = usePlanTier();
const { review, summary: weeklySummary } = useFocusStats(); const { review, summary: weeklySummary } = useFocusStats();
const initialSceneId = useMemo( const initialSceneId = useMemo(() => SCENE_THEMES[0].id, []);
() => resolveInitialSceneId(sceneQuery, undefined),
[sceneQuery],
);
const initialScene = useMemo( const initialScene = useMemo(
() => getSceneById(initialSceneId) ?? SCENE_THEMES[0], () => getSceneById(initialSceneId) ?? SCENE_THEMES[0],
[initialSceneId], [initialSceneId],
); );
const initialSoundPresetId = useMemo( const initialSoundPresetId = useMemo(
() => () => initialScene.recommendedSoundPresetId,
resolveInitialSoundPreset( [initialScene.recommendedSoundPresetId],
soundQuery,
undefined,
initialScene.recommendedSoundPresetId,
),
[initialScene.recommendedSoundPresetId, soundQuery],
); );
const initialTimerLabel = useMemo( const initialAtmosphere = useMemo(
() => () =>
resolveInitialTimerLabel( findAtmosphereOptionForSelection(initialSceneId, initialSoundPresetId) ??
timerQuery, findAtmosphereOptionForSelection(initialSceneId),
[initialSceneId, initialSoundPresetId],
);
const initialDurationMinutes = useMemo(
() =>
resolveInitialDurationMinutes(
undefined, undefined,
initialScene.recommendedTimerPresetId, initialAtmosphere ? getRecommendedDurationMinutes(initialAtmosphere) : undefined,
), ),
[initialScene.recommendedTimerPresetId, timerQuery], [initialAtmosphere],
); );
const [workspaceMode, setWorkspaceMode] = useState<WorkspaceMode>("setup"); const [workspaceMode, setWorkspaceMode] = useState<WorkspaceMode>("setup");
@@ -143,14 +133,9 @@ export const SpaceWorkspaceWidget = () => {
const selection = useSpaceWorkspaceSelection({ const selection = useSpaceWorkspaceSelection({
initialSceneId, initialSceneId,
initialGoal: goalQuery, initialGoal: "",
initialFocusPlanItemId: focusPlanItemIdQuery, initialFocusPlanItemId: null,
initialTimerLabel, initialDurationMinutes,
sceneQuery,
goalQuery,
soundQuery,
timerQuery,
hasQueryOverrides,
currentSession, currentSession,
sceneAssetMap, sceneAssetMap,
selectedPresetId, selectedPresetId,
@@ -177,7 +162,7 @@ export const SpaceWorkspaceWidget = () => {
goalInput: selection.goalInput, goalInput: selection.goalInput,
linkedFocusPlanItemId: selection.linkedFocusPlanItemId, linkedFocusPlanItemId: selection.linkedFocusPlanItemId,
selectedSceneId: selection.selectedSceneId, selectedSceneId: selection.selectedSceneId,
selectedTimerLabel: selection.selectedTimerLabel, selectedDurationMinutes: selection.selectedDurationMinutes,
selectedPresetId, selectedPresetId,
soundPlaybackError, soundPlaybackError,
pushStatusLine, pushStatusLine,
@@ -222,10 +207,10 @@ export const SpaceWorkspaceWidget = () => {
: undefined; : undefined;
useEffect(() => { useEffect(() => {
if (!isBootstrapping && !currentSession && !hasQueryOverrides) { if (!isBootstrapping && !currentSession) {
router.replace("/app"); router.replace("/app");
} }
}, [isBootstrapping, currentSession, hasQueryOverrides, router]); }, [isBootstrapping, currentSession, router]);
useEffect(() => { useEffect(() => {
if (isBootstrapping || didResolveEntryRouteRef.current) { if (isBootstrapping || didResolveEntryRouteRef.current) {
@@ -237,7 +222,7 @@ export const SpaceWorkspaceWidget = () => {
if (!currentSession) { if (!currentSession) {
return; return;
} }
}, [currentSession, isBootstrapping, router]); }, [currentSession, isBootstrapping]);
useEffect(() => { useEffect(() => {
const preferMobile = const preferMobile =
@@ -255,7 +240,7 @@ export const SpaceWorkspaceWidget = () => {
const resolvedTimeDisplay = const resolvedTimeDisplay =
timeDisplay ?? timeDisplay ??
resolveFocusTimeDisplayFromTimerLabel(selection.selectedTimerLabel); resolveFocusTimeDisplayFromDurationMinutes(selection.selectedDurationMinutes);
return ( return (
<div className="relative h-dvh overflow-hidden text-white"> <div className="relative h-dvh overflow-hidden text-white">
@@ -277,17 +262,17 @@ export const SpaceWorkspaceWidget = () => {
scenes={selection.setupScenes} scenes={selection.setupScenes}
sceneAssetMap={sceneAssetMap} sceneAssetMap={sceneAssetMap}
selectedSceneId={selection.selectedScene.id} selectedSceneId={selection.selectedScene.id}
selectedTimerLabel={selection.selectedTimerLabel} selectedDurationLabel={selection.selectedDurationLabel}
selectedSoundPresetId={selectedPresetId} selectedSoundPresetId={selectedPresetId}
goalInput={selection.goalInput} goalInput={selection.goalInput}
selectedGoalId={selection.selectedGoalId} selectedGoalId={selection.selectedGoalId}
goalChips={GOAL_CHIPS} goalChips={GOAL_CHIPS}
soundPresets={SOUND_PRESETS} soundPresets={SOUND_PRESETS}
timerPresets={TIMER_SELECTION_PRESETS} durationOptions={DURATION_SELECTION_OPTIONS}
canStart={selection.canStart} canStart={selection.canStart}
onSceneSelect={selection.handleSelectScene} onSceneSelect={selection.handleSelectScene}
onTimerSelect={(timerLabel) => onDurationSelect={(durationMinutes) =>
selection.handleSelectTimer(timerLabel, true) selection.handleSelectDuration(durationMinutes, true)
} }
onSoundSelect={(presetId) => onSoundSelect={(presetId) =>
selection.handleSelectSound(presetId, true) selection.handleSelectSound(presetId, true)

View File

@@ -11,12 +11,8 @@ import { cn } from '@/shared/lib/cn';
const DEFAULT_STATS_SCENE_ID = getSceneById('forest')?.id ?? SCENE_THEMES[0].id; const DEFAULT_STATS_SCENE_ID = getSceneById('forest')?.id ?? SCENE_THEMES[0].id;
const reviewStageSceneByPreset = (presetId: string) => { const reviewStageSceneByCarryForward = (sceneId: string) => {
if (presetId.startsWith('forest')) { return getSceneById(sceneId) ?? getSceneById(DEFAULT_STATS_SCENE_ID) ?? SCENE_THEMES[0];
return getSceneById('forest') ?? SCENE_THEMES[0];
}
return getSceneById(DEFAULT_STATS_SCENE_ID) ?? SCENE_THEMES[0];
}; };
export const StatsOverviewWidget = () => { export const StatsOverviewWidget = () => {
@@ -26,8 +22,8 @@ export const StatsOverviewWidget = () => {
const { review, isLoading, error, source, refetch } = useFocusStats(); const { review, isLoading, error, source, refetch } = useFocusStats();
const activeScene = useMemo( const activeScene = useMemo(
() => reviewStageSceneByPreset(review.carryForward.presetId), () => reviewStageSceneByCarryForward(review.carryForward.sceneId),
[review.carryForward.presetId], [review.carryForward.sceneId],
); );
const sourceLabel = source === 'api' ? stats.sourceApi : stats.sourceMock; const sourceLabel = source === 'api' ? stats.sourceApi : stats.sourceMock;
const syncLabel = error ? error : isLoading ? stats.loading : stats.synced; const syncLabel = error ? error : isLoading ? stats.loading : stats.synced;
@@ -221,9 +217,11 @@ export const StatsOverviewWidget = () => {
{isPro && <span className="text-white/30">PRO</span>} {isPro && <span className="text-white/30">PRO</span>}
</p> </p>
<p className="mt-3 text-[15px] font-medium tracking-tight text-white/90"> <p className="mt-3 text-[15px] font-medium tracking-tight text-white/90">
{review.carryForward.presetLabel} {review.carryForward.atmosphereLabel}
</p>
<p className="mt-1 text-[12px] text-white/50">
{review.carryForward.durationMinutes} · .
</p> </p>
<p className="mt-1 text-[12px] text-white/50"> .</p>
</div> </div>
</div> </div>