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>
This commit is contained in:
619
TaskTracker.Api/wwwroot/css/app.css
Normal file
619
TaskTracker.Api/wwwroot/css/app.css
Normal file
@@ -0,0 +1,619 @@
|
||||
/* ── Variables ── */
|
||||
:root {
|
||||
--bg-primary: #1a1b23;
|
||||
--bg-secondary: #22232e;
|
||||
--bg-card: #2a2b38;
|
||||
--bg-input: #32333f;
|
||||
--bg-hover: #353647;
|
||||
--border: #3a3b4a;
|
||||
--text-primary: #e4e4e8;
|
||||
--text-secondary: #9d9db0;
|
||||
--text-muted: #6b6b80;
|
||||
--accent: #6c8cff;
|
||||
--accent-hover: #8ba3ff;
|
||||
--success: #4caf7c;
|
||||
--warning: #e0a040;
|
||||
--danger: #e05555;
|
||||
--info: #50b0d0;
|
||||
--sidebar-width: 220px;
|
||||
--radius: 8px;
|
||||
--radius-sm: 4px;
|
||||
}
|
||||
|
||||
/* ── Reset ── */
|
||||
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
|
||||
html, body { height: 100%; }
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
||||
background: var(--bg-primary);
|
||||
color: var(--text-primary);
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
/* ── Layout ── */
|
||||
#app {
|
||||
display: flex;
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
#sidebar {
|
||||
width: var(--sidebar-width);
|
||||
background: var(--bg-secondary);
|
||||
border-right: 1px solid var(--border);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
bottom: 0;
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
.sidebar-brand {
|
||||
padding: 20px 16px;
|
||||
font-size: 18px;
|
||||
font-weight: 700;
|
||||
color: var(--accent);
|
||||
border-bottom: 1px solid var(--border);
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
.nav-links {
|
||||
list-style: none;
|
||||
padding: 8px;
|
||||
}
|
||||
|
||||
.nav-link {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 10px 12px;
|
||||
color: var(--text-secondary);
|
||||
text-decoration: none;
|
||||
border-radius: var(--radius-sm);
|
||||
transition: background 0.15s, color 0.15s;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.nav-link:hover {
|
||||
background: var(--bg-hover);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.nav-link.active {
|
||||
background: var(--accent);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.nav-icon {
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
width: 24px;
|
||||
text-align: center;
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
#content {
|
||||
flex: 1;
|
||||
margin-left: var(--sidebar-width);
|
||||
padding: 24px 32px;
|
||||
max-width: 1200px;
|
||||
}
|
||||
|
||||
.page { animation: fadeIn 0.15s ease; }
|
||||
.hidden { display: none !important; }
|
||||
|
||||
@keyframes fadeIn {
|
||||
from { opacity: 0; transform: translateY(4px); }
|
||||
to { opacity: 1; transform: translateY(0); }
|
||||
}
|
||||
|
||||
/* ── Typography ── */
|
||||
.page-title {
|
||||
font-size: 22px;
|
||||
font-weight: 600;
|
||||
margin-bottom: 20px;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.section-title {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
color: var(--text-muted);
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
/* ── Cards ── */
|
||||
.card {
|
||||
background: var(--bg-card);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
padding: 16px 20px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.card-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.card-title {
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
/* ── Stats Grid ── */
|
||||
.stats-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(140px, 1fr));
|
||||
gap: 12px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.stat-card {
|
||||
background: var(--bg-card);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
padding: 16px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.stat-value {
|
||||
font-size: 28px;
|
||||
font-weight: 700;
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.stat-label {
|
||||
font-size: 12px;
|
||||
color: var(--text-muted);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
/* ── Status Badges ── */
|
||||
.badge {
|
||||
display: inline-block;
|
||||
padding: 2px 10px;
|
||||
border-radius: 12px;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.3px;
|
||||
}
|
||||
|
||||
.badge-pending { background: #3a3b4a; color: var(--text-secondary); }
|
||||
.badge-active { background: rgba(76, 175, 124, 0.2); color: var(--success); }
|
||||
.badge-paused { background: rgba(224, 160, 64, 0.2); color: var(--warning); }
|
||||
.badge-completed { background: rgba(108, 140, 255, 0.2); color: var(--accent); }
|
||||
.badge-abandoned { background: rgba(224, 85, 85, 0.2); color: var(--danger); }
|
||||
|
||||
/* ── Buttons ── */
|
||||
.btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 7px 14px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--bg-input);
|
||||
color: var(--text-primary);
|
||||
font-size: 13px;
|
||||
cursor: pointer;
|
||||
transition: background 0.15s, border-color 0.15s;
|
||||
font-family: inherit;
|
||||
}
|
||||
|
||||
.btn:hover { background: var(--bg-hover); border-color: var(--text-muted); }
|
||||
|
||||
.btn-primary { background: var(--accent); border-color: var(--accent); color: #fff; }
|
||||
.btn-primary:hover { background: var(--accent-hover); border-color: var(--accent-hover); }
|
||||
|
||||
.btn-success { background: var(--success); border-color: var(--success); color: #fff; }
|
||||
.btn-success:hover { opacity: 0.9; }
|
||||
|
||||
.btn-warning { background: var(--warning); border-color: var(--warning); color: #1a1b23; }
|
||||
.btn-warning:hover { opacity: 0.9; }
|
||||
|
||||
.btn-danger { background: var(--danger); border-color: var(--danger); color: #fff; }
|
||||
.btn-danger:hover { opacity: 0.9; }
|
||||
|
||||
.btn-sm { padding: 4px 10px; font-size: 12px; }
|
||||
|
||||
.btn-group { display: flex; gap: 6px; flex-wrap: wrap; }
|
||||
|
||||
/* ── Forms ── */
|
||||
.form-group {
|
||||
margin-bottom: 14px;
|
||||
}
|
||||
|
||||
.form-label {
|
||||
display: block;
|
||||
font-size: 13px;
|
||||
color: var(--text-secondary);
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.form-input, .form-select, .form-textarea {
|
||||
width: 100%;
|
||||
padding: 8px 12px;
|
||||
background: var(--bg-input);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-sm);
|
||||
color: var(--text-primary);
|
||||
font-size: 14px;
|
||||
font-family: inherit;
|
||||
transition: border-color 0.15s;
|
||||
}
|
||||
|
||||
.form-input:focus, .form-select:focus, .form-textarea:focus {
|
||||
outline: none;
|
||||
border-color: var(--accent);
|
||||
}
|
||||
|
||||
.form-textarea { resize: vertical; min-height: 60px; }
|
||||
|
||||
.form-inline {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
align-items: flex-end;
|
||||
}
|
||||
|
||||
.form-inline .form-group { margin-bottom: 0; flex: 1; }
|
||||
|
||||
/* ── Tables ── */
|
||||
.table-wrap {
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
th, td {
|
||||
padding: 10px 12px;
|
||||
text-align: left;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
|
||||
th {
|
||||
font-size: 11px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
color: var(--text-muted);
|
||||
font-weight: 600;
|
||||
background: var(--bg-secondary);
|
||||
position: sticky;
|
||||
top: 0;
|
||||
}
|
||||
|
||||
tr:hover td { background: var(--bg-hover); }
|
||||
|
||||
/* ── Tabs / Filters ── */
|
||||
.filter-bar {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
margin-bottom: 16px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.filter-btn {
|
||||
padding: 6px 14px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 16px;
|
||||
background: transparent;
|
||||
color: var(--text-secondary);
|
||||
font-size: 13px;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s;
|
||||
font-family: inherit;
|
||||
}
|
||||
|
||||
.filter-btn:hover { border-color: var(--text-muted); color: var(--text-primary); }
|
||||
.filter-btn.active { background: var(--accent); border-color: var(--accent); color: #fff; }
|
||||
|
||||
/* ── Active Task Card ── */
|
||||
.active-task-card {
|
||||
border-left: 3px solid var(--success);
|
||||
}
|
||||
|
||||
.active-task-card .card-title {
|
||||
color: var(--success);
|
||||
}
|
||||
|
||||
.no-active-task {
|
||||
color: var(--text-muted);
|
||||
font-style: italic;
|
||||
padding: 12px 0;
|
||||
}
|
||||
|
||||
/* ── Task List ── */
|
||||
.task-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 12px 16px;
|
||||
background: var(--bg-card);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
margin-bottom: 8px;
|
||||
cursor: pointer;
|
||||
transition: border-color 0.15s;
|
||||
}
|
||||
|
||||
.task-item:hover { border-color: var(--accent); }
|
||||
|
||||
.task-item-left {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.task-item-title {
|
||||
font-weight: 500;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.task-item-meta {
|
||||
font-size: 12px;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
/* ── Task Detail ── */
|
||||
.task-detail { margin-top: 16px; }
|
||||
|
||||
.task-detail-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-start;
|
||||
gap: 16px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.task-detail-title {
|
||||
font-size: 20px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.task-meta-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
|
||||
gap: 12px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.meta-item {
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.meta-label {
|
||||
color: var(--text-muted);
|
||||
font-size: 11px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
.note-item {
|
||||
padding: 10px 14px;
|
||||
background: var(--bg-secondary);
|
||||
border-radius: var(--radius-sm);
|
||||
margin-bottom: 8px;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.note-item-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.note-type {
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.note-time {
|
||||
font-size: 11px;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
/* ── Context Summary ── */
|
||||
.summary-item {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 10px 0;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.summary-item:last-child { border-bottom: none; }
|
||||
|
||||
.summary-app {
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.summary-category {
|
||||
font-size: 12px;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.summary-count {
|
||||
font-size: 18px;
|
||||
font-weight: 700;
|
||||
color: var(--accent);
|
||||
min-width: 50px;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
/* ── Modal ── */
|
||||
.modal-overlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: rgba(0, 0, 0, 0.6);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 100;
|
||||
}
|
||||
|
||||
.modal {
|
||||
background: var(--bg-card);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
padding: 24px;
|
||||
min-width: 400px;
|
||||
max-width: 90vw;
|
||||
max-height: 80vh;
|
||||
overflow-y: auto;
|
||||
animation: modalIn 0.15s ease;
|
||||
}
|
||||
|
||||
@keyframes modalIn {
|
||||
from { opacity: 0; transform: scale(0.95); }
|
||||
to { opacity: 1; transform: scale(1); }
|
||||
}
|
||||
|
||||
.modal-title {
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.modal-actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 8px;
|
||||
margin-top: 20px;
|
||||
}
|
||||
|
||||
/* ── Utilities ── */
|
||||
.text-muted { color: var(--text-muted); }
|
||||
.text-sm { font-size: 12px; }
|
||||
.mt-8 { margin-top: 8px; }
|
||||
.mt-16 { margin-top: 16px; }
|
||||
.mb-8 { margin-bottom: 8px; }
|
||||
.mb-16 { margin-bottom: 16px; }
|
||||
.flex-between { display: flex; justify-content: space-between; align-items: center; }
|
||||
|
||||
.empty-state {
|
||||
text-align: center;
|
||||
padding: 40px 20px;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.truncate {
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
max-width: 300px;
|
||||
}
|
||||
|
||||
/* ── Breadcrumbs ── */
|
||||
.breadcrumb {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
font-size: 13px;
|
||||
margin-bottom: 12px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.breadcrumb-link {
|
||||
color: var(--accent);
|
||||
text-decoration: none;
|
||||
transition: color 0.15s;
|
||||
}
|
||||
|
||||
.breadcrumb-link:hover {
|
||||
color: var(--accent-hover);
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.breadcrumb-sep {
|
||||
color: var(--text-muted);
|
||||
margin: 0 2px;
|
||||
}
|
||||
|
||||
.breadcrumb-current {
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.breadcrumb-parent {
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
/* ── Subtasks ── */
|
||||
.subtask-list {
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.subtask-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 8px 12px;
|
||||
background: var(--bg-secondary);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-sm);
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
.subtask-item:hover {
|
||||
border-color: var(--accent);
|
||||
}
|
||||
|
||||
.subtask-item-left {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.subtask-item-title {
|
||||
color: var(--text-primary);
|
||||
text-decoration: none;
|
||||
font-weight: 500;
|
||||
font-size: 13px;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.subtask-item-title:hover {
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.subtask-count {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-width: 20px;
|
||||
height: 20px;
|
||||
padding: 0 6px;
|
||||
border-radius: 10px;
|
||||
background: var(--bg-hover);
|
||||
color: var(--text-muted);
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
/* ── Scrollbar ── */
|
||||
::-webkit-scrollbar { width: 8px; }
|
||||
::-webkit-scrollbar-track { background: var(--bg-primary); }
|
||||
::-webkit-scrollbar-thumb { background: var(--border); border-radius: 4px; }
|
||||
::-webkit-scrollbar-thumb:hover { background: var(--text-muted); }
|
||||
38
TaskTracker.Api/wwwroot/index.html
Normal file
38
TaskTracker.Api/wwwroot/index.html
Normal file
@@ -0,0 +1,38 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>TaskTracker</title>
|
||||
<link rel="stylesheet" href="/css/app.css">
|
||||
</head>
|
||||
<body>
|
||||
<div id="app">
|
||||
<nav id="sidebar">
|
||||
<div class="sidebar-brand">TaskTracker</div>
|
||||
<ul class="nav-links">
|
||||
<li><a href="#/" data-page="dashboard" class="nav-link active">
|
||||
<span class="nav-icon">[D]</span> Dashboard
|
||||
</a></li>
|
||||
<li><a href="#/tasks" data-page="tasks" class="nav-link">
|
||||
<span class="nav-icon">[T]</span> Tasks
|
||||
</a></li>
|
||||
<li><a href="#/context" data-page="context" class="nav-link">
|
||||
<span class="nav-icon">[C]</span> Context
|
||||
</a></li>
|
||||
<li><a href="#/mappings" data-page="mappings" class="nav-link">
|
||||
<span class="nav-icon">[M]</span> Mappings
|
||||
</a></li>
|
||||
</ul>
|
||||
</nav>
|
||||
<main id="content">
|
||||
<div id="page-dashboard" class="page"></div>
|
||||
<div id="page-tasks" class="page hidden"></div>
|
||||
<div id="page-context" class="page hidden"></div>
|
||||
<div id="page-mappings" class="page hidden"></div>
|
||||
</main>
|
||||
</div>
|
||||
<div id="modal-overlay" class="modal-overlay hidden"></div>
|
||||
<script type="module" src="/js/app.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
48
TaskTracker.Api/wwwroot/js/api.js
Normal file
48
TaskTracker.Api/wwwroot/js/api.js
Normal file
@@ -0,0 +1,48 @@
|
||||
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' }),
|
||||
};
|
||||
53
TaskTracker.Api/wwwroot/js/app.js
Normal file
53
TaskTracker.Api/wwwroot/js/app.js
Normal file
@@ -0,0 +1,53 @@
|
||||
import { initDashboard, refreshDashboard } from './pages/dashboard.js';
|
||||
import { initTasks } from './pages/tasks.js';
|
||||
import { initContext } from './pages/context.js';
|
||||
import { initMappings } from './pages/mappings.js';
|
||||
|
||||
const pages = ['dashboard', 'tasks', 'context', 'mappings'];
|
||||
let currentPage = null;
|
||||
let refreshTimer = null;
|
||||
|
||||
function navigate(page) {
|
||||
if (!pages.includes(page)) page = 'dashboard';
|
||||
if (currentPage === page) return;
|
||||
currentPage = page;
|
||||
|
||||
// Update nav
|
||||
document.querySelectorAll('.nav-link').forEach(link => {
|
||||
link.classList.toggle('active', link.dataset.page === page);
|
||||
});
|
||||
|
||||
// Show/hide pages
|
||||
pages.forEach(p => {
|
||||
document.getElementById(`page-${p}`).classList.toggle('hidden', p !== page);
|
||||
});
|
||||
|
||||
// Load page content
|
||||
const loaders = { dashboard: refreshDashboard, tasks: initTasks, context: initContext, mappings: initMappings };
|
||||
loaders[page]?.();
|
||||
|
||||
// Auto-refresh for dashboard and context
|
||||
clearInterval(refreshTimer);
|
||||
if (page === 'dashboard' || page === 'context') {
|
||||
refreshTimer = setInterval(() => loaders[page]?.(), 30000);
|
||||
}
|
||||
}
|
||||
|
||||
function onHashChange() {
|
||||
const hash = location.hash.slice(2) || 'dashboard';
|
||||
navigate(hash);
|
||||
}
|
||||
|
||||
// Init
|
||||
document.querySelectorAll('.nav-link').forEach(link => {
|
||||
link.addEventListener('click', (e) => {
|
||||
e.preventDefault();
|
||||
location.hash = link.getAttribute('href').slice(1);
|
||||
});
|
||||
});
|
||||
|
||||
window.addEventListener('hashchange', onHashChange);
|
||||
|
||||
initDashboard();
|
||||
initTasks();
|
||||
onHashChange();
|
||||
33
TaskTracker.Api/wwwroot/js/components/modal.js
Normal file
33
TaskTracker.Api/wwwroot/js/components/modal.js
Normal file
@@ -0,0 +1,33 @@
|
||||
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();
|
||||
}
|
||||
91
TaskTracker.Api/wwwroot/js/pages/context.js
Normal file
91
TaskTracker.Api/wwwroot/js/pages/context.js
Normal file
@@ -0,0 +1,91 @@
|
||||
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;
|
||||
}
|
||||
127
TaskTracker.Api/wwwroot/js/pages/dashboard.js
Normal file
127
TaskTracker.Api/wwwroot/js/pages/dashboard.js
Normal file
@@ -0,0 +1,127 @@
|
||||
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;
|
||||
}
|
||||
119
TaskTracker.Api/wwwroot/js/pages/mappings.js
Normal file
119
TaskTracker.Api/wwwroot/js/pages/mappings.js
Normal file
@@ -0,0 +1,119 @@
|
||||
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;
|
||||
}
|
||||
357
TaskTracker.Api/wwwroot/js/pages/tasks.js
Normal file
357
TaskTracker.Api/wwwroot/js/pages/tasks.js
Normal file
@@ -0,0 +1,357 @@
|
||||
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;
|
||||
}
|
||||
Reference in New Issue
Block a user