From bc08a049b612b19a386023aa3c545571e8358ce9 Mon Sep 17 00:00:00 2001 From: corpi Date: Sat, 14 Mar 2026 16:28:26 +0900 Subject: [PATCH] =?UTF-8?q?fix(space):=20=EC=A0=95=EB=A6=AC=EB=90=9C=20int?= =?UTF-8?q?ent=20hud=EC=99=80=20=EB=A6=AC=EB=B7=B0=20=EB=B0=98=EC=98=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/entities/plan/model/usePlanTier.ts | 66 +++-- .../focus-session/api/focusSessionApi.ts | 16 ++ .../model/useFocusSessionEngine.ts | 16 +- src/shared/i18n/messages/core.ts | 1 + src/shared/i18n/messages/space.ts | 17 ++ .../ui/FocusDashboardWidget.tsx | 34 +-- .../space-focus-hud/ui/FloatingGoalWidget.tsx | 63 ----- .../space-focus-hud/ui/GoalCompleteSheet.tsx | 34 ++- .../space-focus-hud/ui/IntentCapsule.tsx | 105 ++++++++ .../ui/NextMicroStepPrompt.tsx | 75 ++++++ .../space-focus-hud/ui/RefocusSheet.tsx | 165 ++++++++++++ .../ui/SpaceFocusHudWidget.tsx | 242 ++++++++++++++---- .../ui/SpaceTimerHudWidget.tsx | 2 - .../model/useSpaceToolsDockHandlers.ts | 4 +- .../space-tools-dock/ui/FocusModeAnchors.tsx | 13 - .../model/useSpaceWorkspaceSessionControls.ts | 50 ++++ .../ui/SpaceWorkspaceWidget.tsx | 57 ++--- 17 files changed, 746 insertions(+), 214 deletions(-) delete mode 100644 src/widgets/space-focus-hud/ui/FloatingGoalWidget.tsx create mode 100644 src/widgets/space-focus-hud/ui/IntentCapsule.tsx create mode 100644 src/widgets/space-focus-hud/ui/NextMicroStepPrompt.tsx create mode 100644 src/widgets/space-focus-hud/ui/RefocusSheet.tsx diff --git a/src/entities/plan/model/usePlanTier.ts b/src/entities/plan/model/usePlanTier.ts index 6a7c413..94eb58f 100644 --- a/src/entities/plan/model/usePlanTier.ts +++ b/src/entities/plan/model/usePlanTier.ts @@ -1,14 +1,43 @@ 'use client'; -import { useCallback, useEffect, useState } from 'react'; +import { useCallback, useSyncExternalStore } from 'react'; import type { PlanTier } from './types'; const PLAN_TIER_STORAGE_KEY = 'viberoom:plan-tier:v1'; +const planTierSubscribers = new Set<() => void>(); const normalizePlanTier = (value: unknown): PlanTier => { return value === 'pro' ? 'pro' : 'normal'; }; +const notifyPlanTierSubscribers = () => { + for (const subscriber of planTierSubscribers) { + subscriber(); + } +}; + +const subscribeToPlanTier = (onStoreChange: () => void) => { + if (typeof window === 'undefined') { + return () => {}; + } + + const handleStorage = (event: StorageEvent) => { + if (event.key !== PLAN_TIER_STORAGE_KEY) { + return; + } + + onStoreChange(); + }; + + planTierSubscribers.add(onStoreChange); + window.addEventListener('storage', handleStorage); + + return () => { + planTierSubscribers.delete(onStoreChange); + window.removeEventListener('storage', handleStorage); + }; +}; + export const readStoredPlanTier = (): PlanTier => { if (typeof window === 'undefined') { return 'normal'; @@ -22,41 +51,26 @@ export const readStoredPlanTier = (): PlanTier => { }; export const usePlanTier = () => { - const [plan, setPlanState] = useState('normal'); - const [hasHydratedPlan, setHasHydratedPlan] = useState(false); - - useEffect(() => { - setPlanState(readStoredPlanTier()); - setHasHydratedPlan(true); - - const handleStorage = (event: StorageEvent) => { - if (event.key !== PLAN_TIER_STORAGE_KEY) { - return; - } - - setPlanState(normalizePlanTier(event.newValue)); - }; - - window.addEventListener('storage', handleStorage); - - return () => { - window.removeEventListener('storage', handleStorage); - }; - }, []); + const plan = useSyncExternalStore( + subscribeToPlanTier, + readStoredPlanTier, + () => 'normal', + ); const setPlan = useCallback((nextPlan: PlanTier) => { - setPlanState(nextPlan); - if (typeof window === 'undefined') { return; } - window.localStorage.setItem(PLAN_TIER_STORAGE_KEY, nextPlan); + try { + window.localStorage.setItem(PLAN_TIER_STORAGE_KEY, nextPlan); + } finally { + notifyPlanTierSubscribers(); + } }, []); return { plan, - hasHydratedPlan, isPro: plan === 'pro', setPlan, }; diff --git a/src/features/focus-session/api/focusSessionApi.ts b/src/features/focus-session/api/focusSessionApi.ts index 25c93fc..638fe5d 100644 --- a/src/features/focus-session/api/focusSessionApi.ts +++ b/src/features/focus-session/api/focusSessionApi.ts @@ -74,6 +74,11 @@ export interface UpdateCurrentFocusSessionSelectionRequest { soundPresetId?: string | null; } +export interface UpdateCurrentFocusSessionIntentRequest { + goal?: string; + microStep?: string | null; +} + export interface AdvanceCurrentGoalRequest { completedGoal: string; nextGoal: string; @@ -170,6 +175,17 @@ export const focusSessionApi = { return normalizeFocusSession(response); }, + updateCurrentIntent: async ( + payload: UpdateCurrentFocusSessionIntentRequest, + ): Promise => { + const response = await apiClient('api/v1/focus-sessions/current/intent', { + method: 'PATCH', + body: JSON.stringify(payload), + }); + + return normalizeFocusSession(response); + }, + completeSession: async (payload: CompleteFocusSessionRequest): Promise => { const response = await apiClient('api/v1/focus-sessions/current/complete', { method: 'POST', diff --git a/src/features/focus-session/model/useFocusSessionEngine.ts b/src/features/focus-session/model/useFocusSessionEngine.ts index 856c85d..d5029b0 100644 --- a/src/features/focus-session/model/useFocusSessionEngine.ts +++ b/src/features/focus-session/model/useFocusSessionEngine.ts @@ -11,6 +11,7 @@ import { type FocusSessionPhase, type FocusSessionState, type StartFocusSessionRequest, + type UpdateCurrentFocusSessionIntentRequest, type UpdateCurrentFocusSessionSelectionRequest, } from '../api/focusSessionApi'; @@ -72,6 +73,7 @@ interface UseFocusSessionEngineResult { pauseSession: () => Promise; resumeSession: () => Promise; restartCurrentPhase: () => Promise; + updateCurrentIntent: (payload: UpdateCurrentFocusSessionIntentRequest) => Promise; updateCurrentSelection: (payload: UpdateCurrentFocusSessionSelectionRequest) => Promise; completeSession: (payload: CompleteFocusSessionRequest) => Promise; advanceGoal: (payload: AdvanceCurrentGoalRequest) => Promise; @@ -120,7 +122,7 @@ export const useFocusSessionEngine = (): UseFocusSessionEngineResult => { }, [syncCurrentSession]); useEffect(() => { - if (!currentSession) { + if (!currentSession || currentSession.state !== 'running') { return; } @@ -235,6 +237,18 @@ export const useFocusSessionEngine = (): UseFocusSessionEngineResult => { return applySession(session); }, + updateCurrentIntent: async (payload) => { + if (!currentSession) { + return null; + } + + const session = await runMutation( + () => focusSessionApi.updateCurrentIntent(payload), + copy.focusSession.intentUpdateFailed, + ); + + return applySession(session); + }, updateCurrentSelection: async (payload) => { if (!currentSession) { return null; diff --git a/src/shared/i18n/messages/core.ts b/src/shared/i18n/messages/core.ts index 202d46e..bb245e0 100644 --- a/src/shared/i18n/messages/core.ts +++ b/src/shared/i18n/messages/core.ts @@ -157,6 +157,7 @@ export const core = { pauseFailed: '세션을 일시정지하지 못했어요.', resumeFailed: '세션을 다시 시작하지 못했어요.', restartPhaseFailed: '현재 페이즈를 다시 시작하지 못했어요.', + intentUpdateFailed: '현재 세션의 방향을 저장하지 못했어요.', completeFailed: '세션을 완료 처리하지 못했어요.', abandonFailed: '세션을 종료하지 못했어요.', }, diff --git a/src/shared/i18n/messages/space.ts b/src/shared/i18n/messages/space.ts index 8a94ba5..e5f3f2d 100644 --- a/src/shared/i18n/messages/space.ts +++ b/src/shared/i18n/messages/space.ts @@ -36,6 +36,22 @@ export const space = { goalFallback: '집중을 시작해요.', goalToast: (goal: string) => `이번 한 조각 · ${goal}`, restReminder: '5분이 지났어요. 다음 한 조각으로 돌아와요.', + intentLabel: '이번 세션 목표', + microStepLabel: '지금 할 한 조각', + refocusButton: '다시 방향', + refocusTitle: '다시 방향 잡기', + refocusDescription: '딱 한 줄만 다듬고 다시 시작해요.', + refocusApply: '적용', + refocusApplying: '적용 중…', + refocusSaved: '이번 세션 방향을 다듬었어요.', + refocusOpenOnPause: '잠시 멈춘 김에 다음 한 조각을 다시 맞춰볼까요?', + microStepCompleteAriaLabel: '현재 한 조각 완료', + microStepPromptTitle: '다음 한 조각이 있나요?', + microStepPromptDescription: '리스트를 만들지 않고, 지금 다시 시작할 한 조각만 정해요.', + microStepPromptKeep: '이 목표만 유지', + microStepPromptDefine: '한 조각 정하기', + microStepCleared: '지금 할 한 조각을 비우고 목표만 유지해요.', + completeAction: '이번 목표 완료', }, goalComplete: { suggestions: ['리뷰 코멘트 2개 처리', '문서 1문단 다듬기', '이슈 1개 정리', '메일 2개 회신'], @@ -173,6 +189,7 @@ export const space = { pauseFailed: '세션을 일시정지하지 못했어요.', restartFailed: '현재 페이즈를 다시 시작하지 못했어요.', restarted: '현재 페이즈를 처음부터 다시 시작했어요.', + intentSyncFailed: '현재 세션 방향을 서버에 반영하지 못했어요.', goalCompleteSyncFailed: '현재 세션 완료를 서버에 반영하지 못했어요.', nextGoalStarted: '다음 한 조각을 바로 시작했어요.', selectionPreferenceSaveFailed: '배경/사운드 기본 설정을 저장하지 못했어요.', diff --git a/src/widgets/focus-dashboard/ui/FocusDashboardWidget.tsx b/src/widgets/focus-dashboard/ui/FocusDashboardWidget.tsx index f7f949e..aa8393f 100644 --- a/src/widgets/focus-dashboard/ui/FocusDashboardWidget.tsx +++ b/src/widgets/focus-dashboard/ui/FocusDashboardWidget.tsx @@ -1,6 +1,5 @@ 'use client'; -import Link from 'next/link'; import { useRouter } from 'next/navigation'; import { useEffect, useMemo, useRef, useState } from 'react'; import { @@ -14,7 +13,6 @@ import { useMediaCatalog, getSceneStageBackgroundStyle } from '@/entities/media' import { SOUND_PRESETS } from '@/entities/session'; import { PaywallSheetContent } from '@/features/paywall-sheet'; import { PlanPill } from '@/features/plan-pill'; -import { useFocusStats } from '@/features/stats'; import { focusSessionApi } from '@/features/focus-session/api/focusSessionApi'; import { copy } from '@/shared/i18n'; import { cn } from '@/shared/lib/cn'; @@ -85,7 +83,7 @@ const itemCardGlassSelectedClass = 'border-white/40 bg-white/20 shadow-[0_0_20px export const FocusDashboardWidget = () => { const router = useRouter(); const { plan: planTier, isPro, setPlan } = usePlanTier(); - const { plan, isLoading, isSaving, error, source, createItem, updateItem, deleteItem } = useFocusPlan(); + const { plan, isLoading, isSaving, source, createItem, updateItem, deleteItem } = useFocusPlan(); const { sceneAssetMap } = useMediaCatalog(); const [step, setStep] = useState('goal'); @@ -124,11 +122,16 @@ export const FocusDashboardWidget = () => { }, [maxItems, plan.currentItem, plan.nextItems]); const currentItem = planItems[0] ?? null; + const shouldUseCurrentPlanDefaults = + Boolean(currentItem) && (entrySource === 'starter' || (entrySource === 'plan' && !selectedPlanItemId)); + const resolvedEntryDraft = shouldUseCurrentPlanDefaults && currentItem ? currentItem.title : entryDraft; + const resolvedSelectedPlanItemId = + shouldUseCurrentPlanDefaults && currentItem ? currentItem.id : selectedPlanItemId; const hasPendingEdit = editingState !== null; const canAddMore = planItems.length < maxItems; const canManagePlan = source === 'api' && !isLoading; - const trimmedEntryGoal = entryDraft.trim(); + const trimmedEntryGoal = resolvedEntryDraft.trim(); const isGoalReady = trimmedEntryGoal.length > 0; useEffect(() => { @@ -140,15 +143,6 @@ export const FocusDashboardWidget = () => { return () => window.cancelAnimationFrame(rafId); }, [editingState]); - useEffect(() => { - if (!currentItem) return; - if (entrySource === 'starter' || (entrySource === 'plan' && !selectedPlanItemId)) { - setEntryDraft(currentItem.title); - setSelectedPlanItemId(currentItem.id); - setEntrySource('plan'); - } - }, [currentItem, entryDraft, entrySource, selectedPlanItemId]); - const openPaywall = () => setPaywallSource(focusEntryCopy.paywallSource); const handleSelectPlanItem = (item: FocusPlanItem) => { @@ -162,7 +156,7 @@ export const FocusDashboardWidget = () => { const handleSelectSuggestion = (goal: string) => { setEntryDraft(goal); setSelectedPlanItemId(null); - setEntrySource('starter'); + setEntrySource('custom'); }; const handleEntryDraftChange = (value: string) => { @@ -224,7 +218,7 @@ export const FocusDashboardWidget = () => { const nextPlan = await updateItem(editingState.itemId, { title: trimmedTitle }); if (!nextPlan) return; setEditingState(null); - if (selectedPlanItemId === editingState.itemId) { + if (resolvedSelectedPlanItemId === editingState.itemId) { setEntryDraft(trimmedTitle); setEntrySource('plan'); } @@ -236,7 +230,7 @@ export const FocusDashboardWidget = () => { if (editingState?.mode === 'edit' && editingState.itemId === itemId) { setEditingState(null); } - if (selectedPlanItemId === itemId) { + if (resolvedSelectedPlanItemId === itemId) { const nextVisiblePlanItems = resolveVisiblePlanItems(nextPlan, maxItems); const nextCurrentItem = nextVisiblePlanItems[0] ?? null; if (nextCurrentItem) { @@ -270,7 +264,7 @@ export const FocusDashboardWidget = () => { sceneId: selectedSceneId, soundPresetId: selectedSoundId, timerPresetId: selectedTimerId, - focusPlanItemId: selectedPlanItemId || undefined, + focusPlanItemId: resolvedSelectedPlanItemId || undefined, entryPoint: 'space-setup' }); router.push('/space'); @@ -328,7 +322,7 @@ export const FocusDashboardWidget = () => {
handleEntryDraftChange(event.target.value)} onKeyDown={(e) => e.key === 'Enter' && handleNextStep()} placeholder={focusEntryCopy.inputPlaceholder} @@ -359,7 +353,7 @@ export const FocusDashboardWidget = () => {
{ENTRY_SUGGESTIONS.map((suggestion) => { - const isActive = selectedPlanItemId === null && trimmedEntryGoal === suggestion.goal; + const isActive = resolvedSelectedPlanItemId === null && trimmedEntryGoal === suggestion.goal; return ( - ) : null} -
- - {/* Micro Step */} - {microStep && !isMicroStepCompleted && ( -
- - - {microStep} - -
- )} -
-
- ); -}; diff --git a/src/widgets/space-focus-hud/ui/GoalCompleteSheet.tsx b/src/widgets/space-focus-hud/ui/GoalCompleteSheet.tsx index 0b1d15d..e47b4aa 100644 --- a/src/widgets/space-focus-hud/ui/GoalCompleteSheet.tsx +++ b/src/widgets/space-focus-hud/ui/GoalCompleteSheet.tsx @@ -78,48 +78,58 @@ export const GoalCompleteSheet = ({ return (
-
-
+
+
+
+ +
-

{copy.space.goalComplete.title}

-

{copy.space.goalComplete.description}

+

목표 완료

+

{copy.space.goalComplete.title}

+

{copy.space.goalComplete.description}

-
+ setDraft(event.target.value)} placeholder={placeholder} - className="h-9 w-full rounded-xl border border-white/14 bg-white/[0.04] px-3 text-sm text-white placeholder:text-white/40 focus:border-sky-200/42 focus:outline-none" + className="h-11 w-full rounded-[18px] border border-white/10 bg-black/14 px-3.5 text-[0.98rem] tracking-tight text-white placeholder:text-white/30 focus:border-white/20 focus:bg-black/20 focus:outline-none focus:ring-2 focus:ring-white/8" />