feat(space): timer 종료 모달과 10분 연장 추가

This commit is contained in:
2026-03-16 16:17:41 +09:00
parent 627bd82706
commit ec941f3cde
9 changed files with 271 additions and 21 deletions

View File

@@ -25,8 +25,10 @@ interface GoalCompleteSheetProps {
open: boolean;
currentGoal: string;
preferredView?: 'choice' | 'next';
mode?: 'manual' | 'timer-complete';
onConfirm: (nextGoal: string) => Promise<boolean> | boolean;
onFinish: () => Promise<boolean> | boolean;
onExtendTenMinutes?: () => Promise<boolean> | boolean;
onRest: () => void;
onClose: () => void;
}
@@ -35,15 +37,18 @@ export const GoalCompleteSheet = ({
open,
currentGoal,
preferredView = 'choice',
mode = 'manual',
onConfirm,
onFinish,
onExtendTenMinutes,
onRest,
onClose,
}: GoalCompleteSheetProps) => {
const inputRef = useRef<HTMLInputElement | null>(null);
const [draft, setDraft] = useState('');
const [submissionMode, setSubmissionMode] = useState<'next' | 'finish' | null>(null);
const [submissionMode, setSubmissionMode] = useState<'next' | 'finish' | 'extend' | null>(null);
const [view, setView] = useState<'choice' | 'next'>('choice');
const isTimerCompleteMode = mode === 'timer-complete';
useEffect(() => {
if (!open) {
@@ -57,7 +62,7 @@ export const GoalCompleteSheet = ({
};
}
if (view !== 'next') {
if (isTimerCompleteMode || view !== 'next') {
return;
}
@@ -68,7 +73,7 @@ export const GoalCompleteSheet = ({
return () => {
window.cancelAnimationFrame(rafId);
};
}, [open, preferredView, view]);
}, [isTimerCompleteMode, open, preferredView, view]);
useEffect(() => {
if (!open) {
@@ -92,11 +97,15 @@ export const GoalCompleteSheet = ({
const isSubmitting = submissionMode !== null;
const trimmedCurrentGoal = currentGoal.trim();
const title =
view === 'next'
isTimerCompleteMode
? copy.space.goalComplete.timerTitle
: view === 'next'
? copy.space.goalComplete.nextTitle
: copy.space.goalComplete.title;
const description =
view === 'next'
isTimerCompleteMode
? copy.space.goalComplete.timerDescription
: view === 'next'
? copy.space.goalComplete.nextDescription
: copy.space.goalComplete.description;
@@ -138,6 +147,24 @@ export const GoalCompleteSheet = ({
}
};
const handleExtend = async () => {
if (isSubmitting || !onExtendTenMinutes) {
return;
}
setSubmissionMode('extend');
try {
const didExtend = await onExtendTenMinutes();
if (didExtend) {
onClose();
}
} finally {
setSubmissionMode(null);
}
};
return (
<div
className={cn(
@@ -157,18 +184,70 @@ export const GoalCompleteSheet = ({
<h3 className="mt-1 max-w-[22rem] text-[1rem] font-medium tracking-tight text-white/94">{title}</h3>
<p className="mt-1 max-w-[21rem] text-[12px] leading-[1.55] text-white/56">{description}</p>
</div>
<button
type="button"
onClick={onClose}
disabled={isSubmitting}
className="inline-flex h-8 w-8 items-center justify-center rounded-full border border-white/10 bg-black/14 text-[11px] text-white/72 backdrop-blur-md transition-all hover:bg-black/20 hover:text-white disabled:cursor-not-allowed disabled:border-white/6 disabled:bg-black/10 disabled:text-white/26"
aria-label={copy.space.goalComplete.closeAriaLabel}
>
</button>
{isTimerCompleteMode ? null : (
<button
type="button"
onClick={onClose}
disabled={isSubmitting}
className="inline-flex h-8 w-8 items-center justify-center rounded-full border border-white/10 bg-black/14 text-[11px] text-white/72 backdrop-blur-md transition-all hover:bg-black/20 hover:text-white disabled:cursor-not-allowed disabled:border-white/6 disabled:bg-black/10 disabled:text-white/26"
aria-label={copy.space.goalComplete.closeAriaLabel}
>
</button>
)}
</header>
{view === 'choice' ? (
{isTimerCompleteMode ? (
<div className="mt-3 space-y-3">
{trimmedCurrentGoal ? (
<div className="rounded-[18px] border border-white/8 bg-black/10 px-3.5 py-3">
<p className="text-[11px] font-medium uppercase tracking-[0.16em] text-white/36">
{copy.space.goalComplete.currentGoalLabel}
</p>
<p className="mt-1 truncate text-[14px] text-white/86">{trimmedCurrentGoal}</p>
</div>
) : null}
<footer className="mt-4 space-y-2">
<button
type="button"
onClick={handleFinish}
disabled={isSubmitting}
className={cn(HUD_OPTION_ROW, HUD_OPTION_ROW_PRIMARY)}
>
<div className="max-w-[20.5rem]">
<p className="text-[13px] font-semibold tracking-[0.01em] text-white/90">
{submissionMode === 'finish'
? copy.space.goalComplete.timerFinishPending
: copy.space.goalComplete.timerFinishButton}
</p>
<p className="mt-1 text-[12px] leading-[1.55] text-white/48">
{copy.space.goalComplete.timerFinishDescription}
</p>
</div>
<span aria-hidden className={HUD_OPTION_CHEVRON}></span>
</button>
<button
type="button"
onClick={handleExtend}
disabled={isSubmitting}
className={HUD_OPTION_ROW}
>
<div className="max-w-[20.5rem]">
<p className="text-[13px] font-medium tracking-[0.01em] text-white/78">
{submissionMode === 'extend'
? copy.space.goalComplete.extendPending
: copy.space.goalComplete.extendButton}
</p>
<p className="mt-1 text-[12px] leading-[1.55] text-white/44">
{copy.space.goalComplete.extendDescription}
</p>
</div>
<span aria-hidden className={HUD_OPTION_CHEVRON}></span>
</button>
</footer>
</div>
) : view === 'choice' ? (
<div className="mt-3 space-y-3">
{trimmedCurrentGoal ? (
<div className="rounded-[18px] border border-white/8 bg-black/10 px-3.5 py-3">

View File

@@ -8,8 +8,11 @@ import { InlineMicrostep } from './InlineMicrostep';
import { ThoughtOrb } from './ThoughtOrb';
interface SpaceFocusHudWidgetProps {
sessionId?: string | null;
goal: string;
microStep?: string | null;
remainingSeconds?: number | null;
phaseStartedAt?: string | null;
timeDisplay?: string;
hasActiveSession?: boolean;
playbackState?: 'running' | 'paused';
@@ -17,14 +20,19 @@ interface SpaceFocusHudWidgetProps {
onIntentUpdate: (payload: { goal?: string; microStep?: string | null }) => boolean | Promise<boolean>;
onGoalUpdate: (nextGoal: string) => boolean | Promise<boolean>;
onGoalFinish: () => boolean | Promise<boolean>;
onTimerFinish: () => boolean | Promise<boolean>;
onAddTenMinutes: () => boolean | Promise<boolean>;
onStatusMessage: (payload: HudStatusLinePayload) => void;
onCaptureThought: (note: string) => void;
onExitRequested: () => void;
}
export const SpaceFocusHudWidget = ({
sessionId = null,
goal,
microStep,
remainingSeconds = null,
phaseStartedAt = null,
timeDisplay,
hasActiveSession = false,
playbackState = 'paused',
@@ -32,23 +40,34 @@ export const SpaceFocusHudWidget = ({
onIntentUpdate,
onGoalUpdate,
onGoalFinish,
onTimerFinish,
onAddTenMinutes,
onStatusMessage,
onCaptureThought,
onExitRequested,
}: SpaceFocusHudWidgetProps) => {
const [overlay, setOverlay] = useState<'none' | 'complete'>('none');
const [overlay, setOverlay] = useState<'none' | 'complete' | 'timer-complete'>('none');
const [completePreferredView, setCompletePreferredView] = useState<'choice' | 'next'>('choice');
const [isSavingIntent, setSavingIntent] = useState(false);
const visibleRef = useRef(false);
const timerPromptSignatureRef = useRef<string | null>(null);
const normalizedGoal = goal.trim().length > 0 ? goal.trim() : copy.space.focusHud.goalFallback;
const isCompleteOpen = overlay === 'complete';
const isCompleteOpen = overlay === 'complete' || overlay === 'timer-complete';
const timerCompletionSignature =
hasActiveSession &&
sessionPhase === 'focus' &&
remainingSeconds === 0 &&
phaseStartedAt
? `${sessionId ?? 'session'}:${phaseStartedAt}`
: null;
useEffect(() => {
if (!hasActiveSession) {
setOverlay('none');
setSavingIntent(false);
setCompletePreferredView('choice');
timerPromptSignatureRef.current = null;
}
}, [hasActiveSession]);
@@ -62,6 +81,19 @@ export const SpaceFocusHudWidget = ({
visibleRef.current = true;
}, [normalizedGoal, onStatusMessage, playbackState]);
useEffect(() => {
if (!timerCompletionSignature) {
return;
}
if (timerPromptSignatureRef.current === timerCompletionSignature) {
return;
}
timerPromptSignatureRef.current = timerCompletionSignature;
setOverlay('timer-complete');
}, [timerCompletionSignature]);
const handleOpenCompleteSheet = (preferredView: 'choice' | 'next' = 'choice') => {
setCompletePreferredView(preferredView);
setOverlay('complete');
@@ -139,15 +171,23 @@ export const SpaceFocusHudWidget = ({
<GoalCompleteSheet
open={isCompleteOpen}
currentGoal={goal}
mode={overlay === 'timer-complete' ? 'timer-complete' : 'manual'}
preferredView={completePreferredView}
onClose={() => setOverlay('none')}
onFinish={() => Promise.resolve(onGoalFinish())}
onFinish={() =>
overlay === 'timer-complete'
? Promise.resolve(onTimerFinish())
: Promise.resolve(onGoalFinish())
}
onExtendTenMinutes={() => Promise.resolve(onAddTenMinutes())}
onRest={() => {
setOverlay('none');
// The timer doesn't pause, they just rest within the flow.
}}
onConfirm={(nextGoal) => {
return Promise.resolve(onGoalUpdate(nextGoal));
return overlay === 'timer-complete'
? Promise.resolve(false)
: Promise.resolve(onGoalUpdate(nextGoal));
}}
/>
</div>