diff --git a/src/app/session/page.tsx b/src/app/session/page.tsx index 27f348d..12fb60c 100644 --- a/src/app/session/page.tsx +++ b/src/app/session/page.tsx @@ -28,6 +28,24 @@ const PRIMARY = "#2F6FED"; const PRIMARY_HOVER = "#295FD1"; const HOVER = "#F1F5FF"; +// localStorage keys (세션 시간/자리비움 복구용) +const LS = { + active: "hushroom:session-active", + mode: "hushroom:session-mode", + id: "hushroom:session-id", + startedAt: "hushroom:session-startedAt", + awayTotalMs: "hushroom:session-awayTotalMs", + awayStartedAt: "hushroom:session-awayStartedAt", + isAway: "hushroom:session-isAway", + + goal: "hushroom:session-goal", + nextAction: "hushroom:session-nextAction", + + endElapsed: "hushroom:session-elapsed", + endAway: "hushroom:session-away", + endReason: "hushroom:session-end-reason", +} as const; + function clampMode(v: string | null): Mode { if (v === "sprint" || v === "deepwork" || v === "freeflow") return v; return "freeflow"; @@ -53,112 +71,15 @@ function formatHHMMSS(totalSeconds: number) { return `${hh}:${mm}:${ss}`; } -function reasonLabel(r: RecenterReason) { - switch (r) { - case "distracted": - return "산만함"; - case "stuck": - return "막힘"; - case "tired": - return "피로"; - case "overwhelmed": - return "부담"; - } +function safeNumber(v: string | null, fallback = 0) { + const n = Number(v ?? ""); + return Number.isFinite(n) ? n : fallback; } -/** - * ✅ 리라이트 원칙(목적/이유 반영) - * - 3초 안에 “뭘 하면 되는지” 떠오르는 행동 문구 - * - 목표 변경/재설정처럼 읽히는 표현 금지 - * - 작성/입력 강제 금지(원클릭 → 즉시 실행) - * - 도구/도메인 중립 - * - “2분 동안만”이라는 임시성을 기본으로 내포 - */ -function recenterOptions( - goal: string, - reason: RecenterReason, -): RecenterOption[] { - const g = goal.trim(); - const goalHint = g ? `“${g}”로 복귀` : "목표로 복귀"; - - if (reason === "distracted") { - return [ - { - id: "dist_1", - label: "2분 미션: 방해 요소 1개만 끄기(알림/탭/폰)", - pinText: "방해 요소 1개 끄기", - }, - { - id: "dist_2", - label: "2분 미션: 화면을 ‘하나’만 남기기(지금 필요한 것만)", - pinText: "화면 하나만 남기기", - }, - { - id: "dist_3", - label: `2분 미션: ${goalHint} — 가장 쉬운 첫 행동 1개`, - pinText: g ? `“${g}” 첫 행동 1개` : "첫 행동 1개", - }, - ]; - } - - if (reason === "stuck") { - return [ - { - id: "stuck_1", - label: "2분 미션: 막히는 건 잠깐 두고, 확실한 것 1개만 진행", - pinText: "확실한 것 1개 진행", - }, - { - id: "stuck_2", - label: "2분 미션: 가장 작은 결과물 1개만 만들기(한 줄/한 칸/한 개)", - pinText: "가장 작은 결과물 1개", - }, - { - id: "stuck_3", - label: "2분 미션: 60초 정리(다음 한 조각) + 60초 착수(그 조각)", - pinText: "정리 60초 + 착수 60초", - }, - ]; - } - - if (reason === "tired") { - return [ - { - id: "tired_1", - label: "2분 미션: 물 한 모금/자세 정리 후 1분만 손 움직이기", - pinText: "물/자세 정리 후 1분", - }, - { - id: "tired_2", - label: "2분 미션: 30초 숨 고르기 + 90초 아주 쉬운 행동", - pinText: "30초 숨 + 90초 쉬운 행동", - }, - { - id: "tired_3", - label: `2분 미션: ${goalHint} — 가장 가벼운 한 조각만`, - pinText: "가장 가벼운 한 조각", - }, - ]; - } - - // overwhelmed - return [ - { - id: "ovr_1", - label: "2분 미션: (2분만) 범위를 최소로 줄여 ‘첫 행동 1개’만", - pinText: "첫 행동 1개만", - }, - { - id: "ovr_2", - label: "2분 미션: 완성 말고 ‘전진’만(아주 작은 1개)", - pinText: "아주 작은 1개 전진", - }, - { - id: "ovr_3", - label: `2분 미션: ${goalHint} — 지금 당장 가능한 것 1개`, - pinText: "지금 가능한 것 1개", - }, - ]; +function newId() { + return (crypto?.randomUUID?.() ?? `${Date.now()}-${Math.random()}`) + .replace(/[^a-zA-Z0-9]/g, "") + .slice(0, 24); } function useLocalPresence(roomKey: string, status: PresenceStatus) { @@ -268,7 +189,6 @@ function useLocalPresence(roomKey: string, status: PresenceStatus) { } } catch {} }; - // window.addEventListener("storage", onStorage); return () => window.removeEventListener("storage", onStorage); } @@ -276,7 +196,6 @@ function useLocalPresence(roomKey: string, status: PresenceStatus) { channelRef.current?.close(); channelRef.current = null; }; - // eslint-disable-next-line react-hooks/exhaustive-deps }, [roomKey]); useEffect(() => { @@ -284,7 +203,6 @@ function useLocalPresence(roomKey: string, status: PresenceStatus) { participantsRef.current.set(selfId, { id: selfId, status, lastSeen: now }); publish({ type: "ping", roomKey, from: selfId, status, ts: now }); syncStateToReact(); - // eslint-disable-next-line react-hooks/exhaustive-deps }, [status, roomKey, selfId]); useEffect(() => { @@ -326,7 +244,6 @@ function useLocalPresence(roomKey: string, status: PresenceStatus) { window.removeEventListener("beforeunload", onBeforeUnload); publish({ type: "leave", roomKey, from: selfId, ts: Date.now() }); }; - // eslint-disable-next-line react-hooks/exhaustive-deps }, [roomKey, selfId, status]); return { participants, selfId }; @@ -353,31 +270,24 @@ function SessionInner() { const [goal, setGoal] = useState(""); const [nextAction, setNextAction] = useState(""); + // ✅ startedAt/now 기반 시간 + const [startedAt, setStartedAt] = useState(() => Date.now()); + const [now, setNow] = useState(() => Date.now()); + const endedRef = useRef(false); + + // ✅ away: 누적 ms + (현재 away면 now-startedAt) const [isAway, setIsAway] = useState(false); - const [awaySeconds, setAwaySeconds] = useState(0); + const awayTotalMsRef = useRef(0); + const awayStartedAtRef = useRef(null); // presence (로컬) const presenceStatus: PresenceStatus = isAway ? "away" : "focus"; const roomKey = "lounge"; const { participants } = useLocalPresence(roomKey, presenceStatus); - // time - const [elapsed, setElapsed] = useState(0); - const [remaining, setRemaining] = useState(duration ?? 0); - - // refs (interval에서 최신값 쓰기) - const elapsedRef = useRef(0); - const awayRef = useRef(0); - const isAwayRef = useRef(false); - - useEffect(() => { - isAwayRef.current = isAway; - }, [isAway]); - // toast const [toast, setToast] = useState(null); const toastTimerRef = useRef(null); - const showToast = (msg: string) => { setToast(msg); if (toastTimerRef.current) window.clearTimeout(toastTimerRef.current); @@ -419,72 +329,129 @@ function SessionInner() { setCheckinOpen(false); }; - // 세션 초기화(모드/길이 바뀔 때) + // 세션 시작/복구 (mode가 바뀌면 새 세션) useEffect(() => { - setElapsed(0); - elapsedRef.current = 0; + endedRef.current = false; - setAwaySeconds(0); - awayRef.current = 0; + const active = localStorage.getItem(LS.active) === "1"; + const storedMode = localStorage.getItem(LS.mode); + const storedStartedAt = safeNumber(localStorage.getItem(LS.startedAt), 0); - if (duration) setRemaining(duration); - lastCheckpointRef.current = 0; + const shouldResume = active && storedMode === mode && storedStartedAt > 0; - setIsAway(false); - isAwayRef.current = false; + if (shouldResume) { + setStartedAt(storedStartedAt); + setNow(Date.now()); + const isAwayStored = localStorage.getItem(LS.isAway) === "1"; + const awayTotal = safeNumber(localStorage.getItem(LS.awayTotalMs), 0); + const awayStartedAt = safeNumber( + localStorage.getItem(LS.awayStartedAt), + 0, + ); + + awayTotalMsRef.current = Math.max(0, awayTotal); + awayStartedAtRef.current = + isAwayStored && awayStartedAt > 0 ? awayStartedAt : null; + setIsAway(isAwayStored); + } else { + const t = Date.now(); + localStorage.setItem(LS.active, "1"); + localStorage.setItem(LS.mode, mode); + localStorage.setItem(LS.id, newId()); + localStorage.setItem(LS.startedAt, String(t)); + localStorage.setItem(LS.awayTotalMs, "0"); + localStorage.removeItem(LS.awayStartedAt); + localStorage.setItem(LS.isAway, "0"); + + setStartedAt(t); + setNow(t); + + awayTotalMsRef.current = 0; + awayStartedAtRef.current = null; + setIsAway(false); + } + + // UI 관련 리셋(기존 로직 유지) setToast(null); if (toastTimerRef.current) window.clearTimeout(toastTimerRef.current); - + lastCheckpointRef.current = 0; shownCheckinsRef.current = new Set(); resetRecenter(); - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [duration]); + setCheckinOpen(false); + }, [mode, duration]); // 목표/다음 단계(고정) 로드 useEffect(() => { - setGoal(localStorage.getItem("hushroom:session-goal") ?? ""); - setNextAction(localStorage.getItem("hushroom:session-nextAction") ?? ""); + setGoal(localStorage.getItem(LS.goal) ?? ""); + setNextAction(localStorage.getItem(LS.nextAction) ?? ""); }, []); - // 메인 타이머 + // now 갱신(탭 전환/잠자기 복귀 시에도 보정) useEffect(() => { - const id = window.setInterval(() => { - setElapsed((prev) => { - const next = prev + 1; - elapsedRef.current = next; - return next; - }); + const tick = () => setNow(Date.now()); - if (isAwayRef.current) { - setAwaySeconds((prev) => { - const next = prev + 1; - awayRef.current = next; - return next; - }); - } + tick(); + const id = window.setInterval(tick, 250); - if (duration) { - setRemaining((prev) => { - const next = Math.max(0, prev - 1); - if (next === 0 && prev !== 0) { - localStorage.setItem("hushroom:session-elapsed", String(duration)); - localStorage.setItem( - "hushroom:session-away", - String(awayRef.current), - ); - localStorage.setItem("hushroom:session-end-reason", "timed_out"); - router.push(`/session/end?mode=${mode}`); - } - return next; - }); - } - }, 1000); + const onVis = () => tick(); + const onFocus = () => tick(); + const onPageShow = () => tick(); - return () => window.clearInterval(id); - }, [duration, mode, router]); + document.addEventListener("visibilitychange", onVis); + window.addEventListener("focus", onFocus); + window.addEventListener("pageshow", onPageShow); - // freeflow 60분 토스트 + return () => { + window.clearInterval(id); + document.removeEventListener("visibilitychange", onVis); + window.removeEventListener("focus", onFocus); + window.removeEventListener("pageshow", onPageShow); + }; + }, []); + + const elapsed = useMemo(() => { + const d = now - startedAt; + return Math.max(0, Math.floor(d / 1000)); + }, [now, startedAt]); + + const remaining = useMemo(() => { + if (!duration) return 0; + return Math.max(0, duration - elapsed); + }, [duration, elapsed]); + + const awayMs = useMemo(() => { + const base = awayTotalMsRef.current; + const extra = awayStartedAtRef.current ? now - awayStartedAtRef.current : 0; + return Math.max(0, base + extra); + }, [now, isAway]); // isAway 변화 시 즉시 반영 + + const awaySeconds = useMemo(() => Math.floor(awayMs / 1000), [awayMs]); + + const focusSeconds = Math.max(0, elapsed - awaySeconds); + + const timeMain = useMemo(() => { + if (mode === "freeflow") return formatHHMMSS(elapsed); + return formatHHMMSS(remaining); + }, [elapsed, remaining, mode]); + + // ✅ 타임아웃 종료(점프 포함) + useEffect(() => { + if (!duration) return; + if (endedRef.current) return; + if (elapsed < duration) return; + + endedRef.current = true; + + localStorage.setItem(LS.endElapsed, String(duration)); + localStorage.setItem(LS.endAway, String(awaySeconds)); + localStorage.setItem(LS.endReason, "timed_out"); + + localStorage.setItem(LS.active, "0"); + router.push(`/session/end?mode=${mode}`); + }, [elapsed, duration, mode, router, awaySeconds]); + + // freeflow 60분 토스트(점프 시에도 “현재 배수” 기준으로 1회만) useEffect(() => { if (mode !== "freeflow") return; const mins = Math.floor(elapsed / 60); @@ -511,76 +478,40 @@ function SessionInner() { } }, [elapsed, mode, isAway, checkinOpen, mission, checkinTimes]); - // 2분 미션 카운트다운 - useEffect(() => { - if (!mission) return; + // ✅ 자리비움 토글(누적 ms 기반) + const toggleAway = () => { + const t = Date.now(); + const next = !isAway; - const tick = () => { - const left = Math.max(0, Math.ceil((mission.endsAt - Date.now()) / 1000)); - setMissionLeft(left); - if (left === 0) setCheckinStep("done"); - }; + if (next) { + // away 시작 + awayStartedAtRef.current = t; + localStorage.setItem(LS.awayStartedAt, String(t)); + localStorage.setItem(LS.isAway, "1"); + setIsAway(true); + return; + } - tick(); - const id = window.setInterval(tick, 250); - return () => window.clearInterval(id); - }, [mission]); - - const timeMain = useMemo(() => { - if (mode === "freeflow") return formatHHMMSS(elapsed); - return formatHHMMSS(remaining); - }, [elapsed, remaining, mode]); - - const focusSeconds = Math.max(0, elapsed - awaySeconds); - - const openRecenter = () => { - setRecenterReason(null); - setMission(null); - setMissionLeft(0); - setCheckinStep("reason"); - setCheckinOpen(true); - }; - - const startMission = (opt: RecenterOption) => { - const endsAt = Date.now() + 2 * 60 * 1000; - setMission({ option: opt, endsAt }); - setMissionLeft(120); - setCheckinStep("running"); - setCheckinOpen(true); - showToast("좋아요. 딱 2분만."); - }; - - const endMissionEarly = () => { - if (!mission) return; - setMission({ ...mission, endsAt: Date.now() }); - setMissionLeft(0); - setCheckinStep("done"); - }; - - const pinMissionAsNext = () => { - if (!mission) return; - const text = mission.option.pinText.trim(); - if (!text) return; - - setNextAction(text); - localStorage.setItem("hushroom:session-nextAction", text); - showToast("다음 단계로 고정했어요."); - resetRecenter(); - }; - - const unpinNext = () => { - setNextAction(""); - localStorage.removeItem("hushroom:session-nextAction"); - showToast("고정을 해제했어요."); + // away 종료 + if (awayStartedAtRef.current) { + awayTotalMsRef.current += t - awayStartedAtRef.current; + localStorage.setItem(LS.awayTotalMs, String(awayTotalMsRef.current)); + } + awayStartedAtRef.current = null; + localStorage.removeItem(LS.awayStartedAt); + localStorage.setItem(LS.isAway, "0"); + setIsAway(false); }; const onEnd = () => { - localStorage.setItem( - "hushroom:session-elapsed", - String(elapsedRef.current), - ); - localStorage.setItem("hushroom:session-away", String(awayRef.current)); - localStorage.setItem("hushroom:session-end-reason", "manual"); + if (endedRef.current) return; + endedRef.current = true; + + localStorage.setItem(LS.endElapsed, String(elapsed)); + localStorage.setItem(LS.endAway, String(awaySeconds)); + localStorage.setItem(LS.endReason, "manual"); + + localStorage.setItem(LS.active, "0"); router.push(`/session/end?mode=${mode}`); }; @@ -642,32 +573,6 @@ function SessionInner() { )} - - {/* Next action (고정) */} - {nextAction && ( -
-
-
- 다음 단계(고정) -
- -
- -
- {nextAction} -
-
- )}
@@ -675,202 +580,11 @@ function SessionInner() {
- {/* Check-in / Recenter banner (임시 미션) */} - {checkinOpen && ( -
- {/* ASK */} - {checkinStep === "ask" && ( -
-
지금 목표에 붙어있나요?
-
- -
-
- )} - - {/* REASON */} - {checkinStep === "reason" && ( -
-
어느 쪽에 가까워요?
-
- {( - [ - "distracted", - "stuck", - "tired", - "overwhelmed", - ] as RecenterReason[] - ).map((r) => ( - - ))} -
- -
- -
-
- )} - - {/* PICK */} - {checkinStep === "pick" && recenterReason && ( -
-
-
- 그럼 2분만 ({reasonLabel(recenterReason)}) -
- -
- -
- {recenterOptions(goal, recenterReason).map((opt) => ( - - ))} -
- -
- 선택해도 “고정”되지 않습니다. 2분 미션만 실행해요. -
-
- )} - - {/* RUNNING */} - {checkinStep === "running" && mission && ( -
-
-
-
- 복구 중 · {missionLeft}s -
-
- {mission.option.label} -
-
- - -
- -
- 2분만 행동에 붙이면 다시 돌아옵니다. -
-
- )} - - {/* DONE */} - {checkinStep === "done" && mission && ( -
-
- 좋아요. 다시 목표로 돌아가요. -
- -
- - - - - -
- -
- 고정은 선택 사항이에요. 기본은 사라집니다. -
-
- )} -
- )} - {/* Actions */}