From 721212ec1f200afb8e31b7ef5d96b32bd6f029be Mon Sep 17 00:00:00 2001 From: corpi Date: Mon, 16 Mar 2026 12:12:03 +0900 Subject: [PATCH] =?UTF-8?q?feat(app):=20atmosphere=20entry=20shell=201?= =?UTF-8?q?=EC=B0=A8=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/90_current_state.md | 9 +- .../current/19_app_atmosphere_entry_spec.md | 88 ++++- docs/session_brief.md | 8 +- docs/work.md | 4 +- .../focus-dashboard/model/atmosphereEntry.ts | 224 +++++++++++++ .../ui/AppAtmosphereEntryShell.tsx | 288 +++++++++++++++++ .../ui/FocusDashboardWidget.tsx | 304 +++++++++--------- 7 files changed, 759 insertions(+), 166 deletions(-) create mode 100644 src/widgets/focus-dashboard/model/atmosphereEntry.ts create mode 100644 src/widgets/focus-dashboard/ui/AppAtmosphereEntryShell.tsx diff --git a/docs/90_current_state.md b/docs/90_current_state.md index 7c353bb..646d073 100644 --- a/docs/90_current_state.md +++ b/docs/90_current_state.md @@ -1,9 +1,16 @@ # 90. Current State -Last Updated: 2026-03-15 +Last Updated: 2026-03-16 ## DONE +- `/app` Atmosphere Entry Shell 1차 구현: + - no-session `/app`을 `goal + duration + atmosphere` 중심의 premium entry shell로 교체했다 + - `microStep` 입력은 entry에서 제거했고, `예상 시간(분)` 입력과 12개 dummy atmosphere grid를 추가했다 + - atmosphere는 `scene + sound`가 함께 묶인 선택 단위로 동작하며, 선택한 atmosphere가 `/app` 배경과 `/space` start payload에 같이 반영된다 + - custom duration server contract 전까지는 입력한 분 값을 가장 가까운 기본 리듬(`25/5`, `50/10`, `90/20`)으로 매핑한다 + - weekly review entry는 main CTA를 먹지 않도록 no-session shell의 quiet secondary dock 위치로 이동했다 + - `Paused Session Takeover Flow` 구현: - `/app` paused gate에 `새 목표로 전환` 진입점을 추가했다 - takeover confirm sheet에서만 current paused session을 정리하고 single-goal start 상태로 넘어간다 diff --git a/docs/screens/app/current/19_app_atmosphere_entry_spec.md b/docs/screens/app/current/19_app_atmosphere_entry_spec.md index 35426ec..883bc80 100644 --- a/docs/screens/app/current/19_app_atmosphere_entry_spec.md +++ b/docs/screens/app/current/19_app_atmosphere_entry_spec.md @@ -4,13 +4,18 @@ Last Updated: 2026-03-16 이 문서는 `/app`을 **`goal + duration + atmosphere` 중심의 premium focus entry surface**로 재설계하기 위한 기준 문서다. +현재 상태: + +- `Slice 1` no-session shell 구현 완료 +- `Custom Duration Contract`와 `Weekly Review Dock Reposition`은 다음 slice로 남아 있음 + 관련 문서: -- `../../product_principles.md` -- `../../current_context.md` -- `../stats/14_weekly_review_reframe_spec.md` -- `./15_app_stats_entry_flow_spec.md` -- `./18_paused_session_reentry_spec.md` +- `../../../../../product_principles.md` +- `../../../../../current_context.md` +- `../../stats/current/14_weekly_review_reframe_spec.md` +- `../../../flows/current/15_app_stats_entry_flow_spec.md` +- `../../../flows/current/18_paused_session_reentry_spec.md` --- @@ -175,6 +180,9 @@ Last Updated: 2026-03-16 - 최대 180분 - helper: - `이 목표를 끝내는 데 걸릴 것 같은 시간을 적어요.` +- quick duration suggestion은 허용한다. + - 예: `25`, `45`, `70`, `90` + - 단, planner처럼 보이지 않도록 아주 조용한 assistive chip이어야 한다 ### C. Atmosphere Grid @@ -189,6 +197,8 @@ Last Updated: 2026-03-16 - 카드명 - sound label - 1줄 description +- selected state +- hover / focus state 예시: @@ -209,7 +219,14 @@ Last Updated: 2026-03-16 - 이유: - goal + duration + atmosphere가 모두 한 번에 묶여 entry action으로 읽힌다 -### E. Weekly Review Entry +### E. Selection Rules + +- no-session 상태에서는 atmosphere 1개가 기본 선택된 상태로 시작한다 +- review handoff로 들어온 경우에는 handoff preset과 가장 가까운 atmosphere를 preselect한다 +- 사용자가 duration을 직접 수정하기 전까지는 선택된 atmosphere의 기본 duration suggestion을 보여줄 수 있다 +- 사용자가 duration을 직접 수정한 뒤에는 atmosphere를 바꿔도 duration 값을 덮어쓰지 않는다 + +### F. Weekly Review Entry 이건 main stage 바깥의 quiet secondary placement로 둔다. @@ -319,6 +336,65 @@ Last Updated: 2026-03-16 --- +## 9. Visual Direction + +### Primary Stage + +- no-session `/app`의 주인공은 하나의 넓은 start stage다 +- 이 stage 안에서: + - goal + - duration + - primary CTA + 가 먼저 읽혀야 한다 +- stage는 glassmorphism을 쓰더라도 dashboard 카드처럼 보이면 안 된다 +- typography와 spacing으로 premium하게 보여야 한다 + +### Atmosphere Grid + +- 아래 grid는 decoration이 아니라 실제 선택 surface다 +- card는 서로 다른 장면으로 충분히 구분돼야 한다 +- selected state는: + - 두꺼운 outline보다 얇은 light ring + - 미세한 scale + - title/sound contrast 상승 + 정도로 표현하는 편이 맞다 + +### Review Dock + +- review는 top-right quiet dock 또는 stage 바깥의 조용한 secondary panel로 둔다 +- 절대 main CTA와 같은 무게가 되면 안 된다 +- “이번 주 review”는 읽히되, goal 입력을 방해하면 실패다 + +--- + +## 10. Slice 1 구현 원칙 + +이번 구현 slice는 `/app`의 **no-session shell**을 먼저 바꾸는 단계다. + +포함: + +- goal input +- duration input +- 12개 atmosphere grid +- selected atmosphere 기반 background 반영 +- quiet review dock의 기본 위치 + +제외: + +- paused resume gate 재설계 +- `/stats` IA 변경 +- server custom duration contract + +### Slice 1 임시 계약 + +- 사용자는 분 단위 duration을 입력한다 +- 하지만 server contract가 아직 preset 기반이면, 이번 slice에서는 **입력 시간에 가장 가까운 기존 focus preset**으로 임시 매핑한다 +- 이 임시 매핑은 다음 slice(`Custom Duration Contract`)에서 실제 duration 연동으로 대체한다 +- UI에는 이 상태가 과장 없이 드러나야 한다 + - 예: `지금은 가장 가까운 기본 리듬으로 먼저 들어가요.` + +--- + ## 9. UI 방향 ### 톤 diff --git a/docs/session_brief.md b/docs/session_brief.md index 4ccee6e..7751360 100644 --- a/docs/session_brief.md +++ b/docs/session_brief.md @@ -1,6 +1,6 @@ # Session Brief -Last Updated: 2026-03-15 +Last Updated: 2026-03-16 세션 시작 시 항상 읽는 초소형 스냅샷 문서. @@ -26,6 +26,12 @@ Last Updated: 2026-03-15 - microStep은 `/app`에서 제거하고, duration은 분 단위 직접 입력으로 전환한다. - scene + sound 조합 카드는 `Atmosphere`로 부르며, 첫 구현은 12개 dummy grid를 사용한다. - weekly review와 achieved-goal insight는 main stage가 아니라 quiet secondary dock로 유지한다. +- `/app` Atmosphere Entry Shell 1차 구현을 반영했다. + - no-session 상태는 더 이상 legacy `goal + microStep + fixed ritual` 화면을 쓰지 않는다. + - 현재는 `goal 1개 + 예상 시간(분) + atmosphere 12개 grid + start CTA`로 들어간다. + - 선택한 atmosphere는 `/app` 배경 preview와 `/space` start payload의 `scene/sound`에 같이 반영된다. + - duration은 우선 가장 가까운 기본 리듬으로 매핑하는 임시 계약을 사용한다. + - weekly review entry는 right-side quiet dock 위치로 옮겨 main CTA보다 낮은 위계를 유지한다. - `Paused Session Takeover Flow`를 구현했다. - `/app` paused gate에 `새 목표로 전환` 액션이 추가됐다. diff --git a/docs/work.md b/docs/work.md index f525636..1d50bde 100644 --- a/docs/work.md +++ b/docs/work.md @@ -38,7 +38,7 @@ - goal + duration + selected atmosphere가 start surface 안에서 명확히 읽힌다 - 12개 dummy atmosphere가 4열 그리드로 배치된다 - 진행 상태: - - 대기 + - 구현 완료, browser QA 대기 - 검증: - `/app` no-session browser QA - 커밋 힌트: @@ -61,7 +61,7 @@ - `70분` 같은 값이 실제 focus duration으로 반영된다 - break duration이 정책 기준으로 계산된다 - 진행 상태: - - 대기 + - 다음 작업 - 검증: - start -> `/space` -> timer duration 확인 - 커밋 힌트: diff --git a/src/widgets/focus-dashboard/model/atmosphereEntry.ts b/src/widgets/focus-dashboard/model/atmosphereEntry.ts new file mode 100644 index 0000000..82166dc --- /dev/null +++ b/src/widgets/focus-dashboard/model/atmosphereEntry.ts @@ -0,0 +1,224 @@ +import { getSceneById, type SceneTheme } from '@/entities/scene'; +import { SOUND_PRESETS } from '@/entities/session'; + +const TIMER_PRESETS = [ + { id: '25-5', label: '25/5', focusMinutes: 25 }, + { id: '50-10', label: '50/10', focusMinutes: 50 }, + { id: '90-20', label: '90/20', focusMinutes: 90 }, +] as const; + +const DURATION_SUGGESTIONS = [25, 45, 70, 90] as const; + +export interface AtmosphereOption { + id: string; + name: string; + sceneId: string; + soundPresetId: string | null; + description: string; + caption: string; + scene: SceneTheme; + soundLabel: string; +} + +const createAtmosphereOption = ( + id: string, + name: string, + sceneId: string, + soundPresetId: string | null, + description: string, + caption: string, +): AtmosphereOption => { + const scene = getSceneById(sceneId); + if (!scene) { + throw new Error(`Unknown scene for atmosphere option: ${sceneId}`); + } + + return { + id, + name, + sceneId, + soundPresetId, + description, + caption, + scene, + soundLabel: + SOUND_PRESETS.find((preset) => preset.id === soundPresetId)?.label ?? 'Silent', + }; +}; + +export const ATMOSPHERE_OPTIONS: AtmosphereOption[] = [ + createAtmosphereOption( + 'rain-window', + 'Rain Window', + 'rain-window', + 'rain-focus', + '비 소리 위로 조용히 문장을 붙잡기 좋은 흐름.', + '조용한 시작', + ), + createAtmosphereOption( + 'quiet-library', + 'Quiet Library', + 'quiet-library', + 'deep-white', + '소음 없이 길게 읽고 정리할 때 안정적인 조합.', + '길게 읽는 날', + ), + createAtmosphereOption( + 'dawn-cafe', + 'Dawn Cafe', + 'dawn-cafe', + 'cafe-work', + '가볍게 손을 움직이며 초안을 시작하기 좋은 온도.', + '워밍업용', + ), + createAtmosphereOption( + 'forest-draft', + 'Forest Draft', + 'forest', + 'forest-birds', + '딥워크 진입 전에 숨을 고르게 만드는 기본 조합.', + '기본 리듬', + ), + createAtmosphereOption( + 'fireplace-glow', + 'Fireplace Glow', + 'fireplace', + 'fireplace', + '밤에 닫히지 않는 일 하나를 끝까지 가져가고 싶을 때.', + '늦은 시간용', + ), + createAtmosphereOption( + 'deep-night-desk', + 'Deep Night Desk', + 'city-night', + 'deep-white', + '도시의 불빛은 멀리 두고 화면 안의 일만 남기는 조합.', + '몰입 유지', + ), + createAtmosphereOption( + 'snow-light', + 'Snow Light', + 'snow-mountain', + 'deep-white', + '머리를 식히면서 구조를 정리해야 할 때 선명한 공기.', + '정리용', + ), + createAtmosphereOption( + 'sun-window', + 'Sun Window', + 'sun-window', + 'silent', + '과하게 자극적이지 않게 아침 에너지를 가져오는 장면.', + '낮 시간용', + ), + createAtmosphereOption( + 'ocean-still', + 'Ocean Still', + 'wave-sound', + 'ocean-calm', + '넓은 생각이 필요한 기획이나 리서치에 어울리는 흐름.', + '넓게 생각하기', + ), + createAtmosphereOption( + 'orbit-night', + 'Orbit Night', + 'outer-space', + 'deep-white', + '길고 깊은 블록에 들어갈 때 외부 자극을 멀리 밀어낸다.', + '장시간 집중', + ), + createAtmosphereOption( + 'rain-notes', + 'Rain Notes', + 'rain-window', + 'deep-white', + '빗소리 대신 더 조용한 백색 소음으로 문장만 남긴 버전.', + '더 낮은 자극', + ), + createAtmosphereOption( + 'quiet-pages', + 'Quiet Pages', + 'quiet-library', + 'silent', + '읽기와 쓰기 사이를 오갈 때 가장 얇은 존재감으로 머문다.', + '완전 조용함', + ), +]; + +export const ENTRY_DURATION_SUGGESTIONS = [...DURATION_SUGGESTIONS]; + +export const parseDurationMinutes = (value: string) => { + const digitsOnly = value.replace(/[^\d]/g, ''); + if (!digitsOnly) { + return null; + } + + const parsed = Number(digitsOnly); + if (!Number.isFinite(parsed)) { + return null; + } + + return Math.max(10, Math.min(180, parsed)); +}; + +export const sanitizeDurationDraft = (value: string) => { + const digitsOnly = value.replace(/[^\d]/g, ''); + if (!digitsOnly) { + return ''; + } + + const parsed = Number(digitsOnly); + if (!Number.isFinite(parsed)) { + return ''; + } + + return String(Math.max(10, Math.min(180, parsed))); +}; + +export const getTimerPresetMetaById = (timerPresetId: string) => { + return TIMER_PRESETS.find((preset) => preset.id === timerPresetId) ?? TIMER_PRESETS[1]; +}; + +export const resolveNearestTimerPreset = (minutes: number) => { + return TIMER_PRESETS.reduce((best, candidate) => { + const bestDiff = Math.abs(best.focusMinutes - minutes); + const candidateDiff = Math.abs(candidate.focusMinutes - minutes); + + if (candidateDiff < bestDiff) { + return candidate; + } + + if (candidateDiff === bestDiff && candidate.focusMinutes > best.focusMinutes) { + return candidate; + } + + return best; + }); +}; + +export const getRecommendedDurationMinutes = (option: AtmosphereOption) => { + return getTimerPresetMetaById(option.scene.recommendedTimerPresetId).focusMinutes; +}; + +export const getAtmosphereOptionById = (id: string) => { + return ATMOSPHERE_OPTIONS.find((option) => option.id === id) ?? ATMOSPHERE_OPTIONS[0]; +}; + +export const findAtmosphereOptionForSelection = ( + sceneId?: string | null, + soundPresetId?: string | null, +) => { + if (!sceneId) { + return null; + } + + return ( + ATMOSPHERE_OPTIONS.find( + (option) => + option.sceneId === sceneId && + (soundPresetId == null || option.soundPresetId === soundPresetId), + ) ?? + ATMOSPHERE_OPTIONS.find((option) => option.sceneId === sceneId) ?? + null + ); +}; diff --git a/src/widgets/focus-dashboard/ui/AppAtmosphereEntryShell.tsx b/src/widgets/focus-dashboard/ui/AppAtmosphereEntryShell.tsx new file mode 100644 index 0000000..447f24d --- /dev/null +++ b/src/widgets/focus-dashboard/ui/AppAtmosphereEntryShell.tsx @@ -0,0 +1,288 @@ +'use client'; + +import type { ReactNode, RefObject } from 'react'; +import { getSceneCardPhotoUrl } from '@/entities/scene'; +import { cn } from '@/shared/lib/cn'; +import type { AtmosphereOption } from '../model/atmosphereEntry'; + +const shellCardClass = + 'rounded-[2rem] border border-white/12 bg-[#0f1115]/26 shadow-[0_24px_60px_rgba(3,7,18,0.32)] backdrop-blur-xl'; +const inputShellClass = + 'w-full rounded-[1.5rem] border border-white/14 bg-white/[0.06] px-5 py-4 text-white outline-none transition focus:border-white/24 focus:bg-white/[0.09]'; +const primaryButtonClass = + 'inline-flex items-center justify-center rounded-full border border-white/16 bg-white/[0.14] px-6 py-3 text-sm font-medium text-white transition hover:bg-white/[0.18] active:scale-[0.99] disabled:cursor-not-allowed disabled:opacity-48'; + +interface AppAtmosphereEntryShellProps { + canStart: boolean; + durationDraft: string; + durationHelper: string; + durationInputLabel: string; + durationPlaceholder: string; + durationSuggestions: number[]; + goalDraft: string; + goalInputRef: RefObject; + goalPlaceholder: string; + isStartingSession: boolean; + reviewEntry?: ReactNode; + selectedAtmosphere: AtmosphereOption; + sessionLookupError?: string | null; + startButtonLabel: string; + startButtonLoadingLabel: string; + atmosphereOptions: AtmosphereOption[]; + atmosphereTitle: string; + atmosphereBody: string; + onDurationChange: (value: string) => void; + onGoalChange: (value: string) => void; + onSelectAtmosphere: (id: string) => void; + onSelectDuration: (minutes: number) => void; + onStartSession: () => void; +} + +export const AppAtmosphereEntryShell = ({ + canStart, + durationDraft, + durationHelper, + durationInputLabel, + durationPlaceholder, + durationSuggestions, + goalDraft, + goalInputRef, + goalPlaceholder, + isStartingSession, + reviewEntry, + selectedAtmosphere, + sessionLookupError, + startButtonLabel, + startButtonLoadingLabel, + atmosphereOptions, + atmosphereTitle, + atmosphereBody, + onDurationChange, + onGoalChange, + onSelectAtmosphere, + onSelectDuration, + onStartSession, +}: AppAtmosphereEntryShellProps) => { + return ( +
+
+
+
+

