@walknpm/todo-cli 1.1.0 → 2.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +208 -141
- package/README.zh-CN.md +158 -150
- package/package.json +20 -6
- package/skill/CHANGELOG.md +8 -0
- package/skill/README.md +48 -0
- package/skill/SKILL.md +224 -0
- package/src/cli.js +396 -24
- package/src/server.js +422 -0
- package/src/todo.js +544 -35
- package/web/css/style.css +804 -0
- package/web/index.html +216 -0
- package/web/js/app.js +934 -0
package/web/js/app.js
ADDED
|
@@ -0,0 +1,934 @@
|
|
|
1
|
+
// app.js — full SPA application logic for todo-cli web UI.
|
|
2
|
+
// Zero dependencies — vanilla JS with ES modules.
|
|
3
|
+
|
|
4
|
+
const API = "";
|
|
5
|
+
|
|
6
|
+
// ─── API helpers ───────────────────────────────────────────────────────────
|
|
7
|
+
|
|
8
|
+
async function api(path, opts = {}) {
|
|
9
|
+
const res = await fetch(API + "/api/" + path, {
|
|
10
|
+
method: opts.method || "GET",
|
|
11
|
+
headers: opts.body ? { "Content-Type": "application/json" } : {},
|
|
12
|
+
body: opts.body ? JSON.stringify(opts.body) : undefined,
|
|
13
|
+
});
|
|
14
|
+
if (!res.ok) {
|
|
15
|
+
const err = await res.json().catch(() => ({ error: res.statusText }));
|
|
16
|
+
throw new Error(err.error || "Request failed");
|
|
17
|
+
}
|
|
18
|
+
return res.json();
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
// ─── State ─────────────────────────────────────────────────────────────────
|
|
22
|
+
|
|
23
|
+
const state = {
|
|
24
|
+
todos: [],
|
|
25
|
+
stats: {},
|
|
26
|
+
view: "dashboard",
|
|
27
|
+
filter: null, // null | "pending" | "done" | "overdue" | "today" | "pinned"
|
|
28
|
+
search: "",
|
|
29
|
+
category: null,
|
|
30
|
+
sort: "priority",
|
|
31
|
+
activeTimer: null,
|
|
32
|
+
calendarDate: new Date(),
|
|
33
|
+
selectedTodo: null,
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
// ─── Utils ─────────────────────────────────────────────────────────────────
|
|
37
|
+
|
|
38
|
+
const $ = (sel) => document.querySelector(sel);
|
|
39
|
+
const $$ = (sel) => [...document.querySelectorAll(sel)];
|
|
40
|
+
|
|
41
|
+
function fmtDate(d) {
|
|
42
|
+
if (!d) return "";
|
|
43
|
+
return d;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function fmtTime(minutes) {
|
|
47
|
+
const m = Math.round(Number(minutes) || 0);
|
|
48
|
+
if (m < 60) return m + "m";
|
|
49
|
+
const h = Math.floor(m / 60);
|
|
50
|
+
const rem = m % 60;
|
|
51
|
+
return rem ? h + "h " + rem + "m" : h + "h";
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function dueStatus(due) {
|
|
55
|
+
if (!due) return null;
|
|
56
|
+
const today = new Date();
|
|
57
|
+
const t = new Date(today.getFullYear(), today.getMonth(), today.getDate());
|
|
58
|
+
const d = new Date(due + "T00:00:00");
|
|
59
|
+
const diff = Math.round((d - t) / 86400000);
|
|
60
|
+
if (diff < 0) return "overdue";
|
|
61
|
+
if (diff === 0) return "today";
|
|
62
|
+
if (diff <= 3) return "soon";
|
|
63
|
+
return null;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function escapeHtml(s) {
|
|
67
|
+
return String(s ?? "").replace(/[&<>"']/g, (c) => ({
|
|
68
|
+
"&": "&", "<": "<", ">": ">", '"': """, "'": "'",
|
|
69
|
+
}[c]));
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function toast(msg, type = "info") {
|
|
73
|
+
const el = document.createElement("div");
|
|
74
|
+
el.className = "toast " + type;
|
|
75
|
+
el.textContent = msg;
|
|
76
|
+
$("#toast-container").appendChild(el);
|
|
77
|
+
setTimeout(() => { el.style.opacity = "0"; setTimeout(() => el.remove(), 300); }, 3000);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// ─── Data loading ──────────────────────────────────────────────────────────
|
|
81
|
+
|
|
82
|
+
async function loadData() {
|
|
83
|
+
try {
|
|
84
|
+
const [todos, stats, timer] = await Promise.all([
|
|
85
|
+
api("todos"),
|
|
86
|
+
api("stats"),
|
|
87
|
+
api("timer").catch(() => null),
|
|
88
|
+
]);
|
|
89
|
+
state.todos = todos;
|
|
90
|
+
state.stats = stats;
|
|
91
|
+
state.activeTimer = timer;
|
|
92
|
+
updateSidebarCounts();
|
|
93
|
+
updateCategoryList();
|
|
94
|
+
updateFloatingTimer();
|
|
95
|
+
} catch (err) {
|
|
96
|
+
toast("Failed to load: " + err.message, "error");
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
// ─── Sidebar ───────────────────────────────────────────────────────────────
|
|
101
|
+
|
|
102
|
+
function updateSidebarCounts() {
|
|
103
|
+
const s = state.stats;
|
|
104
|
+
$("#count-pending").textContent = s.pending || 0;
|
|
105
|
+
$("#count-done").textContent = s.done || 0;
|
|
106
|
+
$("#count-overdue").textContent = s.overdue || 0;
|
|
107
|
+
$("#count-today").textContent = s.dueToday || 0;
|
|
108
|
+
$("#count-pinned").textContent = s.pinned || 0;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function updateCategoryList() {
|
|
112
|
+
const cats = Object.keys(state.stats.byCategory || {}).sort();
|
|
113
|
+
const el = $("#category-list");
|
|
114
|
+
el.innerHTML = cats.map((c) => `
|
|
115
|
+
<button class="nav-item" data-category="${escapeHtml(c)}">
|
|
116
|
+
<span>📁</span>
|
|
117
|
+
<span>${escapeHtml(c)}</span>
|
|
118
|
+
<span class="badge">${state.stats.byCategory[c]}</span>
|
|
119
|
+
</button>
|
|
120
|
+
`).join("");
|
|
121
|
+
el.querySelectorAll("[data-category]").forEach((btn) => {
|
|
122
|
+
btn.addEventListener("click", () => {
|
|
123
|
+
state.category = btn.dataset.category;
|
|
124
|
+
state.view = "list";
|
|
125
|
+
state.filter = null;
|
|
126
|
+
render();
|
|
127
|
+
});
|
|
128
|
+
});
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
// ─── Views ─────────────────────────────────────────────────────────────────
|
|
132
|
+
|
|
133
|
+
function render() {
|
|
134
|
+
// Update nav active state
|
|
135
|
+
$$(".nav-item").forEach((n) => n.classList.remove("active"));
|
|
136
|
+
const activeNav = document.querySelector(`[data-view="${state.view}"]`);
|
|
137
|
+
if (activeNav) activeNav.classList.add("active");
|
|
138
|
+
if (state.filter) {
|
|
139
|
+
const f = document.querySelector(`[data-filter="${state.filter}"]`);
|
|
140
|
+
if (f) f.classList.add("active");
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
const titles = {
|
|
144
|
+
dashboard: "Dashboard", list: "All Todos", kanban: "Kanban Board",
|
|
145
|
+
calendar: "Calendar", timeline: "Timeline",
|
|
146
|
+
};
|
|
147
|
+
let title = titles[state.view] || "Dashboard";
|
|
148
|
+
if (state.filter) title = state.filter.charAt(0).toUpperCase() + state.filter.slice(1);
|
|
149
|
+
if (state.category) title = "Category: " + state.category;
|
|
150
|
+
$("#page-title").textContent = title;
|
|
151
|
+
|
|
152
|
+
const content = $("#content");
|
|
153
|
+
switch (state.view) {
|
|
154
|
+
case "dashboard": content.innerHTML = renderDashboard(); break;
|
|
155
|
+
case "list": content.innerHTML = renderList(); break;
|
|
156
|
+
case "kanban": content.innerHTML = renderKanban(); break;
|
|
157
|
+
case "calendar": content.innerHTML = renderCalendar(); break;
|
|
158
|
+
case "timeline": content.innerHTML = renderTimeline(); break;
|
|
159
|
+
}
|
|
160
|
+
attachViewEvents();
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
// ─── Dashboard View ────────────────────────────────────────────────────────
|
|
164
|
+
|
|
165
|
+
function renderDashboard() {
|
|
166
|
+
const s = state.stats;
|
|
167
|
+
const recent = [...state.todos].sort((a, b) => (b.createdAt || "").localeCompare(a.createdAt || "")).slice(0, 5);
|
|
168
|
+
const upcoming = state.todos
|
|
169
|
+
.filter((t) => !t.done && t.due)
|
|
170
|
+
.sort((a, b) => a.due.localeCompare(b.due))
|
|
171
|
+
.slice(0, 5);
|
|
172
|
+
|
|
173
|
+
return `
|
|
174
|
+
<div class="stats-grid">
|
|
175
|
+
<div class="stat-card">
|
|
176
|
+
<div class="stat-icon bg-primary">📋</div>
|
|
177
|
+
<div class="stat-info">
|
|
178
|
+
<div class="stat-value">${s.total || 0}</div>
|
|
179
|
+
<div class="stat-label">Total Todos</div>
|
|
180
|
+
</div>
|
|
181
|
+
</div>
|
|
182
|
+
<div class="stat-card">
|
|
183
|
+
<div class="stat-icon bg-success">✓</div>
|
|
184
|
+
<div class="stat-info">
|
|
185
|
+
<div class="stat-value">${s.done || 0}</div>
|
|
186
|
+
<div class="stat-label">Completed</div>
|
|
187
|
+
</div>
|
|
188
|
+
</div>
|
|
189
|
+
<div class="stat-card">
|
|
190
|
+
<div class="stat-icon bg-warning">○</div>
|
|
191
|
+
<div class="stat-info">
|
|
192
|
+
<div class="stat-value">${s.pending || 0}</div>
|
|
193
|
+
<div class="stat-label">Pending</div>
|
|
194
|
+
</div>
|
|
195
|
+
</div>
|
|
196
|
+
<div class="stat-card">
|
|
197
|
+
<div class="stat-icon bg-danger">⚠</div>
|
|
198
|
+
<div class="stat-info">
|
|
199
|
+
<div class="stat-value">${s.overdue || 0}</div>
|
|
200
|
+
<div class="stat-label">Overdue</div>
|
|
201
|
+
</div>
|
|
202
|
+
</div>
|
|
203
|
+
</div>
|
|
204
|
+
|
|
205
|
+
<div class="stat-card" style="margin-bottom:24px">
|
|
206
|
+
<div class="stat-info" style="flex:1">
|
|
207
|
+
<div style="display:flex;justify-content:space-between;align-items:center">
|
|
208
|
+
<span style="font-weight:600">Completion Rate</span>
|
|
209
|
+
<span style="font-size:20px;font-weight:700;color:var(--primary)">${s.completionRate || 0}%</span>
|
|
210
|
+
</div>
|
|
211
|
+
<div class="progress-bar"><div class="progress-fill" style="width:${s.completionRate || 0}%"></div></div>
|
|
212
|
+
</div>
|
|
213
|
+
</div>
|
|
214
|
+
|
|
215
|
+
<div style="display:grid;grid-template-columns:1fr 1fr;gap:24px">
|
|
216
|
+
<div>
|
|
217
|
+
<h3 style="margin-bottom:12px;font-size:16px">Recent Todos</h3>
|
|
218
|
+
${recent.length ? recent.map(t => todoCardHTML(t)).join("") : '<div class="empty-state"><div class="empty-state-text">No todos yet</div></div>'}
|
|
219
|
+
</div>
|
|
220
|
+
<div>
|
|
221
|
+
<h3 style="margin-bottom:12px;font-size:16px">Upcoming Deadlines</h3>
|
|
222
|
+
${upcoming.length ? upcoming.map(t => todoCardHTML(t)).join("") : '<div class="empty-state"><div class="empty-state-text">No upcoming deadlines</div></div>'}
|
|
223
|
+
</div>
|
|
224
|
+
</div>
|
|
225
|
+
`;
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
function todoCardHTML(t) {
|
|
229
|
+
const ds = dueStatus(t.due);
|
|
230
|
+
const dueClass = ds === "overdue" ? "overdue" : ds === "today" ? "due-today" : "";
|
|
231
|
+
return `
|
|
232
|
+
<div class="todo-item ${t.done ? "done" : ""} ${t.pinned ? "pinned" : ""}" data-id="${t.id}" onclick="window.app.showDetail(${t.id})">
|
|
233
|
+
<div class="todo-checkbox ${t.done ? "checked" : ""}" onclick="event.stopPropagation();window.app.toggleTodo(${t.id})"></div>
|
|
234
|
+
<div class="todo-content">
|
|
235
|
+
<div class="todo-title">${escapeHtml(t.title)}</div>
|
|
236
|
+
<div class="todo-meta">
|
|
237
|
+
<span class="priority-badge priority-${t.priority}">${t.priority}</span>
|
|
238
|
+
${t.category ? `<span class="meta-item">📁 ${escapeHtml(t.category)}</span>` : ""}
|
|
239
|
+
${t.due ? `<span class="meta-item ${dueClass}">📅 ${t.due}</span>` : ""}
|
|
240
|
+
${t.assignee ? `<span class="meta-item">👤 ${escapeHtml(t.assignee)}</span>` : ""}
|
|
241
|
+
${t.pinned ? '<span class="meta-item">📌</span>' : ""}
|
|
242
|
+
${t.recurring ? '<span class="meta-item">⟳</span>' : ""}
|
|
243
|
+
${t.timerStartedAt ? '<span class="meta-item">⏱</span>' : ""}
|
|
244
|
+
</div>
|
|
245
|
+
</div>
|
|
246
|
+
</div>
|
|
247
|
+
`;
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
// ─── List View ─────────────────────────────────────────────────────────────
|
|
251
|
+
|
|
252
|
+
function getFilteredTodos() {
|
|
253
|
+
let todos = state.todos.slice();
|
|
254
|
+
if (state.search) {
|
|
255
|
+
const kw = state.search.toLowerCase();
|
|
256
|
+
todos = todos.filter((t) =>
|
|
257
|
+
t.title.toLowerCase().includes(kw) ||
|
|
258
|
+
t.category.toLowerCase().includes(kw) ||
|
|
259
|
+
t.note.toLowerCase().includes(kw) ||
|
|
260
|
+
t.assignee.toLowerCase().includes(kw) ||
|
|
261
|
+
t.tags.some((tag) => tag.includes(kw))
|
|
262
|
+
);
|
|
263
|
+
}
|
|
264
|
+
if (state.filter === "pending") todos = todos.filter((t) => !t.done);
|
|
265
|
+
else if (state.filter === "done") todos = todos.filter((t) => t.done);
|
|
266
|
+
else if (state.filter === "overdue") todos = todos.filter((t) => !t.done && dueStatus(t.due) === "overdue");
|
|
267
|
+
else if (state.filter === "today") todos = todos.filter((t) => !t.done && dueStatus(t.due) === "today");
|
|
268
|
+
else if (state.filter === "pinned") todos = todos.filter((t) => t.pinned);
|
|
269
|
+
if (state.category) todos = todos.filter((t) => t.category === state.category);
|
|
270
|
+
// Sort: pinned first, then by selected sort
|
|
271
|
+
todos.sort((a, b) => (b.pinned ? 1 : 0) - (a.pinned ? 1 : 0));
|
|
272
|
+
return todos;
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
function renderList() {
|
|
276
|
+
const todos = getFilteredTodos();
|
|
277
|
+
const sorts = ["priority", "created", "due", "id", "title", "updated"];
|
|
278
|
+
return `
|
|
279
|
+
<div class="filter-bar">
|
|
280
|
+
${["all", "pending", "done", "overdue", "today", "pinned"].map((f) => `
|
|
281
|
+
<button class="filter-chip ${(!state.filter && f === "all") || state.filter === f ? "active" : ""}" data-filter-chip="${f}">
|
|
282
|
+
${f.charAt(0).toUpperCase() + f.slice(1)}
|
|
283
|
+
</button>
|
|
284
|
+
`).join("")}
|
|
285
|
+
<select id="sort-select" class="filter-chip" style="padding:5px 12px">
|
|
286
|
+
${sorts.map((s) => `<option value="${s}" ${state.sort === s ? "selected" : ""}>Sort: ${s}</option>`).join("")}
|
|
287
|
+
</select>
|
|
288
|
+
</div>
|
|
289
|
+
<div class="todo-list">
|
|
290
|
+
${todos.length ? todos.map((t) => todoItemHTML(t)).join("") : `
|
|
291
|
+
<div class="empty-state">
|
|
292
|
+
<div class="empty-state-icon">📝</div>
|
|
293
|
+
<div class="empty-state-text">No todos found. Click "New Todo" to create one.</div>
|
|
294
|
+
</div>
|
|
295
|
+
`}
|
|
296
|
+
</div>
|
|
297
|
+
`;
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
function todoItemHTML(t) {
|
|
301
|
+
const ds = dueStatus(t.due);
|
|
302
|
+
const dueClass = ds === "overdue" ? "overdue" : ds === "today" ? "due-today" : "";
|
|
303
|
+
const subtaskProgress = t.subtasks && t.subtasks.length
|
|
304
|
+
? `<div class="todo-subtask-bar"><div class="todo-subtask-fill" style="width:${(t.subtasks.filter(s=>s.done).length/t.subtasks.length*100)}%"></div></div>`
|
|
305
|
+
: "";
|
|
306
|
+
return `
|
|
307
|
+
<div class="todo-item ${t.done ? "done" : ""} ${t.pinned ? "pinned" : ""}" data-id="${t.id}">
|
|
308
|
+
<div class="todo-checkbox ${t.done ? "checked" : ""}" data-toggle="${t.id}"></div>
|
|
309
|
+
<div class="todo-content" data-detail="${t.id}">
|
|
310
|
+
<div class="todo-title">${escapeHtml(t.title)}</div>
|
|
311
|
+
<div class="todo-meta">
|
|
312
|
+
<span class="priority-badge priority-${t.priority}">${t.priority}</span>
|
|
313
|
+
${t.category ? `<span class="meta-item">📁 ${escapeHtml(t.category)}</span>` : ""}
|
|
314
|
+
${t.due ? `<span class="meta-item ${dueClass}">📅 ${t.due}</span>` : ""}
|
|
315
|
+
${t.assignee ? `<span class="meta-item">👤 ${escapeHtml(t.assignee)}</span>` : ""}
|
|
316
|
+
${t.estimate ? `<span class="meta-item">⏱ ${fmtTime(t.estimate)}</span>` : ""}
|
|
317
|
+
${t.timeLogged ? `<span class="meta-item">✓ ${fmtTime(t.timeLogged)}</span>` : ""}
|
|
318
|
+
${t.tags.map((tag) => `<span class="tag">${escapeHtml(tag)}</span>`).join("")}
|
|
319
|
+
${t.pinned ? '<span class="meta-item">📌</span>' : ""}
|
|
320
|
+
${t.recurring ? `<span class="meta-item">⟳ ${t.recurring.interval}${t.recurring.freq.charAt(0)}</span>` : ""}
|
|
321
|
+
${t.timerStartedAt ? '<span class="meta-item" style="color:var(--success)">⏱ running</span>' : ""}
|
|
322
|
+
${t.subtasks && t.subtasks.length ? `<span class="meta-item">☑ ${t.subtasks.filter(s=>s.done).length}/${t.subtasks.length}</span>` : ""}
|
|
323
|
+
</div>
|
|
324
|
+
${subtaskProgress}
|
|
325
|
+
</div>
|
|
326
|
+
<div class="todo-actions">
|
|
327
|
+
<button class="icon-btn" data-pin="${t.id}" title="Pin" style="width:30px;height:30px">${t.pinned ? "📌" : "📍"}</button>
|
|
328
|
+
<button class="icon-btn" data-timer="${t.id}" title="Timer" style="width:30px;height:30px">⏱</button>
|
|
329
|
+
<button class="icon-btn" data-edit="${t.id}" title="Edit" style="width:30px;height:30px">✏</button>
|
|
330
|
+
<button class="icon-btn" data-delete="${t.id}" title="Delete" style="width:30px;height:30px;color:var(--danger)">🗑</button>
|
|
331
|
+
</div>
|
|
332
|
+
</div>
|
|
333
|
+
`;
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
// ─── Kanban View ───────────────────────────────────────────────────────────
|
|
337
|
+
|
|
338
|
+
function renderKanban() {
|
|
339
|
+
const cols = [
|
|
340
|
+
{ key: "pending", title: "📋 Pending", filter: (t) => !t.done },
|
|
341
|
+
{ key: "done", title: "✅ Done", filter: (t) => t.done },
|
|
342
|
+
];
|
|
343
|
+
return `
|
|
344
|
+
<div class="kanban-board">
|
|
345
|
+
${cols.map((col) => {
|
|
346
|
+
const items = state.todos.filter(col.filter).sort((a, b) => (b.pinned?1:0)-(a.pinned?1:0));
|
|
347
|
+
return `
|
|
348
|
+
<div class="kanban-column" data-column="${col.key}">
|
|
349
|
+
<div class="kanban-column-header">
|
|
350
|
+
<span>${col.title}</span>
|
|
351
|
+
<span class="badge">${items.length}</span>
|
|
352
|
+
</div>
|
|
353
|
+
<div class="kanban-column-body" data-drop="${col.key}">
|
|
354
|
+
${items.map((t) => kanbanCardHTML(t)).join("") || '<div style="text-align:center;padding:20px;color:var(--text-faint)">Drop here</div>'}
|
|
355
|
+
</div>
|
|
356
|
+
</div>
|
|
357
|
+
`;
|
|
358
|
+
}).join("")}
|
|
359
|
+
</div>
|
|
360
|
+
`;
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
function kanbanCardHTML(t) {
|
|
364
|
+
const ds = dueStatus(t.due);
|
|
365
|
+
const dueClass = ds === "overdue" ? "overdue" : ds === "today" ? "due-today" : "";
|
|
366
|
+
return `
|
|
367
|
+
<div class="kanban-card" draggable="true" data-id="${t.id}" data-detail="${t.id}">
|
|
368
|
+
<div style="font-weight:500;margin-bottom:6px">${escapeHtml(t.title)}</div>
|
|
369
|
+
<div class="todo-meta">
|
|
370
|
+
<span class="priority-badge priority-${t.priority}">${t.priority}</span>
|
|
371
|
+
${t.category ? `<span class="meta-item">📁 ${escapeHtml(t.category)}</span>` : ""}
|
|
372
|
+
${t.due ? `<span class="meta-item ${dueClass}">📅 ${t.due}</span>` : ""}
|
|
373
|
+
${t.pinned ? '<span class="meta-item">📌</span>' : ""}
|
|
374
|
+
${t.subtasks && t.subtasks.length ? `<span class="meta-item">☑ ${t.subtasks.filter(s=>s.done).length}/${t.subtasks.length}</span>` : ""}
|
|
375
|
+
</div>
|
|
376
|
+
</div>
|
|
377
|
+
`;
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
// ─── Calendar View ────────────────────────────────────────────────────────
|
|
381
|
+
|
|
382
|
+
function renderCalendar() {
|
|
383
|
+
const d = state.calendarDate;
|
|
384
|
+
const year = d.getFullYear();
|
|
385
|
+
const month = d.getMonth();
|
|
386
|
+
const firstDay = new Date(year, month, 1);
|
|
387
|
+
const lastDay = new Date(year, month + 1, 0);
|
|
388
|
+
const startWeekday = firstDay.getDay();
|
|
389
|
+
const daysInMonth = lastDay.getDate();
|
|
390
|
+
const prevMonthDays = new Date(year, month, 0).getDate();
|
|
391
|
+
const today = new Date();
|
|
392
|
+
const todayStr = today.getFullYear() + "-" + String(today.getMonth()+1).padStart(2,"0") + "-" + String(today.getDate()).padStart(2,"0");
|
|
393
|
+
|
|
394
|
+
const monthNames = ["January","February","March","April","May","June","July","August","September","October","November","December"];
|
|
395
|
+
const dayNames = ["Sun","Mon","Tue","Wed","Thu","Fri","Sat"];
|
|
396
|
+
|
|
397
|
+
let cells = [];
|
|
398
|
+
for (let i = startWeekday - 1; i >= 0; i--) {
|
|
399
|
+
cells.push({ day: prevMonthDays - i, otherMonth: true, dateStr: null });
|
|
400
|
+
}
|
|
401
|
+
for (let i = 1; i <= daysInMonth; i++) {
|
|
402
|
+
const dateStr = year + "-" + String(month+1).padStart(2,"0") + "-" + String(i).padStart(2,"0");
|
|
403
|
+
cells.push({ day: i, otherMonth: false, dateStr, isToday: dateStr === todayStr });
|
|
404
|
+
}
|
|
405
|
+
while (cells.length % 7 !== 0) cells.push({ day: cells.length - daysInMonth - startWeekday + 1, otherMonth: true, dateStr: null });
|
|
406
|
+
|
|
407
|
+
return `
|
|
408
|
+
<div class="calendar-header">
|
|
409
|
+
<button class="btn btn-secondary" id="cal-prev">← Prev</button>
|
|
410
|
+
<h2 style="font-size:18px">${monthNames[month]} ${year}</h2>
|
|
411
|
+
<div style="display:flex;gap:8px">
|
|
412
|
+
<button class="btn btn-secondary" id="cal-today">Today</button>
|
|
413
|
+
<button class="btn btn-secondary" id="cal-next">Next →</button>
|
|
414
|
+
</div>
|
|
415
|
+
</div>
|
|
416
|
+
<div class="calendar-grid">
|
|
417
|
+
${dayNames.map((d) => `<div class="calendar-day-header">${d}</div>`).join("")}
|
|
418
|
+
${cells.map((c) => {
|
|
419
|
+
if (c.otherMonth) return '<div class="calendar-day empty"></div>';
|
|
420
|
+
const events = state.todos.filter((t) => t.due === c.dateStr);
|
|
421
|
+
return `
|
|
422
|
+
<div class="calendar-day ${c.isToday ? "today" : ""}">
|
|
423
|
+
<div class="calendar-day-num">${c.day}</div>
|
|
424
|
+
${events.map((t) => `
|
|
425
|
+
<div class="calendar-event ${t.priority} ${t.done ? "done" : ""}" data-detail="${t.id}">${escapeHtml(t.title)}</div>
|
|
426
|
+
`).join("")}
|
|
427
|
+
</div>
|
|
428
|
+
`;
|
|
429
|
+
}).join("")}
|
|
430
|
+
</div>
|
|
431
|
+
`;
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
// ─── Timeline View ─────────────────────────────────────────────────────────
|
|
435
|
+
|
|
436
|
+
function renderTimeline() {
|
|
437
|
+
const todos = [...state.todos].sort((a, b) => (b.createdAt || "").localeCompare(a.createdAt || ""));
|
|
438
|
+
if (!todos.length) {
|
|
439
|
+
return '<div class="empty-state"><div class="empty-state-icon">📜</div><div class="empty-state-text">No activity yet</div></div>';
|
|
440
|
+
}
|
|
441
|
+
return `
|
|
442
|
+
<div class="timeline">
|
|
443
|
+
${todos.map((t) => {
|
|
444
|
+
const icon = t.done ? "✓" : t.timerStartedAt ? "⏱" : t.pinned ? "📌" : "○";
|
|
445
|
+
const bg = t.done ? "bg-success" : t.timerStartedAt ? "bg-warning" : "bg-primary";
|
|
446
|
+
const date = new Date(t.createdAt).toLocaleDateString("en", { month: "short", day: "numeric", hour: "2-digit", minute: "2-digit" });
|
|
447
|
+
return `
|
|
448
|
+
<div class="timeline-item">
|
|
449
|
+
<div class="timeline-line"></div>
|
|
450
|
+
<div class="timeline-dot stat-icon ${bg}">${icon}</div>
|
|
451
|
+
<div class="timeline-content" data-detail="${t.id}">
|
|
452
|
+
<div style="font-weight:500">${escapeHtml(t.title)}</div>
|
|
453
|
+
<div class="todo-meta">
|
|
454
|
+
<span class="priority-badge priority-${t.priority}">${t.priority}</span>
|
|
455
|
+
<span class="meta-item">${date}</span>
|
|
456
|
+
${t.category ? `<span class="meta-item">📁 ${escapeHtml(t.category)}</span>` : ""}
|
|
457
|
+
${t.due ? `<span class="meta-item">📅 ${t.due}</span>` : ""}
|
|
458
|
+
</div>
|
|
459
|
+
</div>
|
|
460
|
+
</div>
|
|
461
|
+
`;
|
|
462
|
+
}).join("")}
|
|
463
|
+
</div>
|
|
464
|
+
`;
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
// ─── View Events ───────────────────────────────────────────────────────────
|
|
468
|
+
|
|
469
|
+
function attachViewEvents() {
|
|
470
|
+
// Todo checkbox toggle
|
|
471
|
+
$$("[data-toggle]").forEach((el) => {
|
|
472
|
+
el.addEventListener("click", (e) => { e.stopPropagation(); toggleTodo(Number(el.dataset.toggle)); });
|
|
473
|
+
});
|
|
474
|
+
// Todo detail
|
|
475
|
+
$$("[data-detail]").forEach((el) => {
|
|
476
|
+
el.addEventListener("click", (e) => { e.stopPropagation(); showDetail(Number(el.dataset.detail)); });
|
|
477
|
+
});
|
|
478
|
+
// Pin
|
|
479
|
+
$$("[data-pin]").forEach((el) => {
|
|
480
|
+
el.addEventListener("click", (e) => { e.stopPropagation(); pinTodo(Number(el.dataset.pin)); });
|
|
481
|
+
});
|
|
482
|
+
// Timer
|
|
483
|
+
$$("[data-timer]").forEach((el) => {
|
|
484
|
+
el.addEventListener("click", (e) => { e.stopPropagation(); toggleTimer(Number(el.dataset.timer)); });
|
|
485
|
+
});
|
|
486
|
+
// Edit
|
|
487
|
+
$$("[data-edit]").forEach((el) => {
|
|
488
|
+
el.addEventListener("click", (e) => { e.stopPropagation(); openModal(state.todos.find((t) => t.id === Number(el.dataset.edit))); });
|
|
489
|
+
});
|
|
490
|
+
// Delete
|
|
491
|
+
$$("[data-delete]").forEach((el) => {
|
|
492
|
+
el.addEventListener("click", (e) => { e.stopPropagation(); deleteTodo(Number(el.dataset.delete)); });
|
|
493
|
+
});
|
|
494
|
+
// Filter chips
|
|
495
|
+
$$("[data-filter-chip]").forEach((el) => {
|
|
496
|
+
el.addEventListener("click", () => {
|
|
497
|
+
state.filter = el.dataset.filterChip === "all" ? null : el.dataset.filterChip;
|
|
498
|
+
state.view = "list";
|
|
499
|
+
render();
|
|
500
|
+
});
|
|
501
|
+
});
|
|
502
|
+
// Sort select
|
|
503
|
+
const sortSel = $("#sort-select");
|
|
504
|
+
if (sortSel) sortSel.addEventListener("change", () => { state.sort = sortSel.value; render(); });
|
|
505
|
+
|
|
506
|
+
// Calendar navigation
|
|
507
|
+
const calPrev = $("#cal-prev");
|
|
508
|
+
if (calPrev) calPrev.addEventListener("click", () => { state.calendarDate.setMonth(state.calendarDate.getMonth()-1); render(); });
|
|
509
|
+
const calNext = $("#cal-next");
|
|
510
|
+
if (calNext) calNext.addEventListener("click", () => { state.calendarDate.setMonth(state.calendarDate.getMonth()+1); render(); });
|
|
511
|
+
const calToday = $("#cal-today");
|
|
512
|
+
if (calToday) calToday.addEventListener("click", () => { state.calendarDate = new Date(); render(); });
|
|
513
|
+
|
|
514
|
+
// Kanban drag & drop
|
|
515
|
+
attachKanbanDnD();
|
|
516
|
+
}
|
|
517
|
+
|
|
518
|
+
function attachKanbanDnD() {
|
|
519
|
+
let dragId = null;
|
|
520
|
+
$$(".kanban-card").forEach((card) => {
|
|
521
|
+
card.addEventListener("dragstart", () => { dragId = Number(card.dataset.id); card.classList.add("dragging"); });
|
|
522
|
+
card.addEventListener("dragend", () => { card.classList.remove("dragging"); dragId = null; });
|
|
523
|
+
});
|
|
524
|
+
$$("[data-drop]").forEach((zone) => {
|
|
525
|
+
zone.addEventListener("dragover", (e) => { e.preventDefault(); zone.classList.add("drag-over"); });
|
|
526
|
+
zone.addEventListener("dragleave", () => { zone.classList.remove("drag-over"); });
|
|
527
|
+
zone.addEventListener("drop", (e) => {
|
|
528
|
+
e.preventDefault();
|
|
529
|
+
zone.classList.remove("drag-over");
|
|
530
|
+
if (!dragId) return;
|
|
531
|
+
const col = zone.dataset.drop;
|
|
532
|
+
if (col === "done") completeTodo(dragId);
|
|
533
|
+
else if (col === "pending") {
|
|
534
|
+
const t = state.todos.find((x) => x.id === dragId);
|
|
535
|
+
if (t && t.done) toggleTodo(dragId);
|
|
536
|
+
}
|
|
537
|
+
});
|
|
538
|
+
});
|
|
539
|
+
}
|
|
540
|
+
|
|
541
|
+
// ─── Actions ───────────────────────────────────────────────────────────────
|
|
542
|
+
|
|
543
|
+
async function toggleTodo(id) {
|
|
544
|
+
try {
|
|
545
|
+
await api(`todos/${id}/toggle`, { method: "POST" });
|
|
546
|
+
await loadData();
|
|
547
|
+
render();
|
|
548
|
+
} catch (err) { toast(err.message, "error"); }
|
|
549
|
+
}
|
|
550
|
+
|
|
551
|
+
async function completeTodo(id) {
|
|
552
|
+
try {
|
|
553
|
+
await api(`todos/${id}/complete`, { method: "POST" });
|
|
554
|
+
await loadData();
|
|
555
|
+
render();
|
|
556
|
+
} catch (err) { toast(err.message, "error"); }
|
|
557
|
+
}
|
|
558
|
+
|
|
559
|
+
async function pinTodo(id) {
|
|
560
|
+
try {
|
|
561
|
+
await api(`todos/${id}/pin`, { method: "POST" });
|
|
562
|
+
await loadData();
|
|
563
|
+
render();
|
|
564
|
+
} catch (err) { toast(err.message, "error"); }
|
|
565
|
+
}
|
|
566
|
+
|
|
567
|
+
async function deleteTodo(id) {
|
|
568
|
+
if (!confirm("Delete this todo?")) return;
|
|
569
|
+
try {
|
|
570
|
+
await api(`todos/${id}`, { method: "DELETE" });
|
|
571
|
+
toast("Deleted", "success");
|
|
572
|
+
await loadData();
|
|
573
|
+
render();
|
|
574
|
+
} catch (err) { toast(err.message, "error"); }
|
|
575
|
+
}
|
|
576
|
+
|
|
577
|
+
async function toggleTimer(id) {
|
|
578
|
+
try {
|
|
579
|
+
const t = state.todos.find((x) => x.id === id);
|
|
580
|
+
if (t && t.timerStartedAt) {
|
|
581
|
+
await api(`todos/${id}/timer/stop`, { method: "POST" });
|
|
582
|
+
toast("Timer stopped", "success");
|
|
583
|
+
} else {
|
|
584
|
+
await api(`todos/${id}/timer/start`, { method: "POST" });
|
|
585
|
+
toast("Timer started", "success");
|
|
586
|
+
}
|
|
587
|
+
await loadData();
|
|
588
|
+
render();
|
|
589
|
+
} catch (err) { toast(err.message, "error"); }
|
|
590
|
+
}
|
|
591
|
+
|
|
592
|
+
// ─── Modal (Add/Edit) ──────────────────────────────────────────────────────
|
|
593
|
+
|
|
594
|
+
function openModal(todo = null) {
|
|
595
|
+
const modal = $("#modal-overlay");
|
|
596
|
+
const title = $("#modal-title");
|
|
597
|
+
const delBtn = $("#delete-btn");
|
|
598
|
+
|
|
599
|
+
if (todo) {
|
|
600
|
+
title.textContent = "Edit Todo";
|
|
601
|
+
delBtn.classList.remove("hidden");
|
|
602
|
+
$("#todo-id").value = todo.id;
|
|
603
|
+
$("#f-title").value = todo.title;
|
|
604
|
+
$("#f-priority").value = todo.priority;
|
|
605
|
+
$("#f-category").value = todo.category || "";
|
|
606
|
+
$("#f-due").value = todo.due || "";
|
|
607
|
+
$("#f-assignee").value = todo.assignee || "";
|
|
608
|
+
$("#f-estimate").value = todo.estimate || "";
|
|
609
|
+
$("#f-tags").value = todo.tags.join(", ");
|
|
610
|
+
$("#f-note").value = todo.note || "";
|
|
611
|
+
$("#f-pinned").checked = todo.pinned || false;
|
|
612
|
+
if (todo.recurring) {
|
|
613
|
+
$("#f-recur").value = todo.recurring.freq;
|
|
614
|
+
$("#f-recur-interval").value = todo.recurring.interval;
|
|
615
|
+
} else {
|
|
616
|
+
$("#f-recur").value = "";
|
|
617
|
+
$("#f-recur-interval").value = "1";
|
|
618
|
+
}
|
|
619
|
+
} else {
|
|
620
|
+
title.textContent = "New Todo";
|
|
621
|
+
delBtn.classList.add("hidden");
|
|
622
|
+
$("#todo-form").reset();
|
|
623
|
+
$("#todo-id").value = "";
|
|
624
|
+
$("#f-priority").value = "medium";
|
|
625
|
+
}
|
|
626
|
+
|
|
627
|
+
// Update datalists
|
|
628
|
+
const cats = [...new Set(state.todos.map((t) => t.category).filter(Boolean))];
|
|
629
|
+
$("#category-datalist").innerHTML = cats.map((c) => `<option value="${escapeHtml(c)}">`).join("");
|
|
630
|
+
const assignees = [...new Set(state.todos.map((t) => t.assignee).filter(Boolean))];
|
|
631
|
+
$("#assignee-datalist").innerHTML = assignees.map((a) => `<option value="${escapeHtml(a)}">`).join("");
|
|
632
|
+
|
|
633
|
+
modal.classList.remove("hidden");
|
|
634
|
+
$("#f-title").focus();
|
|
635
|
+
}
|
|
636
|
+
|
|
637
|
+
function closeModal() {
|
|
638
|
+
$("#modal-overlay").classList.add("hidden");
|
|
639
|
+
}
|
|
640
|
+
|
|
641
|
+
async function saveTodo(e) {
|
|
642
|
+
e.preventDefault();
|
|
643
|
+
const id = $("#todo-id").value;
|
|
644
|
+
const data = {
|
|
645
|
+
title: $("#f-title").value,
|
|
646
|
+
priority: $("#f-priority").value,
|
|
647
|
+
category: $("#f-category").value,
|
|
648
|
+
due: $("#f-due").value || undefined,
|
|
649
|
+
assignee: $("#f-assignee").value,
|
|
650
|
+
estimate: $("#f-estimate").value ? Number($("#f-estimate").value) : null,
|
|
651
|
+
tags: $("#f-tags").value,
|
|
652
|
+
note: $("#f-note").value,
|
|
653
|
+
pinned: $("#f-pinned").checked,
|
|
654
|
+
};
|
|
655
|
+
const recur = $("#f-recur").value;
|
|
656
|
+
try {
|
|
657
|
+
let todo;
|
|
658
|
+
if (id) {
|
|
659
|
+
todo = await api(`todos/${id}`, { method: "PATCH", body: data });
|
|
660
|
+
if (recur) {
|
|
661
|
+
await api(`todos/${id}/recurring`, { method: "POST", body: { freq: recur, interval: Number($("#f-recur-interval").value) } });
|
|
662
|
+
} else {
|
|
663
|
+
await api(`todos/${id}/recurring`, { method: "POST", body: {} });
|
|
664
|
+
}
|
|
665
|
+
toast("Updated", "success");
|
|
666
|
+
} else {
|
|
667
|
+
todo = await api("todos", { method: "POST", body: data });
|
|
668
|
+
if (recur) {
|
|
669
|
+
await api(`todos/${todo.id}/recurring`, { method: "POST", body: { freq: recur, interval: Number($("#f-recur-interval").value) } });
|
|
670
|
+
}
|
|
671
|
+
toast("Added", "success");
|
|
672
|
+
}
|
|
673
|
+
closeModal();
|
|
674
|
+
await loadData();
|
|
675
|
+
render();
|
|
676
|
+
} catch (err) { toast(err.message, "error"); }
|
|
677
|
+
}
|
|
678
|
+
|
|
679
|
+
// ─── Detail View ───────────────────────────────────────────────────────────
|
|
680
|
+
|
|
681
|
+
function showDetail(id) {
|
|
682
|
+
const t = state.todos.find((x) => x.id === id);
|
|
683
|
+
if (!t) return;
|
|
684
|
+
state.selectedTodo = t;
|
|
685
|
+
renderDetail(t);
|
|
686
|
+
$("#detail-overlay").classList.remove("hidden");
|
|
687
|
+
}
|
|
688
|
+
|
|
689
|
+
function renderDetail(t) {
|
|
690
|
+
$("#detail-title-text").textContent = t.title;
|
|
691
|
+
const ds = dueStatus(t.due);
|
|
692
|
+
const dueLabel = ds === "overdue" ? " (overdue)" : ds === "today" ? " (today)" : ds === "soon" ? " (soon)" : "";
|
|
693
|
+
|
|
694
|
+
const subtasksHTML = (t.subtasks || []).map((s) => `
|
|
695
|
+
<div class="subtask-item ${s.done ? "done" : ""}">
|
|
696
|
+
<div class="subtask-checkbox ${s.done ? "checked" : ""}" data-subtask-toggle="${s.id}"></div>
|
|
697
|
+
<span class="subtask-title">${escapeHtml(s.title)}</span>
|
|
698
|
+
<button class="icon-btn" data-subtask-delete="${s.id}" style="width:24px;height:24px;color:var(--danger)">✕</button>
|
|
699
|
+
</div>
|
|
700
|
+
`).join("");
|
|
701
|
+
|
|
702
|
+
const depsHTML = (t.dependsOn || []).map((depId) => {
|
|
703
|
+
const dep = state.todos.find((x) => x.id === depId);
|
|
704
|
+
return `<div class="detail-row"><span class="detail-label"></span><span class="detail-value">${dep ? (dep.done ? "✓" : "✗") + " #" + depId + " " + escapeHtml(dep.title) : "#"+depId+" (deleted)"}</span></div>`;
|
|
705
|
+
}).join("");
|
|
706
|
+
|
|
707
|
+
$("#detail-body").innerHTML = `
|
|
708
|
+
<div class="detail-section">
|
|
709
|
+
<div class="detail-row"><span class="detail-label">Status</span><span class="detail-value">${t.done ? "✓ Done" : "○ Pending"}</span></div>
|
|
710
|
+
<div class="detail-row"><span class="detail-label">Priority</span><span class="priority-badge priority-${t.priority}">${t.priority}</span></div>
|
|
711
|
+
${t.category ? `<div class="detail-row"><span class="detail-label">Category</span><span class="detail-value">📁 ${escapeHtml(t.category)}</span></div>` : ""}
|
|
712
|
+
${t.due ? `<div class="detail-row"><span class="detail-label">Due</span><span class="detail-value">📅 ${t.due}${dueLabel}</span></div>` : ""}
|
|
713
|
+
${t.assignee ? `<div class="detail-row"><span class="detail-label">Assignee</span><span class="detail-value">👤 ${escapeHtml(t.assignee)}</span></div>` : ""}
|
|
714
|
+
${t.estimate ? `<div class="detail-row"><span class="detail-label">Estimate</span><span class="detail-value">⏱ ${fmtTime(t.estimate)}</span></div>` : ""}
|
|
715
|
+
${t.timeLogged ? `<div class="detail-row"><span class="detail-label">Time logged</span><span class="detail-value">✓ ${fmtTime(t.timeLogged)}</span></div>` : ""}
|
|
716
|
+
${t.recurring ? `<div class="detail-row"><span class="detail-label">Recurring</span><span class="detail-value">⟳ Every ${t.recurring.interval} ${t.recurring.freq}(s)</span></div>` : ""}
|
|
717
|
+
${t.pinned ? '<div class="detail-row"><span class="detail-label">Pinned</span><span class="detail-value">📌 Yes</span></div>' : ""}
|
|
718
|
+
${t.tags.length ? `<div class="detail-row"><span class="detail-label">Tags</span>${t.tags.map(tag=>`<span class="tag">${escapeHtml(tag)}</span>`).join(" ")}</div>` : ""}
|
|
719
|
+
</div>
|
|
720
|
+
${t.note ? `<div class="detail-section"><div class="detail-section-title">Note</div><div style="white-space:pre-wrap">${escapeHtml(t.note)}</div></div>` : ""}
|
|
721
|
+
${(t.subtasks && t.subtasks.length) || true ? `
|
|
722
|
+
<div class="detail-section">
|
|
723
|
+
<div class="detail-section-title">Subtasks</div>
|
|
724
|
+
${subtasksHTML}
|
|
725
|
+
<div style="display:flex;gap:8px;margin-top:8px">
|
|
726
|
+
<input type="text" id="new-subtask-input" placeholder="Add subtask..." style="flex:1;padding:8px 12px;border:1px solid var(--border);border-radius:var(--radius-sm);background:var(--bg-input);color:var(--text);font-size:13px">
|
|
727
|
+
<button class="btn btn-sm btn-primary" id="add-subtask-btn">Add</button>
|
|
728
|
+
</div>
|
|
729
|
+
</div>
|
|
730
|
+
` : ""}
|
|
731
|
+
${depsHTML ? `<div class="detail-section"><div class="detail-section-title">Dependencies</div>${depsHTML}</div>` : ""}
|
|
732
|
+
<div class="detail-section">
|
|
733
|
+
<div class="detail-section-title">Timestamps</div>
|
|
734
|
+
<div class="detail-row"><span class="detail-label">Created</span><span class="detail-value">${new Date(t.createdAt).toLocaleString()}</span></div>
|
|
735
|
+
${t.updatedAt ? `<div class="detail-row"><span class="detail-label">Updated</span><span class="detail-value">${new Date(t.updatedAt).toLocaleString()}</span></div>` : ""}
|
|
736
|
+
${t.completedAt ? `<div class="detail-row"><span class="detail-label">Completed</span><span class="detail-value">${new Date(t.completedAt).toLocaleString()}</span></div>` : ""}
|
|
737
|
+
</div>
|
|
738
|
+
<div style="display:flex;gap:8px;margin-top:16px">
|
|
739
|
+
<button class="btn btn-primary" onclick="window.app.toggleTodo(${t.id});window.app.closeDetail()">${t.done ? "Reopen" : "Complete"}</button>
|
|
740
|
+
<button class="btn btn-secondary" onclick="window.app.pinTodo(${t.id});window.app.closeDetail()">${t.pinned ? "Unpin" : "Pin"}</button>
|
|
741
|
+
<button class="btn btn-secondary" onclick="window.app.toggleTimer(${t.id});window.app.closeDetail()">${t.timerStartedAt ? "Stop Timer" : "Start Timer"}</button>
|
|
742
|
+
<button class="btn btn-secondary" onclick="window.app.editFromDetail(${t.id})">Edit</button>
|
|
743
|
+
<button class="btn btn-danger" onclick="window.app.deleteFromDetail(${t.id})">Delete</button>
|
|
744
|
+
</div>
|
|
745
|
+
`;
|
|
746
|
+
|
|
747
|
+
// Attach subtask events
|
|
748
|
+
$$("[data-subtask-toggle]").forEach((el) => {
|
|
749
|
+
el.addEventListener("click", () => toggleSubtask(t.id, Number(el.dataset.subtaskToggle)));
|
|
750
|
+
});
|
|
751
|
+
$$("[data-subtask-delete]").forEach((el) => {
|
|
752
|
+
el.addEventListener("click", () => deleteSubtask(t.id, Number(el.dataset.subtaskDelete)));
|
|
753
|
+
});
|
|
754
|
+
const addBtn = $("#add-subtask-btn");
|
|
755
|
+
if (addBtn) {
|
|
756
|
+
addBtn.addEventListener("click", () => {
|
|
757
|
+
const input = $("#new-subtask-input");
|
|
758
|
+
if (input.value.trim()) addSubtask(t.id, input.value.trim());
|
|
759
|
+
});
|
|
760
|
+
$("#new-subtask-input").addEventListener("keydown", (e) => {
|
|
761
|
+
if (e.key === "Enter" && e.target.value.trim()) addSubtask(t.id, e.target.value.trim());
|
|
762
|
+
});
|
|
763
|
+
}
|
|
764
|
+
}
|
|
765
|
+
|
|
766
|
+
function closeDetail() {
|
|
767
|
+
$("#detail-overlay").classList.add("hidden");
|
|
768
|
+
state.selectedTodo = null;
|
|
769
|
+
}
|
|
770
|
+
|
|
771
|
+
function editFromDetail(id) {
|
|
772
|
+
closeDetail();
|
|
773
|
+
const t = state.todos.find((x) => x.id === id);
|
|
774
|
+
if (t) openModal(t);
|
|
775
|
+
}
|
|
776
|
+
|
|
777
|
+
async function deleteFromDetail(id) {
|
|
778
|
+
closeDetail();
|
|
779
|
+
await deleteTodo(id);
|
|
780
|
+
}
|
|
781
|
+
|
|
782
|
+
async function addSubtask(todoId, title) {
|
|
783
|
+
try {
|
|
784
|
+
await api(`todos/${todoId}/subtasks`, { method: "POST", body: { title } });
|
|
785
|
+
await loadData();
|
|
786
|
+
const t = state.todos.find((x) => x.id === todoId);
|
|
787
|
+
if (t) renderDetail(t);
|
|
788
|
+
} catch (err) { toast(err.message, "error"); }
|
|
789
|
+
}
|
|
790
|
+
|
|
791
|
+
async function toggleSubtask(todoId, subId) {
|
|
792
|
+
try {
|
|
793
|
+
await api(`todos/${todoId}/subtasks/${subId}/toggle`, { method: "POST" });
|
|
794
|
+
await loadData();
|
|
795
|
+
const t = state.todos.find((x) => x.id === todoId);
|
|
796
|
+
if (t) renderDetail(t);
|
|
797
|
+
} catch (err) { toast(err.message, "error"); }
|
|
798
|
+
}
|
|
799
|
+
|
|
800
|
+
async function deleteSubtask(todoId, subId) {
|
|
801
|
+
try {
|
|
802
|
+
await api(`todos/${todoId}/subtasks/${subId}`, { method: "DELETE" });
|
|
803
|
+
await loadData();
|
|
804
|
+
const t = state.todos.find((x) => x.id === todoId);
|
|
805
|
+
if (t) renderDetail(t);
|
|
806
|
+
} catch (err) { toast(err.message, "error"); }
|
|
807
|
+
}
|
|
808
|
+
|
|
809
|
+
// ─── Floating Timer ────────────────────────────────────────────────────────
|
|
810
|
+
|
|
811
|
+
function updateFloatingTimer() {
|
|
812
|
+
const el = $("#floating-timer");
|
|
813
|
+
if (state.activeTimer) {
|
|
814
|
+
el.classList.remove("hidden");
|
|
815
|
+
$("#timer-label").textContent = state.activeTimer.title;
|
|
816
|
+
updateTimerDisplay();
|
|
817
|
+
} else {
|
|
818
|
+
el.classList.add("hidden");
|
|
819
|
+
}
|
|
820
|
+
}
|
|
821
|
+
|
|
822
|
+
function updateTimerDisplay() {
|
|
823
|
+
if (!state.activeTimer) return;
|
|
824
|
+
const elapsed = Math.floor((Date.now() - new Date(state.activeTimer.timerStartedAt).getTime()) / 1000);
|
|
825
|
+
const h = Math.floor(elapsed / 3600);
|
|
826
|
+
const m = Math.floor((elapsed % 3600) / 60);
|
|
827
|
+
const s = elapsed % 60;
|
|
828
|
+
$("#timer-time").textContent = h > 0
|
|
829
|
+
? String(h).padStart(2,"0") + ":" + String(m).padStart(2,"0") + ":" + String(s).padStart(2,"0")
|
|
830
|
+
: String(m).padStart(2,"0") + ":" + String(s).padStart(2,"0");
|
|
831
|
+
}
|
|
832
|
+
|
|
833
|
+
setInterval(() => { if (state.activeTimer) updateTimerDisplay(); }, 1000);
|
|
834
|
+
|
|
835
|
+
// ─── Theme ─────────────────────────────────────────────────────────────────
|
|
836
|
+
|
|
837
|
+
function toggleTheme() {
|
|
838
|
+
const html = document.documentElement;
|
|
839
|
+
const current = html.dataset.theme;
|
|
840
|
+
const next = current === "dark" ? "light" : "dark";
|
|
841
|
+
html.dataset.theme = next;
|
|
842
|
+
localStorage.setItem("todo-theme", next);
|
|
843
|
+
}
|
|
844
|
+
|
|
845
|
+
function loadTheme() {
|
|
846
|
+
const saved = localStorage.getItem("todo-theme");
|
|
847
|
+
if (saved) document.documentElement.dataset.theme = saved;
|
|
848
|
+
}
|
|
849
|
+
|
|
850
|
+
// ─── Init ──────────────────────────────────────────────────────────────────
|
|
851
|
+
|
|
852
|
+
function attachGlobalEvents() {
|
|
853
|
+
// Nav items
|
|
854
|
+
$$("[data-view]").forEach((btn) => {
|
|
855
|
+
btn.addEventListener("click", () => {
|
|
856
|
+
state.view = btn.dataset.view;
|
|
857
|
+
state.filter = null;
|
|
858
|
+
state.category = null;
|
|
859
|
+
render();
|
|
860
|
+
// Close sidebar on mobile
|
|
861
|
+
$("#sidebar").classList.remove("open");
|
|
862
|
+
});
|
|
863
|
+
});
|
|
864
|
+
// Filter items
|
|
865
|
+
$$("[data-filter]").forEach((btn) => {
|
|
866
|
+
btn.addEventListener("click", () => {
|
|
867
|
+
state.filter = btn.dataset.filter;
|
|
868
|
+
state.view = "list";
|
|
869
|
+
state.category = null;
|
|
870
|
+
render();
|
|
871
|
+
$("#sidebar").classList.remove("open");
|
|
872
|
+
});
|
|
873
|
+
});
|
|
874
|
+
// Add button
|
|
875
|
+
$("#add-btn").addEventListener("click", () => openModal());
|
|
876
|
+
// Modal close
|
|
877
|
+
$$(".modal-close").forEach((btn) => btn.addEventListener("click", closeModal));
|
|
878
|
+
$("#modal-overlay").addEventListener("click", (e) => { if (e.target === e.currentTarget) closeModal(); });
|
|
879
|
+
// Form submit
|
|
880
|
+
$("#todo-form").addEventListener("submit", saveTodo);
|
|
881
|
+
// Delete button in modal
|
|
882
|
+
$("#delete-btn").addEventListener("click", async () => {
|
|
883
|
+
const id = $("#todo-id").value;
|
|
884
|
+
if (id) { closeModal(); await deleteTodo(Number(id)); }
|
|
885
|
+
});
|
|
886
|
+
// Detail close
|
|
887
|
+
$$(".modal-close-detail").forEach((btn) => btn.addEventListener("click", closeDetail));
|
|
888
|
+
$("#detail-overlay").addEventListener("click", (e) => { if (e.target === e.currentTarget) closeDetail(); });
|
|
889
|
+
// Search
|
|
890
|
+
let searchTimer;
|
|
891
|
+
$("#search-input").addEventListener("input", (e) => {
|
|
892
|
+
clearTimeout(searchTimer);
|
|
893
|
+
searchTimer = setTimeout(() => {
|
|
894
|
+
state.search = e.target.value;
|
|
895
|
+
if (state.view !== "list" && state.search) { state.view = "list"; state.filter = null; }
|
|
896
|
+
render();
|
|
897
|
+
}, 200);
|
|
898
|
+
});
|
|
899
|
+
// Theme toggle
|
|
900
|
+
$("#theme-toggle").addEventListener("click", toggleTheme);
|
|
901
|
+
// Sidebar toggle (mobile)
|
|
902
|
+
$("#sidebar-toggle").addEventListener("click", () => $("#sidebar").classList.add("open"));
|
|
903
|
+
$("#sidebar-close").addEventListener("click", () => $("#sidebar").classList.remove("open"));
|
|
904
|
+
// Floating timer stop
|
|
905
|
+
$("#timer-stop").addEventListener("click", async () => {
|
|
906
|
+
if (state.activeTimer) {
|
|
907
|
+
await api(`todos/${state.activeTimer.id}/timer/stop`, { method: "POST" });
|
|
908
|
+
toast("Timer stopped", "success");
|
|
909
|
+
await loadData();
|
|
910
|
+
render();
|
|
911
|
+
}
|
|
912
|
+
});
|
|
913
|
+
// Keyboard shortcuts
|
|
914
|
+
document.addEventListener("keydown", (e) => {
|
|
915
|
+
if (e.key === "Escape") { closeModal(); closeDetail(); }
|
|
916
|
+
if (e.key === "n" && !e.target.matches("input, textarea, select")) { e.preventDefault(); openModal(); }
|
|
917
|
+
if (e.key === "/" && !e.target.matches("input, textarea, select")) { e.preventDefault(); $("#search-input").focus(); }
|
|
918
|
+
});
|
|
919
|
+
}
|
|
920
|
+
|
|
921
|
+
// Expose for inline onclick handlers
|
|
922
|
+
window.app = {
|
|
923
|
+
toggleTodo, completeTodo, pinTodo, deleteTodo, toggleTimer,
|
|
924
|
+
showDetail, closeDetail, editFromDetail, deleteFromDetail,
|
|
925
|
+
};
|
|
926
|
+
|
|
927
|
+
async function init() {
|
|
928
|
+
loadTheme();
|
|
929
|
+
attachGlobalEvents();
|
|
930
|
+
await loadData();
|
|
931
|
+
render();
|
|
932
|
+
}
|
|
933
|
+
|
|
934
|
+
init();
|