Existing ASP.NET API with vanilla JS SPA, WindowWatcher, Chrome extension, and MCP server. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
358 lines
15 KiB
JavaScript
358 lines
15 KiB
JavaScript
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) + ' · ' : ''}${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">← ${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;
|
|
}
|