+ 이번엔 무엇을 붙잡을까요? +

+

+ 목표는 한 줄이면 충분해요. 얼마나 붙잡을지와 어떤 분위기로 들어갈지만 정하면 바로 + 집중 화면으로 이어집니다. +

+
+ +
+ + +
+ + +
+ {durationSuggestions.map((minutes) => ( + + ))} +
+
+ +
+
+

+ Selected Atmosphere +

+

{selectedAtmosphere.name}

+

+ {selectedAtmosphere.soundLabel} · {selectedAtmosphere.caption} +

+
+ +
+ + {sessionLookupError ? ( +

{sessionLookupError}

+ ) : null} +
+
+ + +
+ +
+
+
+

+ Atmosphere +

+

+ {atmosphereTitle} +

+

+ {atmosphereBody} +

+
+

+ 총 {atmosphereOptions.length}개의 Atmosphere +

+
+ +
+ {atmosphereOptions.map((option) => { + const isSelected = option.id === selectedAtmosphere.id; + + return ( + + ); + })} +
+

+ 고른 Atmosphere는 배경과 사운드가 함께 적용되고, 시간은 가장 가까운 기본 리듬으로 + 먼저 맞춰집니다. +

+
+
+ ); +}; diff --git a/src/widgets/focus-dashboard/ui/FocusDashboardWidget.tsx b/src/widgets/focus-dashboard/ui/FocusDashboardWidget.tsx index 56494ce..3ab4e49 100644 --- a/src/widgets/focus-dashboard/ui/FocusDashboardWidget.tsx +++ b/src/widgets/focus-dashboard/ui/FocusDashboardWidget.tsx @@ -13,6 +13,18 @@ import { focusSessionApi, type FocusSession } from '@/features/focus-session/api import { useFocusStats, type ReviewCarryHint } from '@/features/stats'; import { copy } from '@/shared/i18n'; import { cn } from '@/shared/lib/cn'; +import { + ATMOSPHERE_OPTIONS, + ENTRY_DURATION_SUGGESTIONS, + findAtmosphereOptionForSelection, + getAtmosphereOptionById, + getRecommendedDurationMinutes, + getTimerPresetMetaById, + parseDurationMinutes, + resolveNearestTimerPreset, + sanitizeDurationDraft, +} from '../model/atmosphereEntry'; +import { AppAtmosphereEntryShell } from './AppAtmosphereEntryShell'; const DEFAULT_SCENE_ID = getSceneById('forest')?.id ?? SCENE_THEMES[0].id; const DEFAULT_SOUND_ID = SOUND_PRESETS.find((preset) => preset.id === 'forest-birds')?.id ?? SOUND_PRESETS[0].id; @@ -25,20 +37,20 @@ const REVIEW_ENTRY_PRESETS = { label: '숲 · 50/10 · Forest Birds', }, } as const; -const GOAL_SUGGESTIONS = copy.session.goalChips.slice(0, 4); +const DEFAULT_ATMOSPHERE = + findAtmosphereOptionForSelection(DEFAULT_SCENE_ID, DEFAULT_SOUND_ID) ?? ATMOSPHERE_OPTIONS[0]; const entryCopy = { eyebrow: 'VibeRoom', - title: '지금 붙잡을 한 가지', - description: '길게 정리하지 말고, 한 줄만 남기고 바로 들어가요.', goalPlaceholder: '예: 제안서 첫 문단만 다듬기', - microStepLabel: '지금 할 한 조각', - microStepPlaceholder: '예: 파일 열고 첫 문장만 정리하기', - microStepHelper: '선택 사항이에요. 바로 손이 가게 만드는 한 조각이면 충분해요.', - startNow: '지금 시작', - startLoading: '몰입 준비 중...', - ritualHint: '기본 ritual · 숲 · 50/10 · Forest Birds', - ritualHelper: '공간과 사운드는 들어간 뒤에도 바꿀 수 있어요.', + durationLabel: '예상 시간(분)', + durationPlaceholder: '예: 70', + durationHelper: '입력한 시간은 지금 가장 가까운 기본 리듬으로 먼저 맞춰서 들어가요.', + startNow: '이 분위기로 들어가기', + startLoading: '입장 준비 중...', + atmosphereTitle: '어떤 분위기에서 들어갈까요?', + atmosphereBody: + '배경과 사운드는 같이 움직여요. 오늘 goal에 맞는 atmosphere 하나만 고르면 바로 들어갈 수 있어요.', resumeEyebrow: 'Resume', resumeRunning: '진행 중인 세션이 있어요.', resumePaused: '잠시 멈춘 세션이 있어요.', @@ -84,19 +96,11 @@ const entryCopy = { const goalCardClass = 'w-full rounded-[2rem] border border-white/12 bg-[#0f1115]/26 px-6 py-6 shadow-[0_24px_60px_rgba(3,7,18,0.32)] backdrop-blur-xl md:px-8 md:py-8'; -const inputShellClass = - 'w-full rounded-[1.75rem] border border-white/14 bg-white/[0.06] px-5 py-4 text-white outline-none transition focus:border-white/24 focus:bg-white/[0.09]'; const primaryButtonClass = 'inline-flex items-center justify-center rounded-full border border-white/16 bg-white/[0.14] px-6 py-3 text-sm font-medium text-white transition hover:bg-white/[0.18] active:scale-[0.99] disabled:cursor-not-allowed disabled:opacity-48'; const secondaryButtonClass = 'inline-flex items-center justify-center rounded-full border border-white/10 bg-white/[0.06] px-5 py-3 text-sm font-medium text-white/84 transition hover:bg-white/[0.1] hover:text-white active:scale-[0.99]'; -const timerLabelById: Record = { - '25-5': '25/5', - '50-10': '50/10', - '90-20': '90/20', -}; - const resolveSoundLabel = (soundPresetId?: string | null) => { if (!soundPresetId) { return 'Silent'; @@ -142,9 +146,26 @@ export const FocusDashboardWidget = () => { return REVIEW_ENTRY_PRESETS[reviewEntryPreset as keyof typeof REVIEW_ENTRY_PRESETS] ?? null; }, [reviewEntryPreset]); + const initialAtmosphere = useMemo(() => { + return ( + findAtmosphereOptionForSelection( + reviewEntryPresetConfig?.sceneId ?? DEFAULT_SCENE_ID, + reviewEntryPresetConfig?.soundPresetId ?? DEFAULT_SOUND_ID, + ) ?? DEFAULT_ATMOSPHERE + ); + }, [reviewEntryPresetConfig]); + const initialDurationMinutes = useMemo(() => { + if (reviewEntryPresetConfig) { + return getTimerPresetMetaById(reviewEntryPresetConfig.timerPresetId).focusMinutes; + } + + return getRecommendedDurationMinutes(initialAtmosphere); + }, [initialAtmosphere, reviewEntryPresetConfig]); const [goalDraft, setGoalDraft] = useState(''); - const [microStepDraft, setMicroStepDraft] = useState(''); + const [durationDraft, setDurationDraft] = useState(() => String(initialDurationMinutes)); + const [selectedAtmosphereId, setSelectedAtmosphereId] = useState(initialAtmosphere.id); + const [hasEditedDuration, setHasEditedDuration] = useState(false); const [isStartingSession, setIsStartingSession] = useState(false); const [paywallSource, setPaywallSource] = useState(null); const [currentSession, setCurrentSession] = useState(null); @@ -156,20 +177,35 @@ export const FocusDashboardWidget = () => { const [focusGoalAfterTakeover, setFocusGoalAfterTakeover] = useState(false); const goalInputRef = useRef(null); + const selectedAtmosphere = useMemo( + () => getAtmosphereOptionById(selectedAtmosphereId), + [selectedAtmosphereId], + ); + const parsedDurationMinutes = parseDurationMinutes(durationDraft); + const resolvedTimerPreset = useMemo(() => { + const targetMinutes = + parsedDurationMinutes ?? getRecommendedDurationMinutes(selectedAtmosphere); + return resolveNearestTimerPreset(targetMinutes); + }, [parsedDurationMinutes, selectedAtmosphere]); const activeScene = useMemo(() => { - return getSceneById(currentSession?.sceneId ?? reviewEntryPresetConfig?.sceneId ?? DEFAULT_SCENE_ID) ?? SCENE_THEMES[0]; - }, [currentSession?.sceneId, reviewEntryPresetConfig?.sceneId]); + return getSceneById(currentSession?.sceneId ?? selectedAtmosphere.sceneId) ?? SCENE_THEMES[0]; + }, [currentSession?.sceneId, selectedAtmosphere.sceneId]); const activeRitualMeta = useMemo(() => { - const timerLabel = timerLabelById[currentSession?.timerPresetId ?? DEFAULT_TIMER_ID] ?? '50/10'; + const timerLabel = + getTimerPresetMetaById(currentSession?.timerPresetId ?? DEFAULT_TIMER_ID).label; const soundLabel = resolveSoundLabel(currentSession?.soundPresetId ?? DEFAULT_SOUND_ID); return `${activeScene.name} · ${timerLabel} · ${soundLabel}`; }, [activeScene.name, currentSession?.soundPresetId, currentSession?.timerPresetId]); const trimmedGoal = goalDraft.trim(); - const canStart = trimmedGoal.length > 0 && !isStartingSession && !currentSession; + const canStart = + trimmedGoal.length > 0 && + parsedDurationMinutes !== null && + !isStartingSession && + !currentSession; const hasEnoughWeeklyData = weeklySummary.last7Days.startedSessions >= 3 && (weeklySummary.last7Days.completedSessions >= 2 || @@ -193,7 +229,12 @@ export const FocusDashboardWidget = () => { const reviewTeaserSummary = isPro ? review.carryForward.keepDoing : review.snapshotSummary; const reviewTeaserHelper = isPro ? entryCopy.reviewHelperPro : entryCopy.reviewHelper; const reviewTeaserCta = isPro ? entryCopy.reviewCtaPro : entryCopy.reviewCta; - const entryRitualHint = reviewEntryPresetConfig ? `추천 ritual · ${reviewEntryPresetConfig.label}` : entryCopy.ritualHint; + const durationHelper = + parsedDurationMinutes === null + ? '이 목표를 끝내는 데 걸릴 것 같은 시간을 분 단위로 적어주세요.' + : parsedDurationMinutes === resolvedTimerPreset.focusMinutes + ? `${entryCopy.durationHelper} 지금은 ${resolvedTimerPreset.label} 리듬으로 바로 들어가요.` + : `${entryCopy.durationHelper} ${parsedDurationMinutes}분은 지금 ${resolvedTimerPreset.label} 리듬으로 먼저 들어가요.`; const isRunningSession = currentSession?.state === 'running'; const isPausedSession = currentSession?.state === 'paused'; @@ -257,9 +298,30 @@ export const FocusDashboardWidget = () => { } }; - const handleSelectSuggestion = (label: string) => { - setGoalDraft(label); - goalInputRef.current?.focus(); + const resetEntryDrafts = () => { + setGoalDraft(''); + setSelectedAtmosphereId(initialAtmosphere.id); + setDurationDraft(String(initialDurationMinutes)); + setHasEditedDuration(false); + }; + + const handleDurationChange = (value: string) => { + setDurationDraft(sanitizeDurationDraft(value)); + setHasEditedDuration(true); + }; + + const handleSelectDuration = (minutes: number) => { + setDurationDraft(String(minutes)); + setHasEditedDuration(true); + }; + + const handleSelectAtmosphere = (atmosphereId: string) => { + const nextAtmosphere = getAtmosphereOptionById(atmosphereId); + setSelectedAtmosphereId(nextAtmosphere.id); + + if (!hasEditedDuration) { + setDurationDraft(String(getRecommendedDurationMinutes(nextAtmosphere))); + } }; const handleStartSession = async () => { @@ -270,15 +332,19 @@ export const FocusDashboardWidget = () => { return; } + if (parsedDurationMinutes === null) { + return; + } + setIsStartingSession(true); try { await focusSessionApi.startSession({ goal: trimmedGoal, - microStep: microStepDraft.trim() || null, - sceneId: reviewEntryPresetConfig?.sceneId ?? DEFAULT_SCENE_ID, - soundPresetId: reviewEntryPresetConfig?.soundPresetId ?? DEFAULT_SOUND_ID, - timerPresetId: reviewEntryPresetConfig?.timerPresetId ?? DEFAULT_TIMER_ID, + microStep: null, + sceneId: selectedAtmosphere.sceneId, + soundPresetId: selectedAtmosphere.soundPresetId, + timerPresetId: resolvedTimerPreset.id, entryPoint: 'space-setup', }); router.push('/space'); @@ -338,8 +404,7 @@ export const FocusDashboardWidget = () => { setCurrentSession(null); setIsTakeoverSheetOpen(false); setSessionLookupError(null); - setGoalDraft(''); - setMicroStepDraft(''); + resetEntryDrafts(); setFocusGoalAfterTakeover(true); } catch (error) { setTakeoverError( @@ -478,131 +543,58 @@ export const FocusDashboardWidget = () => { ) : null} ) : ( - <> -
-
-

- {entryCopy.title} -

-

- {entryCopy.description} -

-
- -
- - - - -

{entryCopy.microStepHelper}

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

- {entryRitualHint} -

-

{entryCopy.ritualHelper}

-
-
- - {sessionLookupError ? ( -

{entryCopy.loadFailed}

- ) : null} -
- - {shouldShowWeeklyReviewTeaser ? ( - -
-
-

- {entryCopy.reviewEyebrow} -

-

- {reviewTeaserTitle} -

-

- {reviewTeaserSummary} -

-

{reviewTeaserHelper}

+
+
+

+ {entryCopy.reviewEyebrow} +

+

+ {reviewTeaserTitle} +

+

+ {reviewTeaserSummary} +

+

{reviewTeaserHelper}

+
+ + {reviewTeaserCta} +
- - {reviewTeaserCta} - -
- - ) : null} - + + ) : undefined + } + selectedAtmosphere={selectedAtmosphere} + sessionLookupError={sessionLookupError} + startButtonLabel={entryCopy.startNow} + startButtonLoadingLabel={entryCopy.startLoading} + atmosphereOptions={ATMOSPHERE_OPTIONS} + atmosphereTitle={entryCopy.atmosphereTitle} + atmosphereBody={entryCopy.atmosphereBody} + onDurationChange={handleDurationChange} + onGoalChange={setGoalDraft} + onSelectAtmosphere={handleSelectAtmosphere} + onSelectDuration={handleSelectDuration} + onStartSession={() => { + void handleStartSession(); + }} + /> )}
)}