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

View File

@@ -12,8 +12,13 @@ interface SpaceTimerHudWidgetProps {
goal: string;
className?: string;
isImmersionMode?: boolean;
statusLine?: {
message: string;
actionLabel?: string;
} | null;
onPlaybackStateChange?: (state: 'running' | 'paused') => void;
onGoalCompleteRequest?: () => void;
onStatusAction?: () => void;
}
const HUD_ACTIONS = [
@@ -27,8 +32,10 @@ export const SpaceTimerHudWidget = ({
goal,
className,
isImmersionMode = false,
statusLine = null,
onPlaybackStateChange,
onGoalCompleteRequest,
onStatusAction,
}: SpaceTimerHudWidgetProps) => {
const { isBreatheMode, hintMessage, triggerRestart } = useRestart30s();
const normalizedGoal = goal.trim().length > 0 ? goal.trim() : '이번 한 조각을 설정해 주세요.';
@@ -89,7 +96,22 @@ export const SpaceTimerHudWidget = ({
</button>
</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')}>
{hintMessage}
</p>

View File

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

View File

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