fix(web): address code review findings from Razor Pages migration

- Remove legacy wwwroot files (old index.html, app.css, api.js, page scripts)
- Add Index page that redirects / to /board
- Fix mapping delete button missing handler=Delete in URL
- Add [IgnoreAntiforgeryToken] to AnalyticsModel for consistency
- Remove duplicate JS from _TaskDetail.cshtml (already in app.js)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-01 22:47:49 -05:00
parent cffd09941a
commit 6ea0e40d38
13 changed files with 14 additions and 1495 deletions

View File

@@ -1,48 +0,0 @@
const BASE = '/api';
async function request(path, options = {}) {
const res = await fetch(`${BASE}${path}`, {
headers: { 'Content-Type': 'application/json', ...options.headers },
...options,
});
const json = await res.json();
if (!json.success) throw new Error(json.error || 'Request failed');
return json.data;
}
export const tasks = {
list: (status, { parentId, includeSubTasks } = {}) => {
const params = new URLSearchParams();
if (status) params.set('status', status);
if (parentId != null) params.set('parentId', parentId);
if (includeSubTasks) params.set('includeSubTasks', 'true');
const qs = params.toString();
return request(`/tasks${qs ? `?${qs}` : ''}`);
},
subtasks: (parentId) => request(`/tasks?parentId=${parentId}`),
active: () => request('/tasks/active'),
get: (id) => request(`/tasks/${id}`),
create: (body) => request('/tasks', { method: 'POST', body: JSON.stringify(body) }),
start: (id) => request(`/tasks/${id}/start`, { method: 'PUT' }),
pause: (id, note) => request(`/tasks/${id}/pause`, { method: 'PUT', body: JSON.stringify({ note }) }),
resume: (id, note) => request(`/tasks/${id}/resume`, { method: 'PUT', body: JSON.stringify({ note }) }),
complete: (id) => request(`/tasks/${id}/complete`, { method: 'PUT' }),
abandon: (id) => request(`/tasks/${id}`, { method: 'DELETE' }),
};
export const notes = {
list: (taskId) => request(`/tasks/${taskId}/notes`),
create: (taskId, body) => request(`/tasks/${taskId}/notes`, { method: 'POST', body: JSON.stringify(body) }),
};
export const context = {
recent: (minutes = 30) => request(`/context/recent?minutes=${minutes}`),
summary: () => request('/context/summary'),
};
export const mappings = {
list: () => request('/mappings'),
create: (body) => request('/mappings', { method: 'POST', body: JSON.stringify(body) }),
update: (id, body) => request(`/mappings/${id}`, { method: 'PUT', body: JSON.stringify(body) }),
remove: (id) => request(`/mappings/${id}`, { method: 'DELETE' }),
};

View File

@@ -1,33 +0,0 @@
const overlay = document.getElementById('modal-overlay');
export function showModal(title, contentHtml, actions = []) {
overlay.innerHTML = `
<div class="modal">
<div class="modal-title">${title}</div>
<div class="modal-body">${contentHtml}</div>
<div class="modal-actions" id="modal-actions"></div>
</div>`;
const actionsEl = document.getElementById('modal-actions');
actions.forEach(({ label, cls, onClick }) => {
const btn = document.createElement('button');
btn.className = `btn ${cls || ''}`;
btn.textContent = label;
btn.addEventListener('click', async () => {
await onClick(overlay.querySelector('.modal'));
closeModal();
});
actionsEl.appendChild(btn);
});
overlay.classList.remove('hidden');
overlay.addEventListener('click', onOverlayClick);
}
export function closeModal() {
overlay.classList.add('hidden');
overlay.innerHTML = '';
overlay.removeEventListener('click', onOverlayClick);
}
function onOverlayClick(e) {
if (e.target === overlay) closeModal();
}

View File

