Checkpoint: Initial stable CheckFlow base before i18n and demo mode

This commit is contained in:
2026-08-20 14:01:12 +09:00
parent ab601d90ac
commit 3e56120b00
42 changed files with 4727 additions and 167 deletions
+372
View File
@@ -0,0 +1,372 @@
"use client";
import { useState, useEffect, useCallback, useRef } from "react";
interface Task {
id: string; listId: string; parentId: string | null; title: string; note: string | null;
completed: boolean; completedAt: string | null; dueDate: string | null; priority: number;
sortOrder: number; createdAt: string; updatedAt: string;
children: Task[]; tags: { tag: { id: string; name: string; color: string } }[];
}
const PRIORITY_MAP = [
{ label: "None", color: "var(--text-tertiary)", icon: "" },
{ label: "Low", color: "var(--priority-low)", icon: "▼" },
{ label: "Medium", color: "var(--priority-medium)", icon: "▶" },
{ label: "High", color: "var(--priority-high)", icon: "▲" },
];
interface Props {
task: Task; listId: string;
onClose: () => void;
onUpdate: (t: Task) => void;
onDelete: () => void;
}
export function TaskDetail({ task, listId, onClose, onUpdate, onDelete }: Props) {
const [title, setTitle] = useState(task.title);
const [note, setNote] = useState(task.note || "");
const [dueDate, setDueDate] = useState(task.dueDate ? task.dueDate.split("T")[0] : "");
const [priority, setPriority] = useState(task.priority);
const [newSubtitle, setNewSubtitle] = useState("");
const [subtasks, setSubtasks] = useState(task.children || []);
const [saving, setSaving] = useState(false);
// Store timer + current task id in refs to prevent stale saves across task switches
const saveTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
const currentTaskId = useRef(task.id);
const noteRef = useRef<HTMLTextAreaElement>(null);
// Sync state when switching to a different task — clear any pending timer first
useEffect(() => {
if (saveTimer.current) {
clearTimeout(saveTimer.current);
saveTimer.current = null;
}
currentTaskId.current = task.id;
setTitle(task.title);
setNote(task.note || "");
setDueDate(task.dueDate ? task.dueDate.split("T")[0] : "");
setPriority(task.priority);
setSubtasks(task.children || []);
}, [task.id]);
// Cleanup timer on unmount
useEffect(() => {
return () => {
if (saveTimer.current) clearTimeout(saveTimer.current);
};
}, []);
const save = useCallback(async (taskId: string, data: Record<string, unknown>) => {
// Guard: don't save if task has changed since debounce was scheduled
if (taskId !== currentTaskId.current) return;
setSaving(true);
try {
const res = await fetch(`/api/tasks/${taskId}`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(data),
});
if (res.ok) {
const updated = await res.json();
// Only update if we're still on the same task
if (taskId === currentTaskId.current) {
onUpdate({ ...updated, children: subtasks });
}
}
} catch (err) {
console.error("[TaskDetail] save failed", err);
} finally {
if (taskId === currentTaskId.current) setSaving(false);
}
}, [onUpdate, subtasks]);
const debounceSave = useCallback((overrides: Record<string, unknown>) => {
if (saveTimer.current) clearTimeout(saveTimer.current);
const taskId = currentTaskId.current;
saveTimer.current = setTimeout(() => save(taskId, overrides), 800);
}, [save]);
// Insert text at cursor position in the note textarea
const insertAtCursor = useCallback((before: string, after = "", placeholder = "") => {
const ta = noteRef.current;
if (!ta) {
setNote((n) => { const v = n + before + placeholder + after; debounceSave({ note: v }); return v; });
return;
}
const start = ta.selectionStart ?? ta.value.length;
const end = ta.selectionEnd ?? ta.value.length;
const selected = ta.value.slice(start, end) || placeholder;
const newVal = ta.value.slice(0, start) + before + selected + after + ta.value.slice(end);
setNote(newVal);
debounceSave({ note: newVal });
// Restore cursor after React re-render
requestAnimationFrame(() => {
ta.focus();
ta.selectionStart = start + before.length;
ta.selectionEnd = start + before.length + selected.length;
});
}, [debounceSave]);
const handleToggleCompleted = useCallback(async (newCompleted: boolean) => {
try {
const res = await fetch(`/api/tasks/${task.id}`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ completed: newCompleted }),
});
if (res.ok) {
const updated = await res.json();
onUpdate({ ...updated, children: subtasks });
}
} catch (err) {
console.error("[TaskDetail] toggle failed", err);
}
}, [task.id, subtasks, onUpdate]);
const handleDelete = useCallback(async () => {
if (!confirm("Delete this task?")) return;
try {
await fetch(`/api/tasks/${task.id}`, { method: "DELETE" });
onDelete();
} catch (err) {
console.error("[TaskDetail] delete failed", err);
}
}, [task.id, onDelete]);
const addSubtask = useCallback(async () => {
const t = newSubtitle.trim();
if (!t) return;
try {
const res = await fetch("/api/tasks", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ title: t, listId, parentId: task.id }),
});
if (res.ok) {
const sub = await res.json();
setSubtasks((prev) => {
const next = [...prev, sub];
onUpdate({ ...task, children: next });
return next;
});
setNewSubtitle("");
}
} catch (err) {
console.error("[TaskDetail] addSubtask failed", err);
}
}, [newSubtitle, listId, task, onUpdate]);
const toggleSubtask = useCallback(async (sub: Task) => {
try {
const res = await fetch(`/api/tasks/${sub.id}`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ completed: !sub.completed }),
});
if (res.ok) {
const updated = await res.json();
setSubtasks((prev) => {
const next = prev.map((s) => (s.id === sub.id ? updated : s));
onUpdate({ ...task, children: next });
return next;
});
}
} catch (err) {
console.error("[TaskDetail] toggleSubtask failed", err);
}
}, [task, onUpdate]);
const deleteSubtask = useCallback(async (id: string) => {
try {
await fetch(`/api/tasks/${id}`, { method: "DELETE" });
setSubtasks((prev) => {
const next = prev.filter((s) => s.id !== id);
onUpdate({ ...task, children: next });
return next;
});
} catch (err) {
console.error("[TaskDetail] deleteSubtask failed", err);
}
}, [task, onUpdate]);
const completedCount = subtasks.filter((s) => s.completed).length;
const progressPct = subtasks.length > 0 ? Math.round((completedCount / subtasks.length) * 100) : 0;
return (
<aside className="detail-panel">
{/* Header */}
<div className="detail-header">
<button
className={`task-check-btn${task.completed ? " checked" : ""}`}
style={{ width: 22, height: 22 }}
onClick={() => handleToggleCompleted(!task.completed)}
aria-label={task.completed ? "Mark incomplete" : "Mark complete"}
/>
<span style={{ flex: 1, fontSize: 12, color: "var(--text-tertiary)", fontWeight: 500 }}>
{saving ? "Saving…" : "Auto-saved"}
</span>
<button className="btn btn-ghost btn-sm btn-danger" id="delete-task-btn" onClick={handleDelete} title="Delete task">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><polyline points="3 6 5 6 21 6"/><path d="M19 6l-1 14H6L5 6"/><path d="M10 11v6M14 11v6"/><path d="M9 6V4h6v2"/></svg>
</button>
<button className="detail-close-btn" id="detail-close-btn" onClick={onClose} title="Close">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>
</button>
</div>
{/* Body */}
<div className="detail-body">
{/* Title */}
<textarea
id="detail-title"
className="detail-title-input"
value={title}
onChange={(e) => {
setTitle(e.target.value);
debounceSave({ title: e.target.value });
}}
placeholder="Task title"
rows={2}
style={{
textDecoration: task.completed ? "line-through" : "none",
color: task.completed ? "var(--text-tertiary)" : "var(--text-primary)",
}}
/>
{/* Priority */}
<div className="detail-section">
<div className="detail-section-label">Priority</div>
<div className="priority-selector">
{PRIORITY_MAP.map((p, i) => (
<button
key={i}
id={`priority-${i}`}
className={`priority-btn${priority === i ? ` active-${p.label.toLowerCase()}` : ""}`}
style={{ color: priority === i ? p.color : undefined }}
onClick={() => { setPriority(i); debounceSave({ priority: i }); }}
>
{p.icon && <span style={{ color: p.color }}>{p.icon}</span>}
{p.label}
</button>
))}
</div>
</div>
{/* Due date */}
<div className="detail-section">
<div className="detail-section-label">Due Date</div>
<div style={{ display: "flex", gap: 8, alignItems: "center" }}>
<input
id="due-date-input"
type="date"
className="form-input"
value={dueDate}
onChange={(e) => {
setDueDate(e.target.value);
debounceSave({ dueDate: e.target.value || null });
}}
style={{ width: "auto" }}
/>
{dueDate && (
<button
className="btn btn-ghost btn-sm"
onClick={() => { setDueDate(""); debounceSave({ dueDate: null }); }}
>
Clear
</button>
)}
</div>
</div>
{/* Note / Memo */}
<div className="detail-section" style={{ flex: 1 }}>
<div className="detail-section-label">Notes</div>
<div className="note-editor-wrap">
<div className="note-editor-toolbar">
<button className="note-toolbar-btn" title="Bold (Ctrl+B)" onClick={() => insertAtCursor("**", "**", "bold")}>B</button>
<button className="note-toolbar-btn" title="Italic (Ctrl+I)" style={{ fontStyle: "italic" }} onClick={() => insertAtCursor("*", "*", "italic")}>I</button>
<button className="note-toolbar-btn" title="Heading" onClick={() => insertAtCursor("## ", "", "Heading")}>H</button>
<button className="note-toolbar-btn" title="Bullet list" onClick={() => insertAtCursor("\n- ", "", "item")}></button>
<button className="note-toolbar-btn" title="Numbered list" onClick={() => insertAtCursor("\n1. ", "", "item")}>1.</button>
<button className="note-toolbar-btn" title="Checkbox" onClick={() => insertAtCursor("\n- [ ] ", "", "task")}></button>
<button className="note-toolbar-btn" title="Code" style={{ fontFamily: "monospace", fontSize: 11 }} onClick={() => insertAtCursor("`", "`", "code")}>{"`"}</button>
</div>
<textarea
ref={noteRef}
id="note-textarea"
className="note-textarea"
placeholder={"Add notes, details, or anything you need to remember…\n\nMarkdown: **bold**, *italic*, # heading, - list, - [ ] checkbox"}
value={note}
onChange={(e) => {
setNote(e.target.value);
debounceSave({ note: e.target.value });
}}
/>
</div>
</div>
{/* Sub-tasks */}
<div className="detail-section">
<div style={{ display: "flex", alignItems: "center", justifyContent: "space-between", marginBottom: 8 }}>
<div className="detail-section-label">Sub-tasks</div>
{subtasks.length > 0 && (
<span style={{ fontSize: 11, color: "var(--text-tertiary)" }}>{completedCount}/{subtasks.length}</span>
)}
</div>
{subtasks.length > 0 && (
<div className="progress-bar" style={{ marginBottom: 10 }}>
<div className="progress-bar-fill" style={{ width: `${progressPct}%` }} />
</div>
)}
<div className="detail-subtasks">
{subtasks.map((sub) => (
<div
key={sub.id}
className={`detail-subtask-row${sub.completed ? " completed" : ""}`}
id={`detail-sub-${sub.id}`}
>
<button
className={`subtask-check-btn${sub.completed ? " checked" : ""}`}
onClick={() => toggleSubtask(sub)}
aria-label="Toggle subtask"
/>
<span className="detail-subtask-title">{sub.title}</span>
<button
className="icon-btn"
style={{ width: 20, height: 20, opacity: 0.4 }}
onClick={() => deleteSubtask(sub.id)}
aria-label="Delete subtask"
>
<svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>
</button>
</div>
))}
<div className="add-subtask-row">
<span style={{ fontSize: 16, lineHeight: 1, color: "var(--accent)" }}>+</span>
<input
id="add-subtask-input"
placeholder="Add sub-task…"
style={{ flex: 1, background: "none", fontSize: 13, color: "var(--text-primary)" }}
value={newSubtitle}
onChange={(e) => setNewSubtitle(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter") { e.preventDefault(); addSubtask(); }
}}
/>
{newSubtitle.trim() && (
<button className="btn btn-primary btn-sm" id="add-subtask-btn" onClick={addSubtask}>Add</button>
)}
</div>
</div>
</div>
{/* Metadata */}
<div style={{ fontSize: 11, color: "var(--text-tertiary)", paddingTop: 8, borderTop: "1px solid var(--border)" }}>
Created {new Date(task.createdAt).toLocaleDateString("ko-KR", { year: "numeric", month: "short", day: "numeric" })}
</div>
</div>
</aside>
);
}