feat(flow): session routing contract 정리
This commit is contained in:
@@ -205,8 +205,8 @@ VibeRoom은 아래 방식으로 진행한다.
|
|||||||
상태:
|
상태:
|
||||||
|
|
||||||
- 상세 기획 문서 작성 완료
|
- 상세 기획 문서 작성 완료
|
||||||
- 구현 전
|
- Session Routing Contract 구현 완료
|
||||||
- 다음 구현 slice
|
- 다음 구현 slice는 `/app` Paused Resume Gate
|
||||||
|
|
||||||
### Phase 6. Premium Ambience System
|
### Phase 6. Premium Ambience System
|
||||||
|
|
||||||
@@ -365,6 +365,10 @@ Away / Return이 끼어들기 전, 다음으로 예정된 축은 아래 두 가
|
|||||||
- running / paused / break의 route policy를 한 문서에서 고정했다
|
- running / paused / break의 route policy를 한 문서에서 고정했다
|
||||||
- `/app`은 paused session의 resume gate가 되고, explicit continue 이후 `/space`에서는 자동 resume해야 한다
|
- `/app`은 paused session의 resume gate가 되고, explicit continue 이후 `/space`에서는 자동 resume해야 한다
|
||||||
- paused session 위의 새 시작은 takeover flow로만 허용한다
|
- paused session 위의 새 시작은 takeover flow로만 허용한다
|
||||||
|
- `Paused Session Re-entry` Slice 1
|
||||||
|
- `/app`은 running session을 감지하면 hero 대신 즉시 `/space`로 보낸다
|
||||||
|
- `/space`는 paused session 상태에서 explicit handoff intent 없이 직접 열리면 `/app`으로 되돌린다
|
||||||
|
- 다음 구현은 paused resume gate와 auto-resume handoff다
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -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이어야 함 | 현재 route / CTA / handoff 규칙이 문서 하나로 고정돼 있지 않아, `/app`, `/space`, review 진입의 상태 의미가 다시 흔들릴 수 있음 | `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 | `18_paused_session_reentry_spec.md` 기준으로 routing, paused resume gate, auto-resume handoff, takeover flow를 순서대로 구현 |
|
| 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로 닫기 |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -14,11 +14,11 @@ Last Updated: 2026-03-15
|
|||||||
|
|
||||||
## 현재 우선순위
|
## 현재 우선순위
|
||||||
|
|
||||||
1. `Paused Session Re-entry` Session Routing Contract
|
1. `/app` Paused Resume Gate
|
||||||
2. `/app` Paused Resume Gate
|
2. `/space` Auto-Resume Handoff
|
||||||
3. `/space` Auto-Resume Handoff
|
3. `Paused Session Takeover Flow`
|
||||||
4. `Paused Session Takeover Flow`
|
4. `Core Loop Alignment` browser audit
|
||||||
5. `Core Loop Alignment` browser audit
|
5. `Weekly Review` recovery 집계 연결
|
||||||
|
|
||||||
## 최근 세션 상태
|
## 최근 세션 상태
|
||||||
|
|
||||||
@@ -107,6 +107,10 @@ Last Updated: 2026-03-15
|
|||||||
- `paused focus -> /app`
|
- `paused focus -> /app`
|
||||||
- `/app`의 explicit continue 이후 `/space`에서는 다시 start를 묻지 않고 자동 resume해야 한다.
|
- `/app`의 explicit continue 이후 `/space`에서는 다시 start를 묻지 않고 자동 resume해야 한다.
|
||||||
- paused session 위의 새 시작은 direct가 아니라 takeover flow로만 허용한다.
|
- paused session 위의 새 시작은 direct가 아니라 takeover flow로만 허용한다.
|
||||||
|
- `Paused Session Re-entry`의 Session Routing Contract를 1차 구현했다.
|
||||||
|
- `/app`은 running session을 감지하면 hero를 보여주지 않고 즉시 `/space`로 보낸다.
|
||||||
|
- `/space`는 paused session 상태에서 explicit handoff intent 없이 직접 열리면 `/app`으로 되돌린다.
|
||||||
|
- `/app`의 `이어서 들어가기`는 다음 slice를 위해 `/space?resume=continue` handoff를 사용한다.
|
||||||
- `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를 수집하기 시작했다.
|
||||||
|
|||||||
@@ -37,7 +37,7 @@
|
|||||||
- `no session -> /app`
|
- `no session -> /app`
|
||||||
규칙이 코드와 문서에서 일치한다
|
규칙이 코드와 문서에서 일치한다
|
||||||
- 진행 상태:
|
- 진행 상태:
|
||||||
- 다음 작업
|
- 완료
|
||||||
- 검증:
|
- 검증:
|
||||||
- source-of-truth 문서 대조
|
- source-of-truth 문서 대조
|
||||||
- 커밋 힌트:
|
- 커밋 힌트:
|
||||||
@@ -59,6 +59,8 @@
|
|||||||
- 완료 조건:
|
- 완료 조건:
|
||||||
- paused 사용자가 2초 안에 다음 행동을 이해할 수 있다
|
- paused 사용자가 2초 안에 다음 행동을 이해할 수 있다
|
||||||
- review는 보이지만 resume보다 앞서지 않는다
|
- review는 보이지만 resume보다 앞서지 않는다
|
||||||
|
- 진행 상태:
|
||||||
|
- 다음 작업
|
||||||
- 검증:
|
- 검증:
|
||||||
- `/app` paused state browser QA
|
- `/app` paused state browser QA
|
||||||
- 커밋 힌트:
|
- 커밋 힌트:
|
||||||
|
|||||||
@@ -43,6 +43,7 @@ const entryCopy = {
|
|||||||
resumeRunning: '진행 중인 세션이 있어요.',
|
resumeRunning: '진행 중인 세션이 있어요.',
|
||||||
resumePaused: '잠시 멈춘 세션이 있어요.',
|
resumePaused: '잠시 멈춘 세션이 있어요.',
|
||||||
resumeCta: '이어서 들어가기',
|
resumeCta: '이어서 들어가기',
|
||||||
|
resumeRouting: '진행 중인 세션으로 돌아가는 중이에요.',
|
||||||
resumeMicroStepLabel: '마지막 한 조각',
|
resumeMicroStepLabel: '마지막 한 조각',
|
||||||
resumeNewGoalHint: '새 목표는 현재 세션을 마무리한 뒤 시작할 수 있어요.',
|
resumeNewGoalHint: '새 목표는 현재 세션을 마무리한 뒤 시작할 수 있어요.',
|
||||||
loadFailed: '세션 상태를 불러오지 못했어요. 새로 시작은 계속 할 수 있어요.',
|
loadFailed: '세션 상태를 불러오지 못했어요. 새로 시작은 계속 할 수 있어요.',
|
||||||
@@ -176,6 +177,8 @@ export const FocusDashboardWidget = () => {
|
|||||||
const reviewTeaserHelper = isPro ? entryCopy.reviewHelperPro : entryCopy.reviewHelper;
|
const reviewTeaserHelper = isPro ? entryCopy.reviewHelperPro : entryCopy.reviewHelper;
|
||||||
const reviewTeaserCta = isPro ? entryCopy.reviewCtaPro : entryCopy.reviewCta;
|
const reviewTeaserCta = isPro ? entryCopy.reviewCtaPro : entryCopy.reviewCta;
|
||||||
const entryRitualHint = reviewEntryPresetConfig ? `추천 ritual · ${reviewEntryPresetConfig.label}` : entryCopy.ritualHint;
|
const entryRitualHint = reviewEntryPresetConfig ? `추천 ritual · ${reviewEntryPresetConfig.label}` : entryCopy.ritualHint;
|
||||||
|
const isRunningSession = currentSession?.state === 'running';
|
||||||
|
const isPausedSession = currentSession?.state === 'paused';
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
@@ -210,6 +213,12 @@ export const FocusDashboardWidget = () => {
|
|||||||
};
|
};
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isCheckingSession && isRunningSession) {
|
||||||
|
router.replace('/space');
|
||||||
|
}
|
||||||
|
}, [isCheckingSession, isRunningSession, router]);
|
||||||
|
|
||||||
const openPaywall = () => {
|
const openPaywall = () => {
|
||||||
if (!isPro) {
|
if (!isPro) {
|
||||||
setPaywallSource('app-entry-plan-pill');
|
setPaywallSource('app-entry-plan-pill');
|
||||||
@@ -250,13 +259,13 @@ export const FocusDashboardWidget = () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handleResumeSession = () => {
|
const handleResumeSession = () => {
|
||||||
router.push('/space');
|
router.push('/space?resume=continue');
|
||||||
};
|
};
|
||||||
|
|
||||||
const shouldShowWeeklyReviewTeaser =
|
const shouldShowWeeklyReviewTeaser =
|
||||||
!isCheckingSession && !currentSession && hasEnoughWeeklyData && !isReviewReturn;
|
!isCheckingSession && !currentSession && hasEnoughWeeklyData && !isReviewReturn;
|
||||||
const shouldShowResumeReviewEntry =
|
const shouldShowResumeReviewEntry =
|
||||||
!isCheckingSession && Boolean(currentSession) && hasEnoughWeeklyData;
|
!isCheckingSession && isPausedSession && hasEnoughWeeklyData;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="relative min-h-dvh overflow-hidden bg-slate-950 text-white selection:bg-white/20">
|
<div className="relative min-h-dvh overflow-hidden bg-slate-950 text-white selection:bg-white/20">
|
||||||
@@ -305,7 +314,14 @@ export const FocusDashboardWidget = () => {
|
|||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
{currentSession ? (
|
{isRunningSession ? (
|
||||||
|
<div className={cn(goalCardClass, 'space-y-4 text-center')}>
|
||||||
|
<p className="text-[11px] font-medium uppercase tracking-[0.18em] text-white/46">
|
||||||
|
{entryCopy.resumeEyebrow}
|
||||||
|
</p>
|
||||||
|
<p className="text-[15px] text-white/72">{entryCopy.resumeRouting}</p>
|
||||||
|
</div>
|
||||||
|
) : currentSession ? (
|
||||||
<div className={cn(goalCardClass, 'space-y-5')}>
|
<div className={cn(goalCardClass, 'space-y-5')}>
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
<p className="text-[11px] font-medium uppercase tracking-[0.18em] text-white/46">
|
<p className="text-[11px] font-medium uppercase tracking-[0.18em] text-white/46">
|
||||||
@@ -315,7 +331,7 @@ export const FocusDashboardWidget = () => {
|
|||||||
{currentSession.goal}
|
{currentSession.goal}
|
||||||
</h1>
|
</h1>
|
||||||
<p className="text-sm text-white/68">
|
<p className="text-sm text-white/68">
|
||||||
{currentSession.state === 'paused' ? entryCopy.resumePaused : entryCopy.resumeRunning}
|
{entryCopy.resumePaused}
|
||||||
</p>
|
</p>
|
||||||
{currentSession.microStep ? (
|
{currentSession.microStep ? (
|
||||||
<div className="rounded-[1.1rem] border border-white/10 bg-white/[0.04] px-4 py-3">
|
<div className="rounded-[1.1rem] border border-white/10 bg-white/[0.04] px-4 py-3">
|
||||||
|
|||||||
@@ -39,6 +39,7 @@ import { FocusTopToast } from "./FocusTopToast";
|
|||||||
export const SpaceWorkspaceWidget = () => {
|
export const SpaceWorkspaceWidget = () => {
|
||||||
const searchParams = useSearchParams();
|
const searchParams = useSearchParams();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
const resumeIntent = searchParams.get("resume");
|
||||||
const sceneQuery = searchParams.get("scene") ?? searchParams.get("room");
|
const sceneQuery = searchParams.get("scene") ?? searchParams.get("room");
|
||||||
const goalQuery = searchParams.get("goal")?.trim() ?? "";
|
const goalQuery = searchParams.get("goal")?.trim() ?? "";
|
||||||
const focusPlanItemIdQuery = searchParams.get("planItemId");
|
const focusPlanItemIdQuery = searchParams.get("planItemId");
|
||||||
@@ -222,6 +223,8 @@ export const SpaceWorkspaceWidget = () => {
|
|||||||
workspaceMode === "setup" &&
|
workspaceMode === "setup" &&
|
||||||
showReviewTeaserAfterComplete &&
|
showReviewTeaserAfterComplete &&
|
||||||
hasEnoughWeeklyData;
|
hasEnoughWeeklyData;
|
||||||
|
const allowsPausedReentry =
|
||||||
|
resumeIntent === "continue" || resumeIntent === "refocus";
|
||||||
const secondaryReviewTeaser = shouldShowSecondaryReviewTeaser
|
const secondaryReviewTeaser = shouldShowSecondaryReviewTeaser
|
||||||
? {
|
? {
|
||||||
title: isPro
|
title: isPro
|
||||||
@@ -242,6 +245,16 @@ export const SpaceWorkspaceWidget = () => {
|
|||||||
}
|
}
|
||||||
}, [isBootstrapping, currentSession, hasQueryOverrides, router]);
|
}, [isBootstrapping, currentSession, hasQueryOverrides, router]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (isBootstrapping || !currentSession) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (currentSession.state === "paused" && !allowsPausedReentry) {
|
||||||
|
router.replace("/app");
|
||||||
|
}
|
||||||
|
}, [allowsPausedReentry, currentSession, isBootstrapping, router]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const preferMobile =
|
const preferMobile =
|
||||||
typeof window !== "undefined"
|
typeof window !== "undefined"
|
||||||
|
|||||||
Reference in New Issue
Block a user