@@ -1,91 +0,0 @@
import * as api from '../api.js';
const el = () => document.getElementById('page-context');
export async function initContext() {
el().innerHTML = `
<h1 class="page-title">Context</h1>
<div class="section-title">App Summary (8 hours)</div>
<div id="ctx-summary" class="card mb-16"></div>
<div class="flex-between mb-8">
<div class="section-title" style="margin-bottom:0">Recent Events</div>
<select class="form-select" id="ctx-minutes" style="width:auto">
<option value="15">Last 15 min</option>
<option value="30" selected>Last 30 min</option>
<option value="60">Last hour</option>
<option value="120">Last 2 hours</option>
<option value="480">Last 8 hours</option>
</select>
</div>
<div id="ctx-events" class="card table-wrap"></div>`;
document.getElementById('ctx-minutes').addEventListener('change', loadEvents);
await Promise.all([loadSummary(), loadEvents()]);
}
async function loadSummary() {
try {
const summary = await api.context.summary();
const container = document.getElementById('ctx-summary');
if (!summary || !summary.length) {
container.innerHTML = `<div class="empty-state">No activity recorded</div>`;
return;
}
container.innerHTML = `
<table>
<thead><tr><th>Application</th><th>Category</th><th>Events</th><th>First Seen</th><th>Last Seen</th></tr></thead>
<tbody>
${summary.map(s => `
<tr>
<td>${esc(s.appName)}</td>
<td>${esc(s.category)}</td>
<td>${s.eventCount}</td>
<td>${formatTime(s.firstSeen)}</td>
<td>${formatTime(s.lastSeen)}</td>
</tr>`).join('')}
</tbody>
</table>`;
} catch (e) {
document.getElementById('ctx-summary').innerHTML = `<div class="empty-state">Failed to load summary</div>`;
}
}
async function loadEvents() {
const minutes = parseInt(document.getElementById('ctx-minutes').value);
try {
const events = await api.context.recent(minutes);
const container = document.getElementById('ctx-events');
if (!events || !events.length) {
container.innerHTML = `<div class="empty-state">No recent events</div>`;
return;
}
container.innerHTML = `
<table>
<thead><tr><th>Source</th><th>App</th><th>Window Title</th><th>URL</th><th>Time</th></tr></thead>
<tbody>
${events.map(e => `
<tr>
<td>${esc(e.source)}</td>
<td>${esc(e.appName)}</td>
<td class="truncate">${esc(e.windowTitle)}</td>
<td class="truncate">${e.url ? esc(e.url) : '-'}</td>
<td>${formatTime(e.timestamp)}</td>
</tr>`).join('')}
</tbody>
</table>`;
} catch (e) {
document.getElementById('ctx-events').innerHTML = `<div class="empty-state">Failed to load events</div>`;
}
}
function formatTime(iso) {
const d = new Date(iso);
return d.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', second: '2-digit' });
}
function esc(str) {
if (!str) return '';
const d = document.createElement('div');
d.textContent = str;
return d.innerHTML;
}

View File

