feat(resume): 지난 한 조각 이어서 시작하는 진입 플로우 추가
맥락: - /space 재진입 시 마지막 목표를 다시 쓰게 되어 시작 마찰이 컸다. - work.md 작업 2 요구사항에 맞춰 목표 기반 Resume CTA를 진입 의식 안에 추가했다. 변경사항: - workspace localStorage 스키마에 goal 필드를 추가하고 저장/복원에 반영했다. - Setup Ritual에 지난 한 조각 이어서 블록을 추가했다. - 이어서 시작은 저장 목표로 즉시 Focus 전환, 새로 시작은 목표 초기화 후 새 세션 입력으로 전환하도록 연결했다. - session 문서 docs/session_brief.md, docs/90_current_state.md를 최신 상태로 갱신했다. 검증: - npx tsc --noEmit 세션-상태: Resume CTA와 목표 복원 흐름이 /space 진입에 반영됨 세션-다음: Goal Complete 루프와 Recover(Notes→Inbox) 플로우 마감 세션-리스크: localStorage 기반 복원이라 다중 탭/스토리지 초기화 시 세션 연속성이 약할 수 있음
This commit is contained in:
@@ -48,8 +48,12 @@ Last Updated: 2026-03-05
|
|||||||
- 우하단 Sound Quick 경로를 override 적용의 명시적 경로로 분리:
|
- 우하단 Sound Quick 경로를 override 적용의 명시적 경로로 분리:
|
||||||
- `onQuickSoundSelect` 콜백으로 연결해 `override.sound` 규칙을 코드 레벨에서 고정
|
- `onQuickSoundSelect` 콜백으로 연결해 `override.sound` 규칙을 코드 레벨에서 고정
|
||||||
- 세션 상태 더미 저장/복원 추가:
|
- 세션 상태 더미 저장/복원 추가:
|
||||||
- `sceneId`, `timerPresetId`, `soundPresetId`, `override(sound/timer)`를 localStorage에 저장
|
- `sceneId`, `timerPresetId`, `soundPresetId`, `goal`, `override(sound/timer)`를 localStorage에 저장
|
||||||
- 복원 우선순위: 쿼리 파라미터 > 저장 상태 > Scene 추천
|
- 복원 우선순위: 쿼리 파라미터 > 저장 상태 > Scene 추천
|
||||||
|
- `/space` 진입 Resume CTA 추가:
|
||||||
|
- 저장된 목표가 있고 쿼리 오버라이드가 없을 때 `지난 한 조각 이어서` 블록 1회 노출
|
||||||
|
- `이어서 시작`: 저장 목표로 즉시 Focus 진입
|
||||||
|
- `새로 시작`: 목표를 비워 새 세션 입력 흐름으로 전환
|
||||||
- 세션 복구 운영 문서 추가:
|
- 세션 복구 운영 문서 추가:
|
||||||
- `docs/06_commit_convention.md`
|
- `docs/06_commit_convention.md`
|
||||||
- `docs/07_session_recovery.md`
|
- `docs/07_session_recovery.md`
|
||||||
@@ -149,10 +153,9 @@ Last Updated: 2026-03-05
|
|||||||
|
|
||||||
## NEXT
|
## NEXT
|
||||||
|
|
||||||
1. Packs/Profiles 상세 패널(더미) 설계 여부 결정 및 UX 깊이 조정
|
1. Goal Complete Sheet 플로우(완료 → 다음 한 조각) 전환 감도/카피 마감
|
||||||
2. Plan Pill(NORMAL) 클릭 시 업그레이드 진입 기대치에 대한 카피/마이크로 인터랙션 점검
|
2. Notes(쓰기) / Inbox(읽기·정리) 복귀 흐름과 30초 숨고르기 톤 정리
|
||||||
3. Scene 추천 자동 적용과 override 유지 정책의 체감 검증
|
3. Stage 가독성/모션/레이어 폴리시 최종 통일
|
||||||
4. ESLint 잔여 이슈(`set-state-in-effect` 등) 정리 계획 수립
|
|
||||||
|
|
||||||
## RISKS
|
## RISKS
|
||||||
|
|
||||||
|
|||||||
@@ -14,9 +14,9 @@ Last Updated: 2026-03-05
|
|||||||
|
|
||||||
## 현재 우선순위
|
## 현재 우선순위
|
||||||
|
|
||||||
1. Packs/Profiles 더미 UI의 정보 밀도와 업그레이드 동선 카피 미세 조정
|
1. Goal Complete Sheet 플로우(완료 → 다음 한 조각) 마감 품질 점검
|
||||||
2. Scene 추천 매핑 품질 점검(공간별 사운드/타이머 추천값 보정)
|
2. Notes(쓰기) / Inbox(읽기·정리) 복귀 동선과 30초 숨고르기 카피 정리
|
||||||
3. ESLint 잔여 이슈(`set-state-in-effect` 등) 정리 범위 확정
|
3. Stage 가독성/모션/레이어 폴리시 최종 정리
|
||||||
|
|
||||||
## 최근 세션 상태
|
## 최근 세션 상태
|
||||||
|
|
||||||
@@ -50,8 +50,11 @@ Last Updated: 2026-03-05
|
|||||||
- 추천 정보 1줄 + `추천으로 되돌리기`만 유지
|
- 추천 정보 1줄 + `추천으로 되돌리기`만 유지
|
||||||
- 우하단 Sound Quick 선택 경로를 `onQuickSoundSelect`로 분리해 override.sound 규칙을 명시했다.
|
- 우하단 Sound Quick 선택 경로를 `onQuickSoundSelect`로 분리해 override.sound 규칙을 명시했다.
|
||||||
- `/space` 선택 상태 로컬 저장/복원을 추가했다.
|
- `/space` 선택 상태 로컬 저장/복원을 추가했다.
|
||||||
- 저장: `sceneId`, `timerPresetId`, `soundPresetId`, `override(sound/timer)`
|
- 저장: `sceneId`, `timerPresetId`, `soundPresetId`, `goal`, `override(sound/timer)`
|
||||||
- 복원 우선순위: 쿼리 파라미터 > 저장 상태 > Scene 추천
|
- 복원 우선순위: 쿼리 파라미터 > 저장 상태 > Scene 추천
|
||||||
|
- `/space` 진입 시 Resume CTA를 추가했다.
|
||||||
|
- 저장된 목표가 있고 쿼리 오버라이드가 없으면 `지난 한 조각 이어서`를 1회 노출
|
||||||
|
- `이어서 시작`은 즉시 Focus 진입, `새로 시작`은 목표를 비운 새 세션으로 전환
|
||||||
- 세션 복구용 문서/템플릿/스크립트가 준비되어 있다.
|
- 세션 복구용 문서/템플릿/스크립트가 준비되어 있다.
|
||||||
- `workFlow.md`는 토큰 절약 모드를 사용한다.
|
- `workFlow.md`는 토큰 절약 모드를 사용한다.
|
||||||
- `/space` 하단 사운드 바를 제거하고 오른쪽 `🎧 Sound` 시트로 이동했다.
|
- `/space` 하단 사운드 바를 제거하고 오른쪽 `🎧 Sound` 시트로 이동했다.
|
||||||
|
|||||||
@@ -28,6 +28,11 @@ interface SpaceSetupDrawerWidgetProps {
|
|||||||
onGoalChange: (value: string) => void;
|
onGoalChange: (value: string) => void;
|
||||||
onGoalChipSelect: (chip: GoalChip) => void;
|
onGoalChipSelect: (chip: GoalChip) => void;
|
||||||
onStart: () => void;
|
onStart: () => void;
|
||||||
|
resumeHint?: {
|
||||||
|
goal: string;
|
||||||
|
onResume: () => void;
|
||||||
|
onStartFresh: () => void;
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
interface SummaryChipProps {
|
interface SummaryChipProps {
|
||||||
@@ -74,6 +79,7 @@ export const SpaceSetupDrawerWidget = ({
|
|||||||
onGoalChange,
|
onGoalChange,
|
||||||
onGoalChipSelect,
|
onGoalChipSelect,
|
||||||
onStart,
|
onStart,
|
||||||
|
resumeHint,
|
||||||
}: SpaceSetupDrawerWidgetProps) => {
|
}: SpaceSetupDrawerWidgetProps) => {
|
||||||
const [openPopover, setOpenPopover] = useState<RitualPopover | null>(null);
|
const [openPopover, setOpenPopover] = useState<RitualPopover | null>(null);
|
||||||
const panelRef = useRef<HTMLDivElement | null>(null);
|
const panelRef = useRef<HTMLDivElement | null>(null);
|
||||||
@@ -151,6 +157,29 @@ export const SpaceSetupDrawerWidget = ({
|
|||||||
<p className="text-xs text-white/60">목표만 적으면 바로 Focus 모드로 넘어가요.</p>
|
<p className="text-xs text-white/60">목표만 적으면 바로 Focus 모드로 넘어가요.</p>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
|
{resumeHint ? (
|
||||||
|
<div className="mb-3 rounded-2xl border border-white/14 bg-black/22 px-3 py-2.5">
|
||||||
|
<p className="text-[11px] text-white/62">지난 한 조각 이어서</p>
|
||||||
|
<p className="mt-1 truncate text-sm text-white/88">{resumeHint.goal}</p>
|
||||||
|
<div className="mt-2 flex items-center justify-end gap-1.5">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={resumeHint.onStartFresh}
|
||||||
|
className="rounded-full border border-white/16 bg-white/[0.04] px-2.5 py-1 text-[11px] text-white/72 transition-colors hover:bg-white/[0.1]"
|
||||||
|
>
|
||||||
|
새로 시작
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={resumeHint.onResume}
|
||||||
|
className="rounded-full border border-sky-200/34 bg-sky-200/14 px-2.5 py-1 text-[11px] text-white/90 transition-colors hover:bg-sky-200/22"
|
||||||
|
>
|
||||||
|
이어서 시작
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
<div className="relative mb-3">
|
<div className="relative mb-3">
|
||||||
<div className="flex flex-wrap gap-1.5">
|
<div className="flex flex-wrap gap-1.5">
|
||||||
<SummaryChip
|
<SummaryChip
|
||||||
|
|||||||
@@ -31,6 +31,7 @@ interface StoredWorkspaceSelection {
|
|||||||
sceneId?: string;
|
sceneId?: string;
|
||||||
timerPresetId?: string;
|
timerPresetId?: string;
|
||||||
soundPresetId?: string;
|
soundPresetId?: string;
|
||||||
|
goal?: string;
|
||||||
override?: Partial<SelectionOverride>;
|
override?: Partial<SelectionOverride>;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -143,6 +144,13 @@ const resolveInitialTimerLabel = (
|
|||||||
export const SpaceWorkspaceWidget = () => {
|
export const SpaceWorkspaceWidget = () => {
|
||||||
const searchParams = useSearchParams();
|
const searchParams = useSearchParams();
|
||||||
const storedSelection = useMemo(() => readStoredWorkspaceSelection(), []);
|
const storedSelection = useMemo(() => readStoredWorkspaceSelection(), []);
|
||||||
|
const roomQuery = searchParams.get('room');
|
||||||
|
const goalQuery = searchParams.get('goal');
|
||||||
|
const soundQuery = searchParams.get('sound');
|
||||||
|
const timerQuery = searchParams.get('timer');
|
||||||
|
const storedGoal = storedSelection.goal?.trim() ?? '';
|
||||||
|
const hasQueryOverrides = Boolean(roomQuery || goalQuery || soundQuery || timerQuery);
|
||||||
|
const canOfferResume = storedGoal.length > 0 && !hasQueryOverrides;
|
||||||
const {
|
const {
|
||||||
thoughts,
|
thoughts,
|
||||||
thoughtCount,
|
thoughtCount,
|
||||||
@@ -154,16 +162,16 @@ export const SpaceWorkspaceWidget = () => {
|
|||||||
setThoughtCompleted,
|
setThoughtCompleted,
|
||||||
} = useThoughtInbox();
|
} = useThoughtInbox();
|
||||||
|
|
||||||
const initialRoomId = resolveInitialRoomId(searchParams.get('room'), storedSelection.sceneId);
|
const initialRoomId = resolveInitialRoomId(roomQuery, storedSelection.sceneId);
|
||||||
const initialRoom = getRoomById(initialRoomId) ?? ROOM_THEMES[0];
|
const initialRoom = getRoomById(initialRoomId) ?? ROOM_THEMES[0];
|
||||||
const initialGoal = searchParams.get('goal')?.trim() ?? '';
|
const initialGoal = goalQuery?.trim() ?? '';
|
||||||
const initialSoundPresetId = resolveInitialSoundPreset(
|
const initialSoundPresetId = resolveInitialSoundPreset(
|
||||||
searchParams.get('sound'),
|
soundQuery,
|
||||||
storedSelection.soundPresetId,
|
storedSelection.soundPresetId,
|
||||||
initialRoom.recommendedSoundPresetId,
|
initialRoom.recommendedSoundPresetId,
|
||||||
);
|
);
|
||||||
const initialTimerLabel = resolveInitialTimerLabel(
|
const initialTimerLabel = resolveInitialTimerLabel(
|
||||||
searchParams.get('timer'),
|
timerQuery,
|
||||||
storedSelection.timerPresetId,
|
storedSelection.timerPresetId,
|
||||||
initialRoom.recommendedTimerPresetId,
|
initialRoom.recommendedTimerPresetId,
|
||||||
);
|
);
|
||||||
@@ -173,6 +181,7 @@ export const SpaceWorkspaceWidget = () => {
|
|||||||
const [selectedTimerLabel, setSelectedTimerLabel] = useState(initialTimerLabel);
|
const [selectedTimerLabel, setSelectedTimerLabel] = useState(initialTimerLabel);
|
||||||
const [goalInput, setGoalInput] = useState(initialGoal);
|
const [goalInput, setGoalInput] = useState(initialGoal);
|
||||||
const [selectedGoalId, setSelectedGoalId] = useState<string | null>(null);
|
const [selectedGoalId, setSelectedGoalId] = useState<string | null>(null);
|
||||||
|
const [showResumePrompt, setShowResumePrompt] = useState(canOfferResume);
|
||||||
const [selectionOverride, setSelectionOverride] = useState<SelectionOverride>({
|
const [selectionOverride, setSelectionOverride] = useState<SelectionOverride>({
|
||||||
sound: Boolean(storedSelection.override?.sound),
|
sound: Boolean(storedSelection.override?.sound),
|
||||||
timer: Boolean(storedSelection.override?.timer),
|
timer: Boolean(storedSelection.override?.timer),
|
||||||
@@ -266,11 +275,16 @@ export const SpaceWorkspaceWidget = () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handleGoalChipSelect = (chip: GoalChip) => {
|
const handleGoalChipSelect = (chip: GoalChip) => {
|
||||||
|
setShowResumePrompt(false);
|
||||||
setSelectedGoalId(chip.id);
|
setSelectedGoalId(chip.id);
|
||||||
setGoalInput(chip.label);
|
setGoalInput(chip.label);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleGoalChange = (value: string) => {
|
const handleGoalChange = (value: string) => {
|
||||||
|
if (showResumePrompt) {
|
||||||
|
setShowResumePrompt(false);
|
||||||
|
}
|
||||||
|
|
||||||
setGoalInput(value);
|
setGoalInput(value);
|
||||||
|
|
||||||
if (value.trim().length === 0) {
|
if (value.trim().length === 0) {
|
||||||
@@ -283,6 +297,7 @@ export const SpaceWorkspaceWidget = () => {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
setShowResumePrompt(false);
|
||||||
setWorkspaceMode('focus');
|
setWorkspaceMode('focus');
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -309,6 +324,11 @@ export const SpaceWorkspaceWidget = () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const timerPresetId = resolveTimerPresetIdFromLabel(selectedTimerLabel);
|
const timerPresetId = resolveTimerPresetIdFromLabel(selectedTimerLabel);
|
||||||
|
const normalizedGoal = goalInput.trim().length > 0
|
||||||
|
? goalInput.trim()
|
||||||
|
: showResumePrompt
|
||||||
|
? storedGoal
|
||||||
|
: '';
|
||||||
|
|
||||||
window.localStorage.setItem(
|
window.localStorage.setItem(
|
||||||
WORKSPACE_SELECTION_STORAGE_KEY,
|
WORKSPACE_SELECTION_STORAGE_KEY,
|
||||||
@@ -316,10 +336,11 @@ export const SpaceWorkspaceWidget = () => {
|
|||||||
sceneId: selectedRoomId,
|
sceneId: selectedRoomId,
|
||||||
timerPresetId,
|
timerPresetId,
|
||||||
soundPresetId: selectedPresetId,
|
soundPresetId: selectedPresetId,
|
||||||
|
goal: normalizedGoal,
|
||||||
override: selectionOverride,
|
override: selectionOverride,
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
}, [selectedRoomId, selectedTimerLabel, selectedPresetId, selectionOverride]);
|
}, [goalInput, selectedRoomId, selectedTimerLabel, selectedPresetId, selectionOverride, showResumePrompt, storedGoal]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="relative h-dvh overflow-hidden text-white">
|
<div className="relative h-dvh overflow-hidden text-white">
|
||||||
@@ -351,6 +372,24 @@ export const SpaceWorkspaceWidget = () => {
|
|||||||
onGoalChange={handleGoalChange}
|
onGoalChange={handleGoalChange}
|
||||||
onGoalChipSelect={handleGoalChipSelect}
|
onGoalChipSelect={handleGoalChipSelect}
|
||||||
onStart={handleStart}
|
onStart={handleStart}
|
||||||
|
resumeHint={
|
||||||
|
showResumePrompt
|
||||||
|
? {
|
||||||
|
goal: storedGoal,
|
||||||
|
onResume: () => {
|
||||||
|
setGoalInput(storedGoal);
|
||||||
|
setSelectedGoalId(null);
|
||||||
|
setShowResumePrompt(false);
|
||||||
|
setWorkspaceMode('focus');
|
||||||
|
},
|
||||||
|
onStartFresh: () => {
|
||||||
|
setGoalInput('');
|
||||||
|
setSelectedGoalId(null);
|
||||||
|
setShowResumePrompt(false);
|
||||||
|
},
|
||||||
|
}
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<SpaceFocusHudWidget
|
<SpaceFocusHudWidget
|
||||||
|
|||||||
Reference in New Issue
Block a user