refactor: startAt을 저장하고 계산하여 시간을 표시하도록 수정

This commit is contained in:
2026-02-10 14:58:24 +09:00
parent a0d551ae66
commit 9cde0e927a

View File

@@ -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<number>(() => Date.now());
const [now, setNow] = useState<number>(() => 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<number | null>(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<string | null>(null);
const toastTimerRef = useRef<number | null>(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;
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);
isAwayRef.current = 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");
const onVis = () => tick();
const onFocus = () => tick();
const onPageShow = () => tick();
document.addEventListener("visibilitychange", onVis);
window.addEventListener("focus", onFocus);
window.addEventListener("pageshow", onPageShow);
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}`);
}
return next;
});
}
}, 1000);
}, [elapsed, duration, mode, router, awaySeconds]);
return () => window.clearInterval(id);
}, [duration, mode, router]);
// freeflow 60분 토스트
// 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() {
</div>
</div>
)}
{/* Next action (고정) */}
{nextAction && (
<div
className="mt-3 rounded-2xl border bg-white px-4 py-3"
style={{ borderColor: BORDER }}
>
<div className="flex items-center justify-between gap-3">
<div className="text-xs font-semibold text-slate-600">
()
</div>
<button
type="button"
className="rounded-lg border px-2 py-1 text-xs font-semibold text-slate-700 transition hover:bg-[#F1F5FF] active:scale-[0.99]"
style={{ borderColor: BORDER }}
onClick={unpinNext}
>
</button>
</div>
<div className="mt-1 text-xl font-semibold text-blue-700 line-clamp-2 break-words">
{nextAction}
</div>
</div>
)}
</div>
<div className="text-[44px] font-semibold leading-none text-slate-900 tabular-nums">
@@ -675,202 +580,11 @@ function SessionInner() {
</div>
</div>
{/* Check-in / Recenter banner (임시 미션) */}
{checkinOpen && (
<div
className="mt-4 rounded-2xl border bg-white px-4 py-3 text-sm text-slate-800 shadow-sm"
style={{ borderColor: BORDER }}
>
{/* ASK */}
{checkinStep === "ask" && (
<div className="flex items-center justify-between gap-3">
<div className="font-semibold"> ?</div>
<div className="flex gap-2">
<button
type="button"
className="rounded-xl border px-3 py-2 font-semibold transition active:scale-[0.99]"
style={{ borderColor: BORDER }}
onClick={() => {
setCheckinOpen(false);
setCheckinStep("ask");
showToast("좋아요. 이어가요.");
}}
>
</button>
</div>
</div>
)}
{/* REASON */}
{checkinStep === "reason" && (
<div>
<div className="font-semibold"> ?</div>
<div className="mt-2 grid grid-cols-2 gap-2">
{(
[
"distracted",
"stuck",
"tired",
"overwhelmed",
] as RecenterReason[]
).map((r) => (
<button
key={r}
type="button"
className="rounded-2xl border bg-white px-3 py-2 text-left font-semibold transition hover:bg-[#F1F5FF] active:scale-[0.99]"
style={{ borderColor: BORDER }}
onClick={() => {
setRecenterReason(r);
setCheckinStep("pick");
}}
>
{reasonLabel(r)}
</button>
))}
</div>
<div className="mt-2 flex justify-end">
<button
type="button"
className="rounded-xl border px-3 py-2 font-semibold transition active:scale-[0.99]"
style={{ borderColor: BORDER }}
onClick={() => resetRecenter()}
>
</button>
</div>
</div>
)}
{/* PICK */}
{checkinStep === "pick" && recenterReason && (
<div>
<div className="flex items-center justify-between gap-3">
<div className="font-semibold">
2 ({reasonLabel(recenterReason)})
</div>
<button
type="button"
className="rounded-xl border px-3 py-2 font-semibold transition active:scale-[0.99]"
style={{ borderColor: BORDER }}
onClick={() => setCheckinStep("reason")}
>
</button>
</div>
<div className="mt-2 grid gap-2">
{recenterOptions(goal, recenterReason).map((opt) => (
<button
key={opt.id}
type="button"
className="rounded-2xl border px-3 py-2 text-left font-semibold transition hover:bg-[#F1F5FF] active:scale-[0.99]"
style={{ borderColor: BORDER }}
onClick={() => startMission(opt)}
>
{opt.label}
</button>
))}
</div>
<div className="mt-2 text-xs text-slate-500">
. 2 .
</div>
</div>
)}
{/* RUNNING */}
{checkinStep === "running" && mission && (
<div>
<div className="flex items-start justify-between gap-3">
<div>
<div className="font-semibold">
· {missionLeft}s
</div>
<div className="mt-1 text-slate-700">
{mission.option.label}
</div>
</div>
<button
type="button"
className="rounded-xl border px-3 py-2 font-semibold transition active:scale-[0.99]"
style={{ borderColor: BORDER }}
onClick={endMissionEarly}
>
</button>
</div>
<div className="mt-2 text-xs text-slate-500">
2 .
</div>
</div>
)}
{/* DONE */}
{checkinStep === "done" && mission && (
<div>
<div className="font-semibold">
. .
</div>
<div className="mt-3 flex flex-wrap gap-2">
<button
type="button"
className="rounded-xl border px-3 py-2 font-semibold transition active:scale-[0.99]"
style={{ borderColor: BORDER }}
onClick={() => {
resetRecenter(); // 기본: 임시 미션은 사라짐
showToast("좋아요. 목표로 돌아가요.");
}}
>
</button>
<button
type="button"
className="rounded-xl border px-3 py-2 font-semibold transition active:scale-[0.99]"
style={{ borderColor: BORDER }}
onClick={() => {
setMission(null);
setMissionLeft(0);
setCheckinStep(recenterReason ? "pick" : "reason");
}}
>
2
</button>
<button
type="button"
className="rounded-xl px-3 py-2 font-semibold text-white transition active:scale-[0.99]"
style={{ backgroundColor: PRIMARY }}
onMouseEnter={(e) =>
(e.currentTarget.style.backgroundColor = PRIMARY_HOVER)
}
onMouseLeave={(e) =>
(e.currentTarget.style.backgroundColor = PRIMARY)
}
onClick={pinMissionAsNext}
>
</button>
</div>
<div className="mt-2 text-xs text-slate-500">
. .
</div>
</div>
)}
</div>
)}
{/* Actions */}
<div className="mt-5 grid grid-cols-2 gap-3">
<button
type="button"
onClick={() => setIsAway((v) => !v)}
onClick={toggleAway}
className="rounded-3xl border bg-white px-4 py-4 text-base font-semibold text-slate-800 shadow-sm transition active:scale-[0.99]"
style={{ borderColor: BORDER }}
onMouseEnter={(e) =>