@@ -1,127 +0,0 @@
import * as api from '../api.js';
const el = () => document.getElementById('page-dashboard');
export function initDashboard() {
el().innerHTML = `
<h1 class="page-title">Dashboard</h1>
<div id="dash-active-task"></div>
<div class="section-title mt-16">Task Summary</div>
<div id="dash-stats" class="stats-grid"></div>
<div class="section-title mt-16">Recent Activity (8 hours)</div>
<div id="dash-context" class="card"></div>`;
}
export async function refreshDashboard() {
try {
const [active, allTasks, summary] = await Promise.all([
api.tasks.active(),
api.tasks.list(null, { includeSubTasks: true }),
api.context.summary(),
]);
await renderActiveTask(active);
renderStats(allTasks);
renderContextSummary(summary);
} catch (e) {
console.error('Dashboard refresh failed:', e);
}
}
async function buildParentTrail(task) {
const trail = [];
let current = task;
while (current.parentTaskId) {
try {
current = await api.tasks.get(current.parentTaskId);
trail.unshift({ id: current.id, title: current.title });
} catch {
break;
}
}
return trail;
}
async function renderActiveTask(task) {
const container = document.getElementById('dash-active-task');
if (!task) {
container.innerHTML = `<div class="card"><div class="no-active-task">No active task</div></div>`;
return;
}
const parentTrail = await buildParentTrail(task);
const breadcrumbHtml = parentTrail.length > 0
? `<div class="breadcrumb text-sm mt-8">${parentTrail.map(p => `<span class="breadcrumb-parent">${esc(p.title)}</span><span class="breadcrumb-sep">/</span>`).join('')}<span class="breadcrumb-current">${esc(task.title)}</span></div>`
: '';
const elapsed = task.startedAt ? formatElapsed(new Date(task.startedAt)) : '';
container.innerHTML = `
<div class="card active-task-card">
<div class="card-header">
<div>
<div class="card-title">${esc(task.title)}</div>
${breadcrumbHtml}
${task.description ? `<div class="text-sm text-muted mt-8">${esc(task.description)}</div>` : ''}
${task.category ? `<div class="text-sm text-muted">Category: ${esc(task.category)}</div>` : ''}
${elapsed ? `<div class="text-sm text-muted">Active for ${elapsed}</div>` : ''}
</div>
<div class="btn-group">
<button class="btn btn-warning btn-sm" data-action="pause" data-id="${task.id}">Pause</button>
<button class="btn btn-success btn-sm" data-action="complete" data-id="${task.id}">Complete</button>
</div>
</div>
</div>`;
container.querySelectorAll('[data-action]').forEach(btn => {
btn.addEventListener('click', async () => {
const action = btn.dataset.action;
const id = btn.dataset.id;
try {
if (action === 'pause') await api.tasks.pause(id);
else if (action === 'complete') await api.tasks.complete(id);
refreshDashboard();
} catch (e) { alert(e.message); }
});
});
}
function renderStats(allTasks) {
const counts = { Pending: 0, Active: 0, Paused: 0, Completed: 0, Abandoned: 0 };
allTasks.forEach(t => counts[t.status] = (counts[t.status] || 0) + 1);
const container = document.getElementById('dash-stats');
container.innerHTML = Object.entries(counts).map(([status, count]) => `
<div class="stat-card">
<div class="stat-value">${count}</div>
<div class="stat-label">${status}</div>
</div>`).join('');
}
function renderContextSummary(summary) {
const container = document.getElementById('dash-context');
if (!summary || summary.length === 0) {
container.innerHTML = `<div class="empty-state">No recent activity</div>`;
return;
}
container.innerHTML = summary.slice(0, 10).map(item => `
<div class="summary-item">
<div>
<div class="summary-app">${esc(item.appName)}</div>
<div class="summary-category">${esc(item.category)}</div>
</div>
<div class="summary-count">${item.eventCount}</div>
</div>`).join('');
}
function formatElapsed(since) {
const diff = Math.floor((Date.now() - since.getTime()) / 1000);
if (diff < 60) return `${diff}s`;
if (diff < 3600) return `${Math.floor(diff / 60)}m`;
const h = Math.floor(diff / 3600);
const m = Math.floor((diff % 3600) / 60);
return `${h}h ${m}m`;
}
function esc(str) {
if (!str) return '';
const d = document.createElement('div');
d.textContent = str;
return d.innerHTML;
}

View File

