From 664096257377f095091360f3aaf3743fb2b36883 Mon Sep 17 00:00:00 2001 From: corpi Date: Sat, 14 Feb 2026 02:05:43 +0900 Subject: [PATCH] =?UTF-8?q?refactor:=20=ED=95=AD=ED=95=B4=EC=9D=BC?= =?UTF-8?q?=EC=A7=80=20=EC=9E=91=EC=84=B1=20=EB=AA=A8=EB=8B=AC=EB=A1=9C=20?= =?UTF-8?q?=EB=B3=80=EA=B2=BD=20=EB=B0=8F=20=EA=BE=B9=20=EB=88=8C=EB=9F=AC?= =?UTF-8?q?=EC=84=9C=20=EC=A2=85=EB=A3=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/app/debrief/page.tsx | 21 +- src/app/session/end/page.tsx | 127 ---- src/app/session/layout.tsx | 15 - src/app/session/page.tsx | 648 ------------------ .../flight-session/model/useFlightSession.ts | 77 ++- src/widgets/flight-hud/ui/FlightHudWidget.tsx | 240 ++++++- 6 files changed, 284 insertions(+), 844 deletions(-) delete mode 100644 src/app/session/end/page.tsx delete mode 100644 src/app/session/layout.tsx delete mode 100644 src/app/session/page.tsx diff --git a/src/app/debrief/page.tsx b/src/app/debrief/page.tsx index b67cbea..151a433 100644 --- a/src/app/debrief/page.tsx +++ b/src/app/debrief/page.tsx @@ -11,7 +11,6 @@ export default function DebriefPage() { const [status, setStatus] = useState(null); const [progress, setProgress] = useState(''); - const [nextAction, setNextAction] = useState(''); useEffect(() => { const current = getCurrentVoyage(); @@ -29,7 +28,6 @@ export default function DebriefPage() { ...voyage, status: status, debriefProgress: progress, - nextAction: nextAction, endedAt: voyage.endedAt || Date.now(), // Fallback if missed in flight }; @@ -46,9 +44,9 @@ export default function DebriefPage() { if (!voyage) return null; const statusOptions: { value: VoyageStatus; label: string; desc: string }[] = [ - { value: 'completed', label: '✅ 계획대로', desc: '목표를 달성했습니다' }, - { value: 'partial', label: '🌓 부분 진행', desc: '절반의 성공입니다' }, - { value: 'reoriented', label: '🧭 방향 재설정', desc: '새로운 발견을 했습니다' }, + { value: 'completed', label: '계획대로', desc: '목표를 달성했습니다' }, + { value: 'partial', label: '부분 진행', desc: '절반의 성공입니다' }, + { value: 'reoriented', label: '방향 재설정', desc: '새로운 발견을 했습니다' }, ]; return ( @@ -97,19 +95,6 @@ export default function DebriefPage() { /> - {/* Question 3: Next Action */} -
- - setNextAction(e.target.value)} - placeholder="예: 본문 1챕터 초안 쓰기" - className="w-full bg-slate-900/30 border border-slate-800 rounded-lg px-4 py-3 text-slate-200 focus:border-indigo-500 focus:ring-1 focus:ring-indigo-500 outline-none transition-all" - /> -
- - - - - - ); -} diff --git a/src/app/session/layout.tsx b/src/app/session/layout.tsx deleted file mode 100644 index e9e8a5b..0000000 --- a/src/app/session/layout.tsx +++ /dev/null @@ -1,15 +0,0 @@ -// app/session/layout.tsx -import type { Metadata } from "next"; - -export const metadata: Metadata = { - title: "Session", - description: "Focus session in progress.", -}; - -export default function SessionLayout({ - children, -}: { - children: React.ReactNode; -}) { - return <>{children}; -} diff --git a/src/app/session/page.tsx b/src/app/session/page.tsx deleted file mode 100644 index 241f612..0000000 --- a/src/app/session/page.tsx +++ /dev/null @@ -1,648 +0,0 @@ -// app/session/page.tsx -"use client"; - -import { useRouter, useSearchParams } from "next/navigation"; -import { Suspense, useEffect, useMemo, useRef, useState } from "react"; - -type Mode = "freeflow" | "sprint" | "deepwork"; -type PresenceStatus = "focus" | "away"; - -type Participant = { - id: string; - status: PresenceStatus; - lastSeen: number; // ms - 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"; -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"; -} - -function modeLabel(mode: Mode) { - if (mode === "sprint") return "스프린트"; - if (mode === "deepwork") return "딥워크"; - return "프리플로우"; -} - -function modeDurationSeconds(mode: Mode) { - if (mode === "sprint") return 25 * 60; - if (mode === "deepwork") return 90 * 60; - return null; // freeflow -} - -function formatHHMMSS(totalSeconds: number) { - const s = Math.max(0, Math.floor(totalSeconds)); - const hh = String(Math.floor(s / 3600)).padStart(2, "0"); - const mm = String(Math.floor((s % 3600) / 60)).padStart(2, "0"); - const ss = String(s % 60).padStart(2, "0"); - return `${hh}:${mm}:${ss}`; -} - -function safeNumber(v: string | null, fallback = 0) { - const n = Number(v ?? ""); - return Number.isFinite(n) ? n : fallback; -} - -function newId() { - return (crypto?.randomUUID?.() ?? `${Date.now()}-${Math.random()}`) - .replace(/[^a-zA-Z0-9]/g, "") - .slice(0, 24); -} - -function useLocalPresence(roomKey: string, status: PresenceStatus) { - const selfId = useMemo(() => { - const key = `hushroom:selfId:${roomKey}`; - const existing = sessionStorage.getItem(key); - if (existing) return existing; - - const id = (crypto?.randomUUID?.() ?? `${Date.now()}-${Math.random()}`) - .replace(/[^a-zA-Z0-9]/g, "") - .slice(0, 16); - - sessionStorage.setItem(key, id); - return id; - }, [roomKey]); - - const [participants, setParticipants] = useState(() => [ - { id: selfId, status, lastSeen: Date.now(), isSelf: true }, - ]); - - const participantsRef = useRef>>( - new Map([[selfId, { id: selfId, status, lastSeen: Date.now() }]]), - ); - - const channelRef = useRef(null); - const heartbeatRef = useRef(null); - const cleanupRef = useRef(null); - - const publish = (payload: any) => { - if (channelRef.current) { - channelRef.current.postMessage(payload); - return; - } - try { - localStorage.setItem( - `hushroom:presence:${roomKey}`, - JSON.stringify({ ...payload, _nonce: Math.random() }), - ); - } catch {} - }; - - const syncStateToReact = () => { - const now = Date.now(); - const arr: Participant[] = []; - participantsRef.current.forEach((p, id) => { - arr.push({ - id, - status: p.status, - lastSeen: p.lastSeen, - 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; - }); - - const self = participantsRef.current.get(selfId); - if (self) self.lastSeen = now; - - setParticipants(arr); - }; - - useEffect(() => { - if ("BroadcastChannel" in window) { - const bc = new BroadcastChannel(`hushroom-presence:${roomKey}`); - channelRef.current = bc; - bc.onmessage = (ev) => { - const msg = ev.data; - if (!msg || msg.roomKey !== roomKey) return; - if (!msg.from) return; - - if (msg.type === "ping") { - participantsRef.current.set(msg.from, { - id: msg.from, - status: msg.status as PresenceStatus, - lastSeen: msg.ts ?? Date.now(), - }); - syncStateToReact(); - } - - if (msg.type === "leave") { - participantsRef.current.delete(msg.from); - syncStateToReact(); - } - }; - } else { - const onStorage = (e: StorageEvent) => { - if (e.key !== `hushroom:presence:${roomKey}` || !e.newValue) return; - try { - const msg = JSON.parse(e.newValue); - if (!msg || msg.roomKey !== roomKey) return; - - if (msg.type === "ping") { - participantsRef.current.set(msg.from, { - id: msg.from, - status: msg.status as PresenceStatus, - lastSeen: msg.ts ?? Date.now(), - }); - syncStateToReact(); - } - if (msg.type === "leave") { - participantsRef.current.delete(msg.from); - syncStateToReact(); - } - } catch {} - }; - return () => window.removeEventListener("storage", onStorage); - } - - return () => { - channelRef.current?.close(); - channelRef.current = null; - }; - }, [roomKey]); - - useEffect(() => { - const now = Date.now(); - participantsRef.current.set(selfId, { id: selfId, status, lastSeen: now }); - publish({ type: "ping", roomKey, from: selfId, status, ts: now }); - syncStateToReact(); - }, [status, roomKey, selfId]); - - useEffect(() => { - heartbeatRef.current = window.setInterval(() => { - const now = Date.now(); - participantsRef.current.set(selfId, { - id: selfId, - status, - lastSeen: now, - }); - publish({ type: "ping", roomKey, from: selfId, status, ts: now }); - syncStateToReact(); - }, 2000); - - cleanupRef.current = window.setInterval(() => { - const now = Date.now(); - const STALE_MS = 9000; - let changed = false; - - participantsRef.current.forEach((p, id) => { - if (id === selfId) return; - if (now - p.lastSeen > STALE_MS) { - participantsRef.current.delete(id); - changed = true; - } - }); - - if (changed) syncStateToReact(); - }, 4000); - - const onBeforeUnload = () => { - publish({ type: "leave", roomKey, from: selfId, ts: Date.now() }); - }; - window.addEventListener("beforeunload", onBeforeUnload); - - return () => { - if (heartbeatRef.current) window.clearInterval(heartbeatRef.current); - if (cleanupRef.current) window.clearInterval(cleanupRef.current); - window.removeEventListener("beforeunload", onBeforeUnload); - publish({ type: "leave", roomKey, from: selfId, ts: Date.now() }); - }; - }, [roomKey, selfId, status]); - - return { participants, selfId }; -} - -export default function Page() { - return ( - - } - > - - - ); -} - -function SessionInner() { - const router = useRouter(); - const params = useSearchParams(); - const mode = useMemo(() => clampMode(params.get("mode")), [params]); - const duration = useMemo(() => modeDurationSeconds(mode), [mode]); - - 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 awayTotalMsRef = useRef(0); - const awayStartedAtRef = useRef(null); - - // presence (로컬) - const presenceStatus: PresenceStatus = isAway ? "away" : "focus"; - const roomKey = "lounge"; - const { participants } = useLocalPresence(roomKey, presenceStatus); - - // toast - const [toast, setToast] = useState(null); - const toastTimerRef = useRef(null); - 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); - }; - - // 세션 시작/복구 (mode가 바뀌면 새 세션) - useEffect(() => { - endedRef.current = false; - - const active = localStorage.getItem(LS.active) === "1"; - const storedMode = localStorage.getItem(LS.mode); - const storedStartedAt = safeNumber(localStorage.getItem(LS.startedAt), 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); - } - - // UI 관련 리셋(기존 로직 유지) - setToast(null); - if (toastTimerRef.current) window.clearTimeout(toastTimerRef.current); - lastCheckpointRef.current = 0; - shownCheckinsRef.current = new Set(); - resetRecenter(); - setCheckinOpen(false); - }, [mode, duration]); - - // 목표/다음 단계(고정) 로드 - useEffect(() => { - setGoal(localStorage.getItem(LS.goal) ?? ""); - setNextAction(localStorage.getItem(LS.nextAction) ?? ""); - }, []); - - // now 갱신(탭 전환/잠자기 복귀 시에도 보정) - useEffect(() => { - const tick = () => setNow(Date.now()); - - tick(); - const id = window.setInterval(tick, 250); - - 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}`); - }, [elapsed, duration, mode, router, awaySeconds]); - - // freeflow 60분 토스트(점프 시에도 “현재 배수” 기준으로 1회만) - useEffect(() => { - if (mode !== "freeflow") return; - const mins = Math.floor(elapsed / 60); - if (mins > 0 && mins % 60 === 0 && mins !== lastCheckpointRef.current) { - lastCheckpointRef.current = mins; - showToast(`${mins}분 지났어요`); - } - }, [elapsed, mode]); - - // 체크인(ask) 트리거: freeflow 제외 / 자리비움 제외 / 이미 열림 제외 / 미션 중 제외 - useEffect(() => { - 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]); - - // ✅ 자리비움 토글(누적 ms 기반) - const toggleAway = () => { - const t = Date.now(); - const next = !isAway; - - if (next) { - // away 시작 - awayStartedAtRef.current = t; - localStorage.setItem(LS.awayStartedAt, String(t)); - localStorage.setItem(LS.isAway, "1"); - setIsAway(true); - return; - } - - // 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 = () => { - 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}`); - }; - - return ( -
-
-
-
-
- hushroom -
-
- 집중 {Math.floor(focusSeconds / 60)}분 · 자리비움{" "} - {Math.floor(awaySeconds / 60)}분 -
-
- - -
-
- -
- {toast && ( -
- {toast} -
- )} - - {/* Timer */} -
-
-
-
-
- {modeLabel(mode)} -
-
- {mode === "freeflow" - ? "원할 때 종료" - : "한 번 실행되고 끝나면 요약으로 이동"} -
-
-
- - {/* Goal */} - {goal && ( -
-
- 이번 목표 -
-
- {goal} -
-
- )} -
- -
- {timeMain} -
-
- - {/* Actions */} -
- - - -
-
-
- ); -} - -function PresenceDots({ participants }: { participants: Participant[] }) { - const MAX = 12; - const visible = participants.slice(0, MAX); - const extra = Math.max(0, participants.length - visible.length); - - return ( -
- {visible.map((p) => ( - - ))} - {extra > 0 && ( -
+{extra}
- )} -
- ); -} - -function Dot({ status, isSelf }: { status: PresenceStatus; isSelf: boolean }) { - const base = status === "away" ? "bg-slate-900/20" : "bg-slate-900/60"; - const ring = isSelf ? "ring-2 ring-[#2F6FED]" : "ring-0"; - - return ( -
- ); -} diff --git a/src/features/flight-session/model/useFlightSession.ts b/src/features/flight-session/model/useFlightSession.ts index dacb9ee..efa161b 100644 --- a/src/features/flight-session/model/useFlightSession.ts +++ b/src/features/flight-session/model/useFlightSession.ts @@ -1,7 +1,7 @@ import { useEffect, useRef, useState } from 'react'; import { useRouter } from 'next/navigation'; -import { getCurrentVoyage, getPreferences, saveCurrentVoyage } from '@/shared/lib/store'; +import { getCurrentVoyage, saveCurrentVoyage } from '@/shared/lib/store'; import { Voyage } from '@/shared/types'; const getVoyageFromStore = () => { @@ -17,18 +17,38 @@ const getEndTime = (voyage: Voyage | null) => { return voyage.startedAt + voyage.durationMinutes * 60 * 1000; }; +const getInitialTimerSeconds = (voyage: Voyage | null) => { + if (!voyage) return 0; + + if (voyage.durationMinutes === 0) { + return Math.max(0, Math.floor((Date.now() - voyage.startedAt) / 1000)); + } + + const endTime = getEndTime(voyage); + if (!endTime) return 0; + return Math.max(0, Math.ceil((endTime - Date.now()) / 1000)); +}; + +const formatHHMMSS = (totalSeconds: number) => { + const hours = Math.floor(totalSeconds / 3600); + const minutes = Math.floor((totalSeconds % 3600) / 60); + const seconds = totalSeconds % 60; + + return `${hours.toString().padStart(2, '0')}:${minutes + .toString() + .padStart(2, '0')}:${seconds.toString().padStart(2, '0')}`; +}; + export function useFlightSession() { const router = useRouter(); const [voyage] = useState(() => getVoyageFromStore()); - const [timeLeft, setTimeLeft] = useState(() => { - const current = getVoyageFromStore(); - const endTime = getEndTime(current); - if (!endTime) return 0; - return Math.max(0, Math.ceil((endTime - Date.now()) / 1000)); - }); + const [timeLeft, setTimeLeft] = useState(() => + getInitialTimerSeconds(getVoyageFromStore()), + ); const [isPaused, setIsPaused] = useState(false); - const [hideSeconds] = useState(() => getPreferences().hideSeconds); const endTimeRef = useRef(getEndTime(getVoyageFromStore())); + const pausedElapsedMsRef = useRef(0); + const pausedAtMsRef = useRef(null); useEffect(() => { if (voyage) return; @@ -40,6 +60,12 @@ export function useFlightSession() { if (!voyage || isPaused) return; const interval = setInterval(() => { + if (voyage.durationMinutes === 0) { + const elapsedMs = Date.now() - voyage.startedAt - pausedElapsedMsRef.current; + setTimeLeft(Math.max(0, Math.floor(elapsedMs / 1000))); + return; + } + const diff = endTimeRef.current - Date.now(); if (diff <= 0) { @@ -55,6 +81,21 @@ export function useFlightSession() { }, [voyage, isPaused]); const handlePauseToggle = () => { + if (voyage?.durationMinutes === 0) { + if (isPaused) { + if (pausedAtMsRef.current !== null) { + pausedElapsedMsRef.current += Date.now() - pausedAtMsRef.current; + } + pausedAtMsRef.current = null; + setIsPaused(false); + return; + } + + pausedAtMsRef.current = Date.now(); + setIsPaused(true); + return; + } + if (isPaused) { endTimeRef.current = Date.now() + timeLeft * 1000; setIsPaused(false); @@ -65,33 +106,23 @@ export function useFlightSession() { }; const handleFinish = () => { - if (!voyage) return; + if (!voyage) return null; const endedVoyage: Voyage = { ...voyage, - endedAt: Date.now(), + endedAt: voyage.endedAt || Date.now(), }; saveCurrentVoyage(endedVoyage); - router.push('/debrief'); + return endedVoyage; }; - const formatTime = (seconds: number) => { - const minutes = Math.floor(seconds / 60); - const remainingSeconds = seconds % 60; - - if (hideSeconds) { - return `${minutes}m`; - } - - return `${minutes.toString().padStart(2, '0')}:${remainingSeconds - .toString() - .padStart(2, '0')}`; - }; + const formatTime = (seconds: number) => formatHHMMSS(seconds); return { voyage, timeLeft, + isCountdownCompleted: Boolean(voyage && voyage.durationMinutes > 0 && timeLeft === 0), isPaused, formattedTime: formatTime(timeLeft), handlePauseToggle, diff --git a/src/widgets/flight-hud/ui/FlightHudWidget.tsx b/src/widgets/flight-hud/ui/FlightHudWidget.tsx index 50468b8..3809dbc 100644 --- a/src/widgets/flight-hud/ui/FlightHudWidget.tsx +++ b/src/widgets/flight-hud/ui/FlightHudWidget.tsx @@ -1,14 +1,125 @@ -import { useFlightSession } from '@/features/flight-session/model/useFlightSession'; +import { useRouter } from "next/navigation"; +import { FormEvent, useEffect, useRef, useState } from "react"; + +import { + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +import { useFlightSession } from "@/features/flight-session/model/useFlightSession"; +import { saveCurrentVoyage, saveToHistory } from "@/shared/lib/store"; +import { Voyage, VoyageStatus } from "@/shared/types"; + +const statusOptions: { value: VoyageStatus; label: string; desc: string }[] = [ + { value: "completed", label: "계획대로", desc: "목표를 달성했습니다" }, + { value: "partial", label: "부분 진행", desc: "절반의 성과를 만들었습니다" }, + { + value: "reoriented", + label: "방향 재설정", + desc: "우선순위를 새로 정했습니다", + }, +]; +const FINISH_HOLD_MS = 1000; export function FlightHudWidget() { + const router = useRouter(); const { voyage, isPaused, formattedTime, - timeLeft, + isCountdownCompleted, handlePauseToggle, handleFinish, } = useFlightSession(); + const [isDebriefOpen, setIsDebriefOpen] = useState(false); + const [finishedVoyage, setFinishedVoyage] = useState(null); + const [status, setStatus] = useState(null); + const [progress, setProgress] = useState(""); + const [holdProgress, setHoldProgress] = useState(0); + const holdStartAtRef = useRef(null); + const holdRafRef = useRef(null); + const isHoldCompletedRef = useRef(false); + + const openDebriefModal = () => { + const endedVoyage = handleFinish(); + if (!endedVoyage) return; + + setFinishedVoyage(endedVoyage); + setStatus(null); + setProgress(""); + setIsDebriefOpen(true); + }; + + const handleDebriefSubmit = (event: FormEvent) => { + event.preventDefault(); + if (!status || !finishedVoyage) return; + + const finalVoyage: Voyage = { + ...finishedVoyage, + status, + debriefProgress: progress, + }; + + saveToHistory(finalVoyage); + saveCurrentVoyage(null); + setIsDebriefOpen(false); + router.push("/log"); + }; + + const resetHold = () => { + if (holdRafRef.current !== null) { + cancelAnimationFrame(holdRafRef.current); + holdRafRef.current = null; + } + holdStartAtRef.current = null; + isHoldCompletedRef.current = false; + setHoldProgress(0); + }; + + const openDebriefByHold = () => { + isHoldCompletedRef.current = true; + setHoldProgress(1); + openDebriefModal(); + resetHold(); + }; + + const tickHoldProgress = (timestamp: number) => { + if (holdStartAtRef.current === null) return; + + const elapsed = timestamp - holdStartAtRef.current; + const nextProgress = Math.min(1, elapsed / FINISH_HOLD_MS); + setHoldProgress(nextProgress); + + if (nextProgress >= 1) { + openDebriefByHold(); + return; + } + + holdRafRef.current = requestAnimationFrame(tickHoldProgress); + }; + + const startHoldToFinish = () => { + if (isDebriefOpen) return; + + resetHold(); + holdStartAtRef.current = performance.now(); + holdRafRef.current = requestAnimationFrame(tickHoldProgress); + }; + + const cancelHoldToFinish = () => { + if (isHoldCompletedRef.current) return; + resetHold(); + }; + + useEffect(() => { + return () => { + if (holdRafRef.current !== null) { + cancelAnimationFrame(holdRafRef.current); + } + }; + }, []); if (!voyage) return null; @@ -16,18 +127,25 @@ export function FlightHudWidget() { <>
- {voyage.routeName} · {isPaused ? '일시정지' : '순항 중'} + {voyage.routeName} · {isPaused ? "일시정지" : "순항 중"}
{formattedTime}
-
- “{voyage.missionText}” +
+
+

+ 이번 항해 목표 +

+

+ {voyage.missionText} +

+
@@ -35,15 +153,111 @@ export function FlightHudWidget() { onClick={handlePauseToggle} className="rounded-full border border-slate-600 bg-slate-900/50 px-8 py-3 text-sm font-bold uppercase tracking-wide text-slate-300 backdrop-blur transition-all hover:border-slate-400 hover:bg-slate-800/80 hover:text-white" > - {isPaused ? '다시 시작' : '일시정지'} - - +
+ +
+ + + + + + 이번 항해를 정리하세요 + + + 짧게 기록하고 항해일지에 저장합니다. + + + +
+
+ +
+ {statusOptions.map((opt) => ( + + ))} +
+
+ +
+ + setProgress(event.target.value)} + placeholder="예: 기획안 목차 구성 완료" + className="w-full rounded-lg border border-slate-800 bg-slate-900/30 px-4 py-3 text-slate-200 outline-none transition-all focus:border-indigo-500 focus:ring-1 focus:ring-indigo-500" + /> +
+ +
+ + +
+
+
+
); }