Task module: 7 UX features (detail panel, sort, filters, presets, kanban, badges)
1. Click-to-open slide-out detail panel with full task info 2. Client-side sorting (number, priority, status, updated) with localStorage 3. Toggleable filter chips for status and priority 4. Saved filter presets (Launch Fires, Code Queue, Post-Launch, All Open) 5. Kanban board view with 4 columns (Open, In Progress, Blocked, Done) 6. Session summary badge showing tasks completed today 7. Code queue badge in sidebar nav (cyan count from tags) Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
3773243312
commit
166e4c8424
@@ -27,9 +27,17 @@ const nodeHealthRouter = require('./node-health');
|
||||
|
||||
router.use(requireTrinityAccess);
|
||||
|
||||
// Make CSRF token available to all admin views
|
||||
router.use((req, res, next) => {
|
||||
// Make CSRF token and code queue badge available to all admin views
|
||||
router.use(async (req, res, next) => {
|
||||
res.locals.csrfToken = req.csrfToken();
|
||||
try {
|
||||
const result = await db.query(
|
||||
`SELECT COUNT(*) as count FROM tasks WHERE 'code' = ANY(tags) AND status IN ('open', 'in_progress')`
|
||||
);
|
||||
res.locals.codeQueueCount = parseInt(result.rows[0].count) || 0;
|
||||
} catch (e) {
|
||||
res.locals.codeQueueCount = 0;
|
||||
}
|
||||
next();
|
||||
});
|
||||
|
||||
|
||||
@@ -54,11 +54,26 @@ router.get('/', async (req, res) => {
|
||||
FROM tasks
|
||||
`);
|
||||
|
||||
// Session summary: tasks completed today
|
||||
const todayResult = await db.query(
|
||||
`SELECT COUNT(*) as count FROM tasks WHERE completed_at::date = CURRENT_DATE`
|
||||
);
|
||||
const completedToday = parseInt(todayResult.rows[0].count) || 0;
|
||||
|
||||
// All tasks for kanban (unfiltered active tasks)
|
||||
const kanbanResult = await db.query(
|
||||
`SELECT * FROM tasks ORDER BY
|
||||
CASE priority WHEN 'critical' THEN 1 WHEN 'high' THEN 2 WHEN 'medium' THEN 3 WHEN 'low' THEN 4 WHEN 'wish' THEN 5 END,
|
||||
task_number`
|
||||
);
|
||||
|
||||
res.render('admin/tasks/index', {
|
||||
title: 'Tasks',
|
||||
currentPath: '/tasks',
|
||||
tasks: result.rows,
|
||||
allTasks: kanbanResult.rows,
|
||||
stats: statsResult.rows[0],
|
||||
completedToday,
|
||||
filters: { status, priority, owner, all: req.query.all },
|
||||
priorities: PRIORITIES,
|
||||
statuses: STATUSES,
|
||||
|
||||
@@ -1,5 +1,28 @@
|
||||
<!-- Tasks Module — Trinity Console -->
|
||||
<!-- Chronicler #78 | April 11, 2026 -->
|
||||
<!-- Features 1-7: Code Agent | April 14, 2026 -->
|
||||
|
||||
<style>
|
||||
.slide-panel { transform: translateX(100%); transition: transform 0.3s ease; }
|
||||
.slide-panel.open { transform: translateX(0); }
|
||||
.chip { cursor:pointer; padding:2px 10px; border-radius:9999px; font-size:11px; font-weight:500; border:1px solid transparent; transition:all 0.15s; }
|
||||
.chip.active { background:#06b6d4; color:#fff; border-color:#06b6d4; }
|
||||
.chip.inactive { background:#374151; color:#9ca3af; border-color:#4b5563; }
|
||||
.chip:hover { opacity:0.85; }
|
||||
.sort-btn { cursor:pointer; font-size:11px; padding:2px 8px; border-radius:4px; transition:all 0.15s; }
|
||||
.sort-btn.active { background:#06b6d4; color:#fff; }
|
||||
.sort-btn.inactive { background:#374151; color:#9ca3af; }
|
||||
.kanban-col { min-height:200px; }
|
||||
.kanban-card { background:#2d2d2d; border:1px solid #374151; border-radius:8px; padding:10px; margin-bottom:8px; cursor:pointer; transition:border-color 0.15s; }
|
||||
.kanban-card:hover { border-color:#06b6d4; }
|
||||
</style>
|
||||
|
||||
<!-- Session Summary Badge -->
|
||||
<% if (completedToday > 0) { %>
|
||||
<div class="mb-4 px-4 py-2 bg-green-900/20 border border-green-700/30 rounded-lg inline-block">
|
||||
<span class="text-green-400 text-sm font-medium">✅ <%= completedToday %> task<%= completedToday !== 1 ? 's' : '' %> completed today</span>
|
||||
</div>
|
||||
<% } %>
|
||||
|
||||
<!-- Stats -->
|
||||
<div class="grid grid-cols-2 md:grid-cols-5 gap-4 mb-6">
|
||||
@@ -25,48 +48,66 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Filters + Create -->
|
||||
<div class="flex flex-wrap gap-4 mb-6 items-end">
|
||||
<form method="GET" class="flex flex-wrap gap-3 items-end flex-1">
|
||||
<div>
|
||||
<label class="block text-xs text-gray-500 dark:text-gray-400 mb-1">Status</label>
|
||||
<select name="status" class="bg-gray-100 dark:bg-gray-800 border border-gray-300 dark:border-gray-600 rounded-md px-3 py-2 text-sm">
|
||||
<option value="">Active</option>
|
||||
<% statuses.forEach(s => { %>
|
||||
<option value="<%= s %>" <%= filters.status === s ? 'selected' : '' %>><%= s.replace('_', ' ') %></option>
|
||||
<% }); %>
|
||||
</select>
|
||||
<!-- View Toggle + Presets + New Task -->
|
||||
<div class="flex flex-wrap gap-3 mb-4 items-center">
|
||||
<div class="flex bg-gray-800 rounded-md overflow-hidden">
|
||||
<button onclick="setView('list')" id="view-list-btn" class="px-3 py-1.5 text-xs font-medium transition">📋 List</button>
|
||||
<button onclick="setView('kanban')" id="view-kanban-btn" class="px-3 py-1.5 text-xs font-medium transition">📊 Kanban</button>
|
||||
</div>
|
||||
|
||||
<div class="relative">
|
||||
<button onclick="document.getElementById('presets-dropdown').classList.toggle('hidden')" class="bg-gray-800 text-gray-300 px-3 py-1.5 rounded-md text-xs hover:bg-gray-700 transition">
|
||||
⚡ Presets ▾
|
||||
</button>
|
||||
<div id="presets-dropdown" class="hidden absolute top-full left-0 mt-1 bg-gray-800 border border-gray-600 rounded-md shadow-lg z-20 min-w-[180px]">
|
||||
<button onclick="applyPreset('launch-fires')" class="block w-full text-left px-3 py-2 text-xs text-gray-300 hover:bg-gray-700">🔥 Launch Fires</button>
|
||||
<button onclick="applyPreset('code-queue')" class="block w-full text-left px-3 py-2 text-xs text-gray-300 hover:bg-gray-700">💻 Code Queue</button>
|
||||
<button onclick="applyPreset('post-launch')" class="block w-full text-left px-3 py-2 text-xs text-gray-300 hover:bg-gray-700">🌙 Post-Launch</button>
|
||||
<button onclick="applyPreset('all-open')" class="block w-full text-left px-3 py-2 text-xs text-gray-300 hover:bg-gray-700">📂 All Open</button>
|
||||
<button onclick="applyPreset('clear')" class="block w-full text-left px-3 py-2 text-xs text-gray-400 hover:bg-gray-700 border-t border-gray-700">✕ Clear Filters</button>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-xs text-gray-500 dark:text-gray-400 mb-1">Priority</label>
|
||||
<select name="priority" class="bg-gray-100 dark:bg-gray-800 border border-gray-300 dark:border-gray-600 rounded-md px-3 py-2 text-sm">
|
||||
<option value="">All</option>
|
||||
<% priorities.forEach(p => { %>
|
||||
<option value="<%= p %>" <%= filters.priority === p ? 'selected' : '' %>><%= p %></option>
|
||||
<% }); %>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-xs text-gray-500 dark:text-gray-400 mb-1">Owner</label>
|
||||
<select name="owner" class="bg-gray-100 dark:bg-gray-800 border border-gray-300 dark:border-gray-600 rounded-md px-3 py-2 text-sm">
|
||||
<option value="">All</option>
|
||||
<% owners.forEach(o => { %>
|
||||
<option value="<%= o %>" <%= filters.owner === o ? 'selected' : '' %>><%= o %></option>
|
||||
<% }); %>
|
||||
</select>
|
||||
</div>
|
||||
<div class="flex gap-2">
|
||||
<button type="submit" class="bg-frost text-white px-4 py-2 rounded-md text-sm hover:opacity-90 transition">Filter</button>
|
||||
<a href="/admin/tasks" class="bg-gray-200 dark:bg-gray-700 text-gray-700 dark:text-gray-300 px-4 py-2 rounded-md text-sm hover:opacity-90 transition">Reset</a>
|
||||
<a href="/admin/tasks?all=1" class="bg-gray-200 dark:bg-gray-700 text-gray-700 dark:text-gray-300 px-4 py-2 rounded-md text-sm hover:opacity-90 transition">Show All</a>
|
||||
</div>
|
||||
</form>
|
||||
<button onclick="document.getElementById('create-modal').style.display='flex'" class="bg-gradient-to-r from-fire to-frost text-white px-4 py-2 rounded-md text-sm hover:opacity-90 transition font-medium">
|
||||
+ New Task
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="ml-auto">
|
||||
<button onclick="document.getElementById('create-modal').style.display='flex'" class="bg-gradient-to-r from-fire to-frost text-white px-4 py-1.5 rounded-md text-xs hover:opacity-90 transition font-medium">
|
||||
+ New Task
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Task List -->
|
||||
<!-- Filter Chips -->
|
||||
<div class="mb-4 space-y-2">
|
||||
<div class="flex flex-wrap items-center gap-1.5">
|
||||
<span class="text-[10px] text-gray-500 uppercase tracking-wider mr-1">Status:</span>
|
||||
<button onclick="toggleChip('status','all')" class="chip" data-filter="status" data-value="all">All</button>
|
||||
<button onclick="toggleChip('status','open')" class="chip" data-filter="status" data-value="open">Open</button>
|
||||
<button onclick="toggleChip('status','in_progress')" class="chip" data-filter="status" data-value="in_progress">In Progress</button>
|
||||
<button onclick="toggleChip('status','blocked')" class="chip" data-filter="status" data-value="blocked">Blocked</button>
|
||||
<button onclick="toggleChip('status','done')" class="chip" data-filter="status" data-value="done">Done</button>
|
||||
<button onclick="toggleChip('status','obsolete')" class="chip" data-filter="status" data-value="obsolete">Obsolete</button>
|
||||
</div>
|
||||
<div class="flex flex-wrap items-center gap-1.5">
|
||||
<span class="text-[10px] text-gray-500 uppercase tracking-wider mr-1">Priority:</span>
|
||||
<button onclick="toggleChip('priority','all')" class="chip" data-filter="priority" data-value="all">All</button>
|
||||
<button onclick="toggleChip('priority','critical')" class="chip" data-filter="priority" data-value="critical">Critical</button>
|
||||
<button onclick="toggleChip('priority','high')" class="chip" data-filter="priority" data-value="high">High</button>
|
||||
<button onclick="toggleChip('priority','medium')" class="chip" data-filter="priority" data-value="medium">Medium</button>
|
||||
<button onclick="toggleChip('priority','low')" class="chip" data-filter="priority" data-value="low">Low</button>
|
||||
<button onclick="toggleChip('priority','wish')" class="chip" data-filter="priority" data-value="wish">Wish</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Sort Controls -->
|
||||
<div class="flex flex-wrap items-center gap-1.5 mb-4" id="sort-controls">
|
||||
<span class="text-[10px] text-gray-500 uppercase tracking-wider mr-1">Sort:</span>
|
||||
<button onclick="setSort('task_number')" class="sort-btn" data-sort="task_number">Number</button>
|
||||
<button onclick="setSort('priority')" class="sort-btn" data-sort="priority">Priority</button>
|
||||
<button onclick="setSort('status')" class="sort-btn" data-sort="status">Status</button>
|
||||
<button onclick="setSort('updated_at')" class="sort-btn" data-sort="updated_at">Updated</button>
|
||||
</div>
|
||||
|
||||
<!-- LIST VIEW -->
|
||||
<div id="list-view">
|
||||
<div class="bg-white dark:bg-darkcard rounded-lg shadow-sm border border-gray-200 dark:border-gray-700 overflow-hidden">
|
||||
<table class="w-full">
|
||||
<thead>
|
||||
@@ -79,26 +120,38 @@
|
||||
<th class="px-4 py-3 w-32">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-gray-200 dark:divide-gray-700">
|
||||
<tbody class="divide-y divide-gray-200 dark:divide-gray-700" id="task-tbody">
|
||||
<% if (tasks.length === 0) { %>
|
||||
<tr><td colspan="6" class="px-4 py-8 text-center text-gray-500">No tasks match your filters.</td></tr>
|
||||
<% } %>
|
||||
<% tasks.forEach(task => {
|
||||
const priColors = { critical:'bg-red-500/20 text-red-500', high:'bg-fire/20 text-fire', medium:'bg-yellow-500/20 text-yellow-500', low:'bg-blue-500/20 text-blue-400', wish:'bg-purple-500/20 text-purple-400' };
|
||||
const staColors = { open:'bg-gray-500/20 text-gray-400', in_progress:'bg-frost/20 text-frost', blocked:'bg-red-500/20 text-red-500', done:'bg-green-500/20 text-green-500', obsolete:'bg-gray-700/20 text-gray-600' };
|
||||
const priClass = priColors[task.priority] || 'bg-gray-500/20 text-gray-400';
|
||||
const staClass = staColors[task.status] || 'bg-gray-500/20 text-gray-400';
|
||||
var priColors = { critical:'bg-red-500/20 text-red-500', high:'bg-fire/20 text-fire', medium:'bg-yellow-500/20 text-yellow-500', low:'bg-blue-500/20 text-blue-400', wish:'bg-purple-500/20 text-purple-400' };
|
||||
var staColors = { open:'bg-gray-500/20 text-gray-400', in_progress:'bg-frost/20 text-frost', blocked:'bg-red-500/20 text-red-500', done:'bg-green-500/20 text-green-500', obsolete:'bg-gray-700/20 text-gray-600' };
|
||||
var priClass = priColors[task.priority] || 'bg-gray-500/20 text-gray-400';
|
||||
var staClass = staColors[task.status] || 'bg-gray-500/20 text-gray-400';
|
||||
var taskDesc = task.description || '';
|
||||
var taskTags = task.tags || [];
|
||||
var taskSpecPath = task.spec_path || '';
|
||||
var taskCompletedBy = task.completed_by || '';
|
||||
var taskCreatedAt = task.created_at ? new Date(task.created_at).toLocaleDateString() : '';
|
||||
var taskUpdatedAt = task.updated_at ? new Date(task.updated_at).toLocaleDateString() : '';
|
||||
var taskCompletedAt = task.completed_at ? new Date(task.completed_at).toLocaleDateString() : '';
|
||||
%>
|
||||
<tr class="hover:bg-gray-50 dark:hover:bg-gray-800/50 transition group">
|
||||
<tr class="hover:bg-gray-50 dark:hover:bg-gray-800/50 transition group task-row"
|
||||
data-status="<%= task.status %>" data-priority="<%= task.priority %>"
|
||||
data-task-number="<%= task.task_number %>"
|
||||
data-updated="<%= task.updated_at || '' %>"
|
||||
data-tags="<%= taskTags.join(',') %>"
|
||||
data-title="<%= task.title %>">
|
||||
<td class="px-4 py-3 text-sm font-mono text-gray-500"><%= task.task_number %></td>
|
||||
<td class="px-4 py-3">
|
||||
<div class="font-medium text-sm text-gray-200"><%= task.title %></div>
|
||||
<% if (task.description) { %>
|
||||
<div class="text-xs text-gray-500 mt-0.5 truncate max-w-md"><%= task.description.substring(0, 80) %><%= task.description.length > 80 ? '...' : '' %></div>
|
||||
<div class="font-medium text-sm text-gray-200 cursor-pointer hover:text-cyan-400 transition" onclick="openDetail(<%= JSON.stringify(JSON.stringify(task)) %>)"><%= task.title %></div>
|
||||
<% if (taskDesc) { %>
|
||||
<div class="text-xs text-gray-500 mt-0.5 truncate max-w-md"><%= taskDesc.substring(0, 80) %><%= taskDesc.length > 80 ? '...' : '' %></div>
|
||||
<% } %>
|
||||
<% if (task.tags && task.tags.length > 0) { %>
|
||||
<% if (taskTags.length > 0) { %>
|
||||
<div class="flex gap-1 mt-1">
|
||||
<% task.tags.forEach(tag => { %>
|
||||
<% taskTags.forEach(function(tag) { %>
|
||||
<span class="text-[9px] px-1.5 py-0.5 rounded bg-gray-700 text-gray-400"><%= tag %></span>
|
||||
<% }); %>
|
||||
</div>
|
||||
@@ -110,7 +163,7 @@
|
||||
<td class="px-4 py-3">
|
||||
<form method="POST" action="/admin/tasks/update/<%= task.id %>" class="inline">
|
||||
<select name="status" onchange="this.form.submit()" class="bg-transparent border-0 text-xs cursor-pointer <%= staClass %> rounded-full px-2 py-0.5 font-medium appearance-none">
|
||||
<% statuses.forEach(s => { %>
|
||||
<% statuses.forEach(function(s) { %>
|
||||
<option value="<%= s %>" <%= task.status === s ? 'selected' : '' %> class="bg-gray-800 text-white"><%= s.replace('_', ' ') %></option>
|
||||
<% }); %>
|
||||
</select>
|
||||
@@ -119,7 +172,7 @@
|
||||
<td class="px-4 py-3">
|
||||
<form method="POST" action="/admin/tasks/update/<%= task.id %>" class="inline">
|
||||
<select name="owner" onchange="this.form.submit()" class="bg-transparent border-0 text-xs cursor-pointer text-gray-400 appearance-none">
|
||||
<% owners.forEach(o => { %>
|
||||
<% owners.forEach(function(o) { %>
|
||||
<option value="<%= o %>" <%= task.owner === o ? 'selected' : '' %> class="bg-gray-800 text-white"><%= o %></option>
|
||||
<% }); %>
|
||||
</select>
|
||||
@@ -131,8 +184,8 @@
|
||||
<input type="hidden" name="status" value="done">
|
||||
<button type="submit" class="text-xs text-green-500 hover:text-green-400 transition opacity-0 group-hover:opacity-100">✓ Done</button>
|
||||
</form>
|
||||
<% } else if (task.completed_by) { %>
|
||||
<span class="text-[10px] text-gray-600"><%= task.completed_by %></span>
|
||||
<% } else if (taskCompletedBy) { %>
|
||||
<span class="text-[10px] text-gray-600"><%= taskCompletedBy %></span>
|
||||
<% } %>
|
||||
</td>
|
||||
</tr>
|
||||
@@ -140,9 +193,102 @@
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div class="text-center text-xs text-gray-600 mt-4">
|
||||
<%= tasks.length %> task(s) shown · Source: PostgreSQL · Also available via /tasks in Discord
|
||||
<span id="task-count"><%= tasks.length %></span> task(s) shown · Source: PostgreSQL · Also available via /tasks in Discord
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- KANBAN VIEW -->
|
||||
<div id="kanban-view" style="display:none">
|
||||
<div class="grid grid-cols-1 md:grid-cols-4 gap-4">
|
||||
<% var kanbanCols = [
|
||||
{ key: 'open', label: 'Open', color: 'text-gray-400', border: 'border-gray-600' },
|
||||
{ key: 'in_progress', label: 'In Progress', color: 'text-cyan-400', border: 'border-cyan-600' },
|
||||
{ key: 'blocked', label: 'Blocked', color: 'text-red-400', border: 'border-red-600' },
|
||||
{ key: 'done', label: 'Done', color: 'text-green-400', border: 'border-green-600' }
|
||||
]; %>
|
||||
<% kanbanCols.forEach(function(col) { %>
|
||||
<div>
|
||||
<h3 class="text-xs font-bold uppercase tracking-wider mb-3 <%= col.color %>">
|
||||
<%= col.label %>
|
||||
<span class="ml-1 text-gray-600">(<%= allTasks.filter(function(t) { return t.status === col.key; }).length %>)</span>
|
||||
</h3>
|
||||
<div class="kanban-col border-t-2 <%= col.border %> pt-3">
|
||||
<% allTasks.filter(function(t) { return t.status === col.key; }).forEach(function(task) {
|
||||
var kPriColors = { critical:'text-red-500', high:'text-fire', medium:'text-yellow-500', low:'text-blue-400', wish:'text-purple-400' };
|
||||
var kPriClass = kPriColors[task.priority] || 'text-gray-400';
|
||||
%>
|
||||
<div class="kanban-card" onclick="openDetail(<%= JSON.stringify(JSON.stringify(task)) %>)">
|
||||
<div class="flex items-center justify-between mb-1">
|
||||
<span class="text-[10px] font-mono text-gray-500">#<%= task.task_number %></span>
|
||||
<span class="text-[10px] font-medium <%= kPriClass %>"><%= task.priority %></span>
|
||||
</div>
|
||||
<div class="text-xs font-medium text-gray-200"><%= task.title %></div>
|
||||
<% if (task.owner && task.owner !== 'unassigned') { %>
|
||||
<div class="text-[10px] text-gray-500 mt-1"><%= task.owner %></div>
|
||||
<% } %>
|
||||
</div>
|
||||
<% }); %>
|
||||
</div>
|
||||
</div>
|
||||
<% }); %>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Slide-Out Detail Panel -->
|
||||
<div id="detail-overlay" class="fixed inset-0 bg-black/50 z-40" style="display:none" onclick="closeDetail()"></div>
|
||||
<div id="detail-panel" class="slide-panel fixed top-0 right-0 h-full w-full max-w-md bg-darkcard border-l border-gray-700 z-50 overflow-y-auto shadow-2xl">
|
||||
<div class="p-6">
|
||||
<div class="flex items-center justify-between mb-6">
|
||||
<span class="text-xs font-mono text-gray-500" id="detail-number"></span>
|
||||
<button onclick="closeDetail()" class="text-gray-400 hover:text-white text-lg">✕</button>
|
||||
</div>
|
||||
<h2 class="text-lg font-bold text-white mb-4" id="detail-title"></h2>
|
||||
<div class="flex gap-2 mb-4">
|
||||
<span id="detail-status" class="px-2 py-0.5 text-xs rounded-full font-medium"></span>
|
||||
<span id="detail-priority" class="px-2 py-0.5 text-xs rounded-full font-medium"></span>
|
||||
</div>
|
||||
<div class="space-y-4 text-sm">
|
||||
<div>
|
||||
<span class="text-[10px] text-gray-500 uppercase tracking-wider block mb-1">Owner</span>
|
||||
<span class="text-gray-300" id="detail-owner"></span>
|
||||
</div>
|
||||
<div>
|
||||
<span class="text-[10px] text-gray-500 uppercase tracking-wider block mb-1">Description</span>
|
||||
<div class="text-gray-300 text-xs whitespace-pre-wrap" id="detail-description"></div>
|
||||
</div>
|
||||
<div id="detail-tags-section">
|
||||
<span class="text-[10px] text-gray-500 uppercase tracking-wider block mb-1">Tags</span>
|
||||
<div id="detail-tags" class="flex flex-wrap gap-1"></div>
|
||||
</div>
|
||||
<div class="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<span class="text-[10px] text-gray-500 uppercase tracking-wider block mb-1">Created</span>
|
||||
<span class="text-gray-400 text-xs" id="detail-created"></span>
|
||||
</div>
|
||||
<div>
|
||||
<span class="text-[10px] text-gray-500 uppercase tracking-wider block mb-1">Updated</span>
|
||||
<span class="text-gray-400 text-xs" id="detail-updated"></span>
|
||||
</div>
|
||||
</div>
|
||||
<div id="detail-completed-section" style="display:none">
|
||||
<div class="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<span class="text-[10px] text-gray-500 uppercase tracking-wider block mb-1">Completed</span>
|
||||
<span class="text-green-400 text-xs" id="detail-completed-at"></span>
|
||||
</div>
|
||||
<div>
|
||||
<span class="text-[10px] text-gray-500 uppercase tracking-wider block mb-1">Completed By</span>
|
||||
<span class="text-green-400 text-xs" id="detail-completed-by"></span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div id="detail-spec-section" style="display:none">
|
||||
<span class="text-[10px] text-gray-500 uppercase tracking-wider block mb-1">Spec Path</span>
|
||||
<span class="text-cyan-400 text-xs font-mono" id="detail-spec"></span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Create Task Modal -->
|
||||
@@ -154,7 +300,7 @@
|
||||
<div>
|
||||
<label class="block text-xs text-gray-500 mb-1">Title *</label>
|
||||
<input type="text" name="title" required
|
||||
class="w-full bg-gray-100 dark:bg-gray-800 border border-gray-300 dark:border-gray-600 rounded-md px-3 py-2 text-sm"
|
||||
class="w-full bg-gray-100 dark:bg-gray-800 border border-gray-300 dark:border-gray-600 rounded-md px-3 py-2 text-sm"
|
||||
placeholder="What needs to be done?">
|
||||
</div>
|
||||
<div>
|
||||
@@ -197,3 +343,241 @@
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
// --- State ---
|
||||
var activeFilters = { status: ['all'], priority: ['all'] };
|
||||
var currentSort = localStorage.getItem('taskSort') || 'task_number';
|
||||
var currentSortDir = localStorage.getItem('taskSortDir') || 'asc';
|
||||
var currentView = localStorage.getItem('taskView') || 'list';
|
||||
|
||||
// --- View Toggle ---
|
||||
function setView(view) {
|
||||
currentView = view;
|
||||
localStorage.setItem('taskView', view);
|
||||
document.getElementById('list-view').style.display = view === 'list' ? '' : 'none';
|
||||
document.getElementById('kanban-view').style.display = view === 'kanban' ? '' : 'none';
|
||||
document.getElementById('view-list-btn').className = 'px-3 py-1.5 text-xs font-medium transition ' + (view === 'list' ? 'bg-cyan-500 text-white' : 'text-gray-400 hover:text-white');
|
||||
document.getElementById('view-kanban-btn').className = 'px-3 py-1.5 text-xs font-medium transition ' + (view === 'kanban' ? 'bg-cyan-500 text-white' : 'text-gray-400 hover:text-white');
|
||||
}
|
||||
|
||||
// --- Filter Chips ---
|
||||
function toggleChip(type, value) {
|
||||
if (value === 'all') {
|
||||
activeFilters[type] = ['all'];
|
||||
} else {
|
||||
var idx = activeFilters[type].indexOf(value);
|
||||
// Remove 'all' if selecting specific
|
||||
var allIdx = activeFilters[type].indexOf('all');
|
||||
if (allIdx > -1) activeFilters[type].splice(allIdx, 1);
|
||||
if (idx > -1) {
|
||||
activeFilters[type].splice(idx, 1);
|
||||
if (activeFilters[type].length === 0) activeFilters[type] = ['all'];
|
||||
} else {
|
||||
activeFilters[type].push(value);
|
||||
}
|
||||
}
|
||||
applyFilters();
|
||||
}
|
||||
|
||||
function applyFilters() {
|
||||
// Update chip visuals
|
||||
document.querySelectorAll('.chip').forEach(function(el) {
|
||||
var filter = el.getAttribute('data-filter');
|
||||
var value = el.getAttribute('data-value');
|
||||
var isActive = activeFilters[filter].indexOf(value) > -1;
|
||||
el.className = 'chip ' + (isActive ? 'active' : 'inactive');
|
||||
});
|
||||
|
||||
// Filter rows
|
||||
var rows = document.querySelectorAll('.task-row');
|
||||
var visibleCount = 0;
|
||||
rows.forEach(function(row) {
|
||||
var rowStatus = row.getAttribute('data-status');
|
||||
var rowPriority = row.getAttribute('data-priority');
|
||||
var statusMatch = activeFilters.status.indexOf('all') > -1 || activeFilters.status.indexOf(rowStatus) > -1;
|
||||
var priorityMatch = activeFilters.priority.indexOf('all') > -1 || activeFilters.priority.indexOf(rowPriority) > -1;
|
||||
if (statusMatch && priorityMatch) {
|
||||
row.style.display = '';
|
||||
visibleCount++;
|
||||
} else {
|
||||
row.style.display = 'none';
|
||||
}
|
||||
});
|
||||
document.getElementById('task-count').textContent = visibleCount;
|
||||
}
|
||||
|
||||
// --- Sorting ---
|
||||
function setSort(field) {
|
||||
if (currentSort === field) {
|
||||
currentSortDir = currentSortDir === 'asc' ? 'desc' : 'asc';
|
||||
} else {
|
||||
currentSort = field;
|
||||
currentSortDir = 'asc';
|
||||
}
|
||||
localStorage.setItem('taskSort', currentSort);
|
||||
localStorage.setItem('taskSortDir', currentSortDir);
|
||||
applySorting();
|
||||
}
|
||||
|
||||
function applySorting() {
|
||||
// Update button visuals
|
||||
document.querySelectorAll('.sort-btn').forEach(function(el) {
|
||||
var sort = el.getAttribute('data-sort');
|
||||
var isActive = currentSort === sort;
|
||||
el.className = 'sort-btn ' + (isActive ? 'active' : 'inactive');
|
||||
if (isActive) {
|
||||
el.textContent = el.textContent.replace(/ [▲▼]/, '') + (currentSortDir === 'asc' ? ' ▲' : ' ▼');
|
||||
} else {
|
||||
el.textContent = el.textContent.replace(/ [▲▼]/, '');
|
||||
}
|
||||
});
|
||||
|
||||
var tbody = document.getElementById('task-tbody');
|
||||
var rows = Array.from(tbody.querySelectorAll('.task-row'));
|
||||
var priOrder = { critical:1, high:2, medium:3, low:4, wish:5 };
|
||||
var staOrder = { in_progress:1, blocked:2, open:3, done:4, obsolete:5 };
|
||||
|
||||
rows.sort(function(a, b) {
|
||||
var aVal, bVal;
|
||||
if (currentSort === 'task_number') {
|
||||
aVal = parseInt(a.getAttribute('data-task-number'));
|
||||
bVal = parseInt(b.getAttribute('data-task-number'));
|
||||
} else if (currentSort === 'priority') {
|
||||
aVal = priOrder[a.getAttribute('data-priority')] || 99;
|
||||
bVal = priOrder[b.getAttribute('data-priority')] || 99;
|
||||
} else if (currentSort === 'status') {
|
||||
aVal = staOrder[a.getAttribute('data-status')] || 99;
|
||||
bVal = staOrder[b.getAttribute('data-status')] || 99;
|
||||
} else if (currentSort === 'updated_at') {
|
||||
aVal = a.getAttribute('data-updated') || '';
|
||||
bVal = b.getAttribute('data-updated') || '';
|
||||
if (aVal < bVal) return currentSortDir === 'asc' ? -1 : 1;
|
||||
if (aVal > bVal) return currentSortDir === 'asc' ? 1 : -1;
|
||||
return 0;
|
||||
}
|
||||
var diff = aVal - bVal;
|
||||
return currentSortDir === 'asc' ? diff : -diff;
|
||||
});
|
||||
|
||||
rows.forEach(function(row) { tbody.appendChild(row); });
|
||||
}
|
||||
|
||||
// --- Presets ---
|
||||
function applyPreset(name) {
|
||||
document.getElementById('presets-dropdown').classList.add('hidden');
|
||||
if (name === 'launch-fires') {
|
||||
activeFilters = { status: ['open'], priority: ['critical', 'high'] };
|
||||
} else if (name === 'code-queue') {
|
||||
activeFilters = { status: ['all'], priority: ['all'] };
|
||||
applyFilters();
|
||||
// Further filter by tags containing 'code' or title containing 'Code'
|
||||
document.querySelectorAll('.task-row').forEach(function(row) {
|
||||
var tags = row.getAttribute('data-tags') || '';
|
||||
var title = row.getAttribute('data-title') || '';
|
||||
if (tags.indexOf('code') === -1 && title.indexOf('Code') === -1) {
|
||||
row.style.display = 'none';
|
||||
}
|
||||
});
|
||||
updateVisibleCount();
|
||||
return;
|
||||
} else if (name === 'post-launch') {
|
||||
activeFilters = { status: ['open'], priority: ['low', 'wish'] };
|
||||
} else if (name === 'all-open') {
|
||||
activeFilters = { status: ['open', 'in_progress', 'blocked'], priority: ['all'] };
|
||||
} else if (name === 'clear') {
|
||||
activeFilters = { status: ['all'], priority: ['all'] };
|
||||
}
|
||||
applyFilters();
|
||||
}
|
||||
|
||||
function updateVisibleCount() {
|
||||
var count = 0;
|
||||
document.querySelectorAll('.task-row').forEach(function(row) {
|
||||
if (row.style.display !== 'none') count++;
|
||||
});
|
||||
document.getElementById('task-count').textContent = count;
|
||||
}
|
||||
|
||||
// --- Detail Panel ---
|
||||
function openDetail(taskJson) {
|
||||
var task = JSON.parse(taskJson);
|
||||
var priColors = { critical:'bg-red-500/20 text-red-500', high:'bg-fire/20 text-fire', medium:'bg-yellow-500/20 text-yellow-500', low:'bg-blue-500/20 text-blue-400', wish:'bg-purple-500/20 text-purple-400' };
|
||||
var staColors = { open:'bg-gray-500/20 text-gray-400', in_progress:'bg-frost/20 text-frost', blocked:'bg-red-500/20 text-red-500', done:'bg-green-500/20 text-green-500', obsolete:'bg-gray-700/20 text-gray-600' };
|
||||
|
||||
document.getElementById('detail-number').textContent = '#' + task.task_number;
|
||||
document.getElementById('detail-title').textContent = task.title;
|
||||
document.getElementById('detail-owner').textContent = task.owner || 'Unassigned';
|
||||
document.getElementById('detail-description').textContent = task.description || 'No description.';
|
||||
|
||||
var statusEl = document.getElementById('detail-status');
|
||||
statusEl.textContent = (task.status || '').replace('_', ' ');
|
||||
statusEl.className = 'px-2 py-0.5 text-xs rounded-full font-medium ' + (staColors[task.status] || '');
|
||||
|
||||
var priEl = document.getElementById('detail-priority');
|
||||
priEl.textContent = task.priority || '';
|
||||
priEl.className = 'px-2 py-0.5 text-xs rounded-full font-medium ' + (priColors[task.priority] || '');
|
||||
|
||||
// Tags
|
||||
var tagsContainer = document.getElementById('detail-tags');
|
||||
tagsContainer.innerHTML = '';
|
||||
var tagsSection = document.getElementById('detail-tags-section');
|
||||
if (task.tags && task.tags.length > 0) {
|
||||
tagsSection.style.display = '';
|
||||
task.tags.forEach(function(tag) {
|
||||
var span = document.createElement('span');
|
||||
span.className = 'text-[9px] px-1.5 py-0.5 rounded bg-gray-700 text-gray-400';
|
||||
span.textContent = tag;
|
||||
tagsContainer.appendChild(span);
|
||||
});
|
||||
} else {
|
||||
tagsSection.style.display = 'none';
|
||||
}
|
||||
|
||||
// Dates
|
||||
document.getElementById('detail-created').textContent = task.created_at ? new Date(task.created_at).toLocaleString() : 'Unknown';
|
||||
document.getElementById('detail-updated').textContent = task.updated_at ? new Date(task.updated_at).toLocaleString() : 'Unknown';
|
||||
|
||||
// Completed
|
||||
var compSection = document.getElementById('detail-completed-section');
|
||||
if (task.completed_at) {
|
||||
compSection.style.display = '';
|
||||
document.getElementById('detail-completed-at').textContent = new Date(task.completed_at).toLocaleString();
|
||||
document.getElementById('detail-completed-by').textContent = task.completed_by || 'Unknown';
|
||||
} else {
|
||||
compSection.style.display = 'none';
|
||||
}
|
||||
|
||||
// Spec path
|
||||
var specSection = document.getElementById('detail-spec-section');
|
||||
if (task.spec_path) {
|
||||
specSection.style.display = '';
|
||||
document.getElementById('detail-spec').textContent = task.spec_path;
|
||||
} else {
|
||||
specSection.style.display = 'none';
|
||||
}
|
||||
|
||||
document.getElementById('detail-overlay').style.display = '';
|
||||
document.getElementById('detail-panel').classList.add('open');
|
||||
}
|
||||
|
||||
function closeDetail() {
|
||||
document.getElementById('detail-panel').classList.remove('open');
|
||||
document.getElementById('detail-overlay').style.display = 'none';
|
||||
}
|
||||
|
||||
// --- Init ---
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
setView(currentView);
|
||||
applyFilters();
|
||||
applySorting();
|
||||
|
||||
// Close presets dropdown when clicking outside
|
||||
document.addEventListener('click', function(e) {
|
||||
var dropdown = document.getElementById('presets-dropdown');
|
||||
if (!e.target.closest('.relative')) {
|
||||
dropdown.classList.add('hidden');
|
||||
}
|
||||
});
|
||||
});
|
||||
</script>
|
||||
|
||||
@@ -102,8 +102,11 @@
|
||||
<a href="/admin/dashboard" class="block px-4 py-2 rounded-md <%= currentPath === '/dashboard' ? 'bg-gray-200 dark:bg-gray-700' : 'hover:bg-gray-100 dark:hover:bg-gray-800' %>">
|
||||
📊 Dashboard
|
||||
</a>
|
||||
<a href="/admin/tasks" class="block px-4 py-2 rounded-md <%= currentPath.startsWith('/tasks') ? 'bg-gray-200 dark:bg-gray-700' : 'hover:bg-gray-100 dark:hover:bg-gray-800' %>">
|
||||
📋 Tasks
|
||||
<a href="/admin/tasks" class="flex items-center justify-between px-4 py-2 rounded-md <%= currentPath.startsWith('/tasks') ? 'bg-gray-200 dark:bg-gray-700' : 'hover:bg-gray-100 dark:hover:bg-gray-800' %>">
|
||||
<span>📋 Tasks</span>
|
||||
<% if (typeof codeQueueCount !== 'undefined' && codeQueueCount > 0) { %>
|
||||
<span class="inline-flex items-center justify-center w-5 h-5 text-[10px] font-bold text-white bg-cyan-500 rounded-full"><%= codeQueueCount %></span>
|
||||
<% } %>
|
||||
</a>
|
||||
<a href="/admin/servers" class="block px-4 py-2 rounded-md <%= currentPath.startsWith('/servers') ? 'bg-gray-200 dark:bg-gray-700' : 'hover:bg-gray-100 dark:hover:bg-gray-800' %>">
|
||||
🖥️ Servers
|
||||
|
||||
Reference in New Issue
Block a user