feat(goal-flow): Goal 1줄 앵커와 완료 시트 기반 다음 한 조각 플로우 적용

This commit is contained in:
2026-03-04 19:57:11 +09:00
parent b38455bf56
commit 85b4333798
4 changed files with 202 additions and 21 deletions

View File

@@ -0,0 +1,126 @@
'use client';
import { useEffect, useMemo, useRef, useState } from 'react';
import { cn } from '@/shared/lib/cn';
interface GoalCompleteSheetProps {
open: boolean;
currentGoal: string;
onConfirm: (nextGoal: string) => void;
onRest: () => void;
onClose: () => void;
}
const GOAL_SUGGESTIONS = [
'리뷰 코멘트 2개 처리',
'문서 1문단 다듬기',
'이슈 1개 정리',
'메일 2개 회신',
];
export const GoalCompleteSheet = ({
open,
currentGoal,
onConfirm,
onRest,
onClose,
}: GoalCompleteSheetProps) => {
const inputRef = useRef<HTMLInputElement | null>(null);
const [draft, setDraft] = useState('');
useEffect(() => {
if (!open) {
setDraft('');
return;
}
const rafId = window.requestAnimationFrame(() => {
inputRef.current?.focus();
});
return () => {
window.cancelAnimationFrame(rafId);
};
}, [open]);
const placeholder = useMemo(() => {
const trimmed = currentGoal.trim();
if (!trimmed) {
return '다음 한 조각을 적어보세요';
}
return `예: ${trimmed}`;
}, [currentGoal]);
const canConfirm = draft.trim().length > 0;
return (
<div
className={cn(
'pointer-events-none fixed inset-x-0 z-20 flex justify-center px-4 transition-all duration-[260ms] ease-out motion-reduce:duration-0',
open ? 'translate-y-0 opacity-100' : 'translate-y-3 opacity-0',
)}
style={{ bottom: 'calc(env(safe-area-inset-bottom, 0px) + 4.9rem)' }}
aria-hidden={!open}
>
<section className="pointer-events-auto w-[min(460px,94vw)] rounded-2xl border border-white/12 bg-black/26 px-3.5 py-3 text-white shadow-[0_14px_30px_rgba(2,6,23,0.28)] backdrop-blur-md">
<header className="flex items-start justify-between gap-2">
<div>
<h3 className="text-sm font-semibold text-white/92">. ?</h3>
<p className="mt-0.5 text-[11px] text-white/58"> , .</p>
</div>
<button
type="button"
onClick={onClose}
className="inline-flex h-6 w-6 items-center justify-center rounded-full border border-white/16 bg-white/[0.05] text-[11px] text-white/72 transition-colors hover:bg-white/[0.12]"
aria-label="닫기"
>
</button>
</header>
<div className="mt-2.5 space-y-2.5">
<input
ref={inputRef}
value={draft}
onChange={(event) => 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"
/>
<div className="flex flex-wrap gap-1.5">
{GOAL_SUGGESTIONS.map((suggestion) => (
<button
key={suggestion}
type="button"
onClick={() => setDraft(suggestion)}
className="rounded-full border border-white/16 bg-white/[0.04] px-2.5 py-1 text-[11px] text-white/76 transition-colors hover:bg-white/[0.1]"
>
{suggestion}
</button>
))}
</div>
</div>
<footer className="mt-3 flex items-center justify-end gap-2">
<button
type="button"
onClick={onRest}
className="rounded-full border border-white/18 bg-white/[0.05] px-3 py-1.5 text-xs text-white/74 transition-colors hover:bg-white/[0.11]"
>
</button>
<button
type="button"
disabled={!canConfirm}
onClick={() => onConfirm(draft.trim())}
className="rounded-full border border-sky-200/42 bg-sky-300/84 px-3.5 py-1.5 text-xs font-semibold text-slate-900 transition-colors hover:bg-sky-300 disabled:cursor-not-allowed disabled:border-white/16 disabled:bg-white/[0.08] disabled:text-white/48"
>
</button>
</footer>
</section>
</div>
);
};

View File

@@ -1,23 +1,32 @@
import { useCallback, useEffect, useRef, useState } from 'react'; import { useCallback, useEffect, useRef, useState } from 'react';
import { useReducedMotion } from '@/shared/lib/useReducedMotion'; import { useReducedMotion } from '@/shared/lib/useReducedMotion';
import { cn } from '@/shared/lib/cn';
import { useToast } from '@/shared/ui';
import { SpaceTimerHudWidget } from '@/widgets/space-timer-hud'; import { SpaceTimerHudWidget } from '@/widgets/space-timer-hud';
import { GoalCompleteSheet } from './GoalCompleteSheet';
import { GoalFlashOverlay } from './GoalFlashOverlay'; import { GoalFlashOverlay } from './GoalFlashOverlay';
interface SpaceFocusHudWidgetProps { interface SpaceFocusHudWidgetProps {
goal: string; goal: string;
timerLabel: string; timerLabel: string;
visible: boolean; visible: boolean;
onGoalUpdate: (nextGoal: string) => void;
} }
export const SpaceFocusHudWidget = ({ export const SpaceFocusHudWidget = ({
goal, goal,
timerLabel, timerLabel,
visible, visible,
onGoalUpdate,
}: 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 [completePulseVisible, setCompletePulseVisible] = useState(false);
const playbackStateRef = useRef<'running' | 'paused'>('running'); const playbackStateRef = useRef<'running' | 'paused'>('running');
const flashTimerRef = useRef<number | null>(null); const flashTimerRef = useRef<number | null>(null);
const completePulseTimerRef = useRef<number | null>(null);
const triggerFlash = useCallback((durationMs: number) => { const triggerFlash = useCallback((durationMs: number) => {
if (reducedMotion || !visible) { if (reducedMotion || !visible) {
@@ -42,6 +51,10 @@ export const SpaceFocusHudWidget = ({
window.clearTimeout(flashTimerRef.current); window.clearTimeout(flashTimerRef.current);
flashTimerRef.current = null; flashTimerRef.current = null;
} }
if (completePulseTimerRef.current) {
window.clearTimeout(completePulseTimerRef.current);
completePulseTimerRef.current = null;
}
}; };
}, []); }, []);
@@ -55,13 +68,15 @@ export const SpaceFocusHudWidget = ({
}, [visible, reducedMotion, triggerFlash]); }, [visible, reducedMotion, triggerFlash]);
useEffect(() => { useEffect(() => {
if (!visible || reducedMotion) { const ENABLE_PERIODIC_FLASH = false;
if (!visible || reducedMotion || !ENABLE_PERIODIC_FLASH) {
return; return;
} }
const intervalId = window.setInterval(() => { const intervalId = window.setInterval(() => {
triggerFlash(800); triggerFlash(800);
}, 5 * 60 * 1000); }, 10 * 60 * 1000);
return () => { return () => {
window.clearInterval(intervalId); window.clearInterval(intervalId);
@@ -72,14 +87,41 @@ export const SpaceFocusHudWidget = ({
return null; return null;
} }
const handleOpenCompleteSheet = () => {
setCompletePulseVisible(true);
if (completePulseTimerRef.current) {
window.clearTimeout(completePulseTimerRef.current);
}
completePulseTimerRef.current = window.setTimeout(() => {
setCompletePulseVisible(false);
setSheetOpen(true);
completePulseTimerRef.current = null;
}, 700);
};
return ( return (
<> <>
<GoalFlashOverlay goal={goal} visible={flashVisible} /> <GoalFlashOverlay goal={goal} visible={flashVisible} />
<div
className={cn(
'pointer-events-none fixed inset-x-0 z-20 flex justify-center px-4 transition-opacity duration-[240ms] ease-out motion-reduce:duration-0',
completePulseVisible ? 'opacity-100' : 'opacity-0',
)}
style={{ bottom: 'calc(env(safe-area-inset-bottom, 0px) + 5.1rem)' }}
aria-hidden
>
<div className="rounded-full border border-white/12 bg-black/24 px-3 py-1 text-xs text-white/84 backdrop-blur-md">
!
</div>
</div>
<SpaceTimerHudWidget <SpaceTimerHudWidget
timerLabel={timerLabel} timerLabel={timerLabel}
goal={goal} goal={goal}
isImmersionMode isImmersionMode
className="pr-[4.2rem]" className="pr-[4.2rem]"
onGoalCompleteRequest={handleOpenCompleteSheet}
onPlaybackStateChange={(state) => { onPlaybackStateChange={(state) => {
if (reducedMotion) { if (reducedMotion) {
playbackStateRef.current = state; playbackStateRef.current = state;
@@ -93,6 +135,21 @@ export const SpaceFocusHudWidget = ({
playbackStateRef.current = state; playbackStateRef.current = state;
}} }}
/> />
<GoalCompleteSheet
open={sheetOpen}
currentGoal={goal}
onClose={() => setSheetOpen(false)}
onRest={() => {
setSheetOpen(false);
pushToast({ title: '좋아요, 잠깐 쉬고 다시 이어가요.' });
}}
onConfirm={(nextGoal) => {
onGoalUpdate(nextGoal);
setSheetOpen(false);
pushToast({ title: '다음 한 조각으로 이어갑니다.' });
triggerFlash(1200);
}}
/>
</> </>
); );
}; };

View File

@@ -1,7 +1,6 @@
'use client'; 'use client';
import { cn } from '@/shared/lib/cn'; import { cn } from '@/shared/lib/cn';
import { useToast } from '@/shared/ui';
import { import {
RECOVERY_30S_MODE_LABEL, RECOVERY_30S_MODE_LABEL,
Restart30sAction, Restart30sAction,
@@ -14,6 +13,7 @@ interface SpaceTimerHudWidgetProps {
className?: string; className?: string;
isImmersionMode?: boolean; isImmersionMode?: boolean;
onPlaybackStateChange?: (state: 'running' | 'paused') => void; onPlaybackStateChange?: (state: 'running' | 'paused') => void;
onGoalCompleteRequest?: () => void;
} }
const HUD_ACTIONS = [ const HUD_ACTIONS = [
@@ -28,8 +28,8 @@ export const SpaceTimerHudWidget = ({
className, className,
isImmersionMode = false, isImmersionMode = false,
onPlaybackStateChange, onPlaybackStateChange,
onGoalCompleteRequest,
}: SpaceTimerHudWidgetProps) => { }: SpaceTimerHudWidgetProps) => {
const { pushToast } = useToast();
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() : '이번 한 조각을 설정해 주세요.';
@@ -76,24 +76,18 @@ export const SpaceTimerHudWidget = ({
{timerLabel} {timerLabel}
</span> </span>
</div> </div>
<div className="mt-1.5 min-w-0 rounded-lg border border-white/10 bg-black/16 px-2.5 py-1.5"> <div className="mt-1.5 flex min-w-0 items-center gap-2">
<div className="flex items-center justify-between gap-2"> <p className={cn('min-w-0 truncate text-sm', isImmersionMode ? 'text-white/88' : 'text-white/86')}>
<p className="text-[10px] font-medium uppercase tracking-[0.12em] text-white/55">Goal</p> <span className="text-white/62"> · </span>
<button <span className="text-white/90">{normalizedGoal}</span>
type="button"
onClick={() => {
pushToast({ title: '완료(더미) — 다음 조각으로 갈까요?' });
}}
className="inline-flex h-5 w-5 items-center justify-center rounded-full border border-white/16 bg-white/[0.05] text-[10px] text-white/78 transition-colors hover:bg-white/[0.12]"
aria-label="목표 완료"
title="목표 완료"
>
</button>
</div>
<p className={cn('mt-0.5 truncate text-sm', isImmersionMode ? 'text-white/90' : 'text-white/88')}>
{normalizedGoal}
</p> </p>
<button
type="button"
onClick={onGoalCompleteRequest}
className="shrink-0 rounded-full border border-white/16 bg-white/[0.04] px-2 py-0.5 text-[10px] text-white/70 transition-colors hover:bg-white/[0.1] hover:text-white/86"
>
</button>
</div> </div>
{hintMessage ? ( {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')}>

View File

@@ -182,6 +182,10 @@ export const SpaceWorkspaceWidget = () => {
goal={goalInput.trim()} goal={goalInput.trim()}
timerLabel={selectedTimerLabel} timerLabel={selectedTimerLabel}
visible={isFocusMode} visible={isFocusMode}
onGoalUpdate={(nextGoal) => {
setGoalInput(nextGoal);
setSelectedGoalId(null);
}}
/> />
<SpaceToolsDockWidget <SpaceToolsDockWidget