From 38abc1e0c720784a79881ce0e477fa32ccfad306 Mon Sep 17 00:00:00 2001 From: corpi Date: Mon, 16 Mar 2026 17:30:52 +0900 Subject: [PATCH] =?UTF-8?q?feat(flow):=20focus=20session=20api=20v2=20?= =?UTF-8?q?=EC=9B=B9=20=EA=B3=84=EC=95=BD=20=EC=A0=84=ED=99=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/90_current_state.md | 23 +- .../current/15_app_stats_entry_flow_spec.md | 18 +- docs/product/17_product_alignment_findings.md | 2 +- .../current/19_app_atmosphere_entry_spec.md | 45 ++- .../current/14_weekly_review_reframe_spec.md | 8 +- docs/session_brief.md | 23 +- src/entities/scene/model/scenes.ts | 10 - src/entities/scene/model/types.ts | 1 - src/entities/session/index.ts | 1 - src/entities/session/model/focusSystem.ts | 306 ------------------ src/entities/session/model/mockSession.ts | 3 - src/entities/session/model/types.ts | 7 - .../focus-session/api/focusSessionApi.ts | 13 +- .../preferences/api/preferencesApi.ts | 10 +- src/features/stats/model/useFocusStats.ts | 17 +- src/shared/config/settingsOptions.ts | 2 - src/shared/i18n/messages/app.ts | 21 +- src/shared/i18n/messages/product.ts | 13 +- src/shared/i18n/messages/session.ts | 6 - .../focus-dashboard/model/atmosphereEntry.ts | 44 ++- .../ui/FocusDashboardWidget.tsx | 88 +++-- .../settings-panel/ui/SettingsPanelWidget.tsx | 50 ++- .../ui/SpaceSetupDrawerWidget.tsx | 25 +- src/widgets/space-workspace/model/types.ts | 4 +- .../model/useSpaceWorkspaceSelection.ts | 137 +++++--- .../model/useSpaceWorkspaceSessionControls.ts | 34 +- .../model/useWorkspacePersistence.ts | 8 +- .../model/workspaceSelection.ts | 74 ++--- .../ui/SpaceWorkspaceWidget.tsx | 81 ++--- .../stats-overview/ui/StatsOverviewWidget.tsx | 18 +- 30 files changed, 390 insertions(+), 702 deletions(-) delete mode 100644 src/entities/session/model/focusSystem.ts diff --git a/docs/90_current_state.md b/docs/90_current_state.md index ef90b38..40bbfb2 100644 --- a/docs/90_current_state.md +++ b/docs/90_current_state.md @@ -12,7 +12,7 @@ Last Updated: 2026-03-16 - no-session `/app`을 `goal + duration + atmosphere` 중심의 premium entry shell로 교체했다 - `microStep` 입력은 entry에서 제거했고, `예상 시간(분)` 입력과 12개 dummy atmosphere grid를 추가했다 - 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 위치로 이동했다 - `/app` Atmosphere Entry Shell visual premium polish: - 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이 있으면 `/app` 자체가 `/space`로 이동하므로, `/app` review entry는 no-session entry shell 안에서만 다룬다 - `/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를 노출한다 - goal과 duration은 자동 입력하지 않고, 방향만 가볍게 제안한다 - Pro personalized handoff 3차 연결: - 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 톤으로 표시된다 -- `/space` secondary review teaser 4차 연결: - - goal complete로 setup 상태로 돌아왔을 때만 setup drawer 아래에 low-emphasis review teaser가 보인다 - - teaser는 `주간 review 보기`로 `/stats`를 열고, 방금 끝낸 흐름 반영을 과장하지 않는 카피만 사용한다 - - 다시 시작하거나 dismiss하면 사라지며, live execution 중에는 보이지 않는다 - `Weekly Review` recovery의 서버 연결: - server `focus-summary` 응답에 `recovery`가 추가됐다 - `pause_count / resume_count` 기반 `pause 뒤 복귀`를 실제 수치로 보여준다 @@ -184,7 +180,6 @@ Last Updated: 2026-03-16 - HUD 내부 status line을 제거하고 상단 중앙 고정 토스트로 통합 - Notes 저장/Undo, Goal 전환, 잠금 안내 피드백이 동일 위치에서 노출 - Free 코어 루프 개방: - - Quick Controls Time의 `90/20` 잠금을 제거 - 기본 Sound 잠금 제거로 Free에서도 기본 3~6 프리셋 선택 가능 - Pro 가치 재배치: - Pro 잠금 대상을 `Daily Focus Plan / Rituals / Weekly Review`로 재정의 @@ -210,11 +205,11 @@ Last Updated: 2026-03-16 - 모드 설명 1줄(기본: 모든 컨트롤 표시, 몰입: 필수만 남기고 숨김) 추가 - 모드 상태를 workspace -> tools-dock -> focus-hud 경로로 연결해 HUD 톤 반영 유지 - `/space` Scene 기반 자동 추천 적용: - - `RoomTheme`에 `recommendedSoundPresetId`, `recommendedTimerPresetId` 필드 추가 - - 첫 진입/시작 시 Scene 추천 타이머/사운드가 자동 반영되도록 초기화 로직 정리 - - Scene 변경 시 `override.sound/timer`가 `false`인 항목만 자동 동기화 + - `RoomTheme`에 `recommendedSoundPresetId` 필드 추가 + - 첫 진입/시작 시 Scene 추천 사운드와 atmosphere 기반 duration이 자동 반영되도록 초기화 로직 정리 + - Scene 변경 시 `override.sound/duration`가 `false`인 항목만 자동 동기화 - `/space` 사용자 override 존중 규칙 도입: - - `override.sound`, `override.timer` UI 상태 추가 + - `override.sound`, `override.duration` UI 상태 추가 - 사용자가 직접 고른 항목은 이후 Scene 변경에도 자동 덮어쓰기되지 않도록 반영 - `추천으로 되돌리기(더미)` 액션으로 override 초기화 + 추천값 즉시 복원 지원 - `Control Center`를 Scene/Time 중심으로 단순화: @@ -224,8 +219,8 @@ Last Updated: 2026-03-16 - 우하단 Sound Quick 경로를 override 적용의 명시적 경로로 분리: - `onQuickSoundSelect` 콜백으로 연결해 `override.sound` 규칙을 코드 레벨에서 고정 - 세션 상태 더미 저장/복원 추가: - - `sceneId`, `timerPresetId`, `soundPresetId`, `goal`, `override(sound/timer)`를 localStorage에 저장 - - 복원 우선순위: 쿼리 파라미터 > 저장 상태 > Scene 추천 + - `sceneId`, `durationMinutes`, `soundPresetId`, `goal`, `override(sound/duration)`를 localStorage에 저장 + - 복원 우선순위: 저장 상태 > 사용자 기본 설정 > atmosphere 추천 - `/space` 진입 Resume CTA 추가: - 저장된 목표가 있고 쿼리 오버라이드가 없을 때 `지난 한 조각 이어서` 블록 1회 노출 - `이어서 시작`: 저장 목표로 즉시 Focus 진입 diff --git a/docs/flows/current/15_app_stats_entry_flow_spec.md b/docs/flows/current/15_app_stats_entry_flow_spec.md index 977611e..9dfff52 100644 --- a/docs/flows/current/15_app_stats_entry_flow_spec.md +++ b/docs/flows/current/15_app_stats_entry_flow_spec.md @@ -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` -- `../stats/14_weekly_review_reframe_spec.md` +- `../../screens/stats/current/14_weekly_review_reframe_spec.md` - `../../product_principles.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를 넘긴다 중요: @@ -267,7 +267,7 @@ review-aware state가 된다. 예시: -- `이번 주에 가장 잘 맞았던 흐름 · Forest · 50/10` +- `이번 주에 가장 잘 맞았던 흐름 · Forest Draft · 50분` - `이번엔 시작을 더 작게 잡아보세요` ### 유지할 것 @@ -300,9 +300,9 @@ review-aware state가 된다. ### Pro - teaser가 더 구체적일 수 있다 - - `Forest · 50/10에서 pause 뒤 복귀율이 가장 높았어요` +- `Forest Draft · 50분에서 pause 뒤 복귀율이 가장 높았어요` - `/stats` 마지막 CTA가 더 개인화된다 - - `가장 잘 맞은 ritual로 /app 돌아가기` + - `가장 잘 맞은 atmosphere로 /app 돌아가기` - `/app` 복귀 후 ritual prefill / carry-forward hint가 더 정교하다 핵심: @@ -399,7 +399,8 @@ review-aware state가 된다. 추천 query: - `review=weekly` -- `entryPreset=forest-50-10` +- `entryAtmosphereId=forest-draft` +- `entryDurationMinutes=50` - `carryHint=start-smaller` 주의: @@ -412,7 +413,8 @@ review-aware state가 된다. 로컬 상태: - `reviewSource` -- `suggestedEntryPreset` +- `suggestedEntryAtmosphereId` +- `suggestedEntryDurationMinutes` - `carryForwardHint` --- diff --git a/docs/product/17_product_alignment_findings.md b/docs/product/17_product_alignment_findings.md index 99110c1..3811e8b 100644 --- a/docs/product/17_product_alignment_findings.md +++ b/docs/product/17_product_alignment_findings.md @@ -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-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-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-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 정책 재정의 | diff --git a/docs/screens/app/current/19_app_atmosphere_entry_spec.md b/docs/screens/app/current/19_app_atmosphere_entry_spec.md index f52ac3b..664d1fa 100644 --- a/docs/screens/app/current/19_app_atmosphere_entry_spec.md +++ b/docs/screens/app/current/19_app_atmosphere_entry_spec.md @@ -8,7 +8,9 @@ Last Updated: 2026-03-16 - `Slice 1` no-session shell 구현 완료 - `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 - no-session 상태에서는 atmosphere 1개가 기본 선택된 상태로 시작한다 -- review handoff로 들어온 경우에는 handoff preset과 가장 가까운 atmosphere를 preselect한다 +- review handoff로 들어온 경우에는 handoff `entryAtmosphereId + entryDurationMinutes`를 preselect한다 - 사용자가 duration을 직접 수정하기 전까지는 선택된 atmosphere의 기본 duration suggestion을 보여줄 수 있다 - 사용자가 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 - duration input - 12개 atmosphere grid - selected atmosphere 기반 background 반영 - quiet review dock의 기본 위치 +- raw `focusDurationMinutes` start payload +- review handoff의 `entryAtmosphereId + entryDurationMinutes` -제외: +이번 라운드에서 제외된 것: -- current session routing 재설계 - `/stats` IA 변경 -- server custom duration contract +- `/space` current-session-only dead path cleanup -### Slice 1 임시 계약 +### 현재 계약 - 사용자는 분 단위 duration을 입력한다 -- 하지만 server contract가 아직 preset 기반이면, 이번 slice에서는 **입력 시간에 가장 가까운 기존 focus preset**으로 임시 매핑한다 -- 이 임시 매핑은 다음 slice(`Custom Duration Contract`)에서 실제 duration 연동으로 대체한다 -- UI에는 이 상태가 과장 없이 드러나야 한다 - - 예: `지금은 가장 가까운 기본 리듬으로 먼저 들어가요.` +- server는 `focusDurationMinutes`를 공식 start 계약으로 받는다 +- selected atmosphere는 `atmosphereId + sceneId + soundPresetId`로 함께 저장된다 +- `/space`는 current session 응답의 `focusDurationSeconds`, `atmosphereId`, `sceneId`, `soundPresetId`를 source of truth로 읽는다 --- @@ -486,19 +488,10 @@ Last Updated: 2026-03-16 ### 필요한 변화 -1. `startSession`이 custom duration minutes를 받도록 확장 -2. session에 `focusDurationMinutes` 저장 -3. break duration 계산 정책 정의 -4. atmosphere 선택 단위를 위한 `sceneId + soundPresetId` 조합 저장 - -### break duration 정책 제안 - -- 10~30분: 5분 -- 31~60분: 10분 -- 61~120분: 15분 -- 121분 이상: 20분 - -이건 사용자가 break를 직접 고르지 않아도 자연스럽게 이어지게 하기 위한 기본 정책이다. +1. `startSession`이 `focusDurationMinutes`를 공식 필드로 받는다 +2. session에 `focusDurationSeconds`와 `atmosphereId`를 저장한다 +3. `GET /focus-sessions/current`가 `/space` hydration에 필요한 goal / duration / atmosphere / scene / sound를 모두 반환한다 +4. `/stats -> /app` handoff는 preset이 아니라 atmosphere / duration 힌트를 사용한다 --- diff --git a/docs/screens/stats/current/14_weekly_review_reframe_spec.md b/docs/screens/stats/current/14_weekly_review_reframe_spec.md index 3bddb03..bbc5714 100644 --- a/docs/screens/stats/current/14_weekly_review_reframe_spec.md +++ b/docs/screens/stats/current/14_weekly_review_reframe_spec.md @@ -266,7 +266,7 @@ Weekly Review는 `/stats` 안에서 아래 5개 구역으로 재구성한다. 예시: -- `Forest · 50/10에서 가장 오래 이어졌어요.` +- `Forest Draft · 50분에서 가장 오래 이어졌어요.` - `Rain 계열에서는 pause 후 복귀가 더 높았어요.` 중요: @@ -400,7 +400,7 @@ Free에서 하지 않는 것: - multi-week trend - ritual 비교표 -- scene/sound/timer 조합 비교 +- atmosphere/duration 조합 비교 - archive 기반 long-term pattern Free 가치: @@ -471,7 +471,7 @@ trigger: teaser 예시: - `이번 주 pause 뒤 복귀가 3번 있었어요` -- `Forest 50/10에서 가장 잘 이어졌어요` +- `Forest Draft 50분에서 가장 잘 이어졌어요` CTA: @@ -482,7 +482,7 @@ CTA: weekly review 마지막 CTA: - `이 조합으로 다음 세션 시작` -- `/app`으로 연결하되 ritual preset을 prefill +- `/app`으로 연결하되 atmosphere와 duration을 prefill 즉 review는 읽고 끝나는 화면이 아니라 다음 entry에 연결돼야 한다. diff --git a/docs/session_brief.md b/docs/session_brief.md index 4238da0..7394c1a 100644 --- a/docs/session_brief.md +++ b/docs/session_brief.md @@ -14,10 +14,10 @@ Last Updated: 2026-03-16 ## 현재 우선순위 -1. `/app` Atmosphere Entry Shell -2. `Custom Duration Contract` -3. `Weekly Review Dock Reposition` -4. `Core Loop Alignment` browser audit +1. `/space` current-session-only cleanup +2. `Core Loop Alignment` browser audit +3. `Weekly Review` carry-forward 고도화 +4. `Premium Ambience` polish ## 최근 세션 상태 @@ -30,7 +30,7 @@ Last Updated: 2026-03-16 - no-session 상태는 더 이상 legacy `goal + microStep + fixed ritual` 화면을 쓰지 않는다. - 현재는 `goal 1개 + 예상 시간(분) + atmosphere 12개 grid + start CTA`로 들어간다. - 선택한 atmosphere는 `/app` 배경 preview와 `/space` start payload의 `scene/sound`에 같이 반영된다. - - duration은 우선 가장 가까운 기본 리듬으로 매핑하는 임시 계약을 사용한다. + - duration은 raw `focusDurationMinutes`로 server에 전달한다. - weekly review entry는 right-side quiet dock 위치로 옮겨 main CTA보다 낮은 위계를 유지한다. - `/app` Atmosphere Entry Shell visual premium polish를 반영했다. - 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까지 연결됐다. - Pro에서는 `/stats` carry-forward에 추천 ritual을 함께 보여준다. - `/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의 서버 연결이 들어갔다. - server `focus-summary` 응답에 `recovery`가 추가됐다. - 현재는 `pause 뒤 복귀`만 실집계이며, `자리 비움 뒤 복귀`는 partial note로 남아 있다. @@ -166,7 +162,6 @@ Last Updated: 2026-03-16 - HUD 내부 status line 제거 - Notes/Goal/잠금 피드백이 동일 위치 토스트로 표시 - 기본 기능 잠금을 해소했다. - - Time `90/20`을 Free로 개방 - 기본 Sound 잠금 제거 - Pro 잠금 구조를 Session OS 중심으로 재구성했다. - `Daily Focus Plan / Rituals / Weekly Review` 요약 카드 추가 @@ -182,17 +177,17 @@ Last Updated: 2026-03-16 - 옵션: `컨트롤 자동 숨김` - ON 상태에서 Control Center가 8초 무입력이면 자동 닫힘 처리 - `/space`에 Scene 추천 자동 적용 규칙을 도입했다. - - Room 데이터에 `recommendedSoundPresetId`, `recommendedTimerPresetId`를 추가했다. + - Room 데이터에 `recommendedSoundPresetId`를 추가했다. - 초기 진입/Scene 변경 시 override가 없는 항목만 추천값으로 자동 반영된다. -- `/space`에 override 상태(`sound`, `timer`)를 추가했다. - - 사용자가 직접 고른 사운드/타이머는 Scene 변경에도 자동 덮어쓰지 않는다. +- `/space`에 override 상태(`sound`, `duration`)를 추가했다. + - 사용자가 직접 고른 사운드/duration은 Scene 변경에도 자동 덮어쓰지 않는다. - `추천으로 되돌리기(더미)` 액션으로 override 초기화 + 추천값 즉시 복원이 가능하다. - Control Center를 Scene/Time 중심으로 단순화했다. - Sound/Preset Packs 섹션 제거 - 추천 정보 1줄 + `추천으로 되돌리기`만 유지 - 우하단 Sound Quick 선택 경로를 `onQuickSoundSelect`로 분리해 override.sound 규칙을 명시했다. - `/space` 선택 상태 로컬 저장/복원을 추가했다. - - 저장: `sceneId`, `timerPresetId`, `soundPresetId`, `goal`, `override(sound/timer)` + - 저장: `sceneId`, `durationMinutes`, `soundPresetId`, `goal`, `override(sound/duration)` - 복원 우선순위: 쿼리 파라미터 > 저장 상태 > Scene 추천 - `/space` 진입 시 Resume CTA를 추가했다. - 저장된 목표가 있고 쿼리 오버라이드가 없으면 `지난 한 조각 이어서`를 1회 노출 diff --git a/src/entities/scene/model/scenes.ts b/src/entities/scene/model/scenes.ts index bef5b97..ce3449b 100644 --- a/src/entities/scene/model/scenes.ts +++ b/src/entities/scene/model/scenes.ts @@ -23,7 +23,6 @@ export const SCENE_THEMES: SceneTheme[] = [ tags: [...copy.scenes[0].tags], recommendedSound: copy.scenes[0].recommendedSound, recommendedSoundPresetId: 'rain-focus', - recommendedTimerPresetId: '25-5', recommendedTime: copy.scenes[0].recommendedTime, vibeLabel: copy.scenes[0].vibeLabel, hubColor: '#D6E6F7', @@ -45,7 +44,6 @@ export const SCENE_THEMES: SceneTheme[] = [ tags: [...copy.scenes[1].tags], recommendedSound: copy.scenes[1].recommendedSound, recommendedSoundPresetId: 'cafe-work', - recommendedTimerPresetId: '25-5', recommendedTime: copy.scenes[1].recommendedTime, vibeLabel: copy.scenes[1].vibeLabel, hubColor: '#F5DDCB', @@ -67,7 +65,6 @@ export const SCENE_THEMES: SceneTheme[] = [ tags: [...copy.scenes[2].tags], recommendedSound: copy.scenes[2].recommendedSound, recommendedSoundPresetId: 'deep-white', - recommendedTimerPresetId: '50-10', recommendedTime: copy.scenes[2].recommendedTime, vibeLabel: copy.scenes[2].vibeLabel, hubColor: '#DCE4D1', @@ -89,7 +86,6 @@ export const SCENE_THEMES: SceneTheme[] = [ tags: [...copy.scenes[3].tags], recommendedSound: copy.scenes[3].recommendedSound, recommendedSoundPresetId: 'ocean-calm', - recommendedTimerPresetId: '25-5', recommendedTime: copy.scenes[3].recommendedTime, vibeLabel: copy.scenes[3].vibeLabel, hubColor: '#CFE9EA', @@ -111,7 +107,6 @@ export const SCENE_THEMES: SceneTheme[] = [ tags: [...copy.scenes[4].tags], recommendedSound: copy.scenes[4].recommendedSound, recommendedSoundPresetId: 'forest-birds', - recommendedTimerPresetId: '50-10', recommendedTime: copy.scenes[4].recommendedTime, vibeLabel: copy.scenes[4].vibeLabel, hubColor: '#D1E7C9', @@ -133,7 +128,6 @@ export const SCENE_THEMES: SceneTheme[] = [ tags: [...copy.scenes[5].tags], recommendedSound: copy.scenes[5].recommendedSound, recommendedSoundPresetId: 'fireplace', - recommendedTimerPresetId: '25-5', recommendedTime: copy.scenes[5].recommendedTime, vibeLabel: copy.scenes[5].vibeLabel, hubColor: '#F2D4C0', @@ -155,7 +149,6 @@ export const SCENE_THEMES: SceneTheme[] = [ tags: [...copy.scenes[6].tags], recommendedSound: copy.scenes[6].recommendedSound, recommendedSoundPresetId: 'deep-white', - recommendedTimerPresetId: '50-10', recommendedTime: copy.scenes[6].recommendedTime, vibeLabel: copy.scenes[6].vibeLabel, hubColor: '#D9D3ED', @@ -177,7 +170,6 @@ export const SCENE_THEMES: SceneTheme[] = [ tags: [...copy.scenes[7].tags], recommendedSound: copy.scenes[7].recommendedSound, recommendedSoundPresetId: 'deep-white', - recommendedTimerPresetId: '50-10', recommendedTime: copy.scenes[7].recommendedTime, vibeLabel: copy.scenes[7].vibeLabel, hubColor: '#D8E7F3', @@ -199,7 +191,6 @@ export const SCENE_THEMES: SceneTheme[] = [ tags: [...copy.scenes[8].tags], recommendedSound: copy.scenes[8].recommendedSound, recommendedSoundPresetId: 'silent', - recommendedTimerPresetId: '25-5', recommendedTime: copy.scenes[8].recommendedTime, vibeLabel: copy.scenes[8].vibeLabel, hubColor: '#F6EDC7', @@ -221,7 +212,6 @@ export const SCENE_THEMES: SceneTheme[] = [ tags: [...copy.scenes[9].tags], recommendedSound: copy.scenes[9].recommendedSound, recommendedSoundPresetId: 'deep-white', - recommendedTimerPresetId: '90-20', recommendedTime: copy.scenes[9].recommendedTime, vibeLabel: copy.scenes[9].vibeLabel, hubColor: '#D4DCF4', diff --git a/src/entities/scene/model/types.ts b/src/entities/scene/model/types.ts index 4177335..5eb7ea0 100644 --- a/src/entities/scene/model/types.ts +++ b/src/entities/scene/model/types.ts @@ -13,7 +13,6 @@ export interface SceneTheme { tags: SceneTag[]; recommendedSound: string; recommendedSoundPresetId: string; - recommendedTimerPresetId: string; recommendedTime: string; vibeLabel: string; hubColor: string; diff --git a/src/entities/session/index.ts b/src/entities/session/index.ts index e2c6b82..0db20f4 100644 --- a/src/entities/session/index.ts +++ b/src/entities/session/index.ts @@ -1,4 +1,3 @@ export * from './model/mockSession'; -export * from './model/focusSystem'; export * from './model/types'; export * from './model/useThoughtInbox'; diff --git a/src/entities/session/model/focusSystem.ts b/src/entities/session/model/focusSystem.ts deleted file mode 100644 index 6303e13..0000000 --- a/src/entities/session/model/focusSystem.ts +++ /dev/null @@ -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 = (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, - }); -}; diff --git a/src/entities/session/model/mockSession.ts b/src/entities/session/model/mockSession.ts index daf16a5..9a24759 100644 --- a/src/entities/session/model/mockSession.ts +++ b/src/entities/session/model/mockSession.ts @@ -5,7 +5,6 @@ import type { RecentThought, ReactionOption, SoundPreset, - TimerPreset, } from './types'; 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 TIMER_PRESETS: TimerPreset[] = [...copy.session.timerPresets]; - export const DISTRACTION_DUMP_PLACEHOLDER = [...copy.session.distractionDumpPlaceholder]; export const TODAY_STATS: FocusStatCard[] = [...copy.session.todayStats]; diff --git a/src/entities/session/model/types.ts b/src/entities/session/model/types.ts index 0bc0939..f88d9e9 100644 --- a/src/entities/session/model/types.ts +++ b/src/entities/session/model/types.ts @@ -19,13 +19,6 @@ export interface SoundPreset { label: string; } -export interface TimerPreset { - id: string; - label: string; - focusMinutes?: number; - breakMinutes?: number; -} - export interface FocusStatCard { id: string; label: string; diff --git a/src/features/focus-session/api/focusSessionApi.ts b/src/features/focus-session/api/focusSessionApi.ts index 40df745..0a3b351 100644 --- a/src/features/focus-session/api/focusSessionApi.ts +++ b/src/features/focus-session/api/focusSessionApi.ts @@ -9,7 +9,7 @@ interface RawFocusSession { id: number | string; sceneId: string; goal: string; - timerPresetId: string; + atmosphereId?: string | null; soundPresetId: string | null; focusPlanItemId?: number | null; microStep?: string | null; @@ -35,7 +35,7 @@ export interface FocusSession { id: string; sceneId: string; goal: string; - timerPresetId: string; + atmosphereId?: string | null; soundPresetId: string | null; focusPlanItemId?: string | null; microStep?: string | null; @@ -55,7 +55,8 @@ export interface FocusSession { export interface StartFocusSessionRequest { sceneId: string; goal: string; - timerPresetId: string; + focusDurationMinutes: number; + atmosphereId: string; soundPresetId?: string | null; focusPlanItemId?: string; microStep?: string | null; @@ -71,6 +72,7 @@ export interface CompleteFocusSessionRequest { export interface UpdateCurrentFocusSessionSelectionRequest { sceneId?: string; + atmosphereId?: string | null; soundPresetId?: string | null; } @@ -86,8 +88,9 @@ export interface ExtendCurrentPhaseRequest { export interface AdvanceCurrentGoalRequest { completedGoal: string; nextGoal: string; - sceneId: string; - timerPresetId: string; + sceneId?: string; + focusDurationMinutes?: number; + atmosphereId?: string | null; soundPresetId?: string | null; focusPlanItemId?: string; } diff --git a/src/features/preferences/api/preferencesApi.ts b/src/features/preferences/api/preferencesApi.ts index 43052ac..fe23cd8 100644 --- a/src/features/preferences/api/preferencesApi.ts +++ b/src/features/preferences/api/preferencesApi.ts @@ -1,17 +1,16 @@ import { - DEFAULT_PRESET_OPTIONS, NOTIFICATION_INTENSITY_OPTIONS, } from '@/shared/config/settingsOptions'; import { copy } from '@/shared/i18n'; import { apiClient } from '@/shared/lib/apiClient'; export type NotificationIntensity = (typeof NOTIFICATION_INTENSITY_OPTIONS)[number]; -export type DefaultPresetId = (typeof DEFAULT_PRESET_OPTIONS)[number]['id']; export interface UserFocusPreferences { reduceMotion: boolean; notificationIntensity: NotificationIntensity; - defaultPresetId: DefaultPresetId; + defaultAtmosphereId?: string | null; + defaultDurationMinutes?: number | null; defaultSceneId: string | null; defaultSoundPresetId: string | null; } @@ -21,7 +20,8 @@ export type UpdateUserFocusPreferencesRequest = Partial; export const DEFAULT_USER_FOCUS_PREFERENCES: UserFocusPreferences = { reduceMotion: false, notificationIntensity: copy.preferences.defaultNotificationIntensity, - defaultPresetId: DEFAULT_PRESET_OPTIONS[0].id, + defaultAtmosphereId: null, + defaultDurationMinutes: null, defaultSceneId: null, defaultSoundPresetId: null, }; @@ -30,7 +30,7 @@ export const preferencesApi = { /** * Backend Codex: * - 로그인한 사용자의 집중 관련 개인 설정을 반환한다. - * - 최소 reduceMotion, notificationIntensity, defaultPresetId를 포함한다. + * - 최소 reduceMotion, notificationIntensity, defaultAtmosphereId, defaultDurationMinutes를 포함한다. * - 아직 저장된 값이 없으면 서버 기본값을 내려주거나 null 필드 없이 기본 스키마로 응답한다. */ getFocusPreferences: async (): Promise => { diff --git a/src/features/stats/model/useFocusStats.ts b/src/features/stats/model/useFocusStats.ts index 85c9cbb..7a78a37 100644 --- a/src/features/stats/model/useFocusStats.ts +++ b/src/features/stats/model/useFocusStats.ts @@ -86,8 +86,11 @@ export interface WeeklyReviewViewModel { completionQuality: WeeklyReviewSection; carryForward: { hintKey: ReviewCarryHint; - presetId: string; - presetLabel: string; + atmosphereId: string; + atmosphereLabel: string; + sceneId: string; + soundPresetId: string | null; + durationMinutes: number; keepDoing: string; tryNext: string; ctaLabel: string; @@ -256,13 +259,17 @@ const buildCarryForward = (summary: FocusStatsSummary): WeeklyReviewViewModel['c const params = new URLSearchParams({ review: 'weekly', carryHint: hintKey, - entryPreset: 'forest-50-10', + entryAtmosphereId: 'forest-draft', + entryDurationMinutes: '50', }); return { hintKey, - presetId: 'forest-50-10', - presetLabel: 'Forest · Forest Birds', + atmosphereId: 'forest-draft', + atmosphereLabel: 'Forest Draft', + sceneId: 'forest', + soundPresetId: 'forest-birds', + durationMinutes: 50, keepDoing, tryNext, ctaLabel: copy.stats.reviewCarryCta, diff --git a/src/shared/config/settingsOptions.ts b/src/shared/config/settingsOptions.ts index 30d5e0e..7ef23b0 100644 --- a/src/shared/config/settingsOptions.ts +++ b/src/shared/config/settingsOptions.ts @@ -1,5 +1,3 @@ import { copy } from '@/shared/i18n'; export const NOTIFICATION_INTENSITY_OPTIONS = copy.settings.notificationIntensityOptions; - -export const DEFAULT_PRESET_OPTIONS = copy.settings.defaultPresetOptions; diff --git a/src/shared/i18n/messages/app.ts b/src/shared/i18n/messages/app.ts index d3dec49..e9d0c4c 100644 --- a/src/shared/i18n/messages/app.ts +++ b/src/shared/i18n/messages/app.ts @@ -9,14 +9,11 @@ export const app = { reduceMotionDescription: '전환 애니메이션을 최소화합니다. (UI 토글 목업)', notificationIntensityTitle: '알림 강도', notificationIntensityDescription: '집중 시작/종료 신호의 존재감을 선택합니다.', - defaultPresetTitle: '기본 프리셋', - defaultPresetDescription: '입장 시 자동 선택될 추천 세트를 고릅니다.', + defaultDurationTitle: '기본 집중 시간', + defaultDurationDescription: '새 세션에 기본으로 채울 시간을 정합니다.', + defaultAtmosphereTitle: '기본 Atmosphere', + defaultAtmosphereDescription: '새 세션에 기본으로 제안할 분위기를 정합니다.', 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: { title: 'Weekly Review', @@ -79,10 +76,10 @@ export const app = { reviewCarryTryClosure: '시작은 있었지만 마무리가 약했어요. 다음 주에는 완료 직전에 다른 블록으로 넘어가지 않는 흐름을 한 번 만들어 보세요.', reviewCarryTryStart: '시작 횟수가 적었어요. 다음 주에는 길이를 늘리기보다 첫 세션을 한 번 더 여는 것에 집중해 보세요.', reviewCarryCta: '이 흐름으로 다음 세션 시작', - reviewCarryCtaPro: '추천 ritual과 함께 /app 돌아가기', + reviewCarryCtaPro: '추천 atmosphere와 함께 /app 돌아가기', reviewCarryKeepTitle: '다음 주에 유지할 것', reviewCarryTryTitle: '다음 주에 바꿔볼 것', - reviewCarryPresetLabel: '추천 ritual', + reviewCarryPresetLabel: '추천 atmosphere', today: '오늘', last7Days: '최근 7일', chartTitle: '집중 흐름 그래프', @@ -155,12 +152,6 @@ export const app = { { id: 'fireplace', label: 'Fireplace' }, { 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개 정리'], todayStats: [ { id: 'today-focus', label: '오늘 집중 시간', value: '2h 40m', delta: '+35m' }, diff --git a/src/shared/i18n/messages/product.ts b/src/shared/i18n/messages/product.ts index 1f3c31b..f579703 100644 --- a/src/shared/i18n/messages/product.ts +++ b/src/shared/i18n/messages/product.ts @@ -8,14 +8,11 @@ export const settings = { reduceMotionDescription: '전환 애니메이션을 최소화합니다. (UI 토글 목업)', notificationIntensityTitle: '알림 강도', notificationIntensityDescription: '집중 시작/종료 신호의 존재감을 선택합니다.', - defaultPresetTitle: '기본 프리셋', - defaultPresetDescription: '입장 시 자동 선택될 추천 세트를 고릅니다.', + defaultDurationTitle: '기본 집중 시간', + defaultDurationDescription: '새 세션에 기본으로 채울 시간을 정합니다.', + defaultAtmosphereTitle: '기본 Atmosphere', + defaultAtmosphereDescription: '새 세션에 기본으로 제안할 분위기를 정합니다.', 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; export const stats = { @@ -114,7 +111,7 @@ export const plan = { { id: 'rituals', name: 'Rituals', - description: 'scene + sound + timer 조합을 반복 가능한 시작 방식으로 저장합니다.', + description: 'atmosphere와 duration 조합을 반복 가능한 시작 방식으로 저장합니다.', }, { id: 'weekly-review', diff --git a/src/shared/i18n/messages/session.ts b/src/shared/i18n/messages/session.ts index cb895b4..b007294 100644 --- a/src/shared/i18n/messages/session.ts +++ b/src/shared/i18n/messages/session.ts @@ -30,12 +30,6 @@ export const session = { { id: 'fireplace', label: 'Fireplace' }, { 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개 정리'], todayStats: [ { id: 'today-focus', label: '오늘 집중 시간', value: '2h 40m', delta: '+35m' }, diff --git a/src/widgets/focus-dashboard/model/atmosphereEntry.ts b/src/widgets/focus-dashboard/model/atmosphereEntry.ts index 083fc61..8499e46 100644 --- a/src/widgets/focus-dashboard/model/atmosphereEntry.ts +++ b/src/widgets/focus-dashboard/model/atmosphereEntry.ts @@ -1,12 +1,6 @@ import { getSceneById, type SceneTheme } from '@/entities/scene'; 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; export interface AtmosphereOption { @@ -14,6 +8,7 @@ export interface AtmosphereOption { name: string; sceneId: string; soundPresetId: string | null; + recommendedDurationMinutes: number; description: string; caption: string; scene: SceneTheme; @@ -25,6 +20,7 @@ const createAtmosphereOption = ( name: string, sceneId: string, soundPresetId: string | null, + recommendedDurationMinutes: number, description: string, caption: string, ): AtmosphereOption => { @@ -38,6 +34,7 @@ const createAtmosphereOption = ( name, sceneId, soundPresetId, + recommendedDurationMinutes, description, caption, scene, @@ -52,6 +49,7 @@ export const ATMOSPHERE_OPTIONS: AtmosphereOption[] = [ 'Rain Window', 'rain-window', 'rain-focus', + 45, '비 소리 위로 조용히 문장을 붙잡기 좋은 흐름.', '조용한 시작', ), @@ -60,6 +58,7 @@ export const ATMOSPHERE_OPTIONS: AtmosphereOption[] = [ 'Quiet Library', 'quiet-library', 'deep-white', + 70, '소음 없이 길게 읽고 정리할 때 안정적인 조합.', '길게 읽는 날', ), @@ -68,6 +67,7 @@ export const ATMOSPHERE_OPTIONS: AtmosphereOption[] = [ 'Dawn Cafe', 'dawn-cafe', 'cafe-work', + 25, '가볍게 손을 움직이며 초안을 시작하기 좋은 온도.', '워밍업용', ), @@ -76,6 +76,7 @@ export const ATMOSPHERE_OPTIONS: AtmosphereOption[] = [ 'Forest Draft', 'forest', 'forest-birds', + 50, '딥워크 진입 전에 숨을 고르게 만드는 기본 조합.', '기본 리듬', ), @@ -84,6 +85,7 @@ export const ATMOSPHERE_OPTIONS: AtmosphereOption[] = [ 'Fireplace Glow', 'fireplace', 'fireplace', + 70, '밤에 닫히지 않는 일 하나를 끝까지 가져가고 싶을 때.', '늦은 시간용', ), @@ -92,6 +94,7 @@ export const ATMOSPHERE_OPTIONS: AtmosphereOption[] = [ 'Deep Night Desk', 'city-night', 'deep-white', + 90, '도시의 불빛은 멀리 두고 화면 안의 일만 남기는 조합.', '몰입 유지', ), @@ -100,6 +103,7 @@ export const ATMOSPHERE_OPTIONS: AtmosphereOption[] = [ 'Snow Light', 'snow-mountain', 'deep-white', + 45, '머리를 식히면서 구조를 정리해야 할 때 선명한 공기.', '정리용', ), @@ -108,6 +112,7 @@ export const ATMOSPHERE_OPTIONS: AtmosphereOption[] = [ 'Sun Window', 'sun-window', 'silent', + 25, '과하게 자극적이지 않게 아침 에너지를 가져오는 장면.', '낮 시간용', ), @@ -116,6 +121,7 @@ export const ATMOSPHERE_OPTIONS: AtmosphereOption[] = [ 'Ocean Still', 'wave-sound', 'ocean-calm', + 70, '넓은 생각이 필요한 기획이나 리서치에 어울리는 흐름.', '넓게 생각하기', ), @@ -124,6 +130,7 @@ export const ATMOSPHERE_OPTIONS: AtmosphereOption[] = [ 'Orbit Night', 'outer-space', 'deep-white', + 90, '길고 깊은 블록에 들어갈 때 외부 자극을 멀리 밀어낸다.', '장시간 집중', ), @@ -132,6 +139,7 @@ export const ATMOSPHERE_OPTIONS: AtmosphereOption[] = [ 'Rain Notes', 'rain-window', 'deep-white', + 50, '빗소리 대신 더 조용한 백색 소음으로 문장만 남긴 버전.', '더 낮은 자극', ), @@ -140,6 +148,7 @@ export const ATMOSPHERE_OPTIONS: AtmosphereOption[] = [ 'Quiet Pages', 'quiet-library', 'silent', + 45, '읽기와 쓰기 사이를 오갈 때 가장 얇은 존재감으로 머문다.', '완전 조용함', ), @@ -179,29 +188,12 @@ export const sanitizeDurationDraft = (value: string) => { return String(Math.min(180, parsed)); }; -export const getTimerPresetMetaById = (timerPresetId: string) => { - return TIMER_PRESETS.find((preset) => preset.id === timerPresetId) ?? TIMER_PRESETS[1]; -}; - -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 formatDurationMinutesLabel = (minutes: number) => { + return `${minutes}m`; }; export const getRecommendedDurationMinutes = (option: AtmosphereOption) => { - return getTimerPresetMetaById(option.scene.recommendedTimerPresetId).focusMinutes; + return option.recommendedDurationMinutes; }; export const getAtmosphereOptionById = (id: string) => { diff --git a/src/widgets/focus-dashboard/ui/FocusDashboardWidget.tsx b/src/widgets/focus-dashboard/ui/FocusDashboardWidget.tsx index a171ed5..ef9ecad 100644 --- a/src/widgets/focus-dashboard/ui/FocusDashboardWidget.tsx +++ b/src/widgets/focus-dashboard/ui/FocusDashboardWidget.tsx @@ -7,6 +7,7 @@ import { useMediaCatalog, getSceneStageBackgroundStyle } from '@/entities/media' import { usePlanTier } from '@/entities/plan'; import { getSceneById, SCENE_THEMES } from '@/entities/scene'; import { SOUND_PRESETS } from '@/entities/session'; +import { preferencesApi } from '@/features/preferences/api/preferencesApi'; import { PaywallSheetContent } from '@/features/paywall-sheet'; import { focusSessionApi, type FocusSession } from '@/features/focus-session/api/focusSessionApi'; import { useFocusStats, type ReviewCarryHint } from '@/features/stats'; @@ -18,24 +19,13 @@ import { findAtmosphereOptionForSelection, getAtmosphereOptionById, getRecommendedDurationMinutes, - getTimerPresetMetaById, parseDurationMinutes, - resolveNearestTimerPreset, sanitizeDurationDraft, } from '../model/atmosphereEntry'; import { AppAtmosphereEntryShell } from './AppAtmosphereEntryShell'; const DEFAULT_SCENE_ID = getSceneById('forest')?.id ?? SCENE_THEMES[0].id; const DEFAULT_SOUND_ID = SOUND_PRESETS.find((preset) => preset.id === 'forest-birds')?.id ?? SOUND_PRESETS[0].id; -const DEFAULT_TIMER_ID = '50-10'; -const 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 = 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.', reviewReturnBodyClosure: 'Think about where to close the block first to finish strong.', 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', paywallBody: 'Pro enables faster rituals and deeper reviews for seamless entry and return.', }; @@ -101,29 +91,22 @@ export const FocusDashboardWidget = () => { const { sceneAssetMap } = useMediaCatalog(); const { summary: weeklySummary } = useFocusStats(); - const reviewEntryPreset = searchParams.get('entryPreset'); - const reviewEntryPresetConfig = useMemo(() => { - if (!reviewEntryPreset) { - return null; - } - - return REVIEW_ENTRY_PRESETS[reviewEntryPreset as keyof typeof REVIEW_ENTRY_PRESETS] ?? null; - }, [reviewEntryPreset]); + const reviewEntryAtmosphereId = searchParams.get('entryAtmosphereId'); + const reviewEntryDurationMinutes = searchParams.get('entryDurationMinutes'); const initialAtmosphere = useMemo(() => { return ( - findAtmosphereOptionForSelection( - reviewEntryPresetConfig?.sceneId ?? DEFAULT_SCENE_ID, - reviewEntryPresetConfig?.soundPresetId ?? DEFAULT_SOUND_ID, - ) ?? DEFAULT_ATMOSPHERE + (reviewEntryAtmosphereId ? ATMOSPHERE_OPTIONS.find((option) => option.id === reviewEntryAtmosphereId) : null) ?? + DEFAULT_ATMOSPHERE ); - }, [reviewEntryPresetConfig]); + }, [reviewEntryAtmosphereId]); const initialDurationMinutes = useMemo(() => { - if (reviewEntryPresetConfig) { - return getTimerPresetMetaById(reviewEntryPresetConfig.timerPresetId).focusMinutes; + const parsed = Number(reviewEntryDurationMinutes); + if (Number.isFinite(parsed) && parsed >= 5 && parsed <= 180) { + return parsed; } return getRecommendedDurationMinutes(initialAtmosphere); - }, [initialAtmosphere, reviewEntryPresetConfig]); + }, [initialAtmosphere, reviewEntryDurationMinutes]); const [goalDraft, setGoalDraft] = useState(''); const [durationDraft, setDurationDraft] = useState(() => String(initialDurationMinutes)); @@ -150,11 +133,6 @@ export const FocusDashboardWidget = () => { return Number.isFinite(parsed) ? parsed : null; }, [durationDraft]); const parsedDurationMinutes = parseDurationMinutes(durationDraft); - const resolvedTimerPreset = useMemo(() => { - const targetMinutes = - parsedDurationMinutes ?? getRecommendedDurationMinutes(selectedAtmosphere); - return resolveNearestTimerPreset(targetMinutes); - }, [parsedDurationMinutes, selectedAtmosphere]); const activeScene = useMemo(() => { return getSceneById(currentSession?.sceneId ?? selectedAtmosphere.sceneId) ?? SCENE_THEMES[0]; @@ -184,7 +162,9 @@ export const FocusDashboardWidget = () => { const reviewReturnCopy = normalizedReviewCarryHint !== null ? reviewCarryCopyByHint[normalizedReviewCarryHint] : null; 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 durationHelper = 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(() => { if (!isCheckingSession && hasCurrentSession) { router.replace('/space'); @@ -276,9 +293,10 @@ export const FocusDashboardWidget = () => { await focusSessionApi.startSession({ goal: trimmedGoal, microStep: null, + atmosphereId: selectedAtmosphere.id, + focusDurationMinutes: parsedDurationMinutes, sceneId: selectedAtmosphere.sceneId, soundPresetId: selectedAtmosphere.soundPresetId, - timerPresetId: resolvedTimerPreset.id, entryPoint: 'space-setup', }); router.push('/space'); diff --git a/src/widgets/settings-panel/ui/SettingsPanelWidget.tsx b/src/widgets/settings-panel/ui/SettingsPanelWidget.tsx index b9417b6..64bce2b 100644 --- a/src/widgets/settings-panel/ui/SettingsPanelWidget.tsx +++ b/src/widgets/settings-panel/ui/SettingsPanelWidget.tsx @@ -2,9 +2,12 @@ import Link from 'next/link'; import { - DEFAULT_PRESET_OPTIONS, NOTIFICATION_INTENSITY_OPTIONS, } from '@/shared/config/settingsOptions'; +import { + ATMOSPHERE_OPTIONS, + ENTRY_DURATION_SUGGESTIONS, +} from '@/widgets/focus-dashboard/model/atmosphereEntry'; import { copy } from '@/shared/i18n'; import { useUserFocusPreferences } from '@/features/preferences'; import { cn } from '@/shared/lib/cn'; @@ -114,24 +117,55 @@ export const SettingsPanelWidget = () => {
-

