From f24205f3240c0cddbf59df909c1e84ee29b0d4b3 Mon Sep 17 00:00:00 2001 From: corpi Date: Thu, 5 Mar 2026 12:06:21 +0900 Subject: [PATCH] =?UTF-8?q?feat(curation):=20Scene=20=EC=B6=94=EC=B2=9C=20?= =?UTF-8?q?=EC=9E=90=EB=8F=99=20=EC=A0=81=EC=9A=A9=EA=B3=BC=20override=20?= =?UTF-8?q?=EC=A1=B4=EC=A4=91=20=EA=B7=9C=EC=B9=99=20=EB=8F=84=EC=9E=85?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 맥락: - 목표 입력 후 바로 시작할 수 있도록 Scene 기반 추천값을 자동으로 채우되, 사용자가 직접 바꾼 값은 유지해야 했습니다. 변경사항: - RoomTheme에 recommendedSoundPresetId, recommendedTimerPresetId 필드를 추가하고 각 Scene 더미 데이터에 추천 preset id를 매핑했습니다. - /space 초기 진입 시 선택된 Scene 추천값으로 타이머/사운드 기본값이 설정되도록 초기화 로직을 정리했습니다. - /space 상태에 override.sound/override.timer 플래그를 추가하고, Scene 변경 시 override가 false인 항목만 자동 동기화하도록 반영했습니다. - 추천 복원 액션(추천으로 되돌림)을 위한 핸들러/props 경로를 workspace -> tools-dock -> control-center까지 연결했습니다. 검증: - npx tsc --noEmit 세션-상태: Scene 추천 자동 적용과 override 기반 자동 동기화가 동작합니다. 세션-다음: Control Center를 Scene/Time 중심 구조로 단순화하고 추천 정보/되돌리기 UI를 적용합니다. 세션-리스크: Control Center UI가 아직 기존 구조(Sound/Pack 포함)라 다음 커밋에서 정리가 필요합니다. --- src/entities/room/model/rooms.ts | 20 +++ src/entities/room/model/types.ts | 2 + .../ui/ControlCenterSheetWidget.tsx | 6 + .../ui/SpaceToolsDockWidget.tsx | 9 ++ .../ui/SpaceWorkspaceWidget.tsx | 143 ++++++++++++++++-- 5 files changed, 169 insertions(+), 11 deletions(-) diff --git a/src/entities/room/model/rooms.ts b/src/entities/room/model/rooms.ts index 3d1c8ba..b8f6892 100644 --- a/src/entities/room/model/rooms.ts +++ b/src/entities/room/model/rooms.ts @@ -18,6 +18,8 @@ export const ROOM_THEMES: RoomTheme[] = [ description: '빗소리 위로 스탠드 조명이 부드럽게 번집니다.', tags: ['저자극', '감성'], recommendedSound: 'Rain Focus', + recommendedSoundPresetId: 'rain-focus', + recommendedTimerPresetId: '25-5', recommendedTime: '밤', vibeLabel: '잔잔함', hubColor: '#D6E6F7', @@ -38,6 +40,8 @@ export const ROOM_THEMES: RoomTheme[] = [ description: '첫 커피 향처럼 잔잔하고 따뜻한 좌석.', tags: ['감성', '딥워크'], recommendedSound: 'Cafe Murmur', + recommendedSoundPresetId: 'cafe-work', + recommendedTimerPresetId: '25-5', recommendedTime: '새벽', vibeLabel: '포근함', hubColor: '#F5DDCB', @@ -58,6 +62,8 @@ export const ROOM_THEMES: RoomTheme[] = [ description: '넘기는 종이 소리만 들리는 정돈된 책상.', tags: ['저자극', '딥워크'], recommendedSound: 'Deep White', + recommendedSoundPresetId: 'deep-white', + recommendedTimerPresetId: '50-10', recommendedTime: '오후', vibeLabel: '몰입', hubColor: '#DCE4D1', @@ -78,6 +84,8 @@ export const ROOM_THEMES: RoomTheme[] = [ description: '잔잔한 해변 위로 호흡을 고르는 공간.', tags: ['움직임 적음', '감성'], recommendedSound: 'Ocean Breath', + recommendedSoundPresetId: 'ocean-calm', + recommendedTimerPresetId: '25-5', recommendedTime: '밤', vibeLabel: '차분함', hubColor: '#CFE9EA', @@ -98,6 +106,8 @@ export const ROOM_THEMES: RoomTheme[] = [ description: '바람이 나뭇잎을 스치는 소리로 마음을 낮춥니다.', tags: ['저자극', '움직임 적음'], recommendedSound: 'Forest Hush', + recommendedSoundPresetId: 'rain-focus', + recommendedTimerPresetId: '50-10', recommendedTime: '오전', vibeLabel: '맑음', hubColor: '#D1E7C9', @@ -118,6 +128,8 @@ export const ROOM_THEMES: RoomTheme[] = [ description: '작은 불꽃이 주는 리듬으로 집중을 붙잡습니다.', tags: ['감성', '저자극'], recommendedSound: 'Fireplace', + recommendedSoundPresetId: 'fireplace', + recommendedTimerPresetId: '25-5', recommendedTime: '밤', vibeLabel: '온기', hubColor: '#F2D4C0', @@ -138,6 +150,8 @@ export const ROOM_THEMES: RoomTheme[] = [ description: '유리창 너머 야경이 멀리 흐르는 고요한 밤.', tags: ['딥워크', '감성'], recommendedSound: 'Night Lo-fi', + recommendedSoundPresetId: 'deep-white', + recommendedTimerPresetId: '50-10', recommendedTime: '심야', vibeLabel: '고요함', hubColor: '#D9D3ED', @@ -158,6 +172,8 @@ export const ROOM_THEMES: RoomTheme[] = [ description: '차분한 공기와 선명한 수평선이 머리를 맑게 합니다.', tags: ['움직임 적음', '딥워크'], recommendedSound: 'Cold Wind', + recommendedSoundPresetId: 'deep-white', + recommendedTimerPresetId: '50-10', recommendedTime: '새벽', vibeLabel: '선명함', hubColor: '#D8E7F3', @@ -178,6 +194,8 @@ export const ROOM_THEMES: RoomTheme[] = [ description: '햇살이 들어오는 간결한 책상, 부담 없는 시작.', tags: ['저자극', '딥워크'], recommendedSound: 'Soft Daylight', + recommendedSoundPresetId: 'silent', + recommendedTimerPresetId: '25-5', recommendedTime: '오후', vibeLabel: '가벼움', hubColor: '#F6EDC7', @@ -198,6 +216,8 @@ export const ROOM_THEMES: RoomTheme[] = [ description: '별빛만 남긴 어둠 속에서 깊게 잠수합니다.', tags: ['딥워크', '감성'], recommendedSound: 'Deep Drone', + recommendedSoundPresetId: 'deep-white', + recommendedTimerPresetId: '90-20', recommendedTime: '심야', vibeLabel: '깊음', hubColor: '#D4DCF4', diff --git a/src/entities/room/model/types.ts b/src/entities/room/model/types.ts index 3a86e7e..99a6a32 100644 --- a/src/entities/room/model/types.ts +++ b/src/entities/room/model/types.ts @@ -12,6 +12,8 @@ export interface RoomTheme { description: string; tags: RoomTag[]; recommendedSound: string; + recommendedSoundPresetId: string; + recommendedTimerPresetId: string; recommendedTime: string; vibeLabel: string; hubColor: string; diff --git a/src/widgets/control-center-sheet/ui/ControlCenterSheetWidget.tsx b/src/widgets/control-center-sheet/ui/ControlCenterSheetWidget.tsx index da95551..f3c749b 100644 --- a/src/widgets/control-center-sheet/ui/ControlCenterSheetWidget.tsx +++ b/src/widgets/control-center-sheet/ui/ControlCenterSheetWidget.tsx @@ -18,6 +18,8 @@ interface ControlCenterSheetWidgetProps { selectedRoomId: string; selectedTimerLabel: string; selectedSoundPresetId: string; + sceneRecommendedSoundLabel: string; + sceneRecommendedTimerLabel: string; timerPresets: TimerPreset[]; soundPresets: SoundPreset[]; onSelectRoom: (roomId: string) => void; @@ -25,6 +27,7 @@ interface ControlCenterSheetWidgetProps { onSelectSound: (soundPresetId: string) => void; onApplyPack: (packId: QuickPackId) => void; onLockedClick: (source: string) => void; + onResetToRecommended: () => void; } type QuickPackId = 'balanced' | 'deep-work' | 'gentle'; @@ -80,6 +83,8 @@ export const ControlCenterSheetWidget = ({ selectedRoomId, selectedTimerLabel, selectedSoundPresetId, + sceneRecommendedSoundLabel: _sceneRecommendedSoundLabel, + sceneRecommendedTimerLabel: _sceneRecommendedTimerLabel, timerPresets, soundPresets, onSelectRoom, @@ -87,6 +92,7 @@ export const ControlCenterSheetWidget = ({ onSelectSound, onApplyPack, onLockedClick, + onResetToRecommended: _onResetToRecommended, }: ControlCenterSheetWidgetProps) => { const reducedMotion = useReducedMotion(); const isPro = plan === 'pro'; diff --git a/src/widgets/space-tools-dock/ui/SpaceToolsDockWidget.tsx b/src/widgets/space-tools-dock/ui/SpaceToolsDockWidget.tsx index b0f233e..51a2b27 100644 --- a/src/widgets/space-tools-dock/ui/SpaceToolsDockWidget.tsx +++ b/src/widgets/space-tools-dock/ui/SpaceToolsDockWidget.tsx @@ -32,6 +32,8 @@ interface SpaceToolsDockWidgetProps { onSetSoundMuted: (nextMuted: boolean) => void; thoughts: RecentThought[]; thoughtCount: number; + sceneRecommendedSoundLabel: string; + sceneRecommendedTimerLabel: string; onRoomSelect: (roomId: string) => void; onTimerSelect: (timerLabel: string) => void; onSelectPreset: (presetId: string) => void; @@ -40,6 +42,7 @@ interface SpaceToolsDockWidgetProps { onSetThoughtCompleted: (thoughtId: string, isCompleted: boolean) => RecentThought | null; onRestoreThought: (thought: RecentThought) => void; onClearInbox: () => RecentThought[]; + onResetToSceneRecommended: () => void; onStatusMessage: (payload: HudStatusLinePayload) => void; onExitRequested: () => void; } @@ -57,6 +60,8 @@ export const SpaceToolsDockWidget = ({ onSetSoundMuted, thoughts, thoughtCount, + sceneRecommendedSoundLabel, + sceneRecommendedTimerLabel, onRoomSelect, onTimerSelect, onSelectPreset, @@ -65,6 +70,7 @@ export const SpaceToolsDockWidget = ({ onSetThoughtCompleted, onRestoreThought, onClearInbox, + onResetToSceneRecommended, onStatusMessage, onExitRequested, }: SpaceToolsDockWidgetProps) => { @@ -414,6 +420,8 @@ export const SpaceToolsDockWidget = ({ selectedRoomId={selectedRoomId} selectedTimerLabel={selectedTimerLabel} selectedSoundPresetId={selectedPresetId} + sceneRecommendedSoundLabel={sceneRecommendedSoundLabel} + sceneRecommendedTimerLabel={sceneRecommendedTimerLabel} timerPresets={timerPresets} soundPresets={SOUND_PRESETS} onSelectRoom={(roomId) => { @@ -427,6 +435,7 @@ export const SpaceToolsDockWidget = ({ }} onApplyPack={handleApplyPack} onLockedClick={handleLockedClick} + onResetToRecommended={onResetToSceneRecommended} /> ) : null} diff --git a/src/widgets/space-workspace/ui/SpaceWorkspaceWidget.tsx b/src/widgets/space-workspace/ui/SpaceWorkspaceWidget.tsx index db2cf7b..e6f1b73 100644 --- a/src/widgets/space-workspace/ui/SpaceWorkspaceWidget.tsx +++ b/src/widgets/space-workspace/ui/SpaceWorkspaceWidget.tsx @@ -1,6 +1,6 @@ 'use client'; -import { useEffect, useMemo, useState } from 'react'; +import { useCallback, useEffect, useMemo, useState } from 'react'; import { useSearchParams } from 'next/navigation'; import { getRoomBackgroundStyle, @@ -22,6 +22,10 @@ import { SpaceSetupDrawerWidget } from '@/widgets/space-setup-drawer'; import { SpaceToolsDockWidget } from '@/widgets/space-tools-dock'; type WorkspaceMode = 'setup' | 'focus'; +type SelectionOverride = { + sound: boolean; + timer: boolean; +}; const resolveInitialRoomId = (roomIdFromQuery: string | null) => { if (roomIdFromQuery && getRoomById(roomIdFromQuery)) { @@ -31,11 +35,15 @@ const resolveInitialRoomId = (roomIdFromQuery: string | null) => { return ROOM_THEMES[0].id; }; -const resolveInitialSoundPreset = (presetIdFromQuery: string | null) => { +const resolveInitialSoundPreset = (presetIdFromQuery: string | null, recommendedPresetId?: string) => { if (presetIdFromQuery && SOUND_PRESETS.some((preset) => preset.id === presetIdFromQuery)) { return presetIdFromQuery; } + if (recommendedPresetId && SOUND_PRESETS.some((preset) => preset.id === recommendedPresetId)) { + return recommendedPresetId; + } + return SOUND_PRESETS[0].id; }; @@ -44,11 +52,31 @@ const TIMER_SELECTION_PRESETS = TIMER_PRESETS.filter( typeof preset.focusMinutes === 'number' && typeof preset.breakMinutes === 'number', ).slice(0, 3); -const resolveInitialTimerLabel = (timerLabelFromQuery: string | null) => { +const resolveTimerLabelFromPresetId = (presetId?: string) => { + if (!presetId) { + return null; + } + + const preset = TIMER_SELECTION_PRESETS.find((candidate) => candidate.id === presetId); + + if (!preset) { + return null; + } + + return preset.label; +}; + +const resolveInitialTimerLabel = (timerLabelFromQuery: string | null, recommendedPresetId?: string) => { if (timerLabelFromQuery && TIMER_SELECTION_PRESETS.some((preset) => preset.label === timerLabelFromQuery)) { return timerLabelFromQuery; } + const recommendedLabel = resolveTimerLabelFromPresetId(recommendedPresetId); + + if (recommendedLabel) { + return recommendedLabel; + } + return TIMER_SELECTION_PRESETS[0]?.label ?? '25/5'; }; @@ -65,15 +93,23 @@ export const SpaceWorkspaceWidget = () => { } = useThoughtInbox(); const initialRoomId = resolveInitialRoomId(searchParams.get('room')); + const initialRoom = getRoomById(initialRoomId) ?? ROOM_THEMES[0]; const initialGoal = searchParams.get('goal')?.trim() ?? ''; - const initialSoundPresetId = resolveInitialSoundPreset(searchParams.get('sound')); - const initialTimerLabel = resolveInitialTimerLabel(searchParams.get('timer')); + const initialSoundPresetId = resolveInitialSoundPreset( + searchParams.get('sound'), + initialRoom.recommendedSoundPresetId, + ); + const initialTimerLabel = resolveInitialTimerLabel( + searchParams.get('timer'), + initialRoom.recommendedTimerPresetId, + ); const [workspaceMode, setWorkspaceMode] = useState('setup'); const [selectedRoomId, setSelectedRoomId] = useState(initialRoomId); const [selectedTimerLabel, setSelectedTimerLabel] = useState(initialTimerLabel); const [goalInput, setGoalInput] = useState(initialGoal); const [selectedGoalId, setSelectedGoalId] = useState(null); + const [selectionOverride, setSelectionOverride] = useState({ sound: false, timer: false }); const { selectedPresetId, @@ -102,6 +138,88 @@ export const SpaceWorkspaceWidget = () => { const isFocusMode = workspaceMode === 'focus'; const { activeStatus, pushStatusLine, runActiveAction } = useHudStatusLine(isFocusMode); + const applyRecommendedSelections = useCallback((roomId: string) => { + const room = getRoomById(roomId); + + if (!room) { + return; + } + + if (!selectionOverride.timer) { + const recommendedTimerLabel = resolveTimerLabelFromPresetId(room.recommendedTimerPresetId); + + if (recommendedTimerLabel) { + setSelectedTimerLabel(recommendedTimerLabel); + } + } + + if (!selectionOverride.sound && SOUND_PRESETS.some((preset) => preset.id === room.recommendedSoundPresetId)) { + setSelectedPresetId(room.recommendedSoundPresetId); + } + }, [selectionOverride.sound, selectionOverride.timer, setSelectedPresetId]); + + useEffect(() => { + applyRecommendedSelections(selectedRoomId); + }, [applyRecommendedSelections, selectedRoomId]); + + const handleSelectRoom = (roomId: string) => { + setSelectedRoomId(roomId); + }; + + const handleSelectTimer = (timerLabel: string, markOverride = false) => { + setSelectedTimerLabel(timerLabel); + + if (!markOverride) { + return; + } + + setSelectionOverride((current) => { + if (current.timer) { + return current; + } + + return { ...current, timer: true }; + }); + }; + + const handleSelectSound = (presetId: string, markOverride = false) => { + setSelectedPresetId(presetId); + + if (!markOverride) { + return; + } + + setSelectionOverride((current) => { + if (current.sound) { + return current; + } + + return { ...current, sound: true }; + }); + }; + + const handleResetToSceneRecommended = () => { + const room = getRoomById(selectedRoomId); + + if (!room) { + return; + } + + setSelectionOverride({ sound: false, timer: false }); + + const recommendedTimerLabel = resolveTimerLabelFromPresetId(room.recommendedTimerPresetId); + + if (recommendedTimerLabel) { + setSelectedTimerLabel(recommendedTimerLabel); + } + + if (SOUND_PRESETS.some((preset) => preset.id === room.recommendedSoundPresetId)) { + setSelectedPresetId(room.recommendedSoundPresetId); + } + + pushStatusLine({ message: '추천으로 되돌림(더미)' }); + }; + const handleGoalChipSelect = (chip: GoalChip) => { setSelectedGoalId(chip.id); setGoalInput(chip.label); @@ -164,9 +282,9 @@ export const SpaceWorkspaceWidget = () => { soundPresets={SOUND_PRESETS} timerPresets={TIMER_SELECTION_PRESETS} canStart={canStart} - onRoomSelect={setSelectedRoomId} - onTimerSelect={setSelectedTimerLabel} - onSoundSelect={setSelectedPresetId} + onRoomSelect={handleSelectRoom} + onTimerSelect={(timerLabel) => handleSelectTimer(timerLabel, true)} + onSoundSelect={(presetId) => handleSelectSound(presetId, true)} onGoalChange={handleGoalChange} onGoalChipSelect={handleGoalChipSelect} onStart={handleStart} @@ -194,9 +312,12 @@ export const SpaceWorkspaceWidget = () => { thoughts={thoughts} thoughtCount={thoughtCount} selectedPresetId={selectedPresetId} - onRoomSelect={setSelectedRoomId} - onTimerSelect={setSelectedTimerLabel} - onSelectPreset={setSelectedPresetId} + onRoomSelect={handleSelectRoom} + onTimerSelect={(timerLabel) => handleSelectTimer(timerLabel, true)} + onSelectPreset={(presetId) => handleSelectSound(presetId, true)} + sceneRecommendedSoundLabel={selectedRoom.recommendedSound} + sceneRecommendedTimerLabel={resolveTimerLabelFromPresetId(selectedRoom.recommendedTimerPresetId) ?? selectedTimerLabel} + onResetToSceneRecommended={handleResetToSceneRecommended} soundVolume={masterVolume} onSetSoundVolume={setMasterVolume} isSoundMuted={isMuted}