From d3c5ad1f4d868567c6dcd636c0f2523fbd78bf6a Mon Sep 17 00:00:00 2001 From: corpi Date: Mon, 9 Feb 2026 14:25:14 +0900 Subject: [PATCH] =?UTF-8?q?refactor:=20=ED=9D=94=EB=93=A4=EB=A6=BC=20?= =?UTF-8?q?=EA=B8=B0=EB=8A=A5=20=EC=82=AD=EC=A0=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/app/page.tsx | 137 ++++++---- src/app/session/page.tsx | 562 ++++++++++++++++++++++++++++++++++++--- 2 files changed, 621 insertions(+), 78 deletions(-) diff --git a/src/app/page.tsx b/src/app/page.tsx index 11a2a44..b93acc1 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -18,6 +18,9 @@ import { Separator } from "@/components/ui/separator"; type Mode = "freeflow" | "sprint" | "deepwork"; +const PRIMARY = "#2F6FED"; +const PRIMARY_HOVER = "#295FD1"; + function modeLabel(mode: Mode) { switch (mode) { case "freeflow": @@ -29,6 +32,17 @@ function modeLabel(mode: Mode) { } } +function modeMeta(mode: Mode) { + if (mode === "freeflow") return "무제한"; + if (mode === "sprint") return "25분"; + return "90분"; +} + +function startLabel(mode: Mode) { + if (mode === "freeflow") return "집중 시작"; + return `집중 시작 (${modeMeta(mode)})`; +} + export default function HomePage() { const router = useRouter(); @@ -36,29 +50,28 @@ export default function HomePage() { const [mode, setMode] = useState(null); const [goal, setGoal] = useState(""); - const meta = useMemo(() => { - if (!mode) return ""; - if (mode === "freeflow") return "무제한"; - if (mode === "sprint") return "25분"; - return "90분"; - }, [mode]); + const meta = useMemo(() => (mode ? modeMeta(mode) : ""), [mode]); const go = useCallback( - (mode: Mode, goal?: string) => { + (m: Mode, g?: string) => { const params = new URLSearchParams(); - params.set("mode", mode); + params.set("mode", m); - if (goal && goal.trim().length > 0) { - localStorage.setItem("hushroom:session-goal", goal.trim()); - } + if (g && g.trim()) + localStorage.setItem("hushroom:session-goal", g.trim()); + else localStorage.removeItem("hushroom:session-goal"); + // nextAction은 사용하지 않음 + localStorage.removeItem("hushroom:session-nextAction"); + + localStorage.setItem("hushroom:last-mode", m); router.push(`/session?${params.toString()}`); }, [router], ); - const openDialog = (mode: Mode) => { - setMode(mode); + const openDialog = (m: Mode) => { + setMode(m); setGoal(""); setOpen(true); }; @@ -75,13 +88,17 @@ export default function HomePage() {
hushroom
+
+ 딱 한 가지 목표. 바로 시작. +
+ {/* ✅ 파란 CTA = 프리플로우 */}
자유 세션
- 시간 제한 없이, 원할 때 종료 (60분 마다 노크합니다.) + 시간 제한 없이, 원할 때 종료 (60분마다 가볍게 노크)
@@ -89,7 +106,7 @@ export default function HomePage() { type="button" onClick={() => openDialog("freeflow")} className="h-auto w-full items-start justify-start whitespace-normal rounded-3xl bg-[#2F6FED] - px-8 py-6 text-left text-white shadow-sm transition active:scale-[0.99] hover:bg-[#295FD1]" + px-8 py-6 text-left text-white shadow-sm transition active:scale-[0.99] hover:bg-[#295FD1]" >
@@ -101,17 +118,21 @@ export default function HomePage() {
-
- 시간 고정 세션 -
+
몰입 블록
-
- 한 번 실행되고 끝나면 요약으로 이동 + {/* ✅ 버튼 위에 설명 (버튼 안에 설명 X) */} +
+
+ 시간 고정 세션 +
+
+ 한 번 실행되고 끝나면 요약으로 이동 +
- {/* Sprint / Deepwork */} + {/* ✅ row(2열) */}
{title}
@@ -187,32 +208,58 @@ function SessionGoalDialog({ return ( - - 세션 목표 설정 -
{title}
-
+
{ + e.preventDefault(); + onStart(); + }} + > + + 세션 목표 설정 +
{title}
+
-
- setGoal(e.target.value)} - placeholder="목표를 입력하세요" - className="text-lg" - autoFocus - /> -
- 짧게 적을수록 좋아요. 예: “이력서 1페이지”, “문제 10개” +
+ setGoal(e.target.value)} + placeholder="지금 할 한 가지를 한 줄로 적어주세요 (선택)" + className="text-lg focus-visible:ring-2" + autoFocus + onKeyDown={(e) => { + if (e.key === "Enter" && (e.nativeEvent as any).isComposing) { + e.preventDefault(); + } + }} + /> +
+ 짧게 적을수록 좋아요. 끝이 보이게. +
-
- - - - + + + + +
); diff --git a/src/app/session/page.tsx b/src/app/session/page.tsx index cbe626d..3899f12 100644 --- a/src/app/session/page.tsx +++ b/src/app/session/page.tsx @@ -14,6 +14,14 @@ type Participant = { isSelf: boolean; }; +type RecenterReason = "distracted" | "stuck" | "tired" | "overwhelmed"; + +type RecenterOption = { + id: string; + label: string; // 화면 문구(2분 미션) + pinText: string; // "다음 단계로 고정" 시 저장될 짧은 문구 +}; + const BG = "#E9EEF6"; const BORDER = "#C9D7F5"; const PRIMARY = "#2F6FED"; @@ -45,14 +53,120 @@ 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 "부담"; + } +} + +/** + * ✅ 리라이트 원칙(목적/이유 반영) + * - 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 useLocalPresence(roomKey: string, status: PresenceStatus) { const selfId = useMemo(() => { - // 탭 단위 고유 ID const key = `hushroom:selfId:${roomKey}`; const existing = sessionStorage.getItem(key); if (existing) return existing; - // 짧고 충돌 확률 낮은 ID const id = (crypto?.randomUUID?.() ?? `${Date.now()}-${Math.random()}`) .replace(/[^a-zA-Z0-9]/g, "") .slice(0, 16); @@ -74,12 +188,10 @@ function useLocalPresence(roomKey: string, status: PresenceStatus) { const cleanupRef = useRef(null); const publish = (payload: any) => { - // BroadcastChannel 우선 if (channelRef.current) { channelRef.current.postMessage(payload); return; } - // fallback: localStorage event try { localStorage.setItem( `hushroom:presence:${roomKey}`, @@ -99,14 +211,13 @@ function useLocalPresence(roomKey: string, status: PresenceStatus) { isSelf: id === selfId, }); }); - // 안정적으로 정렬(자기 자신 먼저, 그 다음 최근 순) + arr.sort((a, b) => { if (a.isSelf && !b.isSelf) return -1; if (!a.isSelf && b.isSelf) return 1; return b.lastSeen - a.lastSeen; }); - // self 상태 최신화(React state 변경 시 반영) const self = participantsRef.current.get(selfId); if (self) self.lastSeen = now; @@ -114,7 +225,6 @@ function useLocalPresence(roomKey: string, status: PresenceStatus) { }; useEffect(() => { - // 채널 세팅 if ("BroadcastChannel" in window) { const bc = new BroadcastChannel(`hushroom-presence:${roomKey}`); channelRef.current = bc; @@ -138,7 +248,6 @@ function useLocalPresence(roomKey: string, status: PresenceStatus) { } }; } else { - // fallback: storage event const onStorage = (e: StorageEvent) => { if (e.key !== `hushroom:presence:${roomKey}` || !e.newValue) return; try { @@ -171,7 +280,6 @@ function useLocalPresence(roomKey: string, status: PresenceStatus) { }, [roomKey]); useEffect(() => { - // 내 상태 업데이트 + ping const now = Date.now(); participantsRef.current.set(selfId, { id: selfId, status, lastSeen: now }); publish({ type: "ping", roomKey, from: selfId, status, ts: now }); @@ -180,7 +288,6 @@ function useLocalPresence(roomKey: string, status: PresenceStatus) { }, [status, roomKey, selfId]); useEffect(() => { - // heartbeat: 2초마다 ping heartbeatRef.current = window.setInterval(() => { const now = Date.now(); participantsRef.current.set(selfId, { @@ -192,10 +299,9 @@ function useLocalPresence(roomKey: string, status: PresenceStatus) { syncStateToReact(); }, 2000); - // cleanup: 4초마다 오래된 참가자 제거(탭 닫힘 대비) cleanupRef.current = window.setInterval(() => { const now = Date.now(); - const STALE_MS = 9000; // 9초 이상 ping 없으면 제거 + const STALE_MS = 9000; let changed = false; participantsRef.current.forEach((p, id) => { @@ -233,48 +339,130 @@ export default function SessionPage() { const duration = useMemo(() => modeDurationSeconds(mode), [mode]); const [goal, setGoal] = useState(""); + const [nextAction, setNextAction] = useState(""); + const [isAway, setIsAway] = useState(false); + const [awaySeconds, setAwaySeconds] = useState(0); // presence (로컬) const presenceStatus: PresenceStatus = isAway ? "away" : "focus"; - const roomKey = "lounge"; // 나중에 방 분리하면 여기만 바꾸면 됨 + 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); - // freeflow checkpoint every 60 minutes - const lastCheckpointRef = useRef(0); - const showToast = (msg: string) => { setToast(msg); if (toastTimerRef.current) window.clearTimeout(toastTimerRef.current); toastTimerRef.current = window.setTimeout(() => setToast(null), 8000); }; + // freeflow checkpoint every 60 minutes + const lastCheckpointRef = useRef(0); + + // check-in (배너) + const [checkinOpen, setCheckinOpen] = useState(false); + const [checkinStep, setCheckinStep] = useState< + "ask" | "reason" | "pick" | "running" | "done" + >("ask"); + const shownCheckinsRef = useRef>(new Set()); + + const [recenterReason, setRecenterReason] = useState( + null, + ); + + const [mission, setMission] = useState<{ + option: RecenterOption; + endsAt: number; + } | null>(null); + + const [missionLeft, setMissionLeft] = useState(0); + + const checkinTimes = useMemo(() => { + if (mode === "sprint") return [12 * 60]; + if (mode === "deepwork") return [30 * 60, 60 * 60]; + return []; + }, [mode]); + + const resetRecenter = () => { + setRecenterReason(null); + setMission(null); + setMissionLeft(0); + setCheckinStep("ask"); + setCheckinOpen(false); + }; + + // 세션 초기화(모드/길이 바뀔 때) useEffect(() => { setElapsed(0); + elapsedRef.current = 0; + + setAwaySeconds(0); + awayRef.current = 0; + if (duration) setRemaining(duration); lastCheckpointRef.current = 0; + setIsAway(false); + isAwayRef.current = false; + setToast(null); if (toastTimerRef.current) window.clearTimeout(toastTimerRef.current); + + shownCheckinsRef.current = new Set(); + resetRecenter(); + // eslint-disable-next-line react-hooks/exhaustive-deps }, [duration]); + // 목표/다음 단계(고정) 로드 + useEffect(() => { + setGoal(localStorage.getItem("hushroom:session-goal") ?? ""); + setNextAction(localStorage.getItem("hushroom:session-nextAction") ?? ""); + }, []); + + // 메인 타이머 useEffect(() => { const id = window.setInterval(() => { - setElapsed((prev) => prev + 1); + setElapsed((prev) => { + const next = prev + 1; + elapsedRef.current = next; + return next; + }); + + if (isAwayRef.current) { + setAwaySeconds((prev) => { + const next = prev + 1; + awayRef.current = next; + return next; + }); + } if (duration) { setRemaining((prev) => { const next = Math.max(0, prev - 1); if (next === 0 && prev !== 0) { - router.push(`/session/end?mode=${mode}&elapsed=${duration}`); + 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; }); @@ -284,6 +472,7 @@ export default function SessionPage() { return () => window.clearInterval(id); }, [duration, mode, router]); + // freeflow 60분 토스트 useEffect(() => { if (mode !== "freeflow") return; const mins = Math.floor(elapsed / 60); @@ -293,30 +482,115 @@ export default function SessionPage() { } }, [elapsed, mode]); + // 체크인(ask) 트리거: freeflow 제외 / 자리비움 제외 / 이미 열림 제외 / 미션 중 제외 useEffect(() => { - const goal = localStorage.getItem("hushroom:session-goal") ?? ""; - setGoal(goal); - }, []); + if (mode === "freeflow") return; + if (isAway) return; + if (checkinOpen) return; + if (mission) return; + + for (const t of checkinTimes) { + if (elapsed >= t && !shownCheckinsRef.current.has(t)) { + shownCheckinsRef.current.add(t); + setCheckinStep("ask"); + setCheckinOpen(true); + break; + } + } + }, [elapsed, mode, isAway, checkinOpen, mission, checkinTimes]); + + // 2분 미션 카운트다운 + useEffect(() => { + if (!mission) return; + + const tick = () => { + const left = Math.max(0, Math.ceil((mission.endsAt - Date.now()) / 1000)); + setMissionLeft(left); + if (left === 0) setCheckinStep("done"); + }; + + 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("고정을 해제했어요."); + }; + const onEnd = () => { - localStorage.setItem("hushroom:session-elapsed", elapsed.toString()); + localStorage.setItem( + "hushroom:session-elapsed", + String(elapsedRef.current), + ); + localStorage.setItem("hushroom:session-away", String(awayRef.current)); + localStorage.setItem("hushroom:session-end-reason", "manual"); router.push(`/session/end?mode=${mode}`); }; return (
-
- hushroom +
+
+
+ hushroom +
+
+ 집중 {Math.floor(focusSeconds / 60)}분 · 자리비움{" "} + {Math.floor(awaySeconds / 60)}분 +
+
+ +
-
+
{toast && (
{toast} @@ -329,13 +603,17 @@ export default function SessionPage() { style={{ borderColor: BORDER }} >
-
- {modeLabel(mode)} -
-
- {mode === "freeflow" - ? "원할 때 종료" - : "한 번 실행되고 끝나면 요약으로 이동"} +
+
+
+ {modeLabel(mode)} +
+
+ {mode === "freeflow" + ? "원할 때 종료" + : "한 번 실행되고 끝나면 요약으로 이동"} +
+
{/* Goal */} @@ -347,17 +625,235 @@ export default function SessionPage() {
이번 목표
-
+
{goal}
)} + + {/* Next action (고정) */} + {nextAction && ( +
+
+
+ 다음 단계(고정) +
+ +
+ +
+ {nextAction} +
+
+ )}
+
{timeMain}
+ {/* 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 */}