refactor: startAt을 저장하고 계산하여 시간을 표시하도록 수정
This commit is contained in:
@@ -28,6 +28,24 @@ const PRIMARY = "#2F6FED";
|
|||||||
const PRIMARY_HOVER = "#295FD1";
|
const PRIMARY_HOVER = "#295FD1";
|
||||||
const HOVER = "#F1F5FF";
|
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 {
|
function clampMode(v: string | null): Mode {
|
||||||
if (v === "sprint" || v === "deepwork" || v === "freeflow") return v;
|
if (v === "sprint" || v === "deepwork" || v === "freeflow") return v;
|
||||||
return "freeflow";
|
return "freeflow";
|
||||||
@@ -53,112 +71,15 @@ function formatHHMMSS(totalSeconds: number) {
|
|||||||
return `${hh}:${mm}:${ss}`;
|
return `${hh}:${mm}:${ss}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
function reasonLabel(r: RecenterReason) {
|
function safeNumber(v: string | null, fallback = 0) {
|
||||||
switch (r) {
|
const n = Number(v ?? "");
|
||||||
case "distracted":
|
return Number.isFinite(n) ? n : fallback;
|
||||||
return "산만함";
|
|
||||||
case "stuck":
|
|
||||||
return "막힘";
|
|
||||||
case "tired":
|
|
||||||
return "피로";
|
|
||||||
case "overwhelmed":
|
|
||||||
return "부담";
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
function newId() {
|
||||||
* ✅ 리라이트 원칙(목적/이유 반영)
|
return (crypto?.randomUUID?.() ?? `${Date.now()}-${Math.random()}`)
|
||||||
* - 3초 안에 “뭘 하면 되는지” 떠오르는 행동 문구
|
.replace(/[^a-zA-Z0-9]/g, "")
|
||||||
* - 목표 변경/재설정처럼 읽히는 표현 금지
|
.slice(0, 24);
|
||||||
* - 작성/입력 강제 금지(원클릭 → 즉시 실행)
|
|
||||||
* - 도구/도메인 중립
|
|
||||||
* - “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 useLocalPresence(roomKey: string, status: PresenceStatus) {
|
function useLocalPresence(roomKey: string, status: PresenceStatus) {
|
||||||
@@ -268,7 +189,6 @@ function useLocalPresence(roomKey: string, status: PresenceStatus) {
|
|||||||
}
|
}
|
||||||
} catch {}
|
} catch {}
|
||||||
};
|
};
|
||||||
// window.addEventListener("storage", onStorage);
|
|
||||||
return () => window.removeEventListener("storage", onStorage);
|
return () => window.removeEventListener("storage", onStorage);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -276,7 +196,6 @@ function useLocalPresence(roomKey: string, status: PresenceStatus) {
|
|||||||
channelRef.current?.close();
|
channelRef.current?.close();
|
||||||
channelRef.current = null;
|
channelRef.current = null;
|
||||||
};
|
};
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
||||||
}, [roomKey]);
|
}, [roomKey]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -284,7 +203,6 @@ function useLocalPresence(roomKey: string, status: PresenceStatus) {
|
|||||||
participantsRef.current.set(selfId, { id: selfId, status, lastSeen: now });
|
participantsRef.current.set(selfId, { id: selfId, status, lastSeen: now });
|
||||||
publish({ type: "ping", roomKey, from: selfId, status, ts: now });
|
publish({ type: "ping", roomKey, from: selfId, status, ts: now });
|
||||||
syncStateToReact();
|
syncStateToReact();
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
||||||
}, [status, roomKey, selfId]);
|
}, [status, roomKey, selfId]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -326,7 +244,6 @@ function useLocalPresence(roomKey: string, status: PresenceStatus) {
|
|||||||
window.removeEventListener("beforeunload", onBeforeUnload);
|
window.removeEventListener("beforeunload", onBeforeUnload);
|
||||||
publish({ type: "leave", roomKey, from: selfId, ts: Date.now() });
|
publish({ type: "leave", roomKey, from: selfId, ts: Date.now() });
|
||||||
};
|
};
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
||||||
}, [roomKey, selfId, status]);
|
}, [roomKey, selfId, status]);
|
||||||
|
|
||||||
return { participants, selfId };
|
return { participants, selfId };
|
||||||
@@ -353,31 +270,24 @@ function SessionInner() {
|
|||||||
const [goal, setGoal] = useState("");
|
const [goal, setGoal] = useState("");
|
||||||
const [nextAction, setNextAction] = 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 [isAway, setIsAway] = useState(false);
|
||||||
const [awaySeconds, setAwaySeconds] = useState(0);
|
const awayTotalMsRef = useRef(0);
|
||||||
|
const awayStartedAtRef = useRef<number | null>(null);
|
||||||
|
|
||||||
// presence (로컬)
|
// presence (로컬)
|
||||||
const presenceStatus: PresenceStatus = isAway ? "away" : "focus";
|
const presenceStatus: PresenceStatus = isAway ? "away" : "focus";
|
||||||
const roomKey = "lounge";
|
const roomKey = "lounge";
|
||||||
const { participants } = useLocalPresence(roomKey, presenceStatus);
|
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
|
// toast
|
||||||
const [toast, setToast] = useState<string | null>(null);
|
const [toast, setToast] = useState<string | null>(null);
|
||||||
const toastTimerRef = useRef<number | null>(null);
|
const toastTimerRef = useRef<number | null>(null);
|
||||||
|
|
||||||
const showToast = (msg: string) => {
|
const showToast = (msg: string) => {
|
||||||
setToast(msg);
|
setToast(msg);
|
||||||
if (toastTimerRef.current) window.clearTimeout(toastTimerRef.current);
|
if (toastTimerRef.current) window.clearTimeout(toastTimerRef.current);
|
||||||
@@ -419,72 +329,129 @@ function SessionInner() {
|
|||||||
setCheckinOpen(false);
|
setCheckinOpen(false);
|
||||||
};
|
};
|
||||||
|
|
||||||
// 세션 초기화(모드/길이 바뀔 때)
|
// 세션 시작/복구 (mode가 바뀌면 새 세션)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setElapsed(0);
|
endedRef.current = false;
|
||||||
elapsedRef.current = 0;
|
|
||||||
|
|
||||||
setAwaySeconds(0);
|
const active = localStorage.getItem(LS.active) === "1";
|
||||||
awayRef.current = 0;
|
const storedMode = localStorage.getItem(LS.mode);
|
||||||
|
const storedStartedAt = safeNumber(localStorage.getItem(LS.startedAt), 0);
|
||||||
|
|
||||||
if (duration) setRemaining(duration);
|
const shouldResume = active && storedMode === mode && storedStartedAt > 0;
|
||||||
lastCheckpointRef.current = 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);
|
setIsAway(false);
|
||||||
isAwayRef.current = false;
|
}
|
||||||
|
|
||||||
|
// UI 관련 리셋(기존 로직 유지)
|
||||||
setToast(null);
|
setToast(null);
|
||||||
if (toastTimerRef.current) window.clearTimeout(toastTimerRef.current);
|
if (toastTimerRef.current) window.clearTimeout(toastTimerRef.current);
|
||||||
|
lastCheckpointRef.current = 0;
|
||||||
shownCheckinsRef.current = new Set();
|
shownCheckinsRef.current = new Set();
|
||||||
resetRecenter();
|
resetRecenter();
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
setCheckinOpen(false);
|
||||||
}, [duration]);
|
}, [mode, duration]);
|
||||||
|
|
||||||
// 목표/다음 단계(고정) 로드
|
// 목표/다음 단계(고정) 로드
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setGoal(localStorage.getItem("hushroom:session-goal") ?? "");
|
setGoal(localStorage.getItem(LS.goal) ?? "");
|
||||||
setNextAction(localStorage.getItem("hushroom:session-nextAction") ?? "");
|
setNextAction(localStorage.getItem(LS.nextAction) ?? "");
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// 메인 타이머
|
// now 갱신(탭 전환/잠자기 복귀 시에도 보정)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const id = window.setInterval(() => {
|
const tick = () => setNow(Date.now());
|
||||||
setElapsed((prev) => {
|
|
||||||
const next = prev + 1;
|
|
||||||
elapsedRef.current = next;
|
|
||||||
return next;
|
|
||||||
});
|
|
||||||
|
|
||||||
if (isAwayRef.current) {
|
tick();
|
||||||
setAwaySeconds((prev) => {
|
const id = window.setInterval(tick, 250);
|
||||||
const next = prev + 1;
|
|
||||||
awayRef.current = next;
|
|
||||||
return next;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
if (duration) {
|
const onVis = () => tick();
|
||||||
setRemaining((prev) => {
|
const onFocus = () => tick();
|
||||||
const next = Math.max(0, prev - 1);
|
const onPageShow = () => tick();
|
||||||
if (next === 0 && prev !== 0) {
|
|
||||||
localStorage.setItem("hushroom:session-elapsed", String(duration));
|
document.addEventListener("visibilitychange", onVis);
|
||||||
localStorage.setItem(
|
window.addEventListener("focus", onFocus);
|
||||||
"hushroom:session-away",
|
window.addEventListener("pageshow", onPageShow);
|
||||||
String(awayRef.current),
|
|
||||||
);
|
return () => {
|
||||||
localStorage.setItem("hushroom:session-end-reason", "timed_out");
|
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}`);
|
router.push(`/session/end?mode=${mode}`);
|
||||||
}
|
}, [elapsed, duration, mode, router, awaySeconds]);
|
||||||
return next;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}, 1000);
|
|
||||||
|
|
||||||
return () => window.clearInterval(id);
|
// freeflow 60분 토스트(점프 시에도 “현재 배수” 기준으로 1회만)
|
||||||
}, [duration, mode, router]);
|
|
||||||
|
|
||||||
// freeflow 60분 토스트
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (mode !== "freeflow") return;
|
if (mode !== "freeflow") return;
|
||||||
const mins = Math.floor(elapsed / 60);
|
const mins = Math.floor(elapsed / 60);
|
||||||
@@ -511,76 +478,40 @@ function SessionInner() {
|
|||||||
}
|
}
|
||||||
}, [elapsed, mode, isAway, checkinOpen, mission, checkinTimes]);
|
}, [elapsed, mode, isAway, checkinOpen, mission, checkinTimes]);
|
||||||
|
|
||||||
// 2분 미션 카운트다운
|
// ✅ 자리비움 토글(누적 ms 기반)
|
||||||
useEffect(() => {
|
const toggleAway = () => {
|
||||||
if (!mission) return;
|
const t = Date.now();
|
||||||
|
const next = !isAway;
|
||||||
|
|
||||||
const tick = () => {
|
if (next) {
|
||||||
const left = Math.max(0, Math.ceil((mission.endsAt - Date.now()) / 1000));
|
// away 시작
|
||||||
setMissionLeft(left);
|
awayStartedAtRef.current = t;
|
||||||
if (left === 0) setCheckinStep("done");
|
localStorage.setItem(LS.awayStartedAt, String(t));
|
||||||
};
|
localStorage.setItem(LS.isAway, "1");
|
||||||
|
setIsAway(true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
tick();
|
// away 종료
|
||||||
const id = window.setInterval(tick, 250);
|
if (awayStartedAtRef.current) {
|
||||||
return () => window.clearInterval(id);
|
awayTotalMsRef.current += t - awayStartedAtRef.current;
|
||||||
}, [mission]);
|
localStorage.setItem(LS.awayTotalMs, String(awayTotalMsRef.current));
|
||||||
|
}
|
||||||
const timeMain = useMemo(() => {
|
awayStartedAtRef.current = null;
|
||||||
if (mode === "freeflow") return formatHHMMSS(elapsed);
|
localStorage.removeItem(LS.awayStartedAt);
|
||||||
return formatHHMMSS(remaining);
|
localStorage.setItem(LS.isAway, "0");
|
||||||
}, [elapsed, remaining, mode]);
|
setIsAway(false);
|
||||||
|
|
||||||
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("고정을 해제했어요.");
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const onEnd = () => {
|
const onEnd = () => {
|
||||||
localStorage.setItem(
|
if (endedRef.current) return;
|
||||||
"hushroom:session-elapsed",
|
endedRef.current = true;
|
||||||
String(elapsedRef.current),
|
|
||||||
);
|
localStorage.setItem(LS.endElapsed, String(elapsed));
|
||||||
localStorage.setItem("hushroom:session-away", String(awayRef.current));
|
localStorage.setItem(LS.endAway, String(awaySeconds));
|
||||||
localStorage.setItem("hushroom:session-end-reason", "manual");
|
localStorage.setItem(LS.endReason, "manual");
|
||||||
|
|
||||||
|
localStorage.setItem(LS.active, "0");
|
||||||
router.push(`/session/end?mode=${mode}`);
|
router.push(`/session/end?mode=${mode}`);
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -642,32 +573,6 @@ function SessionInner() {
|
|||||||
</div>
|
</div>
|
||||||
</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>
|
||||||
|
|
||||||
<div className="text-[44px] font-semibold leading-none text-slate-900 tabular-nums">
|
<div className="text-[44px] font-semibold leading-none text-slate-900 tabular-nums">
|
||||||
@@ -675,202 +580,11 @@ function SessionInner() {
|
|||||||
</div>
|
</div>
|
||||||
</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 */}
|
{/* Actions */}
|
||||||
<div className="mt-5 grid grid-cols-2 gap-3">
|
<div className="mt-5 grid grid-cols-2 gap-3">
|
||||||
<button
|
<button
|
||||||
type="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]"
|
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 }}
|
style={{ borderColor: BORDER }}
|
||||||
onMouseEnter={(e) =>
|
onMouseEnter={(e) =>
|
||||||
|
|||||||
Reference in New Issue
Block a user