@@ -1,119 +0,0 @@
import * as api from '../api.js';
import { showModal } from '../components/modal.js';
const el = () => document.getElementById('page-mappings');
export async function initMappings() {
el().innerHTML = `
<h1 class="page-title">App Mappings</h1>
<div class="flex-between mb-16">
<div class="text-muted text-sm">Map process names, window titles, or URLs to categories</div>
<button class="btn btn-primary" id="btn-new-mapping">+ New Mapping</button>
</div>
<div id="mapping-list" class="card table-wrap"></div>`;
document.getElementById('btn-new-mapping').addEventListener('click', () => showMappingForm());
await loadMappings();
}
async function loadMappings() {
try {
const mappings = await api.mappings.list();
const container = document.getElementById('mapping-list');
if (!mappings || !mappings.length) {
container.innerHTML = `<div class="empty-state">No mappings configured</div>`;
return;
}
container.innerHTML = `
<table>
<thead><tr><th>Pattern</th><th>Match Type</th><th>Category</th><th>Friendly Name</th><th>Actions</th></tr></thead>
<tbody>
${mappings.map(m => `
<tr>
<td><code>${esc(m.pattern)}</code></td>
<td><span class="badge badge-pending">${m.matchType}</span></td>
<td>${esc(m.category)}</td>
<td>${esc(m.friendlyName) || '<span class="text-muted">-</span>'}</td>
<td>
<div class="btn-group">
<button class="btn btn-sm" data-edit="${m.id}">Edit</button>
<button class="btn btn-sm btn-danger" data-delete="${m.id}">Delete</button>
</div>
</td>
</tr>`).join('')}
</tbody>
</table>`;
container.querySelectorAll('[data-edit]').forEach(btn => {
btn.addEventListener('click', () => {
const m = mappings.find(x => x.id === parseInt(btn.dataset.edit));
if (m) showMappingForm(m);
});
});
container.querySelectorAll('[data-delete]').forEach(btn => {
btn.addEventListener('click', () => confirmDelete(parseInt(btn.dataset.delete)));
});
} catch (e) {
document.getElementById('mapping-list').innerHTML = `<div class="empty-state">Failed to load mappings</div>`;
}
}
function showMappingForm(existing = null) {
const title = existing ? 'Edit Mapping' : 'New Mapping';
showModal(title, `
<div class="form-group">
<label class="form-label">Pattern *</label>
<input type="text" class="form-input" id="map-pattern" value="${esc(existing?.pattern || '')}">
</div>
<div class="form-group">
<label class="form-label">Match Type *</label>
<select class="form-select" id="map-match-type">
<option value="ProcessName" ${existing?.matchType === 'ProcessName' ? 'selected' : ''}>Process Name</option>
<option value="TitleContains" ${existing?.matchType === 'TitleContains' ? 'selected' : ''}>Title Contains</option>
<option value="UrlContains" ${existing?.matchType === 'UrlContains' ? 'selected' : ''}>URL Contains</option>
</select>
</div>
<div class="form-group">
<label class="form-label">Category *</label>
<input type="text" class="form-input" id="map-category" value="${esc(existing?.category || '')}">
</div>
<div class="form-group">
<label class="form-label">Friendly Name</label>
<input type="text" class="form-input" id="map-friendly" value="${esc(existing?.friendlyName || '')}">
</div>`,
[
{ label: 'Cancel', onClick: () => {} },
{
label: existing ? 'Save' : 'Create', cls: 'btn-primary', onClick: async (modal) => {
const pattern = modal.querySelector('#map-pattern').value.trim();
const matchType = modal.querySelector('#map-match-type').value;
const category = modal.querySelector('#map-category').value.trim();
const friendlyName = modal.querySelector('#map-friendly').value.trim() || null;
if (!pattern || !category) { alert('Pattern and Category are required'); throw new Error('cancel'); }
const body = { pattern, matchType, category, friendlyName };
if (existing) await api.mappings.update(existing.id, body);
else await api.mappings.create(body);
loadMappings();
},
},
]);
setTimeout(() => document.getElementById('map-pattern')?.focus(), 100);
}
function confirmDelete(id) {
showModal('Delete Mapping', `<p>Are you sure you want to delete this mapping?</p>`, [
{ label: 'Cancel', onClick: () => {} },
{
label: 'Delete', cls: 'btn-danger', onClick: async () => {
await api.mappings.remove(id);
loadMappings();
},
},
]);
}
function esc(str) {
if (!str) return '';
const d = document.createElement('div');
d.textContent = str;
return d.innerHTML;
}

View File

