feat: 세션, 세션 종료화면 생성

This commit is contained in:
2026-02-01 20:31:09 +09:00
parent b9b7a07da3
commit 22f318a219
6 changed files with 567 additions and 102 deletions

1
.gitignore vendored
View File

@@ -39,3 +39,4 @@ yarn-error.log*
# typescript
*.tsbuildinfo
next-env.d.ts
.idea

6
.idea/vcs.xml generated
View File

@@ -1,6 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="VcsDirectoryMappings">
<mapping directory="$PROJECT_DIR$" vcs="Git" />
</component>
</project>

67
.idea/workspace.xml generated
View File

@@ -1,67 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="AutoImportSettings">
<option name="autoReloadType" value="SELECTIVE" />
</component>
<component name="ChangeListManager">
<list default="true" id="9e413d25-a9a8-4d83-9fd6-a4d134df4e1b" name="변경" comment="">
<change beforePath="$PROJECT_DIR$/src/app/page.tsx" beforeDir="false" afterPath="$PROJECT_DIR$/src/app/page.tsx" afterDir="false" />
</list>
<option name="SHOW_DIALOG" value="false" />
<option name="HIGHLIGHT_CONFLICTS" value="true" />
<option name="HIGHLIGHT_NON_ACTIVE_CHANGELIST" value="false" />
<option name="LAST_RESOLUTION" value="IGNORE" />
</component>
<component name="Git.Settings">
<option name="RECENT_GIT_ROOT_PATH" value="$PROJECT_DIR$" />
</component>
<component name="ProjectColorInfo"><![CDATA[{
"associatedIndex": 3
}]]></component>
<component name="ProjectId" id="38yBJdMOvLXJQYiNoTURYc7ZYHL" />
<component name="ProjectViewState">
<option name="hideEmptyMiddlePackages" value="true" />
<option name="showLibraryContents" value="true" />
</component>
<component name="PropertiesComponent"><![CDATA[{
"keyToString": {
"ModuleVcsDetector.initialDetectionPerformed": "true",
"RunOnceActivity.ShowReadmeOnStart": "true",
"dart.analysis.tool.window.visible": "false",
"git-widget-placeholder": "main",
"last_opened_file_path": "/Users/ijeongmin/Desktop/corpi/hushroom/web",
"node.js.detected.package.eslint": "true",
"node.js.detected.package.tslint": "true",
"node.js.selected.package.eslint": "(autodetect)",
"node.js.selected.package.tslint": "(autodetect)",
"nodejs_package_manager_path": "npm",
"project.structure.last.edited": "프로젝트",
"project.structure.proportion": "0.0",
"project.structure.side.proportion": "0.0",
"ts.external.directory.path": "/Users/ijeongmin/Desktop/corpi/hushroom/web/node_modules/typescript/lib",
"vue.rearranger.settings.migration": "true"
}
}]]></component>
<component name="SharedIndexes">
<attachedChunks>
<set>
<option value="bundled-jdk-9823dce3aa75-fbdcb00ec9e3-intellij.indexing.shared.core-IU-251.25410.129" />
<option value="bundled-js-predefined-d6986cc7102b-6a121458b545-JavaScript-IU-251.25410.129" />
</set>
</attachedChunks>
</component>
<component name="TaskManager">
<task active="true" id="Default" summary="디폴트 작업">
<changelist id="9e413d25-a9a8-4d83-9fd6-a4d134df4e1b" name="변경" comment="" />
<created>1769761859967</created>
<option name="number" value="Default" />
<option name="presentableId" value="Default" />
<updated>1769761859967</updated>
<workItem from="1769761861354" duration="1935000" />
</task>
<servers />
</component>
<component name="TypeScriptGeneratedFilesManager">
<option name="version" value="3" />
</component>
</project>

View File

