Files
personal-progress-monitor/app/static/app.js
MobKBK bcc2abe623 feat: complete frozen MVP — period-aware stats, layout-driven UI, quick checkin
- Fix first daily record 422 (expectedVersion now accepts 0)
- Add period_window + summarize; /api/summary and /api/tasks/{id}/summary
  aggregate each metric inside its own day/week/month/lifetime window,
  reading raw facts only (never the rebuildable month archives)
- Drive frontend rendering from per-page layout JSON with a component
  registry; scope/cardinality enforced server-side, invalid layouts fall back
- Add quick checkin and duration input in H:M:S that round-trips integer seconds
- Fix heatmap/completedAt timezone drift to Asia/Shanghai
- Add server-side session TTL (30d) matching the login cookie
- Handle date.min/date.max boundaries without OverflowError
- Half-up rounding to match browser Math.round

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-08-24 07:15:50 +08:00

254 lines
24 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
const state = {
tasks: [], version: 1, selectedId: null, selectedTask: null,
daily: null, history: null, summary: null, csrf: "", authEnabled: false,
filter: "", editingId: null, dragId: null,
};
const $ = (selector, root = document) => root.querySelector(selector);
const $$ = (selector, root = document) => [...root.querySelectorAll(selector)];
const today = () => new Intl.DateTimeFormat("en-CA", { timeZone: "Asia/Shanghai" }).format(new Date());
const shanghaiDate = (offsetDays) => {
const now = new Date(Date.now() + offsetDays * 86400000);
return new Intl.DateTimeFormat("en-CA", { timeZone: "Asia/Shanghai" }).format(now);
};
const escapeHtml = (value) => String(value ?? "").replace(/[&<>"']/g, (char) => ({"&":"&amp;","<":"&lt;",">":"&gt;",'"':"&quot;","'":"&#039;"}[char]));
const metricKindLabel = { checkin: "打卡", quantity: "数量", duration: "时长" };
const periodLabel = { day: "每日", week: "每周", month: "每月", lifetime: "总计" };
async function api(path, options = {}) {
const opts = { ...options, headers: { ...(options.body ? { "Content-Type": "application/json" } : {}), ...(options.headers || {}) } };
if (!opts.method || !["GET", "HEAD"].includes(opts.method)) opts.headers["X-CSRF-Token"] = state.csrf;
const response = await fetch(path, opts);
let body = null;
try { body = await response.json(); } catch { body = {}; }
if (response.status === 401 && state.authEnabled) { showLogin(); throw new Error(body.detail || "需要登录"); }
if (!response.ok) throw new Error(body.detail || `请求失败 (${response.status})`);
return body;
}
function toast(message) {
const node = $("#toast"); node.textContent = message; node.classList.add("show");
window.clearTimeout(toast.timer); toast.timer = window.setTimeout(() => node.classList.remove("show"), 2600);
}
function showLogin() { $("#loginOverlay").hidden = false; $("#app").setAttribute("aria-busy", "true"); }
function hideLogin() { $("#loginOverlay").hidden = true; $("#app").setAttribute("aria-busy", "false"); }
async function bootstrap() {
const dateNode = $("#logicalDate"); if (dateNode) dateNode.textContent = today();
try {
const auth = await api("/api/auth/status");
state.authEnabled = auth.enabled; state.csrf = auth.csrfToken || "";
if (auth.enabled && !auth.authenticated) { showLogin(); return; }
hideLogin();
await loadTasks();
} catch (error) { toast(error.message); }
}
const layoutCache = { home: null, "task-detail": null };
async function ensureLayout(page) { if (!layoutCache[page]) layoutCache[page] = await api(`/api/layouts/${page}`); return layoutCache[page]; }
async function loadTasks() {
const result = await api("/api/tasks"); state.tasks = result.items; state.version = result.version;
if (!state.selectedId || !state.tasks.some((task) => task.taskId === state.selectedId)) state.selectedId = state.tasks[0]?.taskId || null;
await render();
}
function renderTaskList() {
const filtered = state.tasks.filter((task) => !state.filter || task.name.toLowerCase().includes(state.filter.toLowerCase()));
const items = filtered.map((task) => `<div class="task-item ${task.taskId === state.selectedId ? "selected" : ""}" draggable="true" data-task-id="${task.taskId}">
<span class="priority-bar priority-${task.priority}"></span><span class="task-copy"><strong>${escapeHtml(task.name)}</strong><small>${periodText(task)} · ${task.metrics.length} 个指标</small></span><span class="drag-handle" title="拖动排序" aria-hidden="true">⠿</span></div>`).join("");
return `<div class="sidebar-heading"><div><p class="eyebrow">WORKSPACE</p><h2>我的任务</h2></div><span>${state.tasks.length} 项</span></div><div class="filter-row"><input id="taskFilter" placeholder="搜索任务" value="${escapeHtml(state.filter)}"><button class="icon-button" id="clearFilter" title="清除搜索" aria-label="清除搜索">×</button></div><div class="task-list">${items || `<div class="empty-list">还没有任务<br>从右上角新建一项计划</div>`}</div>`;
}
function bindSidebar() {
const filterInput = $("#taskFilter");
if (filterInput) {
filterInput.addEventListener("input", (event) => { state.filter = event.target.value; rerenderSidebarList(); });
$("#clearFilter")?.addEventListener("click", () => { state.filter = ""; rerenderSidebarList(); });
}
bindSidebarItems();
}
function rerenderSidebarList() {
$("#sidebar").innerHTML = renderTaskList();
bindSidebar();
}
function bindSidebarItems() {
$$(".task-item").forEach((node) => {
node.addEventListener("click", () => { state.selectedId = node.dataset.taskId; render(); });
node.addEventListener("dragstart", () => { state.dragId = node.dataset.taskId; node.classList.add("dragging"); });
node.addEventListener("dragend", () => { node.classList.remove("dragging"); state.dragId = null; });
node.addEventListener("dragover", (event) => event.preventDefault());
node.addEventListener("drop", async (event) => { event.preventDefault(); if (!state.dragId || state.dragId === node.dataset.taskId) return; await reorder(state.dragId, node.dataset.taskId); });
});
}
async function reorder(fromId, toId) {
const ids = state.tasks.map((task) => task.taskId); const from = ids.indexOf(fromId); const to = ids.indexOf(toId);
ids.splice(from, 1); ids.splice(to, 0, fromId);
try { await api("/api/tasks/reorder/all", { method: "PUT", body: JSON.stringify({ taskIds: ids, expectedVersion: state.version }) }); toast("排序已保存"); await loadTasks(); }
catch (error) { toast(error.message); }
}
function periodText(task) { const metrics = task.metrics || []; return [...new Set(metrics.map((metric) => periodLabel[metric.period] || metric.period))].join(" / "); }
function summaryFor(taskId) { return state.summary?.tasks?.[taskId] || null; }
function metricSummary(taskId, metricId) { return summaryFor(taskId)?.metrics?.find((metric) => metric.metricId === metricId) || null; }
// ---- 组件注册表:布局白名单对应的渲染器与事件绑定 ----
// 页面/区域约束与单例约束由服务端 validate_layout 保证(非法布局回退到内置默认),
// 前端只负责渲染已校验的布局,不再二次过滤以免静默丢组件。
const components = {
"task-list": { render: renderTaskList, bind: bindSidebar },
"overview-summary": { render: renderOverviewSummary },
"task-progress-grid": { render: renderTaskProgressGrid, bind: bindOverview },
"task-header": { render: renderTaskHeader, bind: bindTaskHeader },
"today-record": { render: renderTodayRecord, bind: bindRecordForm },
"metric-progress": { render: renderMetricProgress },
"activity-heatmap": { render: renderActivityHeatmap },
"task-settings": { render: renderTaskSettings, bind: bindSettings },
};
function componentHtml(item) { const entry = components[item.component]; return entry ? entry.render(item.options || {}) : ""; }
function bindComponents(items) {
for (const item of items) {
const entry = components[item.component];
if (entry?.bind) entry.bind(item.options || {});
}
}
async function renderLayout(page) {
const layout = await ensureLayout(page);
const sidebarItems = layout.regions.sidebar || [];
const mainItems = layout.regions.main || [];
$("#sidebar").innerHTML = sidebarItems.map(componentHtml).join("");
$("#main").innerHTML = mainItems.map(componentHtml).join("");
bindComponents(sidebarItems);
bindComponents(mainItems);
}
async function render() {
if (!state.selectedId) {
try { state.summary = await api("/api/summary"); } catch (error) { toast(error.message); }
await renderLayout("home");
return;
}
state.selectedTask = state.tasks.find((task) => task.taskId === state.selectedId);
const layout = await ensureLayout("task-detail");
const heatmap = (layout.regions.main || []).find((item) => item.component === "activity-heatmap");
const historyDays = Math.min(heatmap?.options?.historyDays ?? 365, 730);
const start = shanghaiDate(-(historyDays - 1));
try {
[state.summary, state.daily, state.history] = await Promise.all([
api("/api/summary"),
api(`/api/tasks/${state.selectedId}/daily/${today()}`),
api(`/api/tasks/${state.selectedId}/history?start=${start}&end=${today()}`),
]);
} catch (error) { toast(error.message); return; }
await renderLayout("task-detail");
}
function renderOverviewSummary() {
const active = state.tasks.filter((task) => !task.deletedAt); const activePlans = active.filter((task) => task.state === "active").length;
return `<div class="page-heading"><div><p class="eyebrow">OVERVIEW · ${today()}</p><h1>把注意力放回正在发生的事</h1><p>所有计划、记录和节奏,都在这里保持可见。</p></div></div><div class="summary-strip"><div class="summary-cell"><strong>${active.length}</strong><span>进行中的任务</span></div><div class="summary-cell"><strong>${activePlans}</strong><span>活跃计划</span></div><div class="summary-cell"><strong>${active.filter((task) => task.priority <= 2).length}</strong><span>高优先级</span></div></div>`;
}
function renderTaskProgressGrid() {
const active = state.tasks.filter((task) => !task.deletedAt);
return `<section class="section"><div class="section-heading"><h2>任务进度</h2><span>按各指标自身周期计算</span></div><div class="overview-list">${active.map((task) => `<div class="overview-row" data-task-id="${task.taskId}"><div class="overview-name"><strong>${escapeHtml(task.name)}</strong><small>${periodText(task)} · ${task.metrics.length} 个指标</small></div><div class="progress-track"><div class="progress-fill" style="width:${taskProgress(task) ?? 0}%"></div></div><span class="status-badge ${task.state === "paused" ? "paused" : ""}">${progressBadge(task)}</span></div>`).join("") || `<div class="empty-list">还没有计划,从右上角开始添加。</div>`}</div></section>`;
}
function bindOverview() {
$$(".overview-row").forEach((row) => row.addEventListener("click", () => { state.selectedId = row.dataset.taskId; render(); }));
}
function taskProgress(task) { const value = summaryFor(task.taskId)?.progress; return value === null || value === undefined ? null : value; }
function progressBadge(task) {
if (task.state === "paused") return "已挂起";
const value = taskProgress(task);
return value === null ? "无上限" : `${value}%`;
}
function renderTaskHeader() {
const task = state.selectedTask;
return `<div class="page-heading"><div><p class="eyebrow">TASK DETAIL · 优先级 ${task.priority}</p><h1>${escapeHtml(task.name)}</h1><p>${escapeHtml(task.description || "还没有写下说明")}</p></div><div class="top-actions"><span class="status-badge ${task.state === "paused" ? "paused" : ""}">${task.state === "paused" ? "已挂起" : "进行中"}</span><button class="button" id="editTaskButton">编辑属性</button></div></div>`;
}
function bindTaskHeader() {
$("#editTaskButton").addEventListener("click", () => openTaskDialog(state.selectedTask));
}
function toDurationString(seconds) { const total = Math.max(0, Math.floor(Number(seconds) || 0)); return `${Math.floor(total / 3600)}:${String(Math.floor(total % 3600 / 60)).padStart(2, "0")}:${String(total % 60).padStart(2, "0")}`; }
function fromDurationString(value) { const parts = String(value || "0").trim().split(":").map((part) => Math.max(0, Math.floor(Number(part) || 0))); const [hours = 0, minutes = 0, secs = 0] = parts; return hours * 3600 + minutes * 60 + secs; }
function metricField(metric) {
const value = state.daily?.values?.[metric.metricId] || 0;
if (metric.kind === "checkin") return `<label class="record-field check"><input type="checkbox" data-metric-id="${metric.metricId}" value="1" ${value ? "checked" : ""}><span><strong>${escapeHtml(metric.name)}</strong><small class="muted">${periodLabel[metric.period]}</small></span></label>`;
if (metric.kind === "duration") return `<label class="record-field"><span><strong>${escapeHtml(metric.name)}</strong><small class="muted">时长 · 时:分:秒 · ${periodLabel[metric.period]}</small></span><input type="text" inputmode="numeric" placeholder="0:30:00" data-kind="duration" data-metric-id="${metric.metricId}" value="${toDurationString(value)}"></label>`;
return `<label class="record-field"><span><strong>${escapeHtml(metric.name)}</strong><small class="muted">${metricKindLabel[metric.kind]} · ${metric.unit || "次"} · ${periodLabel[metric.period]}</small></span><input type="number" min="0" step="1" data-metric-id="${metric.metricId}" value="${value}"></label>`;
}
function renderTodayRecord() {
const task = state.selectedTask;
const completedToday = Object.values(state.daily?.values || {}).some((value) => value > 0);
const hasCheckin = task.metrics.some((metric) => metric.kind === "checkin");
const fields = task.metrics.map(metricField).join("");
return `<section class="section" id="todayRecord"><div class="section-heading"><div><h2>今天,${today()}</h2><span>只记录今天真实完成的部分</span></div><span class="status-badge ${completedToday ? "" : "paused"}">${completedToday ? "已有记录" : "尚未记录"}</span></div><div class="record-grid">${fields}<label class="record-field wide">心得<textarea id="dailyNote" rows="4" maxlength="4000" placeholder="记下今天的阻力、收获或下一步">${escapeHtml(state.daily?.note || "")}</textarea></label><label class="record-field">完成时间<input id="completedAt" type="datetime-local" value="${toLocalDatetime(state.daily?.completedAt)}"></label></div><div class="record-actions">${hasCheckin ? `<button class="button primary" id="quickCheckinButton" type="button">一键打卡</button>` : `<span class="muted">修改会保留任务历史版本</span>`}<button class="button primary" id="saveDailyButton">保存今日记录</button></div></section>`;
}
function collectTodayValues() {
return $$(`#todayRecord [data-metric-id]`).map((node) => ({
metricId: node.dataset.metricId,
value: node.type === "checkbox" ? (node.checked ? 1 : 0) : (node.dataset.kind === "duration" ? fromDurationString(node.value) : Math.max(0, Number(node.value || 0))),
}));
}
function saveTodayRecord(values = collectTodayValues()) {
const task = state.selectedTask;
const completedAt = $("#todayRecord #completedAt")?.value || "";
return api(`/api/tasks/${task.taskId}/daily/${today()}`, { method: "PUT", body: JSON.stringify({ expectedVersion: state.daily?.version || 0, note: $("#todayRecord #dailyNote")?.value || "", completedAt: completedAt ? `${completedAt}:00` : null, values }) });
}
function toLocalDatetime(value) { return value ? value.slice(0, 16) : ""; }
function bindRecordForm() {
$("#saveDailyButton").addEventListener("click", async () => {
try { await saveTodayRecord(); toast("今日记录已保存"); await render(); } catch (error) { toast(error.message); }
});
$("#quickCheckinButton")?.addEventListener("click", async () => {
$$(`#todayRecord [data-metric-id][type="checkbox"]`).forEach((node) => { node.checked = true; });
try { await saveTodayRecord(); toast("已打卡"); await render(); } catch (error) { toast(error.message); }
});
}
function renderMetricProgress() {
const task = state.selectedTask;
const cards = task.metrics.map((metric) => { const info = metricSummary(task.taskId, metric.metricId) || { value: 0, target: metric.target, percent: null, windowStart: "", windowEnd: "" }; const scope = metric.period === "lifetime" ? `自 ${info.windowStart}` : `${info.windowStart} ~ ${info.windowEnd}`; return `<div class="metric-card"><div class="metric-card-head"><strong>${escapeHtml(metric.name)}</strong><small>${metricKindLabel[metric.kind]}</small></div><div class="metric-value">${info.value}${info.target ? ` <small>/ ${info.target}</small>` : ""}</div><div class="progress-track"><div class="progress-fill" style="width:${info.percent ?? 0}%"></div></div><small class="muted">${periodLabel[metric.period]} · ${info.percent === null ? "无上限" : `${info.percent}%`} · ${scope}</small></div>`; }).join("");
return `<section class="section"><div class="section-heading"><h2>指标总览</h2><span>基于每日事实计算</span></div><div class="metric-cards">${cards}</div></section>`;
}
function renderActivityHeatmap(options) {
const days = Math.min(options.historyDays ?? 365, 730);
const heat = Array.from({ length: days }, (_, index) => { const iso = shanghaiDate(index - (days - 1)); const record = state.history?.records?.find((item) => item.date === iso); const amount = record ? Object.values(record.values || {}).reduce((sum, value) => sum + Number(value), 0) : 0; return `<span class="heat-cell level-${amount ? Math.min(4, Math.ceil(amount / 2)) : 0}" title="${iso} · ${amount}"></span>`; }).join("");
return `<section class="section"><div class="section-heading"><h2>活动热力图</h2><span>过去 ${Math.round(days / 30)} 个月</span></div><div class="heatmap-wrap"><div class="heatmap">${heat}</div></div><div class="heat-legend"><span>少</span><i></i><i class="heat-cell level-1"></i><i class="heat-cell level-2"></i><i class="heat-cell level-3"></i><i class="heat-cell level-4"></i><span>多</span></div></section>`;
}
function renderTaskSettings() { return `<section class="section"><div class="section-heading"><div><h2>危险操作</h2><span>默认保留历史,可从后台彻底清理</span></div></div><button class="button danger" id="deleteTaskButton">移入回收站</button></section>`; }
function bindSettings() { const task = state.selectedTask; $("#deleteTaskButton").addEventListener("click", async () => { if (!confirm(`确认将“${task.name}”移入回收站?`)) return; try { await api(`/api/tasks/${task.taskId}?expectedVersion=${task.version}`, { method: "DELETE" }); state.selectedId = null; toast("任务已移入回收站"); await loadTasks(); } catch (error) { toast(error.message); } }); }
function openTaskDialog(task = null) {
state.editingId = task?.taskId || null; const form = $("#taskForm"); form.reset(); $("#taskDialogTitle").textContent = task ? "编辑任务属性" : "新建任务";
form.startDate.value = task?.startDate || today(); form.endDate.value = task?.endDate || ""; form.name.value = task?.name || ""; form.description.value = task?.description || ""; form.priority.value = task?.priority || 3; form.state.value = task?.state || "active";
renderMetricEditor(task?.metrics || [{ name: "完成", kind: "checkin", period: "day", target: 1, unit: "次" }]); $("#taskDialog").showModal();
}
function renderMetricEditor(metrics) { $("#metricEditor").innerHTML = metrics.map((metric, index) => `<div class="metric-row" data-index="${index}" data-metric-id="${escapeHtml(metric.metricId || "")}"><label>名称<input data-field="name" value="${escapeHtml(metric.name)}" maxlength="60" required></label><label>类型<select data-field="kind"><option value="checkin" ${metric.kind === "checkin" ? "selected" : ""}>打卡</option><option value="quantity" ${metric.kind === "quantity" ? "selected" : ""}>数量</option><option value="duration" ${metric.kind === "duration" ? "selected" : ""}>时长(秒)</option></select></label><label>周期<select data-field="period"><option value="day" ${metric.period === "day" ? "selected" : ""}>每日</option><option value="week" ${metric.period === "week" ? "selected" : ""}>每周</option><option value="month" ${metric.period === "month" ? "selected" : ""}>每月</option><option value="lifetime" ${metric.period === "lifetime" ? "selected" : ""}>总计</option></select></label><label>目标<input data-field="target" type="number" min="1" placeholder="无上限" value="${metric.target ?? ""}"></label><label>单位<input data-field="unit" maxlength="20" value="${escapeHtml(metric.unit || "")}" placeholder="次"></label><button type="button" class="icon-button remove-metric" data-remove="${index}" title="删除指标" aria-label="删除指标">×</button></div>`).join(""); $$("[data-remove]").forEach((button) => button.addEventListener("click", () => { const rows = $$(".metric-row"); if (rows.length <= 1) return toast("至少保留一个指标"); button.closest(".metric-row").remove(); })); }
$("#addMetricButton").addEventListener("click", () => { const metrics = $$(".metric-row").map((row) => Object.fromEntries($$("[data-field]", row).map((input) => [input.dataset.field, input.value]))); metrics.push({ name: "新指标", kind: "quantity", period: "day", target: "", unit: "次" }); renderMetricEditor(metrics); });
$("#taskForm").addEventListener("submit", async (event) => { event.preventDefault(); const form = event.currentTarget; const metrics = $$(".metric-row").map((row) => { const result = Object.fromEntries($$("[data-field]", row).map((input) => [input.dataset.field, input.value])); result.metricId = row.dataset.metricId || null; result.target = result.target ? Number(result.target) : null; return result; }); const payload = { name: form.name.value, description: form.description.value, startDate: form.startDate.value, endDate: form.endDate.value || null, priority: Number(form.priority.value), state: form.state.value, metrics }; try { const endpoint = state.editingId ? `/api/tasks/${state.editingId}` : "/api/tasks"; const method = state.editingId ? "PUT" : "POST"; if (state.editingId) payload.expectedVersion = state.selectedTask.version; const task = await api(endpoint, { method, body: JSON.stringify(payload) }); $("#taskDialog").close(); state.selectedId = task.taskId; toast(state.editingId ? "任务属性已更新" : "任务已创建"); await loadTasks(); } catch (error) { toast(error.message); } });
$("#addTaskButton").addEventListener("click", () => openTaskDialog()); $("#homeButton").addEventListener("click", () => { state.selectedId = null; render(); });
$("#adminButton").addEventListener("click", async () => { const result = await api("/api/tasks?includeDeleted=true"); const deleted = result.items.filter((task) => task.deletedAt); $("#trashList").innerHTML = deleted.length ? deleted.map((task) => `<div class="trash-item"><span><strong>${escapeHtml(task.name)}</strong><small class="muted">${task.deletedAt}</small></span><span class="trash-item-actions"><button class="button" data-restore="${task.taskId}">恢复</button><button class="button danger" data-purge="${task.taskId}">永久删除</button></span></div>`).join("") : `<p class="muted">回收站为空</p>`; $("#adminDialog").showModal(); $$("[data-restore]").forEach((button) => button.addEventListener("click", async () => { try { await api(`/api/tasks/${button.dataset.restore}/restore?expectedVersion=${deleted.find((task) => task.taskId === button.dataset.restore).version}`, { method: "POST" }); toast("任务已恢复"); $("#adminDialog").close(); await loadTasks(); } catch (error) { toast(error.message); } })); $$("[data-purge]").forEach((button) => button.addEventListener("click", async () => { const task = deleted.find((item) => item.taskId === button.dataset.purge); if (!confirm(`永久删除“${task.name}”?此操作不可恢复。`)) return; try { await api(`/api/tasks/${task.taskId}/purge`, { method: "POST", body: JSON.stringify({ confirmation: task.taskId }) }); toast("任务及其历史已永久删除"); $("#adminDialog").close(); await loadTasks(); } catch (error) { toast(error.message); } })); });
$("#closeAdminButton").addEventListener("click", () => $("#adminDialog").close()); $("#exportButton").addEventListener("click", async () => { const response = await fetch("/api/export"); const blob = await response.blob(); const link = document.createElement("a"); link.href = URL.createObjectURL(blob); link.download = "progress-export.json"; link.click(); URL.revokeObjectURL(link.href); }); $("#archiveButton").addEventListener("click", async () => { try { const result = await api("/api/admin/archive", { method: "POST", body: "{}" }); toast(`补归档完成,处理 ${result.closedDays} 天`); } catch (error) { toast(error.message); } }); $("#logoutButton").addEventListener("click", async () => { await api("/api/auth/logout", { method: "POST", body: "{}" }); showLogin(); });
$("#loginForm").addEventListener("submit", async (event) => { event.preventDefault(); try { const response = await fetch("/api/auth/login", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ password: event.currentTarget.password.value }) }); const body = await response.json(); if (!response.ok) throw new Error(body.detail || "登录失败"); state.csrf = body.csrfToken; hideLogin(); await loadTasks(); } catch (error) { $("#loginError").textContent = error.message; } });
bootstrap();