@@ -1,357 +0,0 @@
import * as api from '../api.js';
import { showModal, closeModal } from '../components/modal.js';
const el = () => document.getElementById('page-tasks');
let currentFilter = null;
let selectedTaskId = null;
export function initTasks() {
el().innerHTML = `
<h1 class="page-title">Tasks</h1>
<div class="flex-between mb-16">
<div id="task-filters" class="filter-bar"></div>
<button class="btn btn-primary" id="btn-new-task">+ New Task</button>
</div>
<div id="task-list"></div>
<div id="task-detail" class="hidden"></div>`;
renderFilters();
document.getElementById('btn-new-task').addEventListener('click', () => showNewTaskModal());
loadTasks();
}
function renderFilters() {
const statuses = [null, 'Pending', 'Active', 'Paused', 'Completed', 'Abandoned'];
const labels = ['All', 'Pending', 'Active', 'Paused', 'Completed', 'Abandoned'];
const container = document.getElementById('task-filters');
container.innerHTML = statuses.map((s, i) => `
<button class="filter-btn ${s === currentFilter ? 'active' : ''}" data-status="${s || ''}">${labels[i]}</button>`).join('');
container.querySelectorAll('.filter-btn').forEach(btn => {
btn.addEventListener('click', () => {
currentFilter = btn.dataset.status || null;
renderFilters();
loadTasks();
});
});
}
async function loadTasks() {
try {
const tasks = await api.tasks.list(currentFilter);
renderTaskList(tasks);
} catch (e) {
document.getElementById('task-list').innerHTML = `<div class="empty-state">Failed to load tasks</div>`;
}
}
function renderTaskList(tasks) {
const container = document.getElementById('task-list');
document.getElementById('task-detail').classList.add('hidden');
container.classList.remove('hidden');
if (!tasks.length) {
container.innerHTML = `<div class="empty-state">No tasks found</div>`;
return;
}
container.innerHTML = tasks.map(t => {
const subCount = t.subTasks ? t.subTasks.length : 0;
return `
<div class="task-item" data-id="${t.id}">
<div class="task-item-left">
<span class="badge badge-${t.status.toLowerCase()}">${t.status}</span>
<span class="task-item-title">${esc(t.title)}</span>
${subCount > 0 ? `<span class="subtask-count">${subCount} subtask${subCount !== 1 ? 's' : ''}</span>` : ''}
</div>
<div class="task-item-meta">${t.category ? esc(t.category) + ' &middot; ' : ''}${formatDate(t.createdAt)}</div>
</div>`;
}).join('');
container.querySelectorAll('.task-item').forEach(item => {
item.addEventListener('click', () => showTaskDetail(parseInt(item.dataset.id)));
});
}
async function buildBreadcrumbs(task) {
const trail = [{ id: task.id, title: task.title }];
let current = task;
while (current.parentTaskId) {
try {
current = await api.tasks.get(current.parentTaskId);
trail.unshift({ id: current.id, title: current.title });
} catch {
break;
}
}
return trail;
}
async function showTaskDetail(id) {
selectedTaskId = id;
try {
const task = await api.tasks.get(id);
const container = document.getElementById('task-detail');
document.getElementById('task-list').classList.add('hidden');
container.classList.remove('hidden');
// Build breadcrumb trail
const breadcrumbs = await buildBreadcrumbs(task);
const breadcrumbHtml = breadcrumbs.length > 1
? `<div class="breadcrumb">${breadcrumbs.map((b, i) =>
i < breadcrumbs.length - 1
? `<a href="#" class="breadcrumb-link" data-id="${b.id}">${esc(b.title)}</a><span class="breadcrumb-sep">/</span>`
: `<span class="breadcrumb-current">${esc(b.title)}</span>`
).join('')}</div>`
: '';
container.innerHTML = `
<button class="btn btn-sm mb-16" id="btn-back-tasks">&larr; ${task.parentTaskId ? 'Back to parent' : 'Back to list'}</button>
${breadcrumbHtml}
<div class="task-detail">
<div class="task-detail-header">
<div>
<div class="task-detail-title">${esc(task.title)}</div>
<span class="badge badge-${task.status.toLowerCase()}">${task.status}</span>
</div>
<div class="btn-group" id="task-actions"></div>
</div>
${task.description ? `<p class="text-muted mb-16">${esc(task.description)}</p>` : ''}
<div class="task-meta-grid card">
<div class="meta-item"><div class="meta-label">Category</div>${esc(task.category) || 'None'}</div>
<div class="meta-item"><div class="meta-label">Created</div>${formatDateTime(task.createdAt)}</div>
<div class="meta-item"><div class="meta-label">Started</div>${task.startedAt ? formatDateTime(task.startedAt) : 'Not started'}</div>
<div class="meta-item"><div class="meta-label">Completed</div>${task.completedAt ? formatDateTime(task.completedAt) : '-'}</div>
</div>
<div class="section-title mt-16">Subtasks</div>
<div id="task-subtasks" class="subtask-list"></div>
${task.status !== 'Completed' && task.status !== 'Abandoned' ? `<button class="btn btn-sm btn-primary mt-8" id="btn-add-subtask">+ Add Subtask</button>` : ''}
<div class="section-title mt-16">Notes</div>
<div id="task-notes"></div>
<div class="form-inline mt-8">
<div class="form-group">
<input type="text" class="form-input" id="note-input" placeholder="Add a note...">
</div>
<button class="btn btn-primary btn-sm" id="btn-add-note">Add</button>
</div>
${task.contextEvents && task.contextEvents.length ? `
<div class="section-title mt-16">Linked Context Events</div>
<div class="table-wrap card">
<table>
<thead><tr><th>App</th><th>Title</th><th>Time</th></tr></thead>
<tbody>
${task.contextEvents.slice(0, 50).map(e => `
<tr>
<td>${esc(e.appName)}</td>
<td class="truncate">${esc(e.windowTitle)}</td>
<td>${formatTime(e.timestamp)}</td>
</tr>`).join('')}
</tbody>
</table>
</div>` : ''}
</div>`;
// Render action buttons based on status
renderActions(task);
// Render subtasks
renderSubTasks(task.subTasks || []);
// Render notes
renderNotes(task.notes || []);
// Breadcrumb navigation
container.querySelectorAll('.breadcrumb-link').forEach(link => {
link.addEventListener('click', (e) => {
e.preventDefault();
showTaskDetail(parseInt(link.dataset.id));
});
});
// Back button
document.getElementById('btn-back-tasks').addEventListener('click', () => {
if (task.parentTaskId) {
showTaskDetail(task.parentTaskId);
} else {
container.classList.add('hidden');
document.getElementById('task-list').classList.remove('hidden');
loadTasks();
}
});
// Add subtask button
const addSubBtn = document.getElementById('btn-add-subtask');
if (addSubBtn) {
addSubBtn.addEventListener('click', () => showNewTaskModal(task.id));
}
// Add note
const noteInput = document.getElementById('note-input');
document.getElementById('btn-add-note').addEventListener('click', () => addNote(task.id, noteInput));
noteInput.addEventListener('keydown', (e) => {
if (e.key === 'Enter') addNote(task.id, noteInput);
});
} catch (e) {
alert('Failed to load task: ' + e.message);
}
}
function renderSubTasks(subTasks) {
const container = document.getElementById('task-subtasks');
if (!subTasks.length) {
container.innerHTML = `<div class="text-muted text-sm">No subtasks</div>`;
return;
}
container.innerHTML = subTasks.map(st => {
const subCount = st.subTasks ? st.subTasks.length : 0;
const canStart = st.status === 'Pending' || st.status === 'Paused';
const canComplete = st.status === 'Active' || st.status === 'Paused';
return `
<div class="subtask-item" data-id="${st.id}">
<div class="subtask-item-left">
<span class="badge badge-${st.status.toLowerCase()}">${st.status}</span>
<a href="#" class="subtask-item-title" data-id="${st.id}">${esc(st.title)}</a>
${subCount > 0 ? `<span class="subtask-count">${subCount}</span>` : ''}
</div>
<div class="btn-group">
${canStart ? `<button class="btn btn-sm btn-success subtask-action" data-action="start" data-id="${st.id}">${st.status === 'Paused' ? 'Resume' : 'Start'}</button>` : ''}
${canComplete ? `<button class="btn btn-sm btn-success subtask-action" data-action="complete" data-id="${st.id}">Complete</button>` : ''}
</div>
</div>`;
}).join('');
// Navigate to subtask detail
container.querySelectorAll('.subtask-item-title').forEach(link => {
link.addEventListener('click', (e) => {
e.preventDefault();
showTaskDetail(parseInt(link.dataset.id));
});
});
// Inline subtask actions
container.querySelectorAll('.subtask-action').forEach(btn => {
btn.addEventListener('click', async (e) => {
e.stopPropagation();
const action = btn.dataset.action;
const stId = parseInt(btn.dataset.id);
try {
if (action === 'start') await api.tasks.start(stId);
else if (action === 'complete') await api.tasks.complete(stId);
showTaskDetail(selectedTaskId);
} catch (err) { alert(err.message); }
});
});
}
function renderActions(task) {
const container = document.getElementById('task-actions');
const actions = [];
switch (task.status) {
case 'Pending':
actions.push({ label: 'Start', cls: 'btn-success', action: () => api.tasks.start(task.id) });
actions.push({ label: 'Abandon', cls: 'btn-danger', action: () => api.tasks.abandon(task.id) });
break;
case 'Active':
actions.push({ label: 'Pause', cls: 'btn-warning', action: () => api.tasks.pause(task.id) });
actions.push({ label: 'Complete', cls: 'btn-success', action: () => api.tasks.complete(task.id) });
actions.push({ label: 'Abandon', cls: 'btn-danger', action: () => api.tasks.abandon(task.id) });
break;
case 'Paused':
actions.push({ label: 'Resume', cls: 'btn-success', action: () => api.tasks.resume(task.id) });
actions.push({ label: 'Complete', cls: 'btn-success', action: () => api.tasks.complete(task.id) });
actions.push({ label: 'Abandon', cls: 'btn-danger', action: () => api.tasks.abandon(task.id) });
break;
}
container.innerHTML = '';
actions.forEach(({ label, cls, action }) => {
const btn = document.createElement('button');
btn.className = `btn btn-sm ${cls}`;
btn.textContent = label;
btn.addEventListener('click', async () => {
try {
await action();
showTaskDetail(task.id);
} catch (e) { alert(e.message); }
});
container.appendChild(btn);
});
}
function renderNotes(notes) {
const container = document.getElementById('task-notes');
if (!notes.length) {
container.innerHTML = `<div class="text-muted text-sm">No notes yet</div>`;
return;
}
container.innerHTML = notes.map(n => `
<div class="note-item">
<div class="note-item-header">
<span class="note-type">${n.type}</span>
<span class="note-time">${formatDateTime(n.createdAt)}</span>
</div>
<div>${esc(n.content)}</div>
</div>`).join('');
}
async function addNote(taskId, input) {
const content = input.value.trim();
if (!content) return;
try {
await api.notes.create(taskId, { content, type: 'General' });
input.value = '';
showTaskDetail(taskId);
} catch (e) { alert(e.message); }
}
function showNewTaskModal(parentTaskId = null) {
const title = parentTaskId ? 'New Subtask' : 'New Task';
showModal(title, `
<div class="form-group">
<label class="form-label">Title *</label>
<input type="text" class="form-input" id="new-task-title">
</div>
<div class="form-group">
<label class="form-label">Description</label>
<textarea class="form-textarea" id="new-task-desc"></textarea>
</div>
<div class="form-group">
<label class="form-label">Category</label>
<input type="text" class="form-input" id="new-task-cat">
</div>`,
[
{ label: 'Cancel', onClick: () => {} },
{
label: 'Create', cls: 'btn-primary', onClick: async (modal) => {
const taskTitle = modal.querySelector('#new-task-title').value.trim();
if (!taskTitle) { alert('Title is required'); throw new Error('cancel'); }
const description = modal.querySelector('#new-task-desc').value.trim() || null;
const category = modal.querySelector('#new-task-cat').value.trim() || null;
const body = { title: taskTitle, description, category };
if (parentTaskId) body.parentTaskId = parentTaskId;
await api.tasks.create(body);
if (parentTaskId) {
showTaskDetail(parentTaskId);
} else {
loadTasks();
}
},
},
]);
setTimeout(() => document.getElementById('new-task-title')?.focus(), 100);
}
function formatDate(iso) {
return new Date(iso).toLocaleDateString();
}
function formatDateTime(iso) {
const d = new Date(iso);
return d.toLocaleDateString() + ' ' + d.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
}
function formatTime(iso) {
return new Date(iso).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
}
function esc(str) {
if (!str) return '';
const d = document.createElement('div');
d.textContent = str;
return d.innerHTML;
}