Files
TaskTracker/TaskTracker.Api/wwwroot/js/pages/tasks.js
AJ Isaacs e12f78c479 chore: initial commit of TaskTracker project
Existing ASP.NET API with vanilla JS SPA, WindowWatcher, Chrome extension, and MCP server.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-26 22:08:45 -05:00

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) + ' &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;
}