refactor: 항해일지 작성 모달로 변경 및 꾹 눌러서 종료
This commit is contained in:
@@ -11,7 +11,6 @@ export default function DebriefPage() {
|
|||||||
|
|
||||||
const [status, setStatus] = useState<VoyageStatus | null>(null);
|
const [status, setStatus] = useState<VoyageStatus | null>(null);
|
||||||
const [progress, setProgress] = useState('');
|
const [progress, setProgress] = useState('');
|
||||||
const [nextAction, setNextAction] = useState('');
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const current = getCurrentVoyage();
|
const current = getCurrentVoyage();
|
||||||
@@ -29,7 +28,6 @@ export default function DebriefPage() {
|
|||||||
...voyage,
|
...voyage,
|
||||||
status: status,
|
status: status,
|
||||||
debriefProgress: progress,
|
debriefProgress: progress,
|
||||||
nextAction: nextAction,
|
|
||||||
endedAt: voyage.endedAt || Date.now(), // Fallback if missed in flight
|
endedAt: voyage.endedAt || Date.now(), // Fallback if missed in flight
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -46,9 +44,9 @@ export default function DebriefPage() {
|
|||||||
if (!voyage) return null;
|
if (!voyage) return null;
|
||||||
|
|
||||||
const statusOptions: { value: VoyageStatus; label: string; desc: string }[] = [
|
const statusOptions: { value: VoyageStatus; label: string; desc: string }[] = [
|
||||||
{ value: 'completed', label: '✅ 계획대로', desc: '목표를 달성했습니다' },
|
{ value: 'completed', label: '계획대로', desc: '목표를 달성했습니다' },
|
||||||
{ value: 'partial', label: '🌓 부분 진행', desc: '절반의 성공입니다' },
|
{ value: 'partial', label: '부분 진행', desc: '절반의 성공입니다' },
|
||||||
{ value: 'reoriented', label: '🧭 방향 재설정', desc: '새로운 발견을 했습니다' },
|
{ value: 'reoriented', label: '방향 재설정', desc: '새로운 발견을 했습니다' },
|
||||||
];
|
];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -97,19 +95,6 @@ export default function DebriefPage() {
|
|||||||
/>
|
/>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
{/* Question 3: Next Action */}
|
|
||||||
<section>
|
|
||||||
<label className="block text-sm font-medium text-slate-300 mb-2">
|
|
||||||
다음 항해의 첫 행동 (Next)
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
value={nextAction}
|
|
||||||
onChange={(e) => 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"
|
|
||||||
/>
|
|
||||||
</section>
|
|
||||||
<button
|
<button
|
||||||
type="submit"
|
type="submit"
|
||||||
disabled={!status}
|
disabled={!status}
|
||||||
|
|||||||
@@ -1,127 +0,0 @@
|
|||||||
// app/session/end/page.tsx
|
|
||||||
"use client";
|
|
||||||
|
|
||||||
import { useRouter, useSearchParams } from "next/navigation";
|
|
||||||
import { Suspense, useMemo, useState } from "react";
|
|
||||||
|
|
||||||
type Mode = "freeflow" | "sprint" | "deepwork";
|
|
||||||
|
|
||||||
const BG = "#E9EEF6";
|
|
||||||
const BORDER = "#C9D7F5";
|
|
||||||
const PRIMARY = "#2F6FED";
|
|
||||||
const PRIMARY_HOVER = "#295FD1";
|
|
||||||
|
|
||||||
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 hhmmss(total: number) {
|
|
||||||
const s = Math.max(0, Math.floor(total));
|
|
||||||
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}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function Page() {
|
|
||||||
return (
|
|
||||||
<Suspense
|
|
||||||
fallback={
|
|
||||||
<main className="min-h-screen w-full" style={{ backgroundColor: BG }} />
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<SessionEndInner />
|
|
||||||
</Suspense>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function SessionEndInner() {
|
|
||||||
const router = useRouter();
|
|
||||||
const params = useSearchParams();
|
|
||||||
|
|
||||||
const [elapsed, setElapsed] = useState(() => {
|
|
||||||
const v = Number(localStorage.getItem("hushroom:session-elapsed") ?? "0");
|
|
||||||
return Number.isFinite(v) ? v : 0;
|
|
||||||
});
|
|
||||||
|
|
||||||
const [goal, setGoal] = useState(() => {
|
|
||||||
const goal = localStorage.getItem("hushroom:session-goal");
|
|
||||||
return goal;
|
|
||||||
});
|
|
||||||
|
|
||||||
const mode = useMemo(() => clampMode(params.get("mode")), [params]);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<main className="min-h-screen w-full" style={{ backgroundColor: BG }}>
|
|
||||||
<header className="px-5 pt-6">
|
|
||||||
<div className="select-none text-xl font-bold tracking-tight leading-none text-slate-800">
|
|
||||||
QuietSprint
|
|
||||||
</div>
|
|
||||||
</header>
|
|
||||||
|
|
||||||
<section className="mx-auto flex min-h-[calc(100vh-64px)] max-w-lg flex-col justify-center px-5 pb-10 pt-6">
|
|
||||||
<div
|
|
||||||
className="rounded-3xl border bg-white px-6 py-6 shadow-sm"
|
|
||||||
style={{ borderColor: BORDER }}
|
|
||||||
>
|
|
||||||
<div className="text-sm font-semibold text-slate-600">
|
|
||||||
{modeLabel(mode)}
|
|
||||||
</div>
|
|
||||||
<div className="mt-3 text-[44px] font-semibold leading-none text-slate-900 tabular-nums">
|
|
||||||
{hhmmss(elapsed)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Goal */}
|
|
||||||
{goal && (
|
|
||||||
<div
|
|
||||||
className="mt-4 rounded-2xl border bg-[#F1F5FF] px-4 py-3"
|
|
||||||
style={{ borderColor: BORDER }}
|
|
||||||
>
|
|
||||||
<div className="text-xs font-semibold text-slate-600">
|
|
||||||
이번 목표
|
|
||||||
</div>
|
|
||||||
<div className="mt-1 text-xl font-semibold text-slate-900 line-clamp-2">
|
|
||||||
{goal}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div className="mt-3 text-base text-slate-700">세션이 종료됐어요</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="mt-5 grid grid-cols-2 gap-3">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => router.push("/")}
|
|
||||||
className="rounded-3xl border bg-white px-5 py-4 text-base font-semibold text-slate-800 shadow-sm transition active:scale-[0.99]"
|
|
||||||
style={{ borderColor: BORDER }}
|
|
||||||
>
|
|
||||||
홈
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => router.push(`/session?mode=${mode}`)}
|
|
||||||
className="rounded-3xl px-5 py-4 text-base font-semibold text-white shadow-sm transition active:scale-[0.99]"
|
|
||||||
style={{ backgroundColor: PRIMARY }}
|
|
||||||
onMouseEnter={(e) =>
|
|
||||||
(e.currentTarget.style.backgroundColor = PRIMARY_HOVER)
|
|
||||||
}
|
|
||||||
onMouseLeave={(e) =>
|
|
||||||
(e.currentTarget.style.backgroundColor = PRIMARY)
|
|
||||||
}
|
|
||||||
>
|
|
||||||
다시
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
</main>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -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}</>;
|
|
||||||
}
|
|
||||||
@@ -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<Participant[]>(() => [
|
|
||||||
{ id: selfId, status, lastSeen: Date.now(), isSelf: true },
|
|
||||||
]);
|
|
||||||
|
|
||||||
const participantsRef = useRef<Map<string, Omit<Participant, "isSelf">>>(
|
|
||||||
new Map([[selfId, { id: selfId, status, lastSeen: Date.now() }]]),
|
|
||||||
);
|
|
||||||
|
|
||||||
const channelRef = useRef<BroadcastChannel | null>(null);
|
|
||||||
const heartbeatRef = useRef<number | null>(null);
|
|
||||||
const cleanupRef = useRef<number | null>(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 (
|
|
||||||
<Suspense
|
|
||||||
fallback={
|
|
||||||
<main className="min-h-screen w-full" style={{ backgroundColor: BG }} />
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<SessionInner />
|
|
||||||
</Suspense>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
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<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 awayTotalMsRef = useRef(0);
|
|
||||||
const awayStartedAtRef = useRef<number | null>(null);
|
|
||||||
|
|
||||||
// presence (로컬)
|
|
||||||
const presenceStatus: PresenceStatus = isAway ? "away" : "focus";
|
|
||||||
const roomKey = "lounge";
|
|
||||||
const { participants } = useLocalPresence(roomKey, presenceStatus);
|
|
||||||
|
|
||||||
// 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);
|
|
||||||
toastTimerRef.current = window.setTimeout(() => setToast(null), 8000);
|
|
||||||
};
|
|
||||||
|
|
||||||
// freeflow checkpoint every 60 minutes
|
|
||||||
const lastCheckpointRef = useRef<number>(0);
|
|
||||||
|
|
||||||
// check-in (배너)
|
|
||||||
const [checkinOpen, setCheckinOpen] = useState(false);
|
|
||||||
const [checkinStep, setCheckinStep] = useState<
|
|
||||||
"ask" | "reason" | "pick" | "running" | "done"
|
|
||||||
>("ask");
|
|
||||||
const shownCheckinsRef = useRef<Set<number>>(new Set());
|
|
||||||
|
|
||||||
const [recenterReason, setRecenterReason] = useState<RecenterReason | null>(
|
|
||||||
null,
|
|
||||||
);
|
|
||||||
|
|
||||||
const [mission, setMission] = useState<{
|
|
||||||
option: RecenterOption;
|
|
||||||
endsAt: number;
|
|
||||||
} | null>(null);
|
|
||||||
|
|
||||||
const [missionLeft, setMissionLeft] = useState<number>(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 (
|
|
||||||
<main className="min-h-screen w-full" style={{ backgroundColor: BG }}>
|
|
||||||
<header className="px-5 pt-6">
|
|
||||||
<div className="flex items-start justify-between gap-3">
|
|
||||||
<div>
|
|
||||||
<div className="select-none text-xl font-bold tracking-tight leading-none text-slate-800">
|
|
||||||
hushroom
|
|
||||||
</div>
|
|
||||||
<div className="mt-2 text-xs font-semibold text-slate-600">
|
|
||||||
집중 {Math.floor(focusSeconds / 60)}분 · 자리비움{" "}
|
|
||||||
{Math.floor(awaySeconds / 60)}분
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<PresenceDots participants={participants} />
|
|
||||||
</div>
|
|
||||||
</header>
|
|
||||||
|
|
||||||
<section className="mx-auto flex min-h-[calc(100vh-64px)] max-w-lg flex-col justify-center px-5 pb-10 pt-6">
|
|
||||||
{toast && (
|
|
||||||
<div className="mb-4 rounded-2xl border border-[#D7E0EE] bg-white px-4 py-3 text-sm text-slate-700 shadow-sm">
|
|
||||||
{toast}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Timer */}
|
|
||||||
<div
|
|
||||||
className="rounded-3xl border bg-white px-6 py-6 shadow-sm"
|
|
||||||
style={{ borderColor: BORDER }}
|
|
||||||
>
|
|
||||||
<div className="mb-4">
|
|
||||||
<div className="flex items-start justify-between gap-3">
|
|
||||||
<div>
|
|
||||||
<div className="text-sm font-semibold text-slate-600">
|
|
||||||
{modeLabel(mode)}
|
|
||||||
</div>
|
|
||||||
<div className="mt-1 text-base leading-relaxed text-slate-700">
|
|
||||||
{mode === "freeflow"
|
|
||||||
? "원할 때 종료"
|
|
||||||
: "한 번 실행되고 끝나면 요약으로 이동"}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Goal */}
|
|
||||||
{goal && (
|
|
||||||
<div
|
|
||||||
className="mt-4 rounded-2xl border bg-[#F1F5FF] px-4 py-3"
|
|
||||||
style={{ borderColor: BORDER }}
|
|
||||||
>
|
|
||||||
<div className="text-xs font-semibold text-slate-600">
|
|
||||||
이번 목표
|
|
||||||
</div>
|
|
||||||
<div className="mt-1 text-xl font-semibold text-slate-900 line-clamp-2 break-words">
|
|
||||||
{goal}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="text-[44px] font-semibold leading-none text-slate-900 tabular-nums">
|
|
||||||
{timeMain}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Actions */}
|
|
||||||
<div className="mt-5 grid grid-cols-2 gap-3">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
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) =>
|
|
||||||
(e.currentTarget.style.backgroundColor = HOVER)
|
|
||||||
}
|
|
||||||
onMouseLeave={(e) =>
|
|
||||||
(e.currentTarget.style.backgroundColor = "#FFFFFF")
|
|
||||||
}
|
|
||||||
>
|
|
||||||
{isAway ? "복귀" : "자리비움"}
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={onEnd}
|
|
||||||
className="rounded-3xl px-4 py-4 text-base font-semibold text-white shadow-sm transition active:scale-[0.99]"
|
|
||||||
style={{ backgroundColor: PRIMARY }}
|
|
||||||
onMouseEnter={(e) =>
|
|
||||||
(e.currentTarget.style.backgroundColor = PRIMARY_HOVER)
|
|
||||||
}
|
|
||||||
onMouseLeave={(e) =>
|
|
||||||
(e.currentTarget.style.backgroundColor = PRIMARY)
|
|
||||||
}
|
|
||||||
>
|
|
||||||
종료
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
</main>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function PresenceDots({ participants }: { participants: Participant[] }) {
|
|
||||||
const MAX = 12;
|
|
||||||
const visible = participants.slice(0, MAX);
|
|
||||||
const extra = Math.max(0, participants.length - visible.length);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
{visible.map((p) => (
|
|
||||||
<Dot key={p.id} status={p.status} isSelf={p.isSelf} />
|
|
||||||
))}
|
|
||||||
{extra > 0 && (
|
|
||||||
<div className="text-sm font-semibold text-slate-600">+{extra}</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
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 (
|
|
||||||
<div
|
|
||||||
className={`h-3 w-3 rounded-full ${base} ${ring}`}
|
|
||||||
aria-label={isSelf ? "나" : status === "away" ? "자리비움" : "집중"}
|
|
||||||
title={isSelf ? "나" : status === "away" ? "자리비움" : "집중"}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
import { useEffect, useRef, useState } from 'react';
|
import { useEffect, useRef, useState } from 'react';
|
||||||
import { useRouter } from 'next/navigation';
|
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';
|
import { Voyage } from '@/shared/types';
|
||||||
|
|
||||||
const getVoyageFromStore = () => {
|
const getVoyageFromStore = () => {
|
||||||
@@ -17,18 +17,38 @@ const getEndTime = (voyage: Voyage | null) => {
|
|||||||
return voyage.startedAt + voyage.durationMinutes * 60 * 1000;
|
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() {
|
export function useFlightSession() {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const [voyage] = useState<Voyage | null>(() => getVoyageFromStore());
|
const [voyage] = useState<Voyage | null>(() => getVoyageFromStore());
|
||||||
const [timeLeft, setTimeLeft] = useState<number>(() => {
|
const [timeLeft, setTimeLeft] = useState<number>(() =>
|
||||||
const current = getVoyageFromStore();
|
getInitialTimerSeconds(getVoyageFromStore()),
|
||||||
const endTime = getEndTime(current);
|
);
|
||||||
if (!endTime) return 0;
|
|
||||||
return Math.max(0, Math.ceil((endTime - Date.now()) / 1000));
|
|
||||||
});
|
|
||||||
const [isPaused, setIsPaused] = useState(false);
|
const [isPaused, setIsPaused] = useState(false);
|
||||||
const [hideSeconds] = useState(() => getPreferences().hideSeconds);
|
|
||||||
const endTimeRef = useRef<number>(getEndTime(getVoyageFromStore()));
|
const endTimeRef = useRef<number>(getEndTime(getVoyageFromStore()));
|
||||||
|
const pausedElapsedMsRef = useRef<number>(0);
|
||||||
|
const pausedAtMsRef = useRef<number | null>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (voyage) return;
|
if (voyage) return;
|
||||||
@@ -40,6 +60,12 @@ export function useFlightSession() {
|
|||||||
if (!voyage || isPaused) return;
|
if (!voyage || isPaused) return;
|
||||||
|
|
||||||
const interval = setInterval(() => {
|
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();
|
const diff = endTimeRef.current - Date.now();
|
||||||
|
|
||||||
if (diff <= 0) {
|
if (diff <= 0) {
|
||||||
@@ -55,6 +81,21 @@ export function useFlightSession() {
|
|||||||
}, [voyage, isPaused]);
|
}, [voyage, isPaused]);
|
||||||
|
|
||||||
const handlePauseToggle = () => {
|
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) {
|
if (isPaused) {
|
||||||
endTimeRef.current = Date.now() + timeLeft * 1000;
|
endTimeRef.current = Date.now() + timeLeft * 1000;
|
||||||
setIsPaused(false);
|
setIsPaused(false);
|
||||||
@@ -65,33 +106,23 @@ export function useFlightSession() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handleFinish = () => {
|
const handleFinish = () => {
|
||||||
if (!voyage) return;
|
if (!voyage) return null;
|
||||||
|
|
||||||
const endedVoyage: Voyage = {
|
const endedVoyage: Voyage = {
|
||||||
...voyage,
|
...voyage,
|
||||||
endedAt: Date.now(),
|
endedAt: voyage.endedAt || Date.now(),
|
||||||
};
|
};
|
||||||
|
|
||||||
saveCurrentVoyage(endedVoyage);
|
saveCurrentVoyage(endedVoyage);
|
||||||
router.push('/debrief');
|
return endedVoyage;
|
||||||
};
|
};
|
||||||
|
|
||||||
const formatTime = (seconds: number) => {
|
const formatTime = (seconds: number) => formatHHMMSS(seconds);
|
||||||
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')}`;
|
|
||||||
};
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
voyage,
|
voyage,
|
||||||
timeLeft,
|
timeLeft,
|
||||||
|
isCountdownCompleted: Boolean(voyage && voyage.durationMinutes > 0 && timeLeft === 0),
|
||||||
isPaused,
|
isPaused,
|
||||||
formattedTime: formatTime(timeLeft),
|
formattedTime: formatTime(timeLeft),
|
||||||
handlePauseToggle,
|
handlePauseToggle,
|
||||||
|
|||||||
@@ -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() {
|
export function FlightHudWidget() {
|
||||||
|
const router = useRouter();
|
||||||
const {
|
const {
|
||||||
voyage,
|
voyage,
|
||||||
isPaused,
|
isPaused,
|
||||||
formattedTime,
|
formattedTime,
|
||||||
timeLeft,
|
isCountdownCompleted,
|
||||||
handlePauseToggle,
|
handlePauseToggle,
|
||||||
handleFinish,
|
handleFinish,
|
||||||
} = useFlightSession();
|
} = useFlightSession();
|
||||||
|
const [isDebriefOpen, setIsDebriefOpen] = useState(false);
|
||||||
|
const [finishedVoyage, setFinishedVoyage] = useState<Voyage | null>(null);
|
||||||
|
const [status, setStatus] = useState<VoyageStatus | null>(null);
|
||||||
|
const [progress, setProgress] = useState("");
|
||||||
|
const [holdProgress, setHoldProgress] = useState(0);
|
||||||
|
const holdStartAtRef = useRef<number | null>(null);
|
||||||
|
const holdRafRef = useRef<number | null>(null);
|
||||||
|
const isHoldCompletedRef = useRef(false);
|
||||||
|
|
||||||
|
const openDebriefModal = () => {
|
||||||
|
const endedVoyage = handleFinish();
|
||||||
|
if (!endedVoyage) return;
|
||||||
|
|
||||||
|
setFinishedVoyage(endedVoyage);
|
||||||
|
setStatus(null);
|
||||||
|
setProgress("");
|
||||||
|
setIsDebriefOpen(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDebriefSubmit = (event: FormEvent<HTMLFormElement>) => {
|
||||||
|
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;
|
if (!voyage) return null;
|
||||||
|
|
||||||
@@ -16,18 +127,25 @@ export function FlightHudWidget() {
|
|||||||
<>
|
<>
|
||||||
<div className="absolute top-8 z-10 text-center">
|
<div className="absolute top-8 z-10 text-center">
|
||||||
<span className="rounded-full border border-indigo-500/30 bg-indigo-950/50 px-4 py-1.5 text-xs font-medium uppercase tracking-widest text-indigo-300 shadow-[0_0_15px_rgba(99,102,241,0.3)] backdrop-blur">
|
<span className="rounded-full border border-indigo-500/30 bg-indigo-950/50 px-4 py-1.5 text-xs font-medium uppercase tracking-widest text-indigo-300 shadow-[0_0_15px_rgba(99,102,241,0.3)] backdrop-blur">
|
||||||
{voyage.routeName} · {isPaused ? '일시정지' : '순항 중'}
|
{voyage.routeName} · {isPaused ? "일시정지" : "순항 중"}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
className={`relative z-10 my-12 font-mono text-7xl font-light tracking-tighter tabular-nums drop-shadow-2xl transition-opacity duration-300 md:text-9xl ${isPaused ? 'opacity-50' : 'opacity-100'}`}
|
className={`relative z-10 my-12 font-mono text-7xl font-light tracking-tighter tabular-nums drop-shadow-2xl transition-opacity duration-300 md:text-9xl ${isPaused ? "opacity-50" : "opacity-100"}`}
|
||||||
>
|
>
|
||||||
{formattedTime}
|
{formattedTime}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="relative z-10 mb-24 max-w-2xl px-4 text-center text-xl font-medium leading-relaxed text-slate-200 drop-shadow-md md:text-2xl">
|
<div className="relative z-10 mb-24 w-full max-w-2xl px-4">
|
||||||
“{voyage.missionText}”
|
<section className="rounded-2xl border border-slate-600/60 bg-slate-950/55 p-4 text-left shadow-[0_16px_40px_rgba(2,6,23,0.35)] backdrop-blur md:p-6">
|
||||||
|
<p className="text-[11px] font-semibold uppercase tracking-[0.24em] text-slate-400 md:text-xs">
|
||||||
|
이번 항해 목표
|
||||||
|
</p>
|
||||||
|
<p className="mt-2 max-h-32 overflow-y-auto break-words whitespace-pre-wrap pr-1 text-base leading-relaxed text-slate-100 md:text-lg">
|
||||||
|
{voyage.missionText}
|
||||||
|
</p>
|
||||||
|
</section>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="absolute bottom-12 z-10 flex gap-6">
|
<div className="absolute bottom-12 z-10 flex gap-6">
|
||||||
@@ -35,15 +153,111 @@ export function FlightHudWidget() {
|
|||||||
onClick={handlePauseToggle}
|
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"
|
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 ? '다시 시작' : '일시정지'}
|
{isPaused ? "다시 시작" : "일시정지"}
|
||||||
</button>
|
</button>
|
||||||
|
<div className="relative">
|
||||||
<button
|
<button
|
||||||
onClick={handleFinish}
|
type="button"
|
||||||
className="rounded-full bg-slate-100 px-8 py-3 text-sm font-bold uppercase tracking-wide text-slate-900 shadow-lg shadow-white/10 transition-all hover:bg-indigo-500 hover:text-white"
|
onPointerDown={startHoldToFinish}
|
||||||
|
onPointerUp={cancelHoldToFinish}
|
||||||
|
onPointerLeave={cancelHoldToFinish}
|
||||||
|
onPointerCancel={cancelHoldToFinish}
|
||||||
|
onKeyDown={(event) => {
|
||||||
|
if (event.key === "Enter" || event.key === " ") {
|
||||||
|
event.preventDefault();
|
||||||
|
startHoldToFinish();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
onKeyUp={(event) => {
|
||||||
|
if (event.key === "Enter" || event.key === " ") {
|
||||||
|
event.preventDefault();
|
||||||
|
cancelHoldToFinish();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
onBlur={cancelHoldToFinish}
|
||||||
|
className="relative overflow-hidden rounded-full border border-slate-200 bg-slate-100 px-8 py-3 text-sm font-bold uppercase tracking-wide text-slate-900 shadow-lg shadow-white/10 transition-all hover:border-indigo-300 hover:text-slate-950"
|
||||||
>
|
>
|
||||||
{timeLeft === 0 ? '도착 (회고)' : '항해 종료'}
|
<span
|
||||||
|
aria-hidden
|
||||||
|
className="absolute inset-0 origin-left rounded-full bg-indigo-400/45 transition-transform duration-75"
|
||||||
|
style={{ transform: `scaleX(${holdProgress})` }}
|
||||||
|
/>
|
||||||
|
<span className="relative z-10">
|
||||||
|
{isCountdownCompleted ? "도착 (회고)" : "항해 종료"}
|
||||||
|
</span>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Dialog open={isDebriefOpen} onOpenChange={setIsDebriefOpen}>
|
||||||
|
<DialogContent className="max-h-[90vh] overflow-y-auto border-slate-700/80 bg-slate-950 text-slate-100">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle className="text-white">
|
||||||
|
이번 항해를 정리하세요
|
||||||
|
</DialogTitle>
|
||||||
|
<DialogDescription className="text-slate-400">
|
||||||
|
짧게 기록하고 항해일지에 저장합니다.
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<form onSubmit={handleDebriefSubmit} className="space-y-6">
|
||||||
|
<section>
|
||||||
|
<label className="mb-3 block text-sm font-medium text-slate-300">
|
||||||
|
항해 결과
|
||||||
|
</label>
|
||||||
|
<div className="grid grid-cols-1 gap-3 sm:grid-cols-3">
|
||||||
|
{statusOptions.map((opt) => (
|
||||||
|
<button
|
||||||
|
key={opt.value}
|
||||||
|
type="button"
|
||||||
|
onClick={() => setStatus(opt.value)}
|
||||||
|
className={`rounded-xl border p-4 text-left transition-all ${
|
||||||
|
status === opt.value
|
||||||
|
? "border-indigo-500 bg-indigo-900/40 ring-1 ring-indigo-500"
|
||||||
|
: "border-slate-800 bg-slate-900/50 hover:bg-slate-800"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<div className="mb-1 font-bold text-slate-200">
|
||||||
|
{opt.label}
|
||||||
|
</div>
|
||||||
|
<div className="text-xs text-slate-500">{opt.desc}</div>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section>
|
||||||
|
<label className="mb-2 block text-sm font-medium text-slate-300">
|
||||||
|
이번 항해에서 확보한 것
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={progress}
|
||||||
|
onChange={(event) => 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"
|
||||||
|
/>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-2 gap-3 pt-1">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setIsDebriefOpen(false)}
|
||||||
|
className="rounded-xl border border-slate-700 bg-slate-900/60 px-4 py-3 font-semibold text-slate-300 transition-colors hover:border-slate-500 hover:text-white"
|
||||||
|
>
|
||||||
|
취소
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={!status}
|
||||||
|
className="rounded-xl bg-indigo-600 px-4 py-3 font-semibold text-white transition-colors hover:bg-indigo-500 disabled:bg-slate-800 disabled:text-slate-500"
|
||||||
|
>
|
||||||
|
항해일지 저장
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user