feat(app): paused session takeover flow 추가
This commit is contained in:
@@ -207,7 +207,10 @@ VibeRoom은 아래 방식으로 진행한다.
|
|||||||
|
|
||||||
- 상세 기획 문서 작성 완료
|
- 상세 기획 문서 작성 완료
|
||||||
- Session Routing Contract 구현 완료
|
- Session Routing Contract 구현 완료
|
||||||
- 다음 구현 slice는 `/app` Paused Resume Gate
|
- `/app` Paused Resume Gate 구현 완료
|
||||||
|
- `/space` Auto-Resume Handoff 구현 완료
|
||||||
|
- `Paused Session Takeover Flow` 구현 완료
|
||||||
|
- 남은 것은 browser QA와 takeover 문구 polish
|
||||||
|
|
||||||
### Phase 6. Premium Ambience System
|
### Phase 6. Premium Ambience System
|
||||||
|
|
||||||
|
|||||||
@@ -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와 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로 닫기 |
|
| 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와 takeover wording polish이다 | `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` | fixed-awaiting-browser | paused resume / refocus / takeover 3경로를 브라우저에서 확인 |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -250,6 +250,7 @@ resume card 안에는 아래만 둔다.
|
|||||||
|
|
||||||
- new start가 아니다
|
- new start가 아니다
|
||||||
- takeover flow 진입점이다
|
- takeover flow 진입점이다
|
||||||
|
- server도 current session이 남아 있으면 direct start를 거절해야 한다
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -4,6 +4,11 @@ Last Updated: 2026-03-15
|
|||||||
|
|
||||||
## DONE
|
## DONE
|
||||||
|
|
||||||
|
- `Paused Session Takeover Flow` 구현:
|
||||||
|
- `/app` paused gate에 `새 목표로 전환` 진입점을 추가했다
|
||||||
|
- takeover confirm sheet에서만 current paused session을 정리하고 single-goal start 상태로 넘어간다
|
||||||
|
- silent abandon을 막기 위해 server `startSession()`도 current session 존재 시 direct start를 거절하도록 정리했다
|
||||||
|
- explicit confirm 이후에만 `abandon -> 새 목표 입력` 흐름이 가능하다
|
||||||
- `/app` single-goal commitment gate 재구성:
|
- `/app` single-goal commitment gate 재구성:
|
||||||
- 2-step `goal -> ritual` flow 제거
|
- 2-step `goal -> ritual` flow 제거
|
||||||
- current session이 있으면 `Resume` UI를 우선 노출하고, `/space`로 바로 이어가기만 제안하되 review entry는 조용한 secondary link로 유지
|
- current session이 있으면 `Resume` UI를 우선 노출하고, `/space`로 바로 이어가기만 제안하되 review entry는 조용한 secondary link로 유지
|
||||||
|
|||||||
@@ -14,13 +14,18 @@ Last Updated: 2026-03-15
|
|||||||
|
|
||||||
## 현재 우선순위
|
## 현재 우선순위
|
||||||
|
|
||||||
1. `Paused Session Takeover Flow`
|
1. `Core Loop Alignment` browser audit
|
||||||
2. `Core Loop Alignment` browser audit
|
2. `Weekly Review` ritual fit highlight
|
||||||
3. `Weekly Review` ritual fit highlight
|
3. `Premium Ambience`
|
||||||
4. `Premium Ambience`
|
4. `Pause / Break / Return` browser polish
|
||||||
|
|
||||||
## 최근 세션 상태
|
## 최근 세션 상태
|
||||||
|
|
||||||
|
- `Paused Session Takeover Flow`를 구현했다.
|
||||||
|
- `/app` paused gate에 `새 목표로 전환` 액션이 추가됐다.
|
||||||
|
- takeover confirm sheet에서만 기존 paused session을 정리하고 새로 시작할 수 있다.
|
||||||
|
- server `startSession()`은 더 이상 silent abandon을 하지 않고, current session이 남아 있으면 direct start를 거절한다.
|
||||||
|
- takeover confirm 후에만 `abandon -> single-goal start` 순서로 넘어간다.
|
||||||
- `/space` Refocus System 첫 slice를 구현했다.
|
- `/space` Refocus System 첫 slice를 구현했다.
|
||||||
- pause 직후 바로 편집 시트가 아니라 작은 recovery prompt를 먼저 띄운다.
|
- pause 직후 바로 편집 시트가 아니라 작은 recovery prompt를 먼저 띄운다.
|
||||||
- 여기서 `한 조각 다시 잡기`를 누르면 refocus tray로 들어간다.
|
- 여기서 `한 조각 다시 잡기`를 누르면 refocus tray로 들어간다.
|
||||||
|
|||||||
@@ -103,6 +103,8 @@
|
|||||||
- 완료 조건:
|
- 완료 조건:
|
||||||
- paused 상태에서 direct new start는 불가능하다
|
- paused 상태에서 direct new start는 불가능하다
|
||||||
- 사용자는 기존 paused session을 어떻게 처리할지 먼저 고른다
|
- 사용자는 기존 paused session을 어떻게 처리할지 먼저 고른다
|
||||||
|
- 진행 상태:
|
||||||
|
- 완료
|
||||||
- 검증:
|
- 검증:
|
||||||
- paused -> new start 브라우저 QA
|
- paused -> new start 브라우저 QA
|
||||||
- 커밋 힌트:
|
- 커밋 힌트:
|
||||||
|
|||||||
@@ -47,7 +47,16 @@ const entryCopy = {
|
|||||||
resumeRouting: '진행 중인 세션으로 돌아가는 중이에요.',
|
resumeRouting: '진행 중인 세션으로 돌아가는 중이에요.',
|
||||||
resumeMicroStepLabel: '마지막 한 조각',
|
resumeMicroStepLabel: '마지막 한 조각',
|
||||||
resumePausedHint: '같은 목표를 바로 이어가거나, 다시 시작할 한 조각만 먼저 정리할 수 있어요.',
|
resumePausedHint: '같은 목표를 바로 이어가거나, 다시 시작할 한 조각만 먼저 정리할 수 있어요.',
|
||||||
resumeNewGoalHint: '새 목표는 현재 세션을 마무리한 뒤 시작할 수 있어요.',
|
resumeNewGoalHint: '새 목표로 전환하려면 지금 멈춘 세션을 먼저 어떻게 정리할지 결정해야 해요.',
|
||||||
|
resumeTakeoverCta: '새 목표로 전환',
|
||||||
|
takeoverEyebrow: '새 목표로 전환',
|
||||||
|
takeoverTitle: '현재 멈춘 세션을 어떻게 할까요?',
|
||||||
|
takeoverBody: '지금 멈춘 흐름을 조용히 없애지 않고, 먼저 정리한 뒤 새 목표로 넘어가요.',
|
||||||
|
takeoverKeepCta: '이어서 하기',
|
||||||
|
takeoverConfirmCta: '이 세션은 여기서 정리하고 새로 시작',
|
||||||
|
takeoverCancelCta: '취소',
|
||||||
|
takeoverLoading: '세션을 정리하는 중...',
|
||||||
|
takeoverFailed: '멈춘 세션을 정리하지 못했어요. 잠시 후 다시 시도해 주세요.',
|
||||||
loadFailed: '세션 상태를 불러오지 못했어요. 새로 시작은 계속 할 수 있어요.',
|
loadFailed: '세션 상태를 불러오지 못했어요. 새로 시작은 계속 할 수 있어요.',
|
||||||
reviewEyebrow: 'Weekly Review',
|
reviewEyebrow: 'Weekly Review',
|
||||||
reviewTitle: '이번 주 review를 잠깐 보고 갈까요?',
|
reviewTitle: '이번 주 review를 잠깐 보고 갈까요?',
|
||||||
@@ -141,6 +150,10 @@ export const FocusDashboardWidget = () => {
|
|||||||
const [currentSession, setCurrentSession] = useState<FocusSession | null>(null);
|
const [currentSession, setCurrentSession] = useState<FocusSession | null>(null);
|
||||||
const [isCheckingSession, setIsCheckingSession] = useState(true);
|
const [isCheckingSession, setIsCheckingSession] = useState(true);
|
||||||
const [sessionLookupError, setSessionLookupError] = useState<string | null>(null);
|
const [sessionLookupError, setSessionLookupError] = useState<string | null>(null);
|
||||||
|
const [isTakeoverSheetOpen, setIsTakeoverSheetOpen] = useState(false);
|
||||||
|
const [isResolvingTakeover, setIsResolvingTakeover] = useState(false);
|
||||||
|
const [takeoverError, setTakeoverError] = useState<string | null>(null);
|
||||||
|
const [focusGoalAfterTakeover, setFocusGoalAfterTakeover] = useState(false);
|
||||||
|
|
||||||
const goalInputRef = useRef<HTMLInputElement | null>(null);
|
const goalInputRef = useRef<HTMLInputElement | null>(null);
|
||||||
|
|
||||||
@@ -223,6 +236,21 @@ export const FocusDashboardWidget = () => {
|
|||||||
}
|
}
|
||||||
}, [isCheckingSession, isRunningSession, router]);
|
}, [isCheckingSession, isRunningSession, router]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!focusGoalAfterTakeover || currentSession) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const frameId = window.requestAnimationFrame(() => {
|
||||||
|
goalInputRef.current?.focus();
|
||||||
|
setFocusGoalAfterTakeover(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
window.cancelAnimationFrame(frameId);
|
||||||
|
};
|
||||||
|
}, [currentSession, focusGoalAfterTakeover]);
|
||||||
|
|
||||||
const openPaywall = () => {
|
const openPaywall = () => {
|
||||||
if (!isPro) {
|
if (!isPro) {
|
||||||
setPaywallSource('app-entry-plan-pill');
|
setPaywallSource('app-entry-plan-pill');
|
||||||
@@ -256,6 +284,19 @@ export const FocusDashboardWidget = () => {
|
|||||||
router.push('/space');
|
router.push('/space');
|
||||||
return;
|
return;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
const message =
|
||||||
|
error instanceof Error ? error.message : entryCopy.loadFailed;
|
||||||
|
setSessionLookupError(message);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const session = await focusSessionApi.getCurrentSession();
|
||||||
|
if (session) {
|
||||||
|
setCurrentSession(session);
|
||||||
|
}
|
||||||
|
} catch (syncError) {
|
||||||
|
console.error('Failed to sync current session after /app start failure', syncError);
|
||||||
|
}
|
||||||
|
|
||||||
console.error('Failed to start focus session from /app', error);
|
console.error('Failed to start focus session from /app', error);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -270,6 +311,45 @@ export const FocusDashboardWidget = () => {
|
|||||||
router.push('/space?resume=refocus');
|
router.push('/space?resume=refocus');
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleOpenTakeoverSheet = () => {
|
||||||
|
setTakeoverError(null);
|
||||||
|
setIsTakeoverSheetOpen(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCloseTakeoverSheet = () => {
|
||||||
|
if (isResolvingTakeover) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setIsTakeoverSheetOpen(false);
|
||||||
|
setTakeoverError(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleConfirmTakeover = async () => {
|
||||||
|
if (!currentSession || isResolvingTakeover) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setIsResolvingTakeover(true);
|
||||||
|
setTakeoverError(null);
|
||||||
|
|
||||||
|
try {
|
||||||
|
await focusSessionApi.abandonSession();
|
||||||
|
setCurrentSession(null);
|
||||||
|
setIsTakeoverSheetOpen(false);
|
||||||
|
setSessionLookupError(null);
|
||||||
|
setGoalDraft('');
|
||||||
|
setMicroStepDraft('');
|
||||||
|
setFocusGoalAfterTakeover(true);
|
||||||
|
} catch (error) {
|
||||||
|
setTakeoverError(
|
||||||
|
error instanceof Error ? error.message : entryCopy.takeoverFailed,
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
setIsResolvingTakeover(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const shouldShowWeeklyReviewTeaser =
|
const shouldShowWeeklyReviewTeaser =
|
||||||
!isCheckingSession && !currentSession && hasEnoughWeeklyData && !isReviewReturn;
|
!isCheckingSession && !currentSession && hasEnoughWeeklyData && !isReviewReturn;
|
||||||
const shouldShowResumeReviewEntry =
|
const shouldShowResumeReviewEntry =
|
||||||
@@ -365,6 +445,13 @@ export const FocusDashboardWidget = () => {
|
|||||||
|
|
||||||
<p className="text-sm text-white/56">{entryCopy.resumePausedHint}</p>
|
<p className="text-sm text-white/56">{entryCopy.resumePausedHint}</p>
|
||||||
<p className="text-sm text-white/46">{entryCopy.resumeNewGoalHint}</p>
|
<p className="text-sm text-white/46">{entryCopy.resumeNewGoalHint}</p>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleOpenTakeoverSheet}
|
||||||
|
className="inline-flex w-fit items-center text-sm font-medium text-white/62 transition hover:text-white/84"
|
||||||
|
>
|
||||||
|
{entryCopy.resumeTakeoverCta}
|
||||||
|
</button>
|
||||||
|
|
||||||
{shouldShowResumeReviewEntry ? (
|
{shouldShowResumeReviewEntry ? (
|
||||||
<Link
|
<Link
|
||||||
@@ -545,6 +632,75 @@ export const FocusDashboardWidget = () => {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
|
{isTakeoverSheetOpen ? (
|
||||||
|
<div className="fixed inset-0 z-50 flex items-end justify-center p-4 sm:items-center">
|
||||||
|
<div className="absolute inset-0 bg-slate-950/52 backdrop-blur-[3px]" />
|
||||||
|
<div className="relative z-10 w-full max-w-[30rem] rounded-[2rem] border border-white/12 bg-[linear-gradient(165deg,rgba(15,17,21,0.92)_0%,rgba(7,10,14,0.98)_100%)] p-6 shadow-[0_24px_70px_rgba(2,6,23,0.38)] backdrop-blur-2xl">
|
||||||
|
<p className="text-[11px] font-medium uppercase tracking-[0.16em] text-white/42">
|
||||||
|
{entryCopy.takeoverEyebrow}
|
||||||
|
</p>
|
||||||
|
<h2 className="mt-3 text-[1.45rem] font-light leading-[1.18] tracking-[-0.03em] text-white">
|
||||||
|
{entryCopy.takeoverTitle}
|
||||||
|
</h2>
|
||||||
|
<p className="mt-3 text-[14px] leading-[1.7] text-white/62">
|
||||||
|
{entryCopy.takeoverBody}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{currentSession ? (
|
||||||
|
<div className="mt-5 rounded-[1.35rem] border border-white/10 bg-white/[0.05] px-4 py-4">
|
||||||
|
<p className="text-[11px] font-medium uppercase tracking-[0.16em] text-white/40">
|
||||||
|
{entryCopy.resumeEyebrow}
|
||||||
|
</p>
|
||||||
|
<p className="mt-2 text-[1rem] font-medium tracking-[-0.02em] text-white/88">
|
||||||
|
{currentSession.goal}
|
||||||
|
</p>
|
||||||
|
{currentSession.microStep ? (
|
||||||
|
<p className="mt-2 text-[13px] leading-[1.6] text-white/58">
|
||||||
|
{entryCopy.resumeMicroStepLabel} · {currentSession.microStep}
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{takeoverError ? (
|
||||||
|
<p className="mt-4 text-sm text-amber-100/82">{takeoverError}</p>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<div className="mt-6 flex flex-col gap-2.5">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleResumeSession}
|
||||||
|
disabled={isResolvingTakeover}
|
||||||
|
className={primaryButtonClass}
|
||||||
|
>
|
||||||
|
{entryCopy.takeoverKeepCta}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
void handleConfirmTakeover();
|
||||||
|
}}
|
||||||
|
disabled={isResolvingTakeover}
|
||||||
|
className={cn(
|
||||||
|
secondaryButtonClass,
|
||||||
|
'border-white/14 bg-white/[0.05] text-white/86 hover:bg-white/[0.1]',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{isResolvingTakeover ? entryCopy.takeoverLoading : entryCopy.takeoverConfirmCta}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleCloseTakeoverSheet}
|
||||||
|
disabled={isResolvingTakeover}
|
||||||
|
className="inline-flex items-center justify-center rounded-full px-4 py-2.5 text-sm font-medium text-white/54 transition hover:text-white/78 disabled:opacity-45"
|
||||||
|
>
|
||||||
|
{entryCopy.takeoverCancelCta}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
Reference in New Issue
Block a user