refactor(toast): HUD 상태 라인 피드백 통합 및 우선순위 큐 적용

This commit is contained in:
2026-03-04 20:46:19 +09:00
parent c451175b9c
commit 06dbee8d63
6 changed files with 186 additions and 32 deletions

View File

@@ -0,0 +1,103 @@
'use client';
import { useCallback, useEffect, useMemo, useState } from 'react';
export type HudStatusLinePriority = 'normal' | 'undo';
export interface HudStatusLineAction {
label: string;
onClick: () => void;
}
export interface HudStatusLinePayload {
message: string;
durationMs?: number;
action?: HudStatusLineAction;
priority?: HudStatusLinePriority;
}
export interface HudStatusLineItem extends HudStatusLinePayload {
id: number;
priority: HudStatusLinePriority;
}
const MAX_PENDING_MESSAGES = 2;
const MAX_TOTAL_MESSAGES = MAX_PENDING_MESSAGES + 1;
const DEFAULT_DURATION_MS = 2000;
const DEFAULT_UNDO_DURATION_MS = 4200;
export const useHudStatusLine = (enabled = true) => {
const [queue, setQueue] = useState<HudStatusLineItem[]>([]);
useEffect(() => {
if (enabled) {
return;
}
setQueue([]);
}, [enabled]);
useEffect(() => {
if (!enabled || queue.length === 0) {
return;
}
const active = queue[0];
const durationMs =
active.durationMs ?? (active.action ? DEFAULT_UNDO_DURATION_MS : DEFAULT_DURATION_MS);
const timerId = window.setTimeout(() => {
setQueue((current) => current.slice(1));
}, durationMs);
return () => {
window.clearTimeout(timerId);
};
}, [enabled, queue]);
const pushStatusLine = useCallback((payload: HudStatusLinePayload) => {
const nextItem: HudStatusLineItem = {
id: Date.now() + Math.floor(Math.random() * 10000),
...payload,
priority: payload.priority ?? (payload.action ? 'undo' : 'normal'),
};
setQueue((current) => {
if (current.length === 0) {
return [nextItem];
}
const [active, ...pending] = current;
const nextQueue =
nextItem.priority === 'undo'
? [active, nextItem, ...pending]
: [active, ...pending, nextItem];
return nextQueue.slice(0, MAX_TOTAL_MESSAGES);
});
}, []);
const dismissActiveStatus = useCallback(() => {
setQueue((current) => current.slice(1));
}, []);
const runActiveAction = useCallback(() => {
const active = queue[0];
if (!active?.action) {
dismissActiveStatus();
return;
}
active.action.onClick();
dismissActiveStatus();
}, [dismissActiveStatus, queue]);
return useMemo(() => {
return {
activeStatus: queue[0] ?? null,
pushStatusLine,
runActiveAction,
dismissActiveStatus,
};
}, [dismissActiveStatus, pushStatusLine, queue, runActiveAction]);
};

View File

