Compare commits

...

2 Commits

Author SHA1 Message Date
d3c5ad1f4d refactor: 흔들림 기능 삭제 2026-02-09 14:25:14 +09:00
b5dd39c6c2 feat: 세션 중, 끝났을 때 목표 표시 2026-02-05 12:35:35 +09:00
5 changed files with 687 additions and 86 deletions

View File

@@ -54,7 +54,7 @@
--card-foreground: oklch(0.129 0.042 264.695);
--popover: oklch(1 0 0);
--popover-foreground: oklch(15.152% 0.01301 277.362);
--primary: oklch(0.208 0.042 265.755);
--primary: oklch(0.574 0.202 262);
--primary-foreground: oklch(0.984 0.003 247.858);
--secondary: oklch(0.968 0.007 247.896);
--secondary-foreground: oklch(0.208 0.042 265.755);
@@ -65,7 +65,7 @@
--destructive: oklch(0.577 0.245 27.325);
--border: oklch(0.929 0.013 255.508);
--input: oklch(0.929 0.013 255.508);
--ring: oklch(0.704 0.04 256.788);
--ring: oklch(0.62 0.202 262);
--chart-1: oklch(0.646 0.222 41.116);
--chart-2: oklch(0.6 0.118 184.704);
--chart-3: oklch(0.398 0.07 227.392);

View File

