feat(flow): paused resume gate와 auto-resume 연결
This commit is contained in:
@@ -369,6 +369,11 @@ Away / Return이 끼어들기 전, 다음으로 예정된 축은 아래 두 가
|
|||||||
- `/app`은 running session을 감지하면 hero 대신 즉시 `/space`로 보낸다
|
- `/app`은 running session을 감지하면 hero 대신 즉시 `/space`로 보낸다
|
||||||
- `/space`는 paused session 상태에서 explicit handoff intent 없이 직접 열리면 `/app`으로 되돌린다
|
- `/space`는 paused session 상태에서 explicit handoff intent 없이 직접 열리면 `/app`으로 되돌린다
|
||||||
- 다음 구현은 paused resume gate와 auto-resume handoff다
|
- 다음 구현은 paused resume gate와 auto-resume handoff다
|
||||||
|
- `Paused Session Re-entry` Slice 2-3
|
||||||
|
- `/app` paused 상태에 `이어서 몰입하기`, `한 조각 다시 잡기`, quiet `주간 review 보기`를 올렸다
|
||||||
|
- explicit continue 이후 `/space`는 자동 resume된다
|
||||||
|
- refocus handoff는 `/space?resume=refocus`로 들어가며, 진입 직후 refocus tray를 연다
|
||||||
|
- 다음 구현은 takeover flow다
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -67,7 +67,7 @@ Last Updated: 2026-03-15
|
|||||||
| ALN-007 | P2 | Weekly Review discoverability | review는 `/app`의 primary ritual이어야 함 | 데이터 gate와 currentSession 조건에 따라 사용자에게 “아예 없는 기능”처럼 느껴질 수 있음 | `src/widgets/focus-dashboard/ui/FocusDashboardWidget.tsx`, `docs/15_app_stats_entry_flow_spec.md` | open | low-data 상태와 resume 상태를 포함한 discoverability 정책 재정의 |
|
| ALN-007 | P2 | Weekly Review discoverability | review는 `/app`의 primary ritual이어야 함 | 데이터 gate와 currentSession 조건에 따라 사용자에게 “아예 없는 기능”처럼 느껴질 수 있음 | `src/widgets/focus-dashboard/ui/FocusDashboardWidget.tsx`, `docs/15_app_stats_entry_flow_spec.md` | open | low-data 상태와 resume 상태를 포함한 discoverability 정책 재정의 |
|
||||||
| ALN-008 | P1 | `잠시 비우기`와 `Break`의 제품 의미 | break는 reward/reset, pause는 recovery로 분리돼야 함 | 현재는 카피와 트레이는 개선됐지만, 제품 차원의 최종 정의와 시각 분리까지 완전히 닫히진 않았음 | `docs/10_refocus_system_spec.md`, `docs/11_away_return_recovery_spec.md`, `src/widgets/space-focus-hud/ui/GoalCompleteSheet.tsx`, `src/widgets/space-focus-hud/ui/ReturnPrompt.tsx` | open | `잠시 비우기`, active break, return(break)를 하나의 최종 state model로 재정의 |
|
| ALN-008 | P1 | `잠시 비우기`와 `Break`의 제품 의미 | break는 reward/reset, pause는 recovery로 분리돼야 함 | 현재는 카피와 트레이는 개선됐지만, 제품 차원의 최종 정의와 시각 분리까지 완전히 닫히진 않았음 | `docs/10_refocus_system_spec.md`, `docs/11_away_return_recovery_spec.md`, `src/widgets/space-focus-hud/ui/GoalCompleteSheet.tsx`, `src/widgets/space-focus-hud/ui/ReturnPrompt.tsx` | open | `잠시 비우기`, active break, return(break)를 하나의 최종 state model로 재정의 |
|
||||||
| ALN-009 | P3 | Spec / current-state drift | 다음 세션 문서가 실제 구현과 맞아야 함 | intent card, goal complete, review entry 관련 오래된 표현이 여러 spec에 남아 있었음 | `docs/10_refocus_system_spec.md`, `docs/13_space_intent_card_collapsed_expanded_spec.md`, `docs/90_current_state.md`, `docs/session_brief.md`, `../../current_context.md` | fixed-awaiting-browser | 이후 라운드부터는 fix와 문서 갱신을 같은 커밋에서 닫는지 점검 |
|
| ALN-009 | P3 | Spec / current-state drift | 다음 세션 문서가 실제 구현과 맞아야 함 | intent card, goal complete, review entry 관련 오래된 표현이 여러 spec에 남아 있었음 | `docs/10_refocus_system_spec.md`, `docs/13_space_intent_card_collapsed_expanded_spec.md`, `docs/90_current_state.md`, `docs/session_brief.md`, `../../current_context.md` | fixed-awaiting-browser | 이후 라운드부터는 fix와 문서 갱신을 같은 커밋에서 닫는지 점검 |
|
||||||
| ALN-010 | P1 | paused session 재진입 정책 | running은 바로 `/space`, paused는 `/app` resume gate, explicit continue 이후에는 자동 resume이어야 함 | Session Routing Contract는 구현됐지만, paused resume gate / auto-resume handoff / takeover flow가 아직 남아 있어 최종 정책이 완전히 닫히진 않았음 | `docs/18_paused_session_reentry_spec.md`, `src/widgets/focus-dashboard/ui/FocusDashboardWidget.tsx`, `src/widgets/space-workspace/ui/SpaceWorkspaceWidget.tsx`, `src/features/focus-session/model/useFocusSessionEngine.ts` | open | Slice 2-4를 이어서 구현하고 browser QA로 닫기 |
|
| ALN-010 | P1 | paused session 재진입 정책 | running은 바로 `/space`, paused는 `/app` resume gate, explicit continue 이후에는 자동 resume이어야 함 | Session Routing Contract, paused resume gate, auto-resume handoff는 구현됐지만 takeover flow와 browser QA가 아직 남아 있어 최종 정책이 완전히 닫히진 않았음 | `docs/18_paused_session_reentry_spec.md`, `src/widgets/focus-dashboard/ui/FocusDashboardWidget.tsx`, `src/widgets/space-workspace/ui/SpaceWorkspaceWidget.tsx`, `src/features/focus-session/model/useFocusSessionEngine.ts` | open | takeover flow를 구현하고 paused re-entry browser QA로 닫기 |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -14,11 +14,10 @@ Last Updated: 2026-03-15
|
|||||||
|
|
||||||
## 현재 우선순위
|
## 현재 우선순위
|
||||||
|
|
||||||
1. `/app` Paused Resume Gate
|
1. `Paused Session Takeover Flow`
|
||||||
2. `/space` Auto-Resume Handoff
|
2. `Core Loop Alignment` browser audit
|
||||||
3. `Paused Session Takeover Flow`
|
3. `Weekly Review` recovery 집계 연결
|
||||||
4. `Core Loop Alignment` browser audit
|
4. `Premium Ambience`
|
||||||
5. `Weekly Review` recovery 집계 연결
|
|
||||||
|
|
||||||
## 최근 세션 상태
|
## 최근 세션 상태
|
||||||
|
|
||||||
@@ -111,6 +110,10 @@ Last Updated: 2026-03-15
|
|||||||
- `/app`은 running session을 감지하면 hero를 보여주지 않고 즉시 `/space`로 보낸다.
|
- `/app`은 running session을 감지하면 hero를 보여주지 않고 즉시 `/space`로 보낸다.
|
||||||
- `/space`는 paused session 상태에서 explicit handoff intent 없이 직접 열리면 `/app`으로 되돌린다.
|
- `/space`는 paused session 상태에서 explicit handoff intent 없이 직접 열리면 `/app`으로 되돌린다.
|
||||||
- `/app`의 `이어서 들어가기`는 다음 slice를 위해 `/space?resume=continue` handoff를 사용한다.
|
- `/app`의 `이어서 들어가기`는 다음 slice를 위해 `/space?resume=continue` handoff를 사용한다.
|
||||||
|
- `Paused Resume Gate`와 `Auto-Resume Handoff`를 구현했다.
|
||||||
|
- paused 상태의 `/app`은 `이어서 몰입하기`, `한 조각 다시 잡기`, quiet `주간 review 보기`를 함께 보여준다.
|
||||||
|
- `이어서 몰입하기`는 `/space?resume=continue`로 들어간 뒤 자동 resume된다.
|
||||||
|
- `한 조각 다시 잡기`는 `/space?resume=refocus`로 들어간 뒤 refocus tray를 바로 연다.
|
||||||
- `Product Alignment Audit` 운영을 시작했다.
|
- `Product Alignment Audit` 운영을 시작했다.
|
||||||
- `16_product_alignment_audit_plan.md`를 기준 문서로 추가했다.
|
- `16_product_alignment_audit_plan.md`를 기준 문서로 추가했다.
|
||||||
- `17_product_alignment_findings.md`에 core loop의 P1/P2 mismatch를 수집하기 시작했다.
|
- `17_product_alignment_findings.md`에 core loop의 P1/P2 mismatch를 수집하기 시작했다.
|
||||||
|
|||||||
@@ -60,7 +60,7 @@
|
|||||||
- paused 사용자가 2초 안에 다음 행동을 이해할 수 있다
|
- paused 사용자가 2초 안에 다음 행동을 이해할 수 있다
|
||||||
- review는 보이지만 resume보다 앞서지 않는다
|
- review는 보이지만 resume보다 앞서지 않는다
|
||||||
- 진행 상태:
|
- 진행 상태:
|
||||||
- 다음 작업
|
- 완료
|
||||||
- 검증:
|
- 검증:
|
||||||
- `/app` paused state browser QA
|
- `/app` paused state browser QA
|
||||||
- 커밋 힌트:
|
- 커밋 힌트:
|
||||||
@@ -81,6 +81,8 @@
|
|||||||
- 완료 조건:
|
- 완료 조건:
|
||||||
- `/app -> /space -> start` 이중 클릭이 사라진다
|
- `/app -> /space -> start` 이중 클릭이 사라진다
|
||||||
- explicit continue 이후에는 `/space`에서 바로 실행 상태로 들어간다
|
- explicit continue 이후에는 `/space`에서 바로 실행 상태로 들어간다
|
||||||
|
- 진행 상태:
|
||||||
|
- 완료
|
||||||
- 검증:
|
- 검증:
|
||||||
- paused -> continue 브라우저 QA
|
- paused -> continue 브라우저 QA
|
||||||
- 커밋 힌트:
|
- 커밋 힌트:
|
||||||
|
|||||||
@@ -42,9 +42,11 @@ const entryCopy = {
|
|||||||
resumeEyebrow: 'Resume',
|
resumeEyebrow: 'Resume',
|
||||||
resumeRunning: '진행 중인 세션이 있어요.',
|
resumeRunning: '진행 중인 세션이 있어요.',
|
||||||
resumePaused: '잠시 멈춘 세션이 있어요.',
|
resumePaused: '잠시 멈춘 세션이 있어요.',
|
||||||
resumeCta: '이어서 들어가기',
|
resumeCta: '이어서 몰입하기',
|
||||||
|
resumeRefocusCta: '한 조각 다시 잡기',
|
||||||
resumeRouting: '진행 중인 세션으로 돌아가는 중이에요.',
|
resumeRouting: '진행 중인 세션으로 돌아가는 중이에요.',
|
||||||
resumeMicroStepLabel: '마지막 한 조각',
|
resumeMicroStepLabel: '마지막 한 조각',
|
||||||
|
resumePausedHint: '같은 목표를 바로 이어가거나, 다시 시작할 한 조각만 먼저 정리할 수 있어요.',
|
||||||
resumeNewGoalHint: '새 목표는 현재 세션을 마무리한 뒤 시작할 수 있어요.',
|
resumeNewGoalHint: '새 목표는 현재 세션을 마무리한 뒤 시작할 수 있어요.',
|
||||||
loadFailed: '세션 상태를 불러오지 못했어요. 새로 시작은 계속 할 수 있어요.',
|
loadFailed: '세션 상태를 불러오지 못했어요. 새로 시작은 계속 할 수 있어요.',
|
||||||
reviewEyebrow: 'Weekly Review',
|
reviewEyebrow: 'Weekly Review',
|
||||||
@@ -77,6 +79,8 @@ const inputShellClass =
|
|||||||
'w-full rounded-[1.75rem] border border-white/14 bg-white/[0.06] px-5 py-4 text-white outline-none transition focus:border-white/24 focus:bg-white/[0.09]';
|
'w-full rounded-[1.75rem] border border-white/14 bg-white/[0.06] px-5 py-4 text-white outline-none transition focus:border-white/24 focus:bg-white/[0.09]';
|
||||||
const primaryButtonClass =
|
const primaryButtonClass =
|
||||||
'inline-flex items-center justify-center rounded-full border border-white/16 bg-white/[0.14] px-6 py-3 text-sm font-medium text-white transition hover:bg-white/[0.18] active:scale-[0.99] disabled:cursor-not-allowed disabled:opacity-48';
|
'inline-flex items-center justify-center rounded-full border border-white/16 bg-white/[0.14] px-6 py-3 text-sm font-medium text-white transition hover:bg-white/[0.18] active:scale-[0.99] disabled:cursor-not-allowed disabled:opacity-48';
|
||||||
|
const secondaryButtonClass =
|
||||||
|
'inline-flex items-center justify-center rounded-full border border-white/10 bg-white/[0.06] px-5 py-3 text-sm font-medium text-white/84 transition hover:bg-white/[0.1] hover:text-white active:scale-[0.99]';
|
||||||
|
|
||||||
const timerLabelById: Record<string, string> = {
|
const timerLabelById: Record<string, string> = {
|
||||||
'25-5': '25/5',
|
'25-5': '25/5',
|
||||||
@@ -262,6 +266,10 @@ export const FocusDashboardWidget = () => {
|
|||||||
router.push('/space?resume=continue');
|
router.push('/space?resume=continue');
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleResumeRefocus = () => {
|
||||||
|
router.push('/space?resume=refocus');
|
||||||
|
};
|
||||||
|
|
||||||
const shouldShowWeeklyReviewTeaser =
|
const shouldShowWeeklyReviewTeaser =
|
||||||
!isCheckingSession && !currentSession && hasEnoughWeeklyData && !isReviewReturn;
|
!isCheckingSession && !currentSession && hasEnoughWeeklyData && !isReviewReturn;
|
||||||
const shouldShowResumeReviewEntry =
|
const shouldShowResumeReviewEntry =
|
||||||
@@ -344,13 +352,19 @@ export const FocusDashboardWidget = () => {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||||
|
<div className="flex flex-col gap-2.5 sm:flex-row sm:flex-wrap sm:items-center">
|
||||||
<button type="button" onClick={handleResumeSession} className={primaryButtonClass}>
|
<button type="button" onClick={handleResumeSession} className={primaryButtonClass}>
|
||||||
{entryCopy.resumeCta}
|
{entryCopy.resumeCta}
|
||||||
</button>
|
</button>
|
||||||
<p className="text-xs text-white/48 sm:text-right">{activeRitualMeta}</p>
|
<button type="button" onClick={handleResumeRefocus} className={secondaryButtonClass}>
|
||||||
|
{entryCopy.resumeRefocusCta}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-white/48 sm:max-w-[15rem] sm:text-right">{activeRitualMeta}</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<p className="text-sm text-white/56">{entryCopy.resumeNewGoalHint}</p>
|
<p className="text-sm text-white/56">{entryCopy.resumePausedHint}</p>
|
||||||
|
<p className="text-sm text-white/46">{entryCopy.resumeNewGoalHint}</p>
|
||||||
|
|
||||||
{shouldShowResumeReviewEntry ? (
|
{shouldShowResumeReviewEntry ? (
|
||||||
<Link
|
<Link
|
||||||
|
|||||||
@@ -20,7 +20,9 @@ interface SpaceFocusHudWidgetProps {
|
|||||||
canStartSession?: boolean;
|
canStartSession?: boolean;
|
||||||
canPauseSession?: boolean;
|
canPauseSession?: boolean;
|
||||||
canRestartSession?: boolean;
|
canRestartSession?: boolean;
|
||||||
|
entryOverlayIntent?: 'resume-refocus' | null;
|
||||||
returnPromptMode?: 'focus' | 'break' | null;
|
returnPromptMode?: 'focus' | 'break' | null;
|
||||||
|
onEntryOverlayIntentHandled?: () => void;
|
||||||
onStartRequested?: () => void;
|
onStartRequested?: () => void;
|
||||||
onPauseRequested?: () => void;
|
onPauseRequested?: () => void;
|
||||||
onRestartRequested?: () => void;
|
onRestartRequested?: () => void;
|
||||||
@@ -42,7 +44,9 @@ export const SpaceFocusHudWidget = ({
|
|||||||
canStartSession = false,
|
canStartSession = false,
|
||||||
canPauseSession = false,
|
canPauseSession = false,
|
||||||
canRestartSession = false,
|
canRestartSession = false,
|
||||||
|
entryOverlayIntent = null,
|
||||||
returnPromptMode = null,
|
returnPromptMode = null,
|
||||||
|
onEntryOverlayIntentHandled,
|
||||||
onStartRequested,
|
onStartRequested,
|
||||||
onPauseRequested,
|
onPauseRequested,
|
||||||
onRestartRequested,
|
onRestartRequested,
|
||||||
@@ -176,6 +180,15 @@ export const SpaceFocusHudWidget = ({
|
|||||||
}
|
}
|
||||||
}, [normalizedMicroStep, overlay]);
|
}, [normalizedMicroStep, overlay]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (entryOverlayIntent !== 'resume-refocus' || !hasActiveSession || overlay !== 'none') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
openRefocus('microStep', 'manual');
|
||||||
|
onEntryOverlayIntentHandled?.();
|
||||||
|
}, [entryOverlayIntent, hasActiveSession, onEntryOverlayIntentHandled, openRefocus, overlay]);
|
||||||
|
|
||||||
const handleOpenCompleteSheet = (preferredView: 'choice' | 'next' = 'choice') => {
|
const handleOpenCompleteSheet = (preferredView: 'choice' | 'next' = 'choice') => {
|
||||||
setIntentError(null);
|
setIntentError(null);
|
||||||
setCompletePreferredView(preferredView);
|
setCompletePreferredView(preferredView);
|
||||||
|
|||||||
@@ -104,6 +104,7 @@ export const SpaceWorkspaceWidget = () => {
|
|||||||
const [pendingSessionEntryPoint, setPendingSessionEntryPoint] =
|
const [pendingSessionEntryPoint, setPendingSessionEntryPoint] =
|
||||||
useState<SessionEntryPoint>("space-setup");
|
useState<SessionEntryPoint>("space-setup");
|
||||||
const [showReviewTeaserAfterComplete, setShowReviewTeaserAfterComplete] = useState(false);
|
const [showReviewTeaserAfterComplete, setShowReviewTeaserAfterComplete] = useState(false);
|
||||||
|
const [hasConsumedEntryOverlayIntent, setHasConsumedEntryOverlayIntent] = useState(false);
|
||||||
|
|
||||||
const {
|
const {
|
||||||
selectedPresetId,
|
selectedPresetId,
|
||||||
@@ -209,6 +210,7 @@ export const SpaceWorkspaceWidget = () => {
|
|||||||
setSelectedGoalId: selection.setSelectedGoalId,
|
setSelectedGoalId: selection.setSelectedGoalId,
|
||||||
setShowResumePrompt: selection.setShowResumePrompt,
|
setShowResumePrompt: selection.setShowResumePrompt,
|
||||||
});
|
});
|
||||||
|
const handleStartRequested = controls.handleStartRequested;
|
||||||
|
|
||||||
const awayReturnRecovery = useAwayReturnRecovery({
|
const awayReturnRecovery = useAwayReturnRecovery({
|
||||||
currentSession,
|
currentSession,
|
||||||
@@ -226,6 +228,13 @@ export const SpaceWorkspaceWidget = () => {
|
|||||||
const allowsPausedReentry =
|
const allowsPausedReentry =
|
||||||
resumeIntent === "continue" || resumeIntent === "refocus";
|
resumeIntent === "continue" || resumeIntent === "refocus";
|
||||||
const didResolveEntryRouteRef = useRef(false);
|
const didResolveEntryRouteRef = useRef(false);
|
||||||
|
const didHandleResumeIntentRef = useRef(false);
|
||||||
|
const entryOverlayIntent =
|
||||||
|
!hasConsumedEntryOverlayIntent &&
|
||||||
|
resumeIntent === "refocus" &&
|
||||||
|
currentSession?.state === "paused"
|
||||||
|
? "resume-refocus"
|
||||||
|
: null;
|
||||||
const secondaryReviewTeaser = shouldShowSecondaryReviewTeaser
|
const secondaryReviewTeaser = shouldShowSecondaryReviewTeaser
|
||||||
? {
|
? {
|
||||||
title: isPro
|
title: isPro
|
||||||
@@ -262,6 +271,28 @@ export const SpaceWorkspaceWidget = () => {
|
|||||||
}
|
}
|
||||||
}, [allowsPausedReentry, currentSession, isBootstrapping, router]);
|
}, [allowsPausedReentry, currentSession, isBootstrapping, router]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (
|
||||||
|
isBootstrapping ||
|
||||||
|
!currentSession ||
|
||||||
|
currentSession.state !== "paused" ||
|
||||||
|
didHandleResumeIntentRef.current
|
||||||
|
) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (resumeIntent === "continue") {
|
||||||
|
didHandleResumeIntentRef.current = true;
|
||||||
|
router.replace("/space");
|
||||||
|
void handleStartRequested();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (resumeIntent === "refocus") {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}, [currentSession, handleStartRequested, isBootstrapping, resumeIntent, router]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const preferMobile =
|
const preferMobile =
|
||||||
typeof window !== "undefined"
|
typeof window !== "undefined"
|
||||||
@@ -359,9 +390,14 @@ export const SpaceWorkspaceWidget = () => {
|
|||||||
canStartSession={controls.canStartSession}
|
canStartSession={controls.canStartSession}
|
||||||
canPauseSession={controls.canPauseSession}
|
canPauseSession={controls.canPauseSession}
|
||||||
canRestartSession={controls.canRestartSession}
|
canRestartSession={controls.canRestartSession}
|
||||||
|
entryOverlayIntent={entryOverlayIntent}
|
||||||
returnPromptMode={awayReturnRecovery.returnPromptMode}
|
returnPromptMode={awayReturnRecovery.returnPromptMode}
|
||||||
|
onEntryOverlayIntentHandled={() => {
|
||||||
|
setHasConsumedEntryOverlayIntent(true);
|
||||||
|
router.replace("/space");
|
||||||
|
}}
|
||||||
onStartRequested={() => {
|
onStartRequested={() => {
|
||||||
void controls.handleStartRequested();
|
void handleStartRequested();
|
||||||
}}
|
}}
|
||||||
onPauseRequested={() => {
|
onPauseRequested={() => {
|
||||||
void controls.handlePauseRequested();
|
void controls.handlePauseRequested();
|
||||||
|
|||||||
Reference in New Issue
Block a user