{settings.defaultPresetTitle}

-

{settings.defaultPresetDescription}

+

{settings.defaultDurationTitle}

+

{settings.defaultDurationDescription}

- {DEFAULT_PRESET_OPTIONS.map((preset) => ( +
+ {ENTRY_DURATION_SUGGESTIONS.map((minutes) => ( + + ))} +
+
+
+ +
+

{settings.defaultAtmosphereTitle}

+

{settings.defaultAtmosphereDescription}

+
+ {ATMOSPHERE_OPTIONS.slice(0, 8).map((atmosphere) => ( ))}
diff --git a/src/widgets/space-setup-drawer/ui/SpaceSetupDrawerWidget.tsx b/src/widgets/space-setup-drawer/ui/SpaceSetupDrawerWidget.tsx index 3c4de16..77604b5 100644 --- a/src/widgets/space-setup-drawer/ui/SpaceSetupDrawerWidget.tsx +++ b/src/widgets/space-setup-drawer/ui/SpaceSetupDrawerWidget.tsx @@ -7,7 +7,6 @@ import type { SceneTheme } from '@/entities/scene'; import type { GoalChip, SoundPreset, - TimerPreset, } from '@/entities/session'; import { copy } from '@/shared/i18n'; import { SceneSelectCarousel } from '@/features/scene-select'; @@ -22,16 +21,16 @@ interface SpaceSetupDrawerWidgetProps { scenes: SceneTheme[]; sceneAssetMap?: SceneAssetMap; selectedSceneId: string; - selectedTimerLabel: string; + selectedDurationLabel: string; selectedSoundPresetId: string; goalInput: string; selectedGoalId: string | null; goalChips: GoalChip[]; soundPresets: SoundPreset[]; - timerPresets: TimerPreset[]; + durationOptions: readonly number[]; canStart: boolean; onSceneSelect: (sceneId: string) => void; - onTimerSelect: (timerLabel: string) => void; + onDurationSelect: (durationMinutes: number) => void; onSoundSelect: (soundPresetId: string) => void; onGoalChange: (value: string) => void; onGoalChipSelect: (chip: GoalChip) => void; @@ -81,16 +80,16 @@ export const SpaceSetupDrawerWidget = ({ scenes, sceneAssetMap, selectedSceneId, - selectedTimerLabel, + selectedDurationLabel, selectedSoundPresetId, goalInput, selectedGoalId, goalChips, soundPresets, - timerPresets, + durationOptions, canStart, onSceneSelect, - onTimerSelect, + onDurationSelect, onSoundSelect, onGoalChange, onGoalChipSelect, @@ -208,7 +207,7 @@ export const SpaceSetupDrawerWidget = ({ /> togglePopover('timer')} /> @@ -237,15 +236,15 @@ export const SpaceSetupDrawerWidget = ({ {openPopover === 'timer' ? (
- {timerPresets.slice(0, 3).map((preset) => { - const selected = preset.label === selectedTimerLabel; + {durationOptions.map((minutes) => { + const selected = `${minutes}m` === selectedDurationLabel; return ( ); })} diff --git a/src/widgets/space-workspace/model/types.ts b/src/widgets/space-workspace/model/types.ts index 83714c9..65cac52 100644 --- a/src/widgets/space-workspace/model/types.ts +++ b/src/widgets/space-workspace/model/types.ts @@ -3,12 +3,12 @@ export type SessionEntryPoint = 'space-setup' | 'goal-complete' | 'resume-restor export type SelectionOverride = { sound: boolean; - timer: boolean; + duration: boolean; }; export interface StoredWorkspaceSelection { sceneId?: string; - timerPresetId?: string; + durationMinutes?: number; soundPresetId?: string; goal?: string; override?: Partial; diff --git a/src/widgets/space-workspace/model/useSpaceWorkspaceSelection.ts b/src/widgets/space-workspace/model/useSpaceWorkspaceSelection.ts index 7846ead..23864f4 100644 --- a/src/widgets/space-workspace/model/useSpaceWorkspaceSelection.ts +++ b/src/widgets/space-workspace/model/useSpaceWorkspaceSelection.ts @@ -16,11 +16,16 @@ import type { FocusSession } from '@/features/focus-session'; import { preferencesApi } from '@/features/preferences/api/preferencesApi'; import { copy } from '@/shared/i18n'; import type { HudStatusLinePayload } from '@/shared/lib/useHudStatusLine'; +import { + findAtmosphereOptionForSelection, + getAtmosphereOptionById, + getRecommendedDurationMinutes, +} from '@/widgets/focus-dashboard/model/atmosphereEntry'; import type { SelectionOverride } from './types'; import { + formatDurationMinutesLabel, readStoredWorkspaceSelection, - resolveTimerLabelFromPresetId, - resolveTimerPresetIdFromLabel, + resolveInitialDurationMinutes, } from './workspaceSelection'; import { useWorkspacePersistence } from './useWorkspacePersistence'; import { useWorkspaceMediaDiagnostics } from './useWorkspaceMediaDiagnostics'; @@ -29,12 +34,7 @@ interface UseSpaceWorkspaceSelectionParams { initialSceneId: string; initialGoal: string; initialFocusPlanItemId: string | null; - initialTimerLabel: string; - sceneQuery: string | null; - goalQuery: string; - soundQuery: string | null; - timerQuery: string | null; - hasQueryOverrides: boolean; + initialDurationMinutes: number; currentSession: FocusSession | null; sceneAssetMap: SceneAssetMap; selectedPresetId: string; @@ -46,6 +46,7 @@ interface UseSpaceWorkspaceSelectionParams { updateCurrentSelection: (payload: { sceneId?: string; soundPresetId?: string | null; + atmosphereId?: string | null; }) => Promise; mediaCatalogError: string | null; usedFallbackManifest: boolean; @@ -66,12 +67,7 @@ export const useSpaceWorkspaceSelection = ({ initialSceneId, initialGoal, initialFocusPlanItemId, - initialTimerLabel, - sceneQuery, - goalQuery, - soundQuery, - timerQuery, - hasQueryOverrides, + initialDurationMinutes, currentSession, sceneAssetMap, selectedPresetId, @@ -86,7 +82,7 @@ export const useSpaceWorkspaceSelection = ({ hasResolvedManifest, }: UseSpaceWorkspaceSelectionParams) => { const [selectedSceneId, setSelectedSceneId] = useState(initialSceneId); - const [selectedTimerLabel, setSelectedTimerLabel] = useState(initialTimerLabel); + const [selectedDurationMinutes, setSelectedDurationMinutes] = useState(initialDurationMinutes); const [goalInput, setGoalInput] = useState(initialGoal); const [linkedFocusPlanItemId, setLinkedFocusPlanItemId] = useState(initialFocusPlanItemId); const [selectedGoalId, setSelectedGoalId] = useState(null); @@ -95,7 +91,7 @@ export const useSpaceWorkspaceSelection = ({ const [hasHydratedSelection, setHasHydratedSelection] = useState(false); const [selectionOverride, setSelectionOverride] = useState({ sound: false, - timer: false, + duration: false, }); const didHydrateServerPreferencesRef = useRef(false); @@ -110,6 +106,10 @@ export const useSpaceWorkspaceSelection = ({ }, [selectedScene]); const canStart = goalInput.trim().length > 0; + const selectedDurationLabel = useMemo( + () => formatDurationMinutesLabel(selectedDurationMinutes), + [selectedDurationMinutes], + ); const applyRecommendedSelections = useCallback(( sceneId: string, @@ -121,12 +121,17 @@ export const useSpaceWorkspaceSelection = ({ return; } - if (!overrideState.timer) { - const recommendedTimerLabel = resolveTimerLabelFromPresetId(scene.recommendedTimerPresetId); + if (!overrideState.duration) { + const recommendedAtmosphere = + findAtmosphereOptionForSelection(sceneId, scene.recommendedSoundPresetId) ?? + findAtmosphereOptionForSelection(sceneId); - if (recommendedTimerLabel) { - setSelectedTimerLabel(recommendedTimerLabel); - } + setSelectedDurationMinutes( + resolveInitialDurationMinutes( + undefined, + recommendedAtmosphere ? getRecommendedDurationMinutes(recommendedAtmosphere) : undefined, + ), + ); } if ( @@ -140,12 +145,16 @@ export const useSpaceWorkspaceSelection = ({ const persistSpaceSelection = useCallback((selection: { sceneId?: string; soundPresetId?: string | null; + durationMinutes?: number; }) => { const preferencePayload: { + defaultAtmosphereId?: string | null; + defaultDurationMinutes?: number | null; defaultSceneId?: string | null; defaultSoundPresetId?: string | null; } = {}; const currentSessionPayload: { + atmosphereId?: string | null; sceneId?: string; soundPresetId?: string | null; } = {}; @@ -160,6 +169,18 @@ export const useSpaceWorkspaceSelection = ({ 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 () => { const [preferencesResult, sessionResult] = await Promise.allSettled([ preferencesApi.updateFocusPreferences(preferencePayload), @@ -181,7 +202,7 @@ export const useSpaceWorkspaceSelection = ({ }); } })(); - }, [currentSession, pushStatusLine, updateCurrentSelection]); + }, [currentSession, pushStatusLine, selectedPresetId, selectedSceneId, updateCurrentSelection]); const handleSelectScene = useCallback((sceneId: string) => { void (async () => { @@ -215,21 +236,26 @@ export const useSpaceWorkspaceSelection = ({ unlockPlayback, ]); - const handleSelectTimer = useCallback((timerLabel: string, markOverride = false) => { - setSelectedTimerLabel(timerLabel); + const handleSelectDuration = useCallback((durationMinutes: number, markOverride = false) => { + if (!Number.isFinite(durationMinutes) || durationMinutes < 5) { + return; + } + + setSelectedDurationMinutes(durationMinutes); + persistSpaceSelection({ durationMinutes }); if (!markOverride) { return; } setSelectionOverride((current) => { - if (current.timer) { + if (current.duration) { return current; } - return { ...current, timer: true }; + return { ...current, duration: true }; }); - }, []); + }, [persistSpaceSelection]); const handleSelectSound = useCallback((presetId: string, markOverride = false) => { void (async () => { @@ -287,17 +313,16 @@ export const useSpaceWorkspaceSelection = ({ const storedSelection = readStoredWorkspaceSelection(); const restoredSelectionOverride: SelectionOverride = { sound: Boolean(storedSelection.override?.sound), - timer: Boolean(storedSelection.override?.timer), + duration: Boolean(storedSelection.override?.duration), }; const restoredSceneId = - !sceneQuery && storedSelection.sceneId && getSceneById(storedSelection.sceneId) + storedSelection.sceneId && getSceneById(storedSelection.sceneId) ? normalizeSceneId(storedSelection.sceneId) : null; - const restoredTimerLabel = !timerQuery - ? resolveTimerLabelFromPresetId(storedSelection.timerPresetId) - : null; + const restoredDurationMinutes = + typeof storedSelection.durationMinutes === 'number' ? storedSelection.durationMinutes : null; 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 : null; const restoredGoal = storedSelection.goal?.trim() ?? ''; @@ -308,15 +333,15 @@ export const useSpaceWorkspaceSelection = ({ setSelectedSceneId(restoredSceneId); } - if (restoredTimerLabel) { - setSelectedTimerLabel(restoredTimerLabel); + if (restoredDurationMinutes) { + setSelectedDurationMinutes(restoredDurationMinutes); } if (restoredSoundPresetId) { setSelectedPresetId(restoredSoundPresetId); } - if (!goalQuery && restoredGoal.length > 0 && !hasQueryOverrides) { + if (restoredGoal.length > 0) { setResumeGoal(restoredGoal); setShowResumePrompt(true); } @@ -327,7 +352,7 @@ export const useSpaceWorkspaceSelection = ({ return () => { window.cancelAnimationFrame(rafId); }; - }, [goalQuery, hasQueryOverrides, sceneQuery, setSelectedPresetId, soundQuery, timerQuery]); + }, [setSelectedPresetId]); useEffect(() => { if (!hasHydratedSelection || didHydrateServerPreferencesRef.current) { @@ -340,19 +365,27 @@ export const useSpaceWorkspaceSelection = ({ void preferencesApi .getFocusPreferences() .then((preferences) => { - if (cancelled || currentSession || hasQueryOverrides) { + if (cancelled || currentSession) { return; } - const normalizedPreferredSceneId = normalizeSceneId(preferences.defaultSceneId); + const preferredAtmosphere = + preferences.defaultAtmosphereId + ? getAtmosphereOptionById(preferences.defaultAtmosphereId) + : null; + const normalizedPreferredSceneId = normalizeSceneId( + preferredAtmosphere?.sceneId ?? preferences.defaultSceneId, + ); const nextSceneId = normalizedPreferredSceneId && getSceneById(normalizedPreferredSceneId) ? normalizedPreferredSceneId : null; const nextSoundPresetId = - preferences.defaultSoundPresetId && - SOUND_PRESETS.some((preset) => preset.id === preferences.defaultSoundPresetId) - ? preferences.defaultSoundPresetId + (preferredAtmosphere?.soundPresetId ?? preferences.defaultSoundPresetId) && + SOUND_PRESETS.some( + (preset) => preset.id === (preferredAtmosphere?.soundPresetId ?? preferences.defaultSoundPresetId), + ) + ? (preferredAtmosphere?.soundPresetId ?? preferences.defaultSoundPresetId) : null; if (nextSceneId) { @@ -363,6 +396,11 @@ export const useSpaceWorkspaceSelection = ({ setSelectedPresetId(nextSoundPresetId); setSelectionOverride((current) => ({ ...current, sound: true })); } + + if (preferences.defaultDurationMinutes) { + setSelectedDurationMinutes(preferences.defaultDurationMinutes); + setSelectionOverride((current) => ({ ...current, duration: true })); + } }) .catch(() => { // Focus preference load failure should not block entering the space. @@ -371,15 +409,13 @@ export const useSpaceWorkspaceSelection = ({ return () => { cancelled = true; }; - }, [currentSession, hasHydratedSelection, hasQueryOverrides, setSelectedPresetId]); + }, [currentSession, hasHydratedSelection, setSelectedPresetId]); useEffect(() => { if (!currentSession) { return; } - const nextTimerLabel = - resolveTimerLabelFromPresetId(currentSession.timerPresetId) ?? selectedTimerLabel; const nextSoundPresetId = currentSession.soundPresetId && SOUND_PRESETS.some((preset) => preset.id === currentSession.soundPresetId) @@ -387,7 +423,7 @@ export const useSpaceWorkspaceSelection = ({ : selectedPresetId; const rafId = window.requestAnimationFrame(() => { setSelectedSceneId(normalizeSceneId(currentSession.sceneId) ?? currentSession.sceneId); - setSelectedTimerLabel(nextTimerLabel); + setSelectedDurationMinutes(Math.max(5, Math.round(currentSession.focusDurationSeconds / 60))); setSelectedPresetId(nextSoundPresetId); setGoalInput(currentSession.goal); setLinkedFocusPlanItemId(currentSession.focusPlanItemId ?? null); @@ -398,12 +434,12 @@ export const useSpaceWorkspaceSelection = ({ return () => { window.cancelAnimationFrame(rafId); }; - }, [currentSession, selectedPresetId, selectedTimerLabel, setSelectedPresetId]); + }, [currentSession, selectedPresetId, setSelectedPresetId]); useWorkspacePersistence({ hasHydratedSelection, selectedScene, - selectedTimerPresetId: resolveTimerPresetIdFromLabel(selectedTimerLabel), + selectedDurationMinutes, selectedPresetId, goalInput, showResumePrompt, @@ -422,7 +458,8 @@ export const useSpaceWorkspaceSelection = ({ return { selectedSceneId, - selectedTimerLabel, + selectedDurationMinutes, + selectedDurationLabel, goalInput, linkedFocusPlanItemId, selectedGoalId, @@ -440,7 +477,7 @@ export const useSpaceWorkspaceSelection = ({ setShowResumePrompt, setResumeGoal, handleSelectScene, - handleSelectTimer, + handleSelectDuration, handleSelectSound, handleGoalChipSelect, handleGoalChange, diff --git a/src/widgets/space-workspace/model/useSpaceWorkspaceSessionControls.ts b/src/widgets/space-workspace/model/useSpaceWorkspaceSessionControls.ts index aa4316f..658cce7 100644 --- a/src/widgets/space-workspace/model/useSpaceWorkspaceSessionControls.ts +++ b/src/widgets/space-workspace/model/useSpaceWorkspaceSessionControls.ts @@ -4,7 +4,7 @@ import { useCallback, useEffect, useRef } from 'react'; import type { FocusSession } from '@/features/focus-session'; import { copy } from '@/shared/i18n'; 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'; interface UseSpaceWorkspaceSessionControlsParams { @@ -19,16 +19,17 @@ interface UseSpaceWorkspaceSessionControlsParams { goalInput: string; linkedFocusPlanItemId: string | null; selectedSceneId: string; - selectedTimerLabel: string; + selectedDurationMinutes: number; selectedPresetId: string; soundPlaybackError: string | null; pushStatusLine: (payload: HudStatusLinePayload) => void; unlockPlayback: (requestedUrl?: string | null) => Promise; resolveSoundPlaybackUrl: (presetId: string) => string | null; startSession: (input: { + atmosphereId: string; sceneId: string; goal: string; - timerPresetId: string; + focusDurationMinutes: number; soundPresetId: string | null; focusPlanItemId?: string; entryPoint: SessionEntryPoint; @@ -50,8 +51,9 @@ interface UseSpaceWorkspaceSessionControlsParams { advanceGoal: (input: { completedGoal: string; nextGoal: string; - sceneId: string; - timerPresetId: string; + sceneId?: string; + atmosphereId?: string | null; + focusDurationMinutes?: number; soundPresetId: string; focusPlanItemId?: string; }) => Promise<{ nextSession: FocusSession } | null>; @@ -74,7 +76,7 @@ export const useSpaceWorkspaceSessionControls = ({ goalInput, linkedFocusPlanItemId, selectedSceneId, - selectedTimerLabel, + selectedDurationMinutes, selectedPresetId, soundPlaybackError, pushStatusLine, @@ -122,16 +124,17 @@ export const useSpaceWorkspaceSessionControls = ({ const startFocusFlow = useCallback(async () => { const trimmedGoal = goalInput.trim(); - const timerPresetId = resolveTimerPresetIdFromLabel(selectedTimerLabel); + const selectedAtmosphere = findAtmosphereOptionForSelection(selectedSceneId, selectedPresetId); - if (!trimmedGoal || !timerPresetId) { + if (!trimmedGoal) { return; } const startedSession = await startSession({ + atmosphereId: selectedAtmosphere?.id ?? selectedSceneId, sceneId: selectedSceneId, goal: trimmedGoal, - timerPresetId, + focusDurationMinutes: selectedDurationMinutes, soundPresetId: selectedPresetId, focusPlanItemId: linkedFocusPlanItemId ?? undefined, entryPoint: pendingSessionEntryPoint, @@ -150,9 +153,9 @@ export const useSpaceWorkspaceSessionControls = ({ goalInput, pendingSessionEntryPoint, pushStatusLine, + selectedDurationMinutes, selectedPresetId, selectedSceneId, - selectedTimerLabel, linkedFocusPlanItemId, setPreviewPlaybackState, startSession, @@ -248,9 +251,11 @@ export const useSpaceWorkspaceSessionControls = ({ const handleGoalAdvance = useCallback(async (nextGoal: string) => { const trimmedNextGoal = nextGoal.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; } @@ -259,8 +264,9 @@ export const useSpaceWorkspaceSessionControls = ({ const nextState = await advanceGoal({ completedGoal: trimmedCurrentGoal, nextGoal: trimmedNextGoal, + atmosphereId: selectedAtmosphere?.id ?? null, + focusDurationMinutes: selectedDurationMinutes, sceneId: selectedSceneId, - timerPresetId, soundPresetId: selectedPresetId, focusPlanItemId: linkedFocusPlanItemId ?? undefined, }); @@ -290,9 +296,9 @@ export const useSpaceWorkspaceSessionControls = ({ linkedFocusPlanItemId, pushStatusLine, resolveSoundPlaybackUrl, + selectedDurationMinutes, selectedPresetId, selectedSceneId, - selectedTimerLabel, setGoalInput, setLinkedFocusPlanItemId, setPendingSessionEntryPoint, diff --git a/src/widgets/space-workspace/model/useWorkspacePersistence.ts b/src/widgets/space-workspace/model/useWorkspacePersistence.ts index f740088..d298cc5 100644 --- a/src/widgets/space-workspace/model/useWorkspacePersistence.ts +++ b/src/widgets/space-workspace/model/useWorkspacePersistence.ts @@ -8,7 +8,7 @@ import type { SceneTheme } from '@/entities/scene'; interface UseWorkspacePersistenceParams { hasHydratedSelection: boolean; selectedScene: SceneTheme; - selectedTimerPresetId: string | undefined; + selectedDurationMinutes: number; selectedPresetId: string; goalInput: string; showResumePrompt: boolean; @@ -19,7 +19,7 @@ interface UseWorkspacePersistenceParams { export const useWorkspacePersistence = ({ hasHydratedSelection, selectedScene, - selectedTimerPresetId, + selectedDurationMinutes, selectedPresetId, goalInput, showResumePrompt, @@ -41,7 +41,7 @@ export const useWorkspacePersistence = ({ WORKSPACE_SELECTION_STORAGE_KEY, JSON.stringify({ sceneId: selectedScene.id, - timerPresetId: selectedTimerPresetId, + durationMinutes: selectedDurationMinutes, soundPresetId: selectedPresetId, goal: normalizedGoal, override: selectionOverride, @@ -53,7 +53,7 @@ export const useWorkspacePersistence = ({ resumeGoal, selectedPresetId, selectedScene.id, - selectedTimerPresetId, + selectedDurationMinutes, selectionOverride, showResumePrompt, ]); diff --git a/src/widgets/space-workspace/model/workspaceSelection.ts b/src/widgets/space-workspace/model/workspaceSelection.ts index 5270a9e..232167e 100644 --- a/src/widgets/space-workspace/model/workspaceSelection.ts +++ b/src/widgets/space-workspace/model/workspaceSelection.ts @@ -3,20 +3,16 @@ import { normalizeSceneId, SCENE_THEMES, } from '@/entities/scene'; -import { - SOUND_PRESETS, - TIMER_PRESETS, - type TimerPreset, -} from '@/entities/session'; +import { SOUND_PRESETS } from '@/entities/session'; export type SelectionOverride = { sound: boolean; - timer: boolean; + duration: boolean; }; export interface StoredWorkspaceSelection { sceneId?: string; - timerPresetId?: string; + durationMinutes?: number; soundPresetId?: string; goal?: string; override?: Partial; @@ -85,56 +81,32 @@ export const resolveInitialSoundPreset = ( return SOUND_PRESETS[0].id; }; -export const TIMER_SELECTION_PRESETS = TIMER_PRESETS.filter( - (preset): preset is TimerPreset & { focusMinutes: number; breakMinutes: number } => - typeof preset.focusMinutes === 'number' && typeof preset.breakMinutes === 'number', -).slice(0, 3); +export const DURATION_SELECTION_OPTIONS = [25, 45, 70, 90] as const; -export const resolveTimerLabelFromPresetId = (presetId?: string) => { - if (!presetId) { - return null; - } - - 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, +export const resolveInitialDurationMinutes = ( + storedDurationMinutes?: number, + recommendedDurationMinutes?: number, ) => { - if (timerLabelFromQuery && TIMER_SELECTION_PRESETS.some((preset) => preset.label === timerLabelFromQuery)) { - return timerLabelFromQuery; + if (typeof storedDurationMinutes === 'number' && storedDurationMinutes >= 5 && storedDurationMinutes <= 180) { + return storedDurationMinutes; } - const storedLabel = resolveTimerLabelFromPresetId(storedPresetId); - - if (storedLabel) { - return storedLabel; + if ( + typeof recommendedDurationMinutes === 'number' && + recommendedDurationMinutes >= 5 && + recommendedDurationMinutes <= 180 + ) { + return recommendedDurationMinutes; } - const recommendedLabel = resolveTimerLabelFromPresetId(recommendedPresetId); - - if (recommendedLabel) { - return recommendedLabel; - } - - return TIMER_SELECTION_PRESETS[0]?.label ?? '25/5'; + return 50; }; -export const resolveFocusTimeDisplayFromTimerLabel = (timerLabel: string) => { - const preset = TIMER_SELECTION_PRESETS.find((candidate) => candidate.label === timerLabel); - const focusMinutes = preset?.focusMinutes ?? 25; - return `${String(focusMinutes).padStart(2, '0')}:00`; +export const formatDurationMinutesLabel = (minutes: number) => { + return `${minutes}m`; +}; + +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`; }; diff --git a/src/widgets/space-workspace/ui/SpaceWorkspaceWidget.tsx b/src/widgets/space-workspace/ui/SpaceWorkspaceWidget.tsx index 92ee214..f9b67d6 100644 --- a/src/widgets/space-workspace/ui/SpaceWorkspaceWidget.tsx +++ b/src/widgets/space-workspace/ui/SpaceWorkspaceWidget.tsx @@ -19,31 +19,24 @@ import { useHudStatusLine } from "@/shared/lib/useHudStatusLine"; import { copy } from "@/shared/i18n"; import { SpaceFocusHudWidget } from "@/widgets/space-focus-hud"; 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 type { SessionEntryPoint, WorkspaceMode } from "../model/types"; import { useSpaceWorkspaceSelection } from "../model/useSpaceWorkspaceSelection"; import { useSpaceWorkspaceSessionControls } from "../model/useSpaceWorkspaceSessionControls"; import { - resolveFocusTimeDisplayFromTimerLabel, - resolveInitialSceneId, - resolveInitialSoundPreset, - resolveInitialTimerLabel, - TIMER_SELECTION_PRESETS, + DURATION_SELECTION_OPTIONS, + resolveFocusTimeDisplayFromDurationMinutes, + resolveInitialDurationMinutes, } from "../model/workspaceSelection"; import { FocusTopToast } from "./FocusTopToast"; export const SpaceWorkspaceWidget = () => { - const searchParams = useSearchParams(); 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 { addThought, @@ -59,31 +52,28 @@ export const SpaceWorkspaceWidget = () => { const { isPro } = usePlanTier(); const { review, summary: weeklySummary } = useFocusStats(); - const initialSceneId = useMemo( - () => resolveInitialSceneId(sceneQuery, undefined), - [sceneQuery], - ); + const initialSceneId = useMemo(() => SCENE_THEMES[0].id, []); const initialScene = useMemo( () => getSceneById(initialSceneId) ?? SCENE_THEMES[0], [initialSceneId], ); const initialSoundPresetId = useMemo( - () => - resolveInitialSoundPreset( - soundQuery, - undefined, - initialScene.recommendedSoundPresetId, - ), - [initialScene.recommendedSoundPresetId, soundQuery], + () => initialScene.recommendedSoundPresetId, + [initialScene.recommendedSoundPresetId], ); - const initialTimerLabel = useMemo( + const initialAtmosphere = useMemo( () => - resolveInitialTimerLabel( - timerQuery, + findAtmosphereOptionForSelection(initialSceneId, initialSoundPresetId) ?? + findAtmosphereOptionForSelection(initialSceneId), + [initialSceneId, initialSoundPresetId], + ); + const initialDurationMinutes = useMemo( + () => + resolveInitialDurationMinutes( undefined, - initialScene.recommendedTimerPresetId, + initialAtmosphere ? getRecommendedDurationMinutes(initialAtmosphere) : undefined, ), - [initialScene.recommendedTimerPresetId, timerQuery], + [initialAtmosphere], ); const [workspaceMode, setWorkspaceMode] = useState("setup"); @@ -143,14 +133,9 @@ export const SpaceWorkspaceWidget = () => { const selection = useSpaceWorkspaceSelection({ initialSceneId, - initialGoal: goalQuery, - initialFocusPlanItemId: focusPlanItemIdQuery, - initialTimerLabel, - sceneQuery, - goalQuery, - soundQuery, - timerQuery, - hasQueryOverrides, + initialGoal: "", + initialFocusPlanItemId: null, + initialDurationMinutes, currentSession, sceneAssetMap, selectedPresetId, @@ -177,7 +162,7 @@ export const SpaceWorkspaceWidget = () => { goalInput: selection.goalInput, linkedFocusPlanItemId: selection.linkedFocusPlanItemId, selectedSceneId: selection.selectedSceneId, - selectedTimerLabel: selection.selectedTimerLabel, + selectedDurationMinutes: selection.selectedDurationMinutes, selectedPresetId, soundPlaybackError, pushStatusLine, @@ -222,10 +207,10 @@ export const SpaceWorkspaceWidget = () => { : undefined; useEffect(() => { - if (!isBootstrapping && !currentSession && !hasQueryOverrides) { + if (!isBootstrapping && !currentSession) { router.replace("/app"); } - }, [isBootstrapping, currentSession, hasQueryOverrides, router]); + }, [isBootstrapping, currentSession, router]); useEffect(() => { if (isBootstrapping || didResolveEntryRouteRef.current) { @@ -237,7 +222,7 @@ export const SpaceWorkspaceWidget = () => { if (!currentSession) { return; } - }, [currentSession, isBootstrapping, router]); + }, [currentSession, isBootstrapping]); useEffect(() => { const preferMobile = @@ -255,7 +240,7 @@ export const SpaceWorkspaceWidget = () => { const resolvedTimeDisplay = timeDisplay ?? - resolveFocusTimeDisplayFromTimerLabel(selection.selectedTimerLabel); + resolveFocusTimeDisplayFromDurationMinutes(selection.selectedDurationMinutes); return (
@@ -277,17 +262,17 @@ export const SpaceWorkspaceWidget = () => { scenes={selection.setupScenes} sceneAssetMap={sceneAssetMap} selectedSceneId={selection.selectedScene.id} - selectedTimerLabel={selection.selectedTimerLabel} + selectedDurationLabel={selection.selectedDurationLabel} selectedSoundPresetId={selectedPresetId} goalInput={selection.goalInput} selectedGoalId={selection.selectedGoalId} goalChips={GOAL_CHIPS} soundPresets={SOUND_PRESETS} - timerPresets={TIMER_SELECTION_PRESETS} + durationOptions={DURATION_SELECTION_OPTIONS} canStart={selection.canStart} onSceneSelect={selection.handleSelectScene} - onTimerSelect={(timerLabel) => - selection.handleSelectTimer(timerLabel, true) + onDurationSelect={(durationMinutes) => + selection.handleSelectDuration(durationMinutes, true) } onSoundSelect={(presetId) => selection.handleSelectSound(presetId, true) diff --git a/src/widgets/stats-overview/ui/StatsOverviewWidget.tsx b/src/widgets/stats-overview/ui/StatsOverviewWidget.tsx index 0b17001..ef69f59 100644 --- a/src/widgets/stats-overview/ui/StatsOverviewWidget.tsx +++ b/src/widgets/stats-overview/ui/StatsOverviewWidget.tsx @@ -11,12 +11,8 @@ import { cn } from '@/shared/lib/cn'; const DEFAULT_STATS_SCENE_ID = getSceneById('forest')?.id ?? SCENE_THEMES[0].id; -const reviewStageSceneByPreset = (presetId: string) => { - if (presetId.startsWith('forest')) { - return getSceneById('forest') ?? SCENE_THEMES[0]; - } - - return getSceneById(DEFAULT_STATS_SCENE_ID) ?? SCENE_THEMES[0]; +const reviewStageSceneByCarryForward = (sceneId: string) => { + return getSceneById(sceneId) ?? getSceneById(DEFAULT_STATS_SCENE_ID) ?? SCENE_THEMES[0]; }; export const StatsOverviewWidget = () => { @@ -26,8 +22,8 @@ export const StatsOverviewWidget = () => { const { review, isLoading, error, source, refetch } = useFocusStats(); const activeScene = useMemo( - () => reviewStageSceneByPreset(review.carryForward.presetId), - [review.carryForward.presetId], + () => reviewStageSceneByCarryForward(review.carryForward.sceneId), + [review.carryForward.sceneId], ); const sourceLabel = source === 'api' ? stats.sourceApi : stats.sourceMock; const syncLabel = error ? error : isLoading ? stats.loading : stats.synced; @@ -221,9 +217,11 @@ export const StatsOverviewWidget = () => { {isPro && PRO}

- {review.carryForward.presetLabel} + {review.carryForward.atmosphereLabel} +

+

+ {review.carryForward.durationMinutes}분 · 가장 무리 없이 들어갈 수 있는 흐름.

-

가장 무리 없이 들어갈 수 있는 흐름.