@@ -1,6 +1,6 @@
import { useCallback, useEffect, useRef, useState } from 'react'; import { useCallback, useEffect, useRef, useState } from 'react';
import type { HudStatusLineItem, HudStatusLinePayload } from '@/shared/lib/useHudStatusLine';
import { useReducedMotion } from '@/shared/lib/useReducedMotion'; import { useReducedMotion } from '@/shared/lib/useReducedMotion';
import { useToast } from '@/shared/ui';
import { SpaceTimerHudWidget } from '@/widgets/space-timer-hud'; import { SpaceTimerHudWidget } from '@/widgets/space-timer-hud';
import { GoalCompleteSheet } from './GoalCompleteSheet'; import { GoalCompleteSheet } from './GoalCompleteSheet';
import { GoalFlashOverlay } from './GoalFlashOverlay'; import { GoalFlashOverlay } from './GoalFlashOverlay';
@@ -10,6 +10,9 @@ interface SpaceFocusHudWidgetProps {
timerLabel: string; timerLabel: string;
visible: boolean; visible: boolean;
onGoalUpdate: (nextGoal: string) => void; onGoalUpdate: (nextGoal: string) => void;
statusLine: HudStatusLineItem | null;
onStatusAction: () => void;
onStatusMessage: (payload: HudStatusLinePayload) => void;
} }
export const SpaceFocusHudWidget = ({ export const SpaceFocusHudWidget = ({
@@ -17,8 +20,10 @@ export const SpaceFocusHudWidget = ({
timerLabel, timerLabel,
visible, visible,
onGoalUpdate, onGoalUpdate,
statusLine,
onStatusAction,
onStatusMessage,
}: SpaceFocusHudWidgetProps) => { }: SpaceFocusHudWidgetProps) => {
const { pushToast } = useToast();
const reducedMotion = useReducedMotion(); const reducedMotion = useReducedMotion();
const [flashVisible, setFlashVisible] = useState(false); const [flashVisible, setFlashVisible] = useState(false);
const [sheetOpen, setSheetOpen] = useState(false); const [sheetOpen, setSheetOpen] = useState(false);
@@ -95,9 +100,18 @@ export const SpaceFocusHudWidget = ({
<SpaceTimerHudWidget <SpaceTimerHudWidget
timerLabel={timerLabel} timerLabel={timerLabel}
goal={goal} goal={goal}
statusLine={
statusLine
? {
message: statusLine.message,
actionLabel: statusLine.action?.label,
}
: null
}
isImmersionMode isImmersionMode
className="pr-[4.2rem]" className="pr-[4.2rem]"
onGoalCompleteRequest={handleOpenCompleteSheet} onGoalCompleteRequest={handleOpenCompleteSheet}
onStatusAction={onStatusAction}
onPlaybackStateChange={(state) => { onPlaybackStateChange={(state) => {
if (reducedMotion) { if (reducedMotion) {
playbackStateRef.current = state; playbackStateRef.current = state;
@@ -117,21 +131,21 @@ export const SpaceFocusHudWidget = ({
onClose={() => setSheetOpen(false)} onClose={() => setSheetOpen(false)}
onRest={() => { onRest={() => {
setSheetOpen(false); setSheetOpen(false);
pushToast({ title: '좋아요. 5분 뒤에 다시 알려드릴게요.' }); onStatusMessage({ message: '좋아요. 5분 뒤에 다시 알려드릴게요.' });
if (restReminderTimerRef.current) { if (restReminderTimerRef.current) {
window.clearTimeout(restReminderTimerRef.current); window.clearTimeout(restReminderTimerRef.current);
} }
restReminderTimerRef.current = window.setTimeout(() => { restReminderTimerRef.current = window.setTimeout(() => {
pushToast({ title: '5분이 지났어요. 다음 한 조각으로 돌아와요.' }); onStatusMessage({ message: '5분이 지났어요. 다음 한 조각으로 돌아와요.' });
restReminderTimerRef.current = null; restReminderTimerRef.current = null;
}, 5 * 60 * 1000); }, 5 * 60 * 1000);
}} }}
onConfirm={(nextGoal) => { onConfirm={(nextGoal) => {
onGoalUpdate(nextGoal); onGoalUpdate(nextGoal);
setSheetOpen(false); setSheetOpen(false);
pushToast({ title: '다음 한 조각으로 이어갑니다.' }); onStatusMessage({ message: '다음 한 조각으로 이어갑니다.' });
triggerFlash(1200); triggerFlash(1200);
}} }}
/> />

View File

@@ -12,8 +12,13 @@ interface SpaceTimerHudWidgetProps {
goal: string; goal: string;
className?: string; className?: string;
isImmersionMode?: boolean; isImmersionMode?: boolean;
statusLine?: {
message: string;
actionLabel?: string;
} | null;
onPlaybackStateChange?: (state: 'running' | 'paused') => void; onPlaybackStateChange?: (state: 'running' | 'paused') => void;
onGoalCompleteRequest?: () => void; onGoalCompleteRequest?: () => void;
onStatusAction?: () => void;
} }
const HUD_ACTIONS = [ const HUD_ACTIONS = [
@@ -27,8 +32,10 @@ export const SpaceTimerHudWidget = ({
goal, goal,
className, className,
isImmersionMode = false, isImmersionMode = false,
statusLine = null,
onPlaybackStateChange, onPlaybackStateChange,
onGoalCompleteRequest, onGoalCompleteRequest,
onStatusAction,
}: SpaceTimerHudWidgetProps) => { }: SpaceTimerHudWidgetProps) => {
const { isBreatheMode, hintMessage, triggerRestart } = useRestart30s(); const { isBreatheMode, hintMessage, triggerRestart } = useRestart30s();
const normalizedGoal = goal.trim().length > 0 ? goal.trim() : '이번 한 조각을 설정해 주세요.'; const normalizedGoal = goal.trim().length > 0 ? goal.trim() : '이번 한 조각을 설정해 주세요.';
@@ -89,7 +96,22 @@ export const SpaceTimerHudWidget = ({
</button> </button>
</div> </div>
{hintMessage ? ( {statusLine ? (
<div className="mt-1 flex min-w-0 items-center gap-2">
<p className={cn('min-w-0 truncate text-[11px]', isImmersionMode ? 'text-white/68' : 'text-white/66')}>
{statusLine.message}
</p>
{statusLine.actionLabel ? (
<button
type="button"
onClick={onStatusAction}
className="shrink-0 text-[11px] font-medium text-white/76 underline-offset-2 transition-colors hover:text-white/92 hover:underline"
>
{statusLine.actionLabel}
</button>
) : null}
</div>
) : hintMessage ? (
<p className={cn('mt-1 truncate text-[10px]', isImmersionMode ? 'text-white/52' : 'text-white/52')}> <p className={cn('mt-1 truncate text-[10px]', isImmersionMode ? 'text-white/52' : 'text-white/52')}>
{hintMessage} {hintMessage}
</p> </p>

View File

@@ -2,30 +2,30 @@ interface ApplyQuickPackParams {
packId: 'balanced' | 'deep-work' | 'gentle'; packId: 'balanced' | 'deep-work' | 'gentle';
onTimerSelect: (timerLabel: string) => void; onTimerSelect: (timerLabel: string) => void;
onSelectPreset: (presetId: string) => void; onSelectPreset: (presetId: string) => void;
pushToast: (payload: { title: string; description?: string }) => void; onApplied?: (message: string) => void;
} }
export const applyQuickPack = ({ export const applyQuickPack = ({
packId, packId,
onTimerSelect, onTimerSelect,
onSelectPreset, onSelectPreset,
pushToast, onApplied,
}: ApplyQuickPackParams) => { }: ApplyQuickPackParams) => {
if (packId === 'balanced') { if (packId === 'balanced') {
onTimerSelect('25/5'); onTimerSelect('25/5');
onSelectPreset('rain-focus'); onSelectPreset('rain-focus');
pushToast({ title: 'Balanced 팩을 적용했어요.' }); onApplied?.('Balanced 팩을 적용했어요.');
return; return;
} }
if (packId === 'deep-work') { if (packId === 'deep-work') {
onTimerSelect('50/10'); onTimerSelect('50/10');
onSelectPreset('deep-white'); onSelectPreset('deep-white');
pushToast({ title: 'Deep Work 팩을 적용했어요.' }); onApplied?.('Deep Work 팩을 적용했어요.');
return; return;
} }
onTimerSelect('25/5'); onTimerSelect('25/5');
onSelectPreset('silent'); onSelectPreset('silent');
pushToast({ title: 'Gentle 팩을 적용했어요.' }); onApplied?.('Gentle 팩을 적용했어요.');
}; };

View File

@@ -6,6 +6,7 @@ import { SOUND_PRESETS, type RecentThought, type TimerPreset } from '@/entities/
import { ExitHoldButton } from '@/features/exit-hold'; import { ExitHoldButton } from '@/features/exit-hold';
import { ManagePlanSheetContent, PaywallSheetContent } from '@/features/paywall-sheet'; import { ManagePlanSheetContent, PaywallSheetContent } from '@/features/paywall-sheet';
import { PlanPill } from '@/features/plan-pill'; import { PlanPill } from '@/features/plan-pill';
import type { HudStatusLinePayload } from '@/shared/lib/useHudStatusLine';
import { useToast } from '@/shared/ui'; import { useToast } from '@/shared/ui';
import { cn } from '@/shared/lib/cn'; import { cn } from '@/shared/lib/cn';
import { ControlCenterSheetWidget } from '@/widgets/control-center-sheet'; import { ControlCenterSheetWidget } from '@/widgets/control-center-sheet';
@@ -40,6 +41,7 @@ interface SpaceToolsDockWidgetProps {
onRestoreThought: (thought: RecentThought) => void; onRestoreThought: (thought: RecentThought) => void;
onRestoreThoughts: (thoughts: RecentThought[]) => void; onRestoreThoughts: (thoughts: RecentThought[]) => void;
onClearInbox: () => RecentThought[]; onClearInbox: () => RecentThought[];
onStatusMessage: (payload: HudStatusLinePayload) => void;
onExitRequested: () => void; onExitRequested: () => void;
} }
@@ -65,6 +67,7 @@ export const SpaceToolsDockWidget = ({
onRestoreThought, onRestoreThought,
onRestoreThoughts, onRestoreThoughts,
onClearInbox, onClearInbox,
onStatusMessage,
onExitRequested, onExitRequested,
}: SpaceToolsDockWidgetProps) => { }: SpaceToolsDockWidgetProps) => {
const { pushToast } = useToast(); const { pushToast } = useToast();
@@ -171,9 +174,10 @@ export const SpaceToolsDockWidget = ({
} }
setNoteDraft(''); setNoteDraft('');
pushToast({ onStatusMessage({
title: '인박스에 저장됨', message: '인박스에 저장됨',
durationMs: 4200, durationMs: 4200,
priority: 'undo',
action: { action: {
label: '실행취소', label: '실행취소',
onClick: () => { onClick: () => {
@@ -183,7 +187,7 @@ export const SpaceToolsDockWidget = ({
return; return;
} }
pushToast({ title: '저장 취소됨' }); onStatusMessage({ message: '저장 취소됨' });
}, },
}, },
}); });
@@ -198,14 +202,15 @@ export const SpaceToolsDockWidget = ({
const willBeCompleted = !thought.isCompleted; const willBeCompleted = !thought.isCompleted;
pushToast({ onStatusMessage({
title: willBeCompleted ? '완료 처리됨' : '완료 해제됨', message: willBeCompleted ? '완료 처리됨' : '완료 해제됨',
durationMs: 4200, durationMs: 4200,
priority: 'undo',
action: { action: {
label: '실행취소', label: '실행취소',
onClick: () => { onClick: () => {
onRestoreThought(previousThought); onRestoreThought(previousThought);
pushToast({ title: willBeCompleted ? '완료 처리를 취소했어요.' : '완료 해제를 취소했어요.' }); onStatusMessage({ message: willBeCompleted ? '완료 처리를 취소했어요.' : '완료 해제를 취소했어요.' });
}, },
}, },
}); });
@@ -218,14 +223,15 @@ export const SpaceToolsDockWidget = ({
return; return;
} }
pushToast({ onStatusMessage({
title: '삭제됨', message: '삭제됨',
durationMs: 4200, durationMs: 4200,
priority: 'undo',
action: { action: {
label: '실행취소', label: '실행취소',
onClick: () => { onClick: () => {
onRestoreThought(removedThought); onRestoreThought(removedThought);
pushToast({ title: '삭제를 취소했어요.' }); onStatusMessage({ message: '삭제를 취소했어요.' });
}, },
}, },
}); });
@@ -235,18 +241,19 @@ export const SpaceToolsDockWidget = ({
const snapshot = onClearInbox(); const snapshot = onClearInbox();
if (snapshot.length === 0) { if (snapshot.length === 0) {
pushToast({ title: '인박스가 비어 있어요.' }); onStatusMessage({ message: '인박스가 비어 있어요.' });
return; return;
} }
pushToast({ onStatusMessage({
title: '모두 비워짐', message: '모두 비워짐',
durationMs: 4200, durationMs: 4200,
priority: 'undo',
action: { action: {
label: '실행취소', label: '실행취소',
onClick: () => { onClick: () => {
onRestoreThoughts(snapshot); onRestoreThoughts(snapshot);
pushToast({ title: '인박스를 복구했어요.' }); onStatusMessage({ message: '인박스를 복구했어요.' });
}, },
}, },
}); });
@@ -273,7 +280,14 @@ export const SpaceToolsDockWidget = ({
}; };
const handleApplyPack = (packId: 'balanced' | 'deep-work' | 'gentle') => const handleApplyPack = (packId: 'balanced' | 'deep-work' | 'gentle') =>
applyQuickPack({ packId, onTimerSelect, onSelectPreset, pushToast }); applyQuickPack({
packId,
onTimerSelect,
onSelectPreset,
onApplied: (message) => {
onStatusMessage({ message, durationMs: 1200 });
},
});
const showVolumeFeedback = (nextVolume: number) => { const showVolumeFeedback = (nextVolume: number) => {
setVolumeFeedback(`${nextVolume}%`); setVolumeFeedback(`${nextVolume}%`);
@@ -413,7 +427,6 @@ export const SpaceToolsDockWidget = ({
onVolumeKeyDown={handleVolumeKeyDown} onVolumeKeyDown={handleVolumeKeyDown}
onSelectPreset={(presetId) => { onSelectPreset={(presetId) => {
onSelectPreset(presetId); onSelectPreset(presetId);
pushToast({ title: '사운드 변경(더미)' });
setOpenPopover(null); setOpenPopover(null);
}} }}
/> />
@@ -447,15 +460,12 @@ export const SpaceToolsDockWidget = ({
soundPresets={SOUND_PRESETS} soundPresets={SOUND_PRESETS}
onSelectRoom={(roomId) => { onSelectRoom={(roomId) => {
onRoomSelect(roomId); onRoomSelect(roomId);
pushToast({ title: '공간을 바꿨어요.' });
}} }}
onSelectTimer={(label) => { onSelectTimer={(label) => {
onTimerSelect(label); onTimerSelect(label);
pushToast({ title: `타이머를 ${label}로 바꿨어요.` });
}} }}
onSelectSound={(presetId) => { onSelectSound={(presetId) => {
onSelectPreset(presetId); onSelectPreset(presetId);
pushToast({ title: '사운드를 바꿨어요.' });
}} }}
onApplyPack={handleApplyPack} onApplyPack={handleApplyPack}
onLockedClick={handleLockedClick} onLockedClick={handleLockedClick}

View File

@@ -16,6 +16,7 @@ import {
type TimerPreset, type TimerPreset,
} from '@/entities/session'; } from '@/entities/session';
import { useSoundPresetSelection } from '@/features/sound-preset'; import { useSoundPresetSelection } from '@/features/sound-preset';
import { useHudStatusLine } from '@/shared/lib/useHudStatusLine';
import { useToast } from '@/shared/ui'; import { useToast } from '@/shared/ui';
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';
@@ -102,6 +103,7 @@ export const SpaceWorkspaceWidget = () => {
const canStart = goalInput.trim().length > 0; const canStart = goalInput.trim().length > 0;
const isFocusMode = workspaceMode === 'focus'; const isFocusMode = workspaceMode === 'focus';
const { activeStatus, pushStatusLine, runActiveAction } = useHudStatusLine(isFocusMode);
const handleGoalChipSelect = (chip: GoalChip) => { const handleGoalChipSelect = (chip: GoalChip) => {
setSelectedGoalId(chip.id); setSelectedGoalId(chip.id);
@@ -122,9 +124,8 @@ export const SpaceWorkspaceWidget = () => {
} }
setWorkspaceMode('focus'); setWorkspaceMode('focus');
pushStatusLine({
pushToast({ message: `목표: ${goalInput.trim()} 시작해요.`,
title: `목표: ${goalInput.trim()} 시작해요.`,
}); });
}; };
@@ -182,6 +183,9 @@ export const SpaceWorkspaceWidget = () => {
goal={goalInput.trim()} goal={goalInput.trim()}
timerLabel={selectedTimerLabel} timerLabel={selectedTimerLabel}
visible={isFocusMode} visible={isFocusMode}
statusLine={activeStatus}
onStatusAction={runActiveAction}
onStatusMessage={pushStatusLine}
onGoalUpdate={(nextGoal) => { onGoalUpdate={(nextGoal) => {
setGoalInput(nextGoal); setGoalInput(nextGoal);
setSelectedGoalId(null); setSelectedGoalId(null);
@@ -210,6 +214,7 @@ export const SpaceWorkspaceWidget = () => {
onRestoreThought={restoreThought} onRestoreThought={restoreThought}
onRestoreThoughts={restoreThoughts} onRestoreThoughts={restoreThoughts}
onClearInbox={clearThoughts} onClearInbox={clearThoughts}
onStatusMessage={pushStatusLine}
onExitRequested={handleExitRequested} onExitRequested={handleExitRequested}
/> />
</div> </div>