@@ -18,6 +18,9 @@ import { Separator } from "@/components/ui/separator";
type Mode = "freeflow" | "sprint" | "deepwork";
const PRIMARY = "#2F6FED";
const PRIMARY_HOVER = "#295FD1";
function modeLabel(mode: Mode) {
switch (mode) {
case "freeflow":
@@ -29,6 +32,17 @@ function modeLabel(mode: Mode) {
}
}
function modeMeta(mode: Mode) {
if (mode === "freeflow") return "무제한";
if (mode === "sprint") return "25분";
return "90분";
}
function startLabel(mode: Mode) {
if (mode === "freeflow") return "집중 시작";
return `집중 시작 (${modeMeta(mode)})`;
}
export default function HomePage() {
const router = useRouter();
@@ -36,29 +50,28 @@ export default function HomePage() {
const [mode, setMode] = useState<Mode | null>(null);
const [goal, setGoal] = useState("");
const meta = useMemo(() => {
if (!mode) return "";
if (mode === "freeflow") return "무제한";
if (mode === "sprint") return "25분";
return "90분";
}, [mode]);
const meta = useMemo(() => (mode ? modeMeta(mode) : ""), [mode]);
const go = useCallback(
(mode: Mode, goal?: string) => {
(m: Mode, g?: string) => {
const params = new URLSearchParams();
params.set("mode", mode);
params.set("mode", m);
if (goal && goal.trim().length > 0) {
localStorage.setItem("goal", goal.trim());
}
if (g && g.trim())
localStorage.setItem("hushroom:session-goal", g.trim());
else localStorage.removeItem("hushroom:session-goal");
// nextAction은 사용하지 않음
localStorage.removeItem("hushroom:session-nextAction");
localStorage.setItem("hushroom:last-mode", m);
router.push(`/session?${params.toString()}`);
},
[router],
);
const openDialog = (mode: Mode) => {
setMode(mode);
const openDialog = (m: Mode) => {
setMode(m);
setGoal("");
setOpen(true);
};
@@ -75,13 +88,17 @@ export default function HomePage() {
<div className="select-none text-xl font-bold tracking-tight leading-none text-slate-800">
hushroom
</div>
<div className="mt-2 text-sm font-semibold text-slate-600">
. .
</div>
</header>
<section className="mx-auto flex min-h-[calc(100vh-64px)] max-w-lg flex-col justify-center px-5 pb-10">
{/* ✅ 파란 CTA = 프리플로우 */}
<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">
, (60 .)
, (60 )
</div>
</div>
@@ -101,17 +118,21 @@ export default function HomePage() {
<div className="my-8 flex items-center gap-3">
<Separator className="flex-1 bg-[#D7E0EE]" />
<div className="text-sm font-semibold text-slate-600">
</div>
<div className="text-sm font-semibold text-slate-600"> </div>
<Separator className="flex-1 bg-[#D7E0EE]" />
</div>
<div className="-mt-2 mb-5 text-base leading-relaxed text-slate-700">
{/* ✅ 버튼 위에 설명 (버튼 안에 설명 X) */}
<div className="-mt-2 mb-5">
<div className="text-sm font-semibold text-slate-600">
</div>
<div className="mt-1 text-base leading-relaxed text-slate-700">
</div>
</div>
{/* Sprint / Deepwork */}
{/* ✅ row(2열) */}
<div className="grid grid-cols-2 gap-4">
<ModeTile
title="스프린트"
@@ -154,7 +175,7 @@ function ModeTile({
type="button"
variant="ghost"
onClick={onClick}
className="h-auto w-full rounded-3xl px-7 text-left active:scale-[0.99]"
className="h-auto w-full rounded-3xl px-7 py-5 text-left active:scale-[0.99] hover:bg-transparent"
>
<div className="flex w-full items-baseline justify-between">
<div className="text-xl font-semibold text-slate-900">{title}</div>
@@ -187,36 +208,58 @@ function SessionGoalDialog({
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-md rounded-2xl">
<form
onSubmit={(e) => {
e.preventDefault();
onStart();
}}
>
<DialogHeader>
<DialogTitle className="text-xl"> </DialogTitle>
<div className="text-sm text-slate-600">{title}</div>
</DialogHeader>
<div className="space-y-2">
<div className="mt-4 space-y-2">
<Input
value={goal}
onChange={(e) => setGoal(e.target.value)}
placeholder="목표를 입력하세요"
className="text-lg border-[#2F6FED] focus-visible:ring-[#2F6FED] focus-visible:ring-1"
placeholder="지금 할 한 가지를 한 줄로 적어주세요 (선택)"
className="text-lg focus-visible:ring-2"
autoFocus
onKeyDown={(e) => {
if (e.key === "Enter" && (e.nativeEvent as any).isComposing) {
e.preventDefault();
}
}}
/>
<div className="text-xs text-slate-500">
. : 1, 10
. .
</div>
</div>
<DialogFooter className="gap-2 sm:gap-2">
<Button variant="ghost" onClick={() => onOpenChange(false)}>
<DialogFooter className="mt-6 gap-2 sm:gap-2">
<Button
type="button"
variant="ghost"
onClick={() => onOpenChange(false)}
>
</Button>
<Button
onClick={onStart}
className="rounded-xl bg-[#2F6FED]"
disabled={!mode}
type="submit"
className="rounded-xl"
style={{ backgroundColor: PRIMARY }}
onMouseEnter={(e) =>
(e.currentTarget.style.backgroundColor = PRIMARY_HOVER)
}
onMouseLeave={(e) =>
(e.currentTarget.style.backgroundColor = PRIMARY)
}
>
{mode ? startLabel(mode) : "집중 시작"}
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
);

View File

@@ -2,7 +2,7 @@
"use client";
import { useRouter, useSearchParams } from "next/navigation";
import { useMemo } from "react";
import { useMemo, useState } from "react";
type Mode = "freeflow" | "sprint" | "deepwork";
@@ -34,11 +34,17 @@ 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");
const [elapsed, setElapsed] = useState(() => {
const v = Number(localStorage.getItem("hushroom:session-elapsed") ?? "0");
return Number.isFinite(v) ? v : 0;
}, [params]);
});
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 }}>
@@ -59,6 +65,22 @@ export default function SessionEndPage() {
<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>

View File

@@ -14,6 +14,14 @@ type Participant = {
isSelf: boolean;
};
type RecenterReason = "distracted" | "stuck" | "tired" | "overwhelmed";
type RecenterOption = {
id: string;
label: string; // 화면 문구(2분 미션)
pinText: string; // "다음 단계로 고정" 시 저장될 짧은 문구
};
const BG = "#E9EEF6";
const BORDER = "#C9D7F5";
const PRIMARY = "#2F6FED";
@@ -45,14 +53,120 @@ function formatHHMMSS(totalSeconds: number) {
return `${hh}:${mm}:${ss}`;
}
function reasonLabel(r: RecenterReason) {
switch (r) {
case "distracted":
return "산만함";
case "stuck":
return "막힘";
case "tired":
return "피로";
case "overwhelmed":
return "부담";
}
}
/**
* ✅ 리라이트 원칙(목적/이유 반영)
* - 3초 안에 “뭘 하면 되는지” 떠오르는 행동 문구
* - 목표 변경/재설정처럼 읽히는 표현 금지
* - 작성/입력 강제 금지(원클릭 → 즉시 실행)
* - 도구/도메인 중립
* - “2분 동안만”이라는 임시성을 기본으로 내포
*/
function recenterOptions(
goal: string,
reason: RecenterReason,
): RecenterOption[] {
const g = goal.trim();
const goalHint = g ? `${g}”로 복귀` : "목표로 복귀";
if (reason === "distracted") {
return [
{
id: "dist_1",
label: "2분 미션: 방해 요소 1개만 끄기(알림/탭/폰)",
pinText: "방해 요소 1개 끄기",
},
{
id: "dist_2",
label: "2분 미션: 화면을 ‘하나’만 남기기(지금 필요한 것만)",
pinText: "화면 하나만 남기기",
},
{
id: "dist_3",
label: `2분 미션: ${goalHint} — 가장 쉬운 첫 행동 1개`,
pinText: g ? `${g}” 첫 행동 1개` : "첫 행동 1개",
},
];
}
if (reason === "stuck") {
return [
{
id: "stuck_1",
label: "2분 미션: 막히는 건 잠깐 두고, 확실한 것 1개만 진행",
pinText: "확실한 것 1개 진행",
},
{
id: "stuck_2",
label: "2분 미션: 가장 작은 결과물 1개만 만들기(한 줄/한 칸/한 개)",
pinText: "가장 작은 결과물 1개",
},
{
id: "stuck_3",
label: "2분 미션: 60초 정리(다음 한 조각) + 60초 착수(그 조각)",
pinText: "정리 60초 + 착수 60초",
},
];
}
if (reason === "tired") {
return [
{
id: "tired_1",
label: "2분 미션: 물 한 모금/자세 정리 후 1분만 손 움직이기",
pinText: "물/자세 정리 후 1분",
},
{
id: "tired_2",
label: "2분 미션: 30초 숨 고르기 + 90초 아주 쉬운 행동",
pinText: "30초 숨 + 90초 쉬운 행동",
},
{
id: "tired_3",
label: `2분 미션: ${goalHint} — 가장 가벼운 한 조각만`,
pinText: "가장 가벼운 한 조각",
},
];
}
// overwhelmed
return [
{
id: "ovr_1",
label: "2분 미션: (2분만) 범위를 최소로 줄여 ‘첫 행동 1개만",
pinText: "첫 행동 1개만",
},
{
id: "ovr_2",
label: "2분 미션: 완성 말고 ‘전진’만(아주 작은 1개)",
pinText: "아주 작은 1개 전진",
},
{
id: "ovr_3",
label: `2분 미션: ${goalHint} — 지금 당장 가능한 것 1개`,
pinText: "지금 가능한 것 1개",
},
];
}
function useLocalPresence(roomKey: string, status: PresenceStatus) {
const selfId = useMemo(() => {
// 탭 단위 고유 ID
const key = `hushroom:selfId:${roomKey}`;
const existing = sessionStorage.getItem(key);
if (existing) return existing;
// 짧고 충돌 확률 낮은 ID
const id = (crypto?.randomUUID?.() ?? `${Date.now()}-${Math.random()}`)
.replace(/[^a-zA-Z0-9]/g, "")
.slice(0, 16);
@@ -74,12 +188,10 @@ function useLocalPresence(roomKey: string, status: PresenceStatus) {
const cleanupRef = useRef<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}`,
@@ -99,14 +211,13 @@ function useLocalPresence(roomKey: string, status: PresenceStatus) {
isSelf: id === selfId,
});
});
// 안정적으로 정렬(자기 자신 먼저, 그 다음 최근 순)
arr.sort((a, b) => {
if (a.isSelf && !b.isSelf) return -1;
if (!a.isSelf && b.isSelf) return 1;
return b.lastSeen - a.lastSeen;
});
// self 상태 최신화(React state 변경 시 반영)
const self = participantsRef.current.get(selfId);
if (self) self.lastSeen = now;
@@ -114,7 +225,6 @@ function useLocalPresence(roomKey: string, status: PresenceStatus) {
};
useEffect(() => {
// 채널 세팅
if ("BroadcastChannel" in window) {
const bc = new BroadcastChannel(`hushroom-presence:${roomKey}`);
channelRef.current = bc;
@@ -138,7 +248,6 @@ function useLocalPresence(roomKey: string, status: PresenceStatus) {
}
};
} else {
// fallback: storage event
const onStorage = (e: StorageEvent) => {
if (e.key !== `hushroom:presence:${roomKey}` || !e.newValue) return;
try {
@@ -171,7 +280,6 @@ function useLocalPresence(roomKey: string, status: PresenceStatus) {
}, [roomKey]);
useEffect(() => {
// 내 상태 업데이트 + ping
const now = Date.now();
participantsRef.current.set(selfId, { id: selfId, status, lastSeen: now });
publish({ type: "ping", roomKey, from: selfId, status, ts: now });
@@ -180,7 +288,6 @@ function useLocalPresence(roomKey: string, status: PresenceStatus) {
}, [status, roomKey, selfId]);
useEffect(() => {
// heartbeat: 2초마다 ping
heartbeatRef.current = window.setInterval(() => {
const now = Date.now();
participantsRef.current.set(selfId, {
@@ -192,10 +299,9 @@ function useLocalPresence(roomKey: string, status: PresenceStatus) {
syncStateToReact();
}, 2000);
// cleanup: 4초마다 오래된 참가자 제거(탭 닫힘 대비)
cleanupRef.current = window.setInterval(() => {
const now = Date.now();
const STALE_MS = 9000; // 9초 이상 ping 없으면 제거
const STALE_MS = 9000;
let changed = false;
participantsRef.current.forEach((p, id) => {
@@ -233,48 +339,130 @@ export default function SessionPage() {
const duration = useMemo(() => modeDurationSeconds(mode), [mode]);
const [goal, setGoal] = useState("");
const [nextAction, setNextAction] = useState("");
const [isAway, setIsAway] = useState(false);
const [awaySeconds, setAwaySeconds] = useState(0);
// presence (로컬)
const presenceStatus: PresenceStatus = isAway ? "away" : "focus";
const roomKey = "lounge"; // 나중에 방 분리하면 여기만 바꾸면 됨
const roomKey = "lounge";
const { participants } = useLocalPresence(roomKey, presenceStatus);
// time
const [elapsed, setElapsed] = useState(0);
const [remaining, setRemaining] = useState(duration ?? 0);
// refs (interval에서 최신값 쓰기)
const elapsedRef = useRef(0);
const awayRef = useRef(0);
const isAwayRef = useRef(false);
useEffect(() => {
isAwayRef.current = isAway;
}, [isAway]);
// toast
const [toast, setToast] = useState<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);
};
// 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);
};
// 세션 초기화(모드/길이 바뀔 때)
useEffect(() => {
setElapsed(0);
elapsedRef.current = 0;
setAwaySeconds(0);
awayRef.current = 0;
if (duration) setRemaining(duration);
lastCheckpointRef.current = 0;
setIsAway(false);
isAwayRef.current = false;
setToast(null);
if (toastTimerRef.current) window.clearTimeout(toastTimerRef.current);
shownCheckinsRef.current = new Set();
resetRecenter();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [duration]);
// 목표/다음 단계(고정) 로드
useEffect(() => {
setGoal(localStorage.getItem("hushroom:session-goal") ?? "");
setNextAction(localStorage.getItem("hushroom:session-nextAction") ?? "");
}, []);
// 메인 타이머
useEffect(() => {
const id = window.setInterval(() => {
setElapsed((prev) => prev + 1);
setElapsed((prev) => {
const next = prev + 1;
elapsedRef.current = next;
return next;
});
if (isAwayRef.current) {
setAwaySeconds((prev) => {
const next = prev + 1;
awayRef.current = next;
return next;
});
}
if (duration) {
setRemaining((prev) => {
const next = Math.max(0, prev - 1);
if (next === 0 && prev !== 0) {
router.push(`/session/end?mode=${mode}&elapsed=${duration}`);
localStorage.setItem("hushroom:session-elapsed", String(duration));
localStorage.setItem(
"hushroom:session-away",
String(awayRef.current),
);
localStorage.setItem("hushroom:session-end-reason", "timed_out");
router.push(`/session/end?mode=${mode}`);
}
return next;
});
@@ -284,6 +472,7 @@ export default function SessionPage() {
return () => window.clearInterval(id);
}, [duration, mode, router]);
// freeflow 60분 토스트
useEffect(() => {
if (mode !== "freeflow") return;
const mins = Math.floor(elapsed / 60);
@@ -293,23 +482,115 @@ export default function SessionPage() {
}
}, [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]);
// 2분 미션 카운트다운
useEffect(() => {
if (!mission) return;
const tick = () => {
const left = Math.max(0, Math.ceil((mission.endsAt - Date.now()) / 1000));
setMissionLeft(left);
if (left === 0) setCheckinStep("done");
};
tick();
const id = window.setInterval(tick, 250);
return () => window.clearInterval(id);
}, [mission]);
const timeMain = useMemo(() => {
if (mode === "freeflow") return formatHHMMSS(elapsed);
return formatHHMMSS(remaining);
}, [elapsed, remaining, mode]);
const onEnd = () =>
router.push(`/session/end?mode=${mode}&elapsed=${elapsed}`);
const focusSeconds = Math.max(0, elapsed - awaySeconds);
const openRecenter = () => {
setRecenterReason(null);
setMission(null);
setMissionLeft(0);
setCheckinStep("reason");
setCheckinOpen(true);
};
const startMission = (opt: RecenterOption) => {
const endsAt = Date.now() + 2 * 60 * 1000;
setMission({ option: opt, endsAt });
setMissionLeft(120);
setCheckinStep("running");
setCheckinOpen(true);
showToast("좋아요. 딱 2분만.");
};
const endMissionEarly = () => {
if (!mission) return;
setMission({ ...mission, endsAt: Date.now() });
setMissionLeft(0);
setCheckinStep("done");
};
const pinMissionAsNext = () => {
if (!mission) return;
const text = mission.option.pinText.trim();
if (!text) return;
setNextAction(text);
localStorage.setItem("hushroom:session-nextAction", text);
showToast("다음 단계로 고정했어요.");
resetRecenter();
};
const unpinNext = () => {
setNextAction("");
localStorage.removeItem("hushroom:session-nextAction");
showToast("고정을 해제했어요.");
};
const onEnd = () => {
localStorage.setItem(
"hushroom:session-elapsed",
String(elapsedRef.current),
);
localStorage.setItem("hushroom:session-away", String(awayRef.current));
localStorage.setItem("hushroom:session-end-reason", "manual");
router.push(`/session/end?mode=${mode}`);
};
return (
<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 justify-center flex min-h-[calc(100vh-64px)] max-w-lg flex-col px-5 pb-10 pt-6">
<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}
@@ -322,6 +603,8 @@ export default function SessionPage() {
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>
@@ -331,11 +614,246 @@ export default function SessionPage() {
: "한 번 실행되고 끝나면 요약으로 이동"}
</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>
)}
{/* Next action (고정) */}
{nextAction && (
<div
className="mt-3 rounded-2xl border bg-white px-4 py-3"
style={{ borderColor: BORDER }}
>
<div className="flex items-center justify-between gap-3">
<div className="text-xs font-semibold text-slate-600">
()
</div>
<button
type="button"
className="rounded-lg border px-2 py-1 text-xs font-semibold text-slate-700 transition hover:bg-[#F1F5FF] active:scale-[0.99]"
style={{ borderColor: BORDER }}
onClick={unpinNext}
>
</button>
</div>
<div className="mt-1 text-xl font-semibold text-blue-700 line-clamp-2 break-words">
{nextAction}
</div>
</div>
)}
</div>
<div className="text-[44px] font-semibold leading-none text-slate-900 tabular-nums">
{timeMain}
</div>
</div>
{/* Check-in / Recenter banner (임시 미션) */}
{checkinOpen && (
<div
className="mt-4 rounded-2xl border bg-white px-4 py-3 text-sm text-slate-800 shadow-sm"
style={{ borderColor: BORDER }}
>
{/* ASK */}
{checkinStep === "ask" && (
<div className="flex items-center justify-between gap-3">
<div className="font-semibold"> ?</div>
<div className="flex gap-2">
<button
type="button"
className="rounded-xl border px-3 py-2 font-semibold transition active:scale-[0.99]"
style={{ borderColor: BORDER }}
onClick={() => {
setCheckinOpen(false);
setCheckinStep("ask");
showToast("좋아요. 이어가요.");
}}
>
</button>
</div>
</div>
)}
{/* REASON */}
{checkinStep === "reason" && (
<div>
<div className="font-semibold"> ?</div>
<div className="mt-2 grid grid-cols-2 gap-2">
{(
[
"distracted",
"stuck",
"tired",
"overwhelmed",
] as RecenterReason[]
).map((r) => (
<button
key={r}
type="button"
className="rounded-2xl border bg-white px-3 py-2 text-left font-semibold transition hover:bg-[#F1F5FF] active:scale-[0.99]"
style={{ borderColor: BORDER }}
onClick={() => {
setRecenterReason(r);
setCheckinStep("pick");
}}
>
{reasonLabel(r)}
</button>
))}
</div>
<div className="mt-2 flex justify-end">
<button
type="button"
className="rounded-xl border px-3 py-2 font-semibold transition active:scale-[0.99]"
style={{ borderColor: BORDER }}
onClick={() => resetRecenter()}
>
</button>
</div>
</div>
)}
{/* PICK */}
{checkinStep === "pick" && recenterReason && (
<div>
<div className="flex items-center justify-between gap-3">
<div className="font-semibold">
2 ({reasonLabel(recenterReason)})
</div>
<button
type="button"
className="rounded-xl border px-3 py-2 font-semibold transition active:scale-[0.99]"
style={{ borderColor: BORDER }}
onClick={() => setCheckinStep("reason")}
>
</button>
</div>
<div className="mt-2 grid gap-2">
{recenterOptions(goal, recenterReason).map((opt) => (
<button
key={opt.id}
type="button"
className="rounded-2xl border px-3 py-2 text-left font-semibold transition hover:bg-[#F1F5FF] active:scale-[0.99]"
style={{ borderColor: BORDER }}
onClick={() => startMission(opt)}
>
{opt.label}
</button>
))}
</div>
<div className="mt-2 text-xs text-slate-500">
. 2 .
</div>
</div>
)}
{/* RUNNING */}
{checkinStep === "running" && mission && (
<div>
<div className="flex items-start justify-between gap-3">
<div>
<div className="font-semibold">
· {missionLeft}s
</div>
<div className="mt-1 text-slate-700">
{mission.option.label}
</div>
</div>
<button
type="button"
className="rounded-xl border px-3 py-2 font-semibold transition active:scale-[0.99]"
style={{ borderColor: BORDER }}
onClick={endMissionEarly}
>
</button>
</div>
<div className="mt-2 text-xs text-slate-500">
2 .
</div>
</div>
)}
{/* DONE */}
{checkinStep === "done" && mission && (
<div>
<div className="font-semibold">
. .
</div>
<div className="mt-3 flex flex-wrap gap-2">
<button
type="button"
className="rounded-xl border px-3 py-2 font-semibold transition active:scale-[0.99]"
style={{ borderColor: BORDER }}
onClick={() => {
resetRecenter(); // 기본: 임시 미션은 사라짐
showToast("좋아요. 목표로 돌아가요.");
}}
>
</button>
<button
type="button"
className="rounded-xl border px-3 py-2 font-semibold transition active:scale-[0.99]"
style={{ borderColor: BORDER }}
onClick={() => {
setMission(null);
setMissionLeft(0);
setCheckinStep(recenterReason ? "pick" : "reason");
}}
>
2
</button>
<button
type="button"
className="rounded-xl px-3 py-2 font-semibold text-white transition active:scale-[0.99]"
style={{ backgroundColor: PRIMARY }}
onMouseEnter={(e) =>
(e.currentTarget.style.backgroundColor = PRIMARY_HOVER)
}
onMouseLeave={(e) =>
(e.currentTarget.style.backgroundColor = PRIMARY)
}
onClick={pinMissionAsNext}
>
</button>
</div>
<div className="mt-2 text-xs text-slate-500">
. .
</div>
</div>
)}
</div>
)}
{/* Actions */}
<div className="mt-5 grid grid-cols-2 gap-3">
<button

18
src/components/Modal.tsx Normal file
View File

@@ -0,0 +1,18 @@
// import { ReactNode } from "react";
// interface ModalProps {
// open: boolean;
// onClose: () => void;
// children: ReactNode;
// }
// export default function Modal({ open, onClose, children }: ModalProps) {
// return (
// <div
// onClick={onClose}
// className={`flex fixed inset-0 justify-center items-center transition-colors ${open ? "visible bg-black/20" : "invisible"}`}
// >
// {children}
// </div>
// );
// }