feat(space): timer 종료 모달과 10분 연장 추가
This commit is contained in:
@@ -72,6 +72,11 @@ Last Updated: 2026-03-16
|
|||||||
- `break` phase에서도 expanded intent card 안에 `이번 목표 완료`를 유지하도록 수정
|
- `break` phase에서도 expanded intent card 안에 `이번 목표 완료`를 유지하도록 수정
|
||||||
- base card가 잠기는 recovery overlay(`pause / return / next-beat`) 안에서도 low-emphasis `여기서 마무리하기` 경로를 추가
|
- base card가 잠기는 recovery overlay(`pause / return / next-beat`) 안에서도 low-emphasis `여기서 마무리하기` 경로를 추가
|
||||||
- 이제 active session 상태에서는 `계속 / 다시 잡기 / 마무리` 중 최소 한 경로가 항상 보이도록 정리
|
- 이제 active session 상태에서는 `계속 / 다시 잡기 / 마무리` 중 최소 한 경로가 항상 보이도록 정리
|
||||||
|
- `/space` timer completion flow 재정의:
|
||||||
|
- focus timer가 끝나면 더 이상 break로 자동 반복되지 않는다
|
||||||
|
- timer가 `00:00`에 도달하면 same-session modal이 자동으로 열리고, `완료하고 종료하기 / 10분 더` 두 경로만 제안한다
|
||||||
|
- `10분 더`를 누르면 현재 focus phase에 10분이 추가되어 바로 running으로 돌아가고, 다시 시간이 끝나면 같은 modal이 다시 열린다
|
||||||
|
- server와 web 모두 `extend-phase` 계약 기준으로 동작한다
|
||||||
- `/space` intent HUD collapsed / expanded 재설계:
|
- `/space` intent HUD collapsed / expanded 재설계:
|
||||||
- 상시 큰 goal 카드 대신 idle에서는 goal 1줄만 남는 collapsed glass rail 구조로 변경
|
- 상시 큰 goal 카드 대신 idle에서는 goal 1줄만 남는 collapsed glass rail 구조로 변경
|
||||||
- hover / focus / rail tap에서만 expanded card로 열리며, 이때만 microStep과 `이번 목표 완료` 액션이 노출됨
|
- hover / focus / rail tap에서만 expanded card로 열리며, 이때만 microStep과 `이번 목표 완료` 액션이 노출됨
|
||||||
|
|||||||
@@ -81,8 +81,11 @@ Last Updated: 2026-03-16
|
|||||||
- `Return(focus)`는 재진입에 맞는 짧은 settle motion으로,
|
- `Return(focus)`는 재진입에 맞는 짧은 settle motion으로,
|
||||||
- `Return(break)`와 `Goal Complete`는 더 느슨한 release/closure reveal로 분리했다.
|
- `Return(break)`와 `Goal Complete`는 더 느슨한 release/closure reveal로 분리했다.
|
||||||
- `/space` active session에서는 goal closure 경로가 항상 남도록 정리했다.
|
- `/space` active session에서는 goal closure 경로가 항상 남도록 정리했다.
|
||||||
- `break`에서도 expanded goal card 안에 `이번 목표 완료`가 보인다.
|
- `break`에서도 expanded goal card 안에 `이번 목표 완료`가 보인다.
|
||||||
- `pause / return / next-beat`처럼 base card가 잠기는 overlay 안에는 low-emphasis `여기서 마무리하기`가 추가됐다.
|
- `pause / return / next-beat`처럼 base card가 잠기는 overlay 안에는 low-emphasis `여기서 마무리하기`가 추가됐다.
|
||||||
|
- focus timer가 끝나면 더 이상 break로 자동 반복되지 않는다.
|
||||||
|
- `00:00`이 되면 `완료하고 종료하기 / 10분 더` 모달이 자동으로 열린다.
|
||||||
|
- `10분 더`는 server `extend-phase` 계약을 타고 현재 focus phase를 10분 연장한 뒤 다시 running으로 이어진다.
|
||||||
- 그래서 사용자는 recovery 상태에서도 `계속 / 다시 잡기 / 마무리` 중 하나를 바로 고를 수 있다.
|
- 그래서 사용자는 recovery 상태에서도 `계속 / 다시 잡기 / 마무리` 중 하나를 바로 고를 수 있다.
|
||||||
- `/space` 목표 카드를 collapsed / expanded 구조로 재설계했다.
|
- `/space` 목표 카드를 collapsed / expanded 구조로 재설계했다.
|
||||||
- idle에서는 goal 1줄만 남는 얇은 glass rail로 줄였다.
|
- idle에서는 goal 1줄만 남는 얇은 glass rail로 줄였다.
|
||||||
|
|||||||
@@ -79,6 +79,10 @@ export interface UpdateCurrentFocusSessionIntentRequest {
|
|||||||
microStep?: string | null;
|
microStep?: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface ExtendCurrentPhaseRequest {
|
||||||
|
additionalMinutes: number;
|
||||||
|
}
|
||||||
|
|
||||||
export interface AdvanceCurrentGoalRequest {
|
export interface AdvanceCurrentGoalRequest {
|
||||||
completedGoal: string;
|
completedGoal: string;
|
||||||
nextGoal: string;
|
nextGoal: string;
|
||||||
@@ -164,6 +168,15 @@ export const focusSessionApi = {
|
|||||||
return normalizeFocusSession(response);
|
return normalizeFocusSession(response);
|
||||||
},
|
},
|
||||||
|
|
||||||
|
extendCurrentPhase: async (payload: ExtendCurrentPhaseRequest): Promise<FocusSession> => {
|
||||||
|
const response = await apiClient<RawFocusSession>('api/v1/focus-sessions/current/extend-phase', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify(payload),
|
||||||
|
});
|
||||||
|
|
||||||
|
return normalizeFocusSession(response);
|
||||||
|
},
|
||||||
|
|
||||||
updateCurrentSelection: async (
|
updateCurrentSelection: async (
|
||||||
payload: UpdateCurrentFocusSessionSelectionRequest,
|
payload: UpdateCurrentFocusSessionSelectionRequest,
|
||||||
): Promise<FocusSession> => {
|
): Promise<FocusSession> => {
|
||||||
|
|||||||
@@ -73,6 +73,7 @@ interface UseFocusSessionEngineResult {
|
|||||||
pauseSession: () => Promise<FocusSession | null>;
|
pauseSession: () => Promise<FocusSession | null>;
|
||||||
resumeSession: () => Promise<FocusSession | null>;
|
resumeSession: () => Promise<FocusSession | null>;
|
||||||
restartCurrentPhase: () => Promise<FocusSession | null>;
|
restartCurrentPhase: () => Promise<FocusSession | null>;
|
||||||
|
extendCurrentPhase: (payload: { additionalMinutes: number }) => Promise<FocusSession | null>;
|
||||||
updateCurrentIntent: (payload: UpdateCurrentFocusSessionIntentRequest) => Promise<FocusSession | null>;
|
updateCurrentIntent: (payload: UpdateCurrentFocusSessionIntentRequest) => Promise<FocusSession | null>;
|
||||||
updateCurrentSelection: (payload: UpdateCurrentFocusSessionSelectionRequest) => Promise<FocusSession | null>;
|
updateCurrentSelection: (payload: UpdateCurrentFocusSessionSelectionRequest) => Promise<FocusSession | null>;
|
||||||
completeSession: (payload: CompleteFocusSessionRequest) => Promise<FocusSession | null>;
|
completeSession: (payload: CompleteFocusSessionRequest) => Promise<FocusSession | null>;
|
||||||
@@ -237,6 +238,18 @@ export const useFocusSessionEngine = (): UseFocusSessionEngineResult => {
|
|||||||
|
|
||||||
return applySession(session);
|
return applySession(session);
|
||||||
},
|
},
|
||||||
|
extendCurrentPhase: async (payload) => {
|
||||||
|
if (!currentSession) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const session = await runMutation(
|
||||||
|
() => focusSessionApi.extendCurrentPhase(payload),
|
||||||
|
copy.focusSession.resumeFailed,
|
||||||
|
);
|
||||||
|
|
||||||
|
return applySession(session);
|
||||||
|
},
|
||||||
updateCurrentIntent: async (payload) => {
|
updateCurrentIntent: async (payload) => {
|
||||||
if (!currentSession) {
|
if (!currentSession) {
|
||||||
return null;
|
return null;
|
||||||
|
|||||||
@@ -98,6 +98,8 @@ export const space = {
|
|||||||
placeholderExample: (goal: string) => `예: ${goal}`,
|
placeholderExample: (goal: string) => `예: ${goal}`,
|
||||||
title: '이 블록을 어떻게 이어갈까요?',
|
title: '이 블록을 어떻게 이어갈까요?',
|
||||||
description: '다음으로 이어가기, 잠시 비우기, 여기서 마무리하기 중 하나만 고르면 돼요.',
|
description: '다음으로 이어가기, 잠시 비우기, 여기서 마무리하기 중 하나만 고르면 돼요.',
|
||||||
|
timerTitle: '시간이 끝났어요. 이 목표를 어떻게 할까요?',
|
||||||
|
timerDescription: '여기서 완료하고 닫거나, 10분 더 이어서 마무리할 수 있어요.',
|
||||||
nextTitle: '좋아요. 다음 한 조각만 정해요.',
|
nextTitle: '좋아요. 다음 한 조각만 정해요.',
|
||||||
nextDescription: '너무 크게 잡지 말고, 바로 손을 올릴 한 줄만 남겨요.',
|
nextDescription: '너무 크게 잡지 말고, 바로 손을 올릴 한 줄만 남겨요.',
|
||||||
currentGoalLabel: '방금 끝낸 블록',
|
currentGoalLabel: '방금 끝낸 블록',
|
||||||
@@ -108,8 +110,14 @@ export const space = {
|
|||||||
closeAriaLabel: '닫기',
|
closeAriaLabel: '닫기',
|
||||||
finishButton: '여기서 마무리하기',
|
finishButton: '여기서 마무리하기',
|
||||||
finishDescription: '이 블록은 여기서 닫고, 다음 진입은 가볍게 남겨둡니다.',
|
finishDescription: '이 블록은 여기서 닫고, 다음 진입은 가볍게 남겨둡니다.',
|
||||||
|
timerFinishButton: '완료하고 종료하기',
|
||||||
|
timerFinishDescription: '이 목표는 여기서 끝냈다고 기록하고 세션을 닫습니다.',
|
||||||
|
timerFinishPending: '종료 중…',
|
||||||
restButton: '잠시 비우기',
|
restButton: '잠시 비우기',
|
||||||
restDescription: '이 블록은 아직 닫지 않고, 잠깐 멈춘 뒤 돌아오라고 알려드려요.',
|
restDescription: '이 블록은 아직 닫지 않고, 잠깐 멈춘 뒤 돌아오라고 알려드려요.',
|
||||||
|
extendButton: '10분 더',
|
||||||
|
extendDescription: '지금 흐름을 그대로 두고 10분만 더 이어갑니다.',
|
||||||
|
extendPending: '10분 추가 중…',
|
||||||
confirmButton: '다음 목표로 바로 시작',
|
confirmButton: '다음 목표로 바로 시작',
|
||||||
confirmPending: '시작 중…',
|
confirmPending: '시작 중…',
|
||||||
finishPending: '마무리 중…',
|
finishPending: '마무리 중…',
|
||||||
@@ -241,6 +249,9 @@ export const space = {
|
|||||||
restarted: '현재 페이즈를 처음부터 다시 시작했어요.',
|
restarted: '현재 페이즈를 처음부터 다시 시작했어요.',
|
||||||
intentSyncFailed: '현재 세션 방향을 서버에 반영하지 못했어요.',
|
intentSyncFailed: '현재 세션 방향을 서버에 반영하지 못했어요.',
|
||||||
goalCompleteSyncFailed: '현재 세션 완료를 서버에 반영하지 못했어요.',
|
goalCompleteSyncFailed: '현재 세션 완료를 서버에 반영하지 못했어요.',
|
||||||
|
timerCompleteSyncFailed: '타이머 종료 후 세션 마무리를 반영하지 못했어요.',
|
||||||
|
timerExtendFailed: '10분 추가를 반영하지 못했어요.',
|
||||||
|
timerExtended: (minutes: number) => `${minutes}분을 더 이어갑니다.`,
|
||||||
nextGoalStarted: '다음 한 조각을 바로 시작했어요.',
|
nextGoalStarted: '다음 한 조각을 바로 시작했어요.',
|
||||||
selectionPreferenceSaveFailed: '배경/사운드 기본 설정을 저장하지 못했어요.',
|
selectionPreferenceSaveFailed: '배경/사운드 기본 설정을 저장하지 못했어요.',
|
||||||
selectionSessionSyncFailed: '현재 세션의 배경/사운드 선택을 동기화하지 못했어요.',
|
selectionSessionSyncFailed: '현재 세션의 배경/사운드 선택을 동기화하지 못했어요.',
|
||||||
|
|||||||
@@ -25,8 +25,10 @@ interface GoalCompleteSheetProps {
|
|||||||
open: boolean;
|
open: boolean;
|
||||||
currentGoal: string;
|
currentGoal: string;
|
||||||
preferredView?: 'choice' | 'next';
|
preferredView?: 'choice' | 'next';
|
||||||
|
mode?: 'manual' | 'timer-complete';
|
||||||
onConfirm: (nextGoal: string) => Promise<boolean> | boolean;
|
onConfirm: (nextGoal: string) => Promise<boolean> | boolean;
|
||||||
onFinish: () => Promise<boolean> | boolean;
|
onFinish: () => Promise<boolean> | boolean;
|
||||||
|
onExtendTenMinutes?: () => Promise<boolean> | boolean;
|
||||||
onRest: () => void;
|
onRest: () => void;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
}
|
}
|
||||||
@@ -35,15 +37,18 @@ export const GoalCompleteSheet = ({
|
|||||||
open,
|
open,
|
||||||
currentGoal,
|
currentGoal,
|
||||||
preferredView = 'choice',
|
preferredView = 'choice',
|
||||||
|
mode = 'manual',
|
||||||
onConfirm,
|
onConfirm,
|
||||||
onFinish,
|
onFinish,
|
||||||
|
onExtendTenMinutes,
|
||||||
onRest,
|
onRest,
|
||||||
onClose,
|
onClose,
|
||||||
}: GoalCompleteSheetProps) => {
|
}: GoalCompleteSheetProps) => {
|
||||||
const inputRef = useRef<HTMLInputElement | null>(null);
|
const inputRef = useRef<HTMLInputElement | null>(null);
|
||||||
const [draft, setDraft] = useState('');
|
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 [view, setView] = useState<'choice' | 'next'>('choice');
|
||||||
|
const isTimerCompleteMode = mode === 'timer-complete';
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!open) {
|
if (!open) {
|
||||||
@@ -57,7 +62,7 @@ export const GoalCompleteSheet = ({
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
if (view !== 'next') {
|
if (isTimerCompleteMode || view !== 'next') {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -68,7 +73,7 @@ export const GoalCompleteSheet = ({
|
|||||||
return () => {
|
return () => {
|
||||||
window.cancelAnimationFrame(rafId);
|
window.cancelAnimationFrame(rafId);
|
||||||
};
|
};
|
||||||
}, [open, preferredView, view]);
|
}, [isTimerCompleteMode, open, preferredView, view]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!open) {
|
if (!open) {
|
||||||
@@ -92,11 +97,15 @@ export const GoalCompleteSheet = ({
|
|||||||
const isSubmitting = submissionMode !== null;
|
const isSubmitting = submissionMode !== null;
|
||||||
const trimmedCurrentGoal = currentGoal.trim();
|
const trimmedCurrentGoal = currentGoal.trim();
|
||||||
const title =
|
const title =
|
||||||
view === 'next'
|
isTimerCompleteMode
|
||||||
|
? copy.space.goalComplete.timerTitle
|
||||||
|
: view === 'next'
|
||||||
? copy.space.goalComplete.nextTitle
|
? copy.space.goalComplete.nextTitle
|
||||||
: copy.space.goalComplete.title;
|
: copy.space.goalComplete.title;
|
||||||
const description =
|
const description =
|
||||||
view === 'next'
|
isTimerCompleteMode
|
||||||
|
? copy.space.goalComplete.timerDescription
|
||||||
|
: view === 'next'
|
||||||
? copy.space.goalComplete.nextDescription
|
? copy.space.goalComplete.nextDescription
|
||||||
: copy.space.goalComplete.description;
|
: 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 (
|
return (
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
@@ -157,6 +184,7 @@ export const GoalCompleteSheet = ({
|
|||||||
<h3 className="mt-1 max-w-[22rem] text-[1rem] font-medium tracking-tight text-white/94">{title}</h3>
|
<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>
|
<p className="mt-1 max-w-[21rem] text-[12px] leading-[1.55] text-white/56">{description}</p>
|
||||||
</div>
|
</div>
|
||||||
|
{isTimerCompleteMode ? null : (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={onClose}
|
onClick={onClose}
|
||||||
@@ -166,9 +194,60 @@ export const GoalCompleteSheet = ({
|
|||||||
>
|
>
|
||||||
✕
|
✕
|
||||||
</button>
|
</button>
|
||||||
|
)}
|
||||||
</header>
|
</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">
|
<div className="mt-3 space-y-3">
|
||||||
{trimmedCurrentGoal ? (
|
{trimmedCurrentGoal ? (
|
||||||
<div className="rounded-[18px] border border-white/8 bg-black/10 px-3.5 py-3">
|
<div className="rounded-[18px] border border-white/8 bg-black/10 px-3.5 py-3">
|
||||||
|
|||||||
@@ -8,8 +8,11 @@ import { InlineMicrostep } from './InlineMicrostep';
|
|||||||
import { ThoughtOrb } from './ThoughtOrb';
|
import { ThoughtOrb } from './ThoughtOrb';
|
||||||
|
|
||||||
interface SpaceFocusHudWidgetProps {
|
interface SpaceFocusHudWidgetProps {
|
||||||
|
sessionId?: string | null;
|
||||||
goal: string;
|
goal: string;
|
||||||
microStep?: string | null;
|
microStep?: string | null;
|
||||||
|
remainingSeconds?: number | null;
|
||||||
|
phaseStartedAt?: string | null;
|
||||||
timeDisplay?: string;
|
timeDisplay?: string;
|
||||||
hasActiveSession?: boolean;
|
hasActiveSession?: boolean;
|
||||||
playbackState?: 'running' | 'paused';
|
playbackState?: 'running' | 'paused';
|
||||||
@@ -17,14 +20,19 @@ interface SpaceFocusHudWidgetProps {
|
|||||||
onIntentUpdate: (payload: { goal?: string; microStep?: string | null }) => boolean | Promise<boolean>;
|
onIntentUpdate: (payload: { goal?: string; microStep?: string | null }) => boolean | Promise<boolean>;
|
||||||
onGoalUpdate: (nextGoal: string) => boolean | Promise<boolean>;
|
onGoalUpdate: (nextGoal: string) => boolean | Promise<boolean>;
|
||||||
onGoalFinish: () => boolean | Promise<boolean>;
|
onGoalFinish: () => boolean | Promise<boolean>;
|
||||||
|
onTimerFinish: () => boolean | Promise<boolean>;
|
||||||
|
onAddTenMinutes: () => boolean | Promise<boolean>;
|
||||||
onStatusMessage: (payload: HudStatusLinePayload) => void;
|
onStatusMessage: (payload: HudStatusLinePayload) => void;
|
||||||
onCaptureThought: (note: string) => void;
|
onCaptureThought: (note: string) => void;
|
||||||
onExitRequested: () => void;
|
onExitRequested: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const SpaceFocusHudWidget = ({
|
export const SpaceFocusHudWidget = ({
|
||||||
|
sessionId = null,
|
||||||
goal,
|
goal,
|
||||||
microStep,
|
microStep,
|
||||||
|
remainingSeconds = null,
|
||||||
|
phaseStartedAt = null,
|
||||||
timeDisplay,
|
timeDisplay,
|
||||||
hasActiveSession = false,
|
hasActiveSession = false,
|
||||||
playbackState = 'paused',
|
playbackState = 'paused',
|
||||||
@@ -32,23 +40,34 @@ export const SpaceFocusHudWidget = ({
|
|||||||
onIntentUpdate,
|
onIntentUpdate,
|
||||||
onGoalUpdate,
|
onGoalUpdate,
|
||||||
onGoalFinish,
|
onGoalFinish,
|
||||||
|
onTimerFinish,
|
||||||
|
onAddTenMinutes,
|
||||||
onStatusMessage,
|
onStatusMessage,
|
||||||
onCaptureThought,
|
onCaptureThought,
|
||||||
onExitRequested,
|
onExitRequested,
|
||||||
}: SpaceFocusHudWidgetProps) => {
|
}: 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 [completePreferredView, setCompletePreferredView] = useState<'choice' | 'next'>('choice');
|
||||||
const [isSavingIntent, setSavingIntent] = useState(false);
|
const [isSavingIntent, setSavingIntent] = useState(false);
|
||||||
|
|
||||||
const visibleRef = useRef(false);
|
const visibleRef = useRef(false);
|
||||||
|
const timerPromptSignatureRef = useRef<string | null>(null);
|
||||||
const normalizedGoal = goal.trim().length > 0 ? goal.trim() : copy.space.focusHud.goalFallback;
|
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(() => {
|
useEffect(() => {
|
||||||
if (!hasActiveSession) {
|
if (!hasActiveSession) {
|
||||||
setOverlay('none');
|
setOverlay('none');
|
||||||
setSavingIntent(false);
|
setSavingIntent(false);
|
||||||
setCompletePreferredView('choice');
|
setCompletePreferredView('choice');
|
||||||
|
timerPromptSignatureRef.current = null;
|
||||||
}
|
}
|
||||||
}, [hasActiveSession]);
|
}, [hasActiveSession]);
|
||||||
|
|
||||||
@@ -62,6 +81,19 @@ export const SpaceFocusHudWidget = ({
|
|||||||
visibleRef.current = true;
|
visibleRef.current = true;
|
||||||
}, [normalizedGoal, onStatusMessage, playbackState]);
|
}, [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') => {
|
const handleOpenCompleteSheet = (preferredView: 'choice' | 'next' = 'choice') => {
|
||||||
setCompletePreferredView(preferredView);
|
setCompletePreferredView(preferredView);
|
||||||
setOverlay('complete');
|
setOverlay('complete');
|
||||||
@@ -139,15 +171,23 @@ export const SpaceFocusHudWidget = ({
|
|||||||
<GoalCompleteSheet
|
<GoalCompleteSheet
|
||||||
open={isCompleteOpen}
|
open={isCompleteOpen}
|
||||||
currentGoal={goal}
|
currentGoal={goal}
|
||||||
|
mode={overlay === 'timer-complete' ? 'timer-complete' : 'manual'}
|
||||||
preferredView={completePreferredView}
|
preferredView={completePreferredView}
|
||||||
onClose={() => setOverlay('none')}
|
onClose={() => setOverlay('none')}
|
||||||
onFinish={() => Promise.resolve(onGoalFinish())}
|
onFinish={() =>
|
||||||
|
overlay === 'timer-complete'
|
||||||
|
? Promise.resolve(onTimerFinish())
|
||||||
|
: Promise.resolve(onGoalFinish())
|
||||||
|
}
|
||||||
|
onExtendTenMinutes={() => Promise.resolve(onAddTenMinutes())}
|
||||||
onRest={() => {
|
onRest={() => {
|
||||||
setOverlay('none');
|
setOverlay('none');
|
||||||
// The timer doesn't pause, they just rest within the flow.
|
// The timer doesn't pause, they just rest within the flow.
|
||||||
}}
|
}}
|
||||||
onConfirm={(nextGoal) => {
|
onConfirm={(nextGoal) => {
|
||||||
return Promise.resolve(onGoalUpdate(nextGoal));
|
return overlay === 'timer-complete'
|
||||||
|
? Promise.resolve(false)
|
||||||
|
: Promise.resolve(onGoalUpdate(nextGoal));
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -36,6 +36,7 @@ interface UseSpaceWorkspaceSessionControlsParams {
|
|||||||
pauseSession: () => Promise<FocusSession | null>;
|
pauseSession: () => Promise<FocusSession | null>;
|
||||||
resumeSession: () => Promise<FocusSession | null>;
|
resumeSession: () => Promise<FocusSession | null>;
|
||||||
restartCurrentPhase: () => Promise<FocusSession | null>;
|
restartCurrentPhase: () => Promise<FocusSession | null>;
|
||||||
|
extendCurrentPhase: (payload: { additionalMinutes: number }) => Promise<FocusSession | null>;
|
||||||
updateCurrentIntent: (payload: {
|
updateCurrentIntent: (payload: {
|
||||||
goal?: string;
|
goal?: string;
|
||||||
microStep?: string | null;
|
microStep?: string | null;
|
||||||
@@ -83,6 +84,7 @@ export const useSpaceWorkspaceSessionControls = ({
|
|||||||
pauseSession,
|
pauseSession,
|
||||||
resumeSession,
|
resumeSession,
|
||||||
restartCurrentPhase,
|
restartCurrentPhase,
|
||||||
|
extendCurrentPhase,
|
||||||
updateCurrentIntent,
|
updateCurrentIntent,
|
||||||
completeSession,
|
completeSession,
|
||||||
advanceGoal,
|
advanceGoal,
|
||||||
@@ -342,6 +344,80 @@ export const useSpaceWorkspaceSessionControls = ({
|
|||||||
setWorkspaceMode,
|
setWorkspaceMode,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
const handleTimerComplete = useCallback(async () => {
|
||||||
|
const trimmedCurrentGoal = goalInput.trim();
|
||||||
|
|
||||||
|
if (!currentSession) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const completedSession = await completeSession({
|
||||||
|
completionType: 'timer-complete',
|
||||||
|
completedGoal: trimmedCurrentGoal || undefined,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!completedSession) {
|
||||||
|
pushStatusLine({
|
||||||
|
message: copy.space.workspace.timerCompleteSyncFailed,
|
||||||
|
});
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
setGoalInput('');
|
||||||
|
setLinkedFocusPlanItemId(null);
|
||||||
|
setSelectedGoalId(null);
|
||||||
|
setShowResumePrompt(false);
|
||||||
|
setPendingSessionEntryPoint('space-setup');
|
||||||
|
setPreviewPlaybackState('paused');
|
||||||
|
setWorkspaceMode('setup');
|
||||||
|
return true;
|
||||||
|
}, [
|
||||||
|
completeSession,
|
||||||
|
currentSession,
|
||||||
|
goalInput,
|
||||||
|
pushStatusLine,
|
||||||
|
setGoalInput,
|
||||||
|
setLinkedFocusPlanItemId,
|
||||||
|
setPendingSessionEntryPoint,
|
||||||
|
setPreviewPlaybackState,
|
||||||
|
setSelectedGoalId,
|
||||||
|
setShowResumePrompt,
|
||||||
|
setWorkspaceMode,
|
||||||
|
]);
|
||||||
|
|
||||||
|
const handleExtendCurrentPhase = useCallback(async (additionalMinutes: number) => {
|
||||||
|
if (!currentSession) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
await unlockPlayback(resolveSoundPlaybackUrl(selectedPresetId));
|
||||||
|
|
||||||
|
const extendedSession = await extendCurrentPhase({
|
||||||
|
additionalMinutes,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!extendedSession) {
|
||||||
|
pushStatusLine({
|
||||||
|
message: copy.space.workspace.timerExtendFailed,
|
||||||
|
});
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
setPreviewPlaybackState('running');
|
||||||
|
pushStatusLine({
|
||||||
|
message: copy.space.workspace.timerExtended(additionalMinutes),
|
||||||
|
});
|
||||||
|
return true;
|
||||||
|
}, [
|
||||||
|
currentSession,
|
||||||
|
extendCurrentPhase,
|
||||||
|
pushStatusLine,
|
||||||
|
resolveSoundPlaybackUrl,
|
||||||
|
selectedPresetId,
|
||||||
|
setPreviewPlaybackState,
|
||||||
|
unlockPlayback,
|
||||||
|
]);
|
||||||
|
|
||||||
const handleIntentUpdate = useCallback(async (input: {
|
const handleIntentUpdate = useCallback(async (input: {
|
||||||
goal?: string;
|
goal?: string;
|
||||||
microStep?: string | null;
|
microStep?: string | null;
|
||||||
@@ -456,6 +532,8 @@ export const useSpaceWorkspaceSessionControls = ({
|
|||||||
handleRestartRequested,
|
handleRestartRequested,
|
||||||
handleIntentUpdate,
|
handleIntentUpdate,
|
||||||
handleGoalComplete,
|
handleGoalComplete,
|
||||||
|
handleTimerComplete,
|
||||||
|
handleExtendCurrentPhase,
|
||||||
handleGoalAdvance,
|
handleGoalAdvance,
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -103,12 +103,14 @@ export const SpaceWorkspaceWidget = () => {
|
|||||||
const {
|
const {
|
||||||
currentSession,
|
currentSession,
|
||||||
isBootstrapping,
|
isBootstrapping,
|
||||||
|
remainingSeconds,
|
||||||
timeDisplay,
|
timeDisplay,
|
||||||
phase,
|
phase,
|
||||||
startSession,
|
startSession,
|
||||||
pauseSession,
|
pauseSession,
|
||||||
resumeSession,
|
resumeSession,
|
||||||
restartCurrentPhase,
|
restartCurrentPhase,
|
||||||
|
extendCurrentPhase,
|
||||||
updateCurrentIntent,
|
updateCurrentIntent,
|
||||||
updateCurrentSelection,
|
updateCurrentSelection,
|
||||||
completeSession,
|
completeSession,
|
||||||
@@ -185,6 +187,7 @@ export const SpaceWorkspaceWidget = () => {
|
|||||||
pauseSession,
|
pauseSession,
|
||||||
resumeSession,
|
resumeSession,
|
||||||
restartCurrentPhase,
|
restartCurrentPhase,
|
||||||
|
extendCurrentPhase,
|
||||||
updateCurrentIntent,
|
updateCurrentIntent,
|
||||||
completeSession,
|
completeSession,
|
||||||
advanceGoal,
|
advanceGoal,
|
||||||
@@ -323,8 +326,11 @@ export const SpaceWorkspaceWidget = () => {
|
|||||||
|
|
||||||
{isFocusMode ? (
|
{isFocusMode ? (
|
||||||
<SpaceFocusHudWidget
|
<SpaceFocusHudWidget
|
||||||
|
sessionId={currentSession?.id ?? null}
|
||||||
goal={selection.goalInput.trim()}
|
goal={selection.goalInput.trim()}
|
||||||
microStep={currentSession?.microStep ?? null}
|
microStep={currentSession?.microStep ?? null}
|
||||||
|
remainingSeconds={remainingSeconds}
|
||||||
|
phaseStartedAt={currentSession?.phaseStartedAt ?? null}
|
||||||
timeDisplay={resolvedTimeDisplay}
|
timeDisplay={resolvedTimeDisplay}
|
||||||
hasActiveSession={Boolean(currentSession)}
|
hasActiveSession={Boolean(currentSession)}
|
||||||
playbackState={resolvedPlaybackState}
|
playbackState={resolvedPlaybackState}
|
||||||
@@ -339,6 +345,8 @@ export const SpaceWorkspaceWidget = () => {
|
|||||||
|
|
||||||
return didFinish;
|
return didFinish;
|
||||||
}}
|
}}
|
||||||
|
onTimerFinish={controls.handleTimerComplete}
|
||||||
|
onAddTenMinutes={() => controls.handleExtendCurrentPhase(10)}
|
||||||
onGoalUpdate={controls.handleGoalAdvance}
|
onGoalUpdate={controls.handleGoalAdvance}
|
||||||
onStatusMessage={pushStatusLine}
|
onStatusMessage={pushStatusLine}
|
||||||
onCaptureThought={(note) => addThought(note, selection.selectedScene.name)}
|
onCaptureThought={(note) => addThought(note, selection.selectedScene.name)}
|
||||||
|
|||||||
Reference in New Issue
Block a user