feat(flow): focus session api v2 웹 계약 전환
This commit is contained in:
@@ -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 진입
|
||||||
|
|||||||
@@ -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`
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|||||||
@@ -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 정책 재정의 |
|
||||||
|
|||||||
@@ -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를 직접 고르지 않아도 자연스럽게 이어지게 하기 위한 기본 정책이다.
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -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에 연결돼야 한다.
|
||||||
|
|
||||||
|
|||||||
@@ -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회 노출
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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';
|
||||||
|
|||||||
@@ -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,
|
|
||||||
});
|
|
||||||
};
|
|
||||||
@@ -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];
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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> => {
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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;
|
|
||||||
|
|||||||
@@ -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' },
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
@@ -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' },
|
||||||
|
|||||||
@@ -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) => {
|
||||||
|
|||||||
@@ -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');
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
|||||||
@@ -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>;
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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,
|
||||||
]);
|
]);
|
||||||
|
|||||||
@@ -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`;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user