@@ -9,27 +9,20 @@ type Mode = "freeflow" | "sprint" | "deepwork";
export default function HomePage() {
const router = useRouter();
const startSession = useCallback(
(mode: Mode) => {
try {
localStorage.setItem("last_mode", mode);
} catch {}
router.push(`/session?mode=${mode}`);
},
const go = useCallback(
(mode: Mode) => router.push(`/session?mode=${mode}`),
[router],
);
return (
<main className="min-h-screen w-full bg-[#E9EEF6]">
{/* Top-left logo */}
<header className="px-5 pt-6">
<div className="select-none text-lg font-bold tracking-tight leading-none text-slate-900">
<div className="select-none text-xl font-bold tracking-tight leading-none text-slate-800">
hushroom
</div>
</header>
<section className="mx-auto flex min-h-[calc(100vh-56px)] max-w-lg flex-col justify-center px-5 pb-10">
{/* Section: Freeflow */}
<section className="mx-auto flex min-h-[calc(100vh-64px)] max-w-lg flex-col justify-center px-5 pb-10">
<div className="mb-4">
<div className="text-sm font-semibold text-slate-600"> </div>
<div className="mt-1 text-base leading-relaxed text-slate-700">
@@ -39,15 +32,14 @@ export default function HomePage() {
<button
type="button"
onClick={() => startSession("freeflow")}
onClick={() => go("freeflow")}
className="w-full rounded-3xl bg-[#2F6FED] px-8 py-6 text-left text-white shadow-sm transition active:scale-[0.99] hover:bg-[#295FD1]"
aria-label="프리플로우 세션"
aria-label="프리플로우"
>
<div className="text-2xl font-semibold"></div>
<div className="mt-2 text-lg opacity-90"></div>
</button>
{/* Divider + Section: Timed sessions */}
<div className="my-8 flex items-center gap-3">
<div className="h-px flex-1 bg-[#D7E0EE]" />
<div className="text-sm font-semibold text-slate-600">
@@ -61,18 +53,8 @@ export default function HomePage() {
</div>
<div className="grid grid-cols-2 gap-4">
<ModeTile
title="스프린트"
meta="25분"
desc="짧게 한 번"
onClick={() => startSession("sprint")}
/>
<ModeTile
title="딥워크"
meta="90분"
desc="길게 한 번"
onClick={() => startSession("deepwork")}
/>
<ModeTile title="스프린트" meta="25분" onClick={() => go("sprint")} />
<ModeTile title="딥워크" meta="90분" onClick={() => go("deepwork")} />
</div>
</section>
</main>
@@ -82,12 +64,10 @@ export default function HomePage() {
function ModeTile({
title,
meta,
desc,
onClick,
}: {
title: string;
meta: string;
desc: string;
onClick: () => void;
}) {
return (
@@ -101,7 +81,6 @@ function ModeTile({
<div className="text-xl font-semibold text-slate-900">{title}</div>
<div className="text-lg font-semibold text-blue-700">{meta}</div>
</div>
<div className="mt-3 text-base text-slate-700">{desc}</div>
</button>
);
}

View File

@@ -0,0 +1,93 @@
// app/session/end/page.tsx
"use client";
import { useRouter, useSearchParams } from "next/navigation";
import { useMemo } 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 SessionEndPage() {
const router = useRouter();
const params = useSearchParams();
const mode = useMemo(() => clampMode(params.get("mode")), [params]);
const elapsed = useMemo(() => {
const v = Number(params.get("elapsed") ?? "0");
return Number.isFinite(v) ? v : 0;
}, [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">
hushroom
</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>
<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>
);
}

465
src/app/session/page.tsx Normal file
View File

@@ -0,0 +1,465 @@
// app/session/page.tsx
"use client";
import { useRouter, useSearchParams } from "next/navigation";
import { 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;
};
const BG = "#E9EEF6";
const BORDER = "#C9D7F5";
const PRIMARY = "#2F6FED";
const PRIMARY_HOVER = "#295FD1";
const HOVER = "#F1F5FF";
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}`;
}
/**
* 로컬 Presence (Supabase 교체 전용)
* - 같은 브라우저에서 여러 탭/창을 열면 "인원 점"이 늘어남
* - BroadcastChannel 미지원 환경은 localStorage 이벤트로 fallback
*
* 교체 포인트:
* const { participants } = useLocalPresence(roomKey, status)
* 를
* const { participants } = useSupabasePresence(roomKey, status)
* 같은 형태로 바꾸면 UI는 그대로 유지 가능
*/
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);
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) => {
// BroadcastChannel 우선
if (channelRef.current) {
channelRef.current.postMessage(payload);
return;
}
// fallback: localStorage event
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;
});
// self 상태 최신화(React state 변경 시 반영)
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 {
// fallback: storage event
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 {}
};
window.addEventListener("storage", onStorage);
return () => window.removeEventListener("storage", onStorage);
}
return () => {
channelRef.current?.close();
channelRef.current = null;
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [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 });
syncStateToReact();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [status, roomKey, selfId]);
useEffect(() => {
// heartbeat: 2초마다 ping
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);
// cleanup: 4초마다 오래된 참가자 제거(탭 닫힘 대비)
cleanupRef.current = window.setInterval(() => {
const now = Date.now();
const STALE_MS = 9000; // 9초 이상 ping 없으면 제거
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() });
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [roomKey, selfId, status]);
return { participants, selfId };
}
export default function SessionPage() {
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 [isAway, setIsAway] = useState(false);
// presence (로컬)
const presenceStatus: PresenceStatus = isAway ? "away" : "focus";
const roomKey = "lounge"; // 나중에 방 분리하면 여기만 바꾸면 됨
const { participants } = useLocalPresence(roomKey, presenceStatus);
// time
const [elapsed, setElapsed] = useState(0);
const [remaining, setRemaining] = useState(duration ?? 0);
// toast
const [toast, setToast] = useState<string | null>(null);
const toastTimerRef = useRef<number | null>(null);
// freeflow checkpoint every 60 minutes
const lastCheckpointRef = useRef<number>(0);
const showToast = (msg: string) => {
setToast(msg);
if (toastTimerRef.current) window.clearTimeout(toastTimerRef.current);
toastTimerRef.current = window.setTimeout(() => setToast(null), 8000);
};
useEffect(() => {
setElapsed(0);
if (duration) setRemaining(duration);
lastCheckpointRef.current = 0;
setIsAway(false);
setToast(null);
if (toastTimerRef.current) window.clearTimeout(toastTimerRef.current);
}, [duration]);
useEffect(() => {
const id = window.setInterval(() => {
setElapsed((prev) => prev + 1);
if (duration) {
setRemaining((prev) => {
const next = Math.max(0, prev - 1);
if (next === 0 && prev !== 0) {
router.push(`/session/end?mode=${mode}&elapsed=${duration}`);
}
return next;
});
}
}, 1000);
return () => window.clearInterval(id);
}, [duration, mode, router]);
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]);
const timeMain = useMemo(() => {
if (mode === "freeflow") return formatHHMMSS(elapsed);
return formatHHMMSS(remaining);
}, [elapsed, remaining, mode]);
const onCheckIn = () => showToast("체크인 기록됨");
const onEnd = () =>
router.push(`/session/end?mode=${mode}&elapsed=${elapsed}`);
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">
hushroom
</div>
</header>
<section className="mx-auto flex min-h-[calc(100vh-64px)] max-w-lg flex-col 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>
)}
<div className="mb-4">
<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>
{/* Timer */}
<div
className="rounded-3xl border bg-white px-6 py-6 shadow-sm"
style={{ borderColor: BORDER }}
>
<div className="text-[44px] font-semibold leading-none text-slate-900 tabular-nums">
{timeMain}
</div>
<div className="mt-6">
<label className="text-sm font-semibold text-slate-600">
()
</label>
<input
value={goal}
onChange={(e) => setGoal(e.target.value)}
placeholder="이번 세션 목표(선택)"
className="mt-2 w-full rounded-2xl border px-4 py-3 text-base text-slate-900 outline-none"
style={{ borderColor: BORDER }}
/>
</div>
</div>
{/* Presence */}
<div
className="mt-4 rounded-3xl border bg-white px-6 py-5 shadow-sm"
style={{ borderColor: BORDER }}
>
<div className="flex items-center justify-between">
<div className="text-sm font-semibold text-slate-700"></div>
<div className="text-sm text-slate-600">{roomKey}</div>
</div>
<div className="mt-4 flex items-center justify-between">
<div className="text-sm font-semibold text-slate-700"></div>
<div className="text-sm text-slate-600">{participants.length}</div>
</div>
<div className="mt-4">
<PresenceDots participants={participants} />
</div>
</div>
{/* Actions */}
<div className="mt-5 grid grid-cols-3 gap-3">
<button
type="button"
onClick={() => setIsAway((v) => !v)}
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={onCheckIn}
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")
}
>
</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" ? "자리비움" : "집중"}
/>
);
}