feat: Task #116 — Trinity Console Tasks Module
- Tasks page at /admin/tasks with filterable table - Status/owner inline dropdowns (change via form submit) - + New Task modal with title, description, priority, owner - ✓ Done button on hover per row - Stats bar: active, in progress, blocked, high priority, completed - Show All toggle for done/obsolete tasks - Sidebar link under Operations (right after Dashboard) - Added to About page module registry Source of truth: PostgreSQL tasks table (shared with Discord /tasks) Chronicler #78 | firefrost-services
This commit is contained in:
@@ -17,6 +17,7 @@ const fs = require('fs');
|
||||
|
||||
const MODULES = [
|
||||
{ name: 'Dashboard', version: '1.0.0', path: '/admin/dashboard', icon: '📊', status: 'stable' },
|
||||
{ name: 'Tasks', version: '1.0.0', path: '/admin/tasks', icon: '📋', status: 'new' },
|
||||
{ name: 'Servers', version: '1.0.0', path: '/admin/servers', icon: '🖥️', status: 'stable' },
|
||||
{ name: 'Players', version: '1.0.0', path: '/admin/players', icon: '👥', status: 'stable' },
|
||||
{ name: 'Financials', version: '1.0.0', path: '/admin/financials', icon: '💰', status: 'stable' },
|
||||
|
||||
@@ -18,6 +18,7 @@ const socialRouter = require('./social');
|
||||
const infrastructureRouter = require('./infrastructure');
|
||||
const aboutRouter = require('./about');
|
||||
const mcpLogsRouter = require('./mcp-logs');
|
||||
const tasksRouter = require('./tasks');
|
||||
|
||||
router.use(requireTrinityAccess);
|
||||
|
||||
@@ -123,5 +124,6 @@ router.use('/social', socialRouter);
|
||||
router.use('/infrastructure', infrastructureRouter);
|
||||
router.use('/about', aboutRouter);
|
||||
router.use('/mcp-logs', mcpLogsRouter);
|
||||
router.use('/tasks', tasksRouter);
|
||||
|
||||
module.exports = router;
|
||||
|
||||
129
services/arbiter-3.0/src/routes/admin/tasks.js
Normal file
129
services/arbiter-3.0/src/routes/admin/tasks.js
Normal file
@@ -0,0 +1,129 @@
|
||||
const express = require('express');
|
||||
const router = express.Router();
|
||||
const db = require('../../database');
|
||||
|
||||
/**
|
||||
* Tasks Module — Trinity Console
|
||||
*
|
||||
* Web interface for task management. Source of truth: PostgreSQL tasks table.
|
||||
* Complements Discord /tasks command for ChatOps workflow.
|
||||
*
|
||||
* GET /admin/tasks — Task board view
|
||||
* POST /admin/tasks/update/:id — Update task (HTMX)
|
||||
* POST /admin/tasks/create — Create task (HTMX)
|
||||
*
|
||||
* Chronicler #78 | April 11, 2026
|
||||
*/
|
||||
|
||||
const PRIORITIES = ['critical', 'high', 'medium', 'low', 'wish'];
|
||||
const STATUSES = ['open', 'in_progress', 'blocked', 'done', 'obsolete'];
|
||||
const OWNERS = ['Michael', 'Meg', 'Holly', 'Trinity', 'unassigned'];
|
||||
|
||||
router.get('/', async (req, res) => {
|
||||
try {
|
||||
const { status, priority, owner } = req.query;
|
||||
let where = 'WHERE 1=1';
|
||||
const params = [];
|
||||
let p = 0;
|
||||
|
||||
// Default: hide done and obsolete
|
||||
if (status) {
|
||||
p++; where += ` AND status = $${p}`; params.push(status);
|
||||
} else if (!req.query.all) {
|
||||
where += ` AND status NOT IN ('done', 'obsolete')`;
|
||||
}
|
||||
if (priority) { p++; where += ` AND priority = $${p}`; params.push(priority); }
|
||||
if (owner) { p++; where += ` AND owner = $${p}`; params.push(owner); }
|
||||
|
||||
const result = await db.query(
|
||||
`SELECT * FROM tasks ${where} ORDER BY
|
||||
CASE status WHEN 'in_progress' THEN 1 WHEN 'blocked' THEN 2 WHEN 'open' THEN 3 WHEN 'done' THEN 4 WHEN 'obsolete' THEN 5 END,
|
||||
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`,
|
||||
params
|
||||
);
|
||||
|
||||
// Stats
|
||||
const statsResult = await db.query(`
|
||||
SELECT
|
||||
COUNT(*) FILTER (WHERE status NOT IN ('done','obsolete')) as active,
|
||||
COUNT(*) FILTER (WHERE status = 'done') as done,
|
||||
COUNT(*) FILTER (WHERE status = 'in_progress') as in_progress,
|
||||
COUNT(*) FILTER (WHERE status = 'blocked') as blocked,
|
||||
COUNT(*) FILTER (WHERE priority IN ('critical','high') AND status NOT IN ('done','obsolete')) as high_priority
|
||||
FROM tasks
|
||||
`);
|
||||
|
||||
res.render('admin/tasks/index', {
|
||||
title: 'Tasks',
|
||||
currentPath: '/tasks',
|
||||
tasks: result.rows,
|
||||
stats: statsResult.rows[0],
|
||||
filters: { status, priority, owner, all: req.query.all },
|
||||
priorities: PRIORITIES,
|
||||
statuses: STATUSES,
|
||||
owners: OWNERS,
|
||||
adminUser: req.user,
|
||||
layout: 'layout'
|
||||
});
|
||||
} catch (err) {
|
||||
console.error('[Tasks] Route error:', err);
|
||||
res.status(500).send('Error loading tasks');
|
||||
}
|
||||
});
|
||||
|
||||
// POST /admin/tasks/update/:id — Quick status update (HTMX)
|
||||
router.post('/update/:id', async (req, res) => {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
const { status, priority, owner } = req.body;
|
||||
const updates = [];
|
||||
const params = [];
|
||||
let p = 0;
|
||||
|
||||
if (status) { p++; updates.push(`status = $${p}`); params.push(status); }
|
||||
if (priority) { p++; updates.push(`priority = $${p}`); params.push(priority); }
|
||||
if (owner) { p++; updates.push(`owner = $${p}`); params.push(owner); }
|
||||
|
||||
if (status === 'done') {
|
||||
updates.push(`completed_at = NOW()`);
|
||||
const completedBy = req.user?.username || 'Trinity Console';
|
||||
p++; updates.push(`completed_by = $${p}`); params.push(completedBy);
|
||||
}
|
||||
|
||||
updates.push('updated_at = NOW()');
|
||||
p++; params.push(id);
|
||||
|
||||
await db.query(`UPDATE tasks SET ${updates.join(', ')} WHERE id = $${p}`, params);
|
||||
|
||||
res.redirect('/admin/tasks');
|
||||
} catch (err) {
|
||||
console.error('[Tasks] Update error:', err);
|
||||
res.status(500).send('Error updating task');
|
||||
}
|
||||
});
|
||||
|
||||
// POST /admin/tasks/create — Create new task
|
||||
router.post('/create', async (req, res) => {
|
||||
try {
|
||||
const { title, description, priority, owner } = req.body;
|
||||
if (!title) return res.redirect('/admin/tasks');
|
||||
|
||||
const maxResult = await db.query('SELECT COALESCE(MAX(task_number), 0) + 1 as next FROM tasks');
|
||||
const taskNumber = maxResult.rows[0].next;
|
||||
|
||||
await db.query(
|
||||
`INSERT INTO tasks (task_number, title, description, priority, owner)
|
||||
VALUES ($1, $2, $3, $4, $5)`,
|
||||
[taskNumber, title, description || null, priority || 'medium', owner || 'unassigned']
|
||||
);
|
||||
|
||||
console.log(`📋 [Tasks] Created #${taskNumber}: ${title} via Trinity Console`);
|
||||
res.redirect('/admin/tasks');
|
||||
} catch (err) {
|
||||
console.error('[Tasks] Create error:', err);
|
||||
res.status(500).send('Error creating task');
|
||||
}
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
199
services/arbiter-3.0/src/views/admin/tasks/index.ejs
Normal file
199
services/arbiter-3.0/src/views/admin/tasks/index.ejs
Normal file
@@ -0,0 +1,199 @@
|
||||
<!-- Tasks Module — Trinity Console -->
|
||||
<!-- Chronicler #78 | April 11, 2026 -->
|
||||
|
||||
<!-- Stats -->
|
||||
<div class="grid grid-cols-2 md:grid-cols-5 gap-4 mb-6">
|
||||
<div class="bg-white dark:bg-darkcard rounded-lg shadow-sm border border-gray-200 dark:border-gray-700 p-4">
|
||||
<div class="text-sm text-gray-500 dark:text-gray-400">Active</div>
|
||||
<div class="text-2xl font-bold mt-1"><%= stats.active %></div>
|
||||
</div>
|
||||
<div class="bg-white dark:bg-darkcard rounded-lg shadow-sm border border-gray-200 dark:border-gray-700 p-4">
|
||||
<div class="text-sm text-gray-500 dark:text-gray-400">In Progress</div>
|
||||
<div class="text-2xl font-bold mt-1 text-frost"><%= stats.in_progress %></div>
|
||||
</div>
|
||||
<div class="bg-white dark:bg-darkcard rounded-lg shadow-sm border border-gray-200 dark:border-gray-700 p-4">
|
||||
<div class="text-sm text-gray-500 dark:text-gray-400">Blocked</div>
|
||||
<div class="text-2xl font-bold mt-1 text-red-500"><%= stats.blocked %></div>
|
||||
</div>
|
||||
<div class="bg-white dark:bg-darkcard rounded-lg shadow-sm border border-gray-200 dark:border-gray-700 p-4">
|
||||
<div class="text-sm text-gray-500 dark:text-gray-400">High Priority</div>
|
||||
<div class="text-2xl font-bold mt-1 text-fire"><%= stats.high_priority %></div>
|
||||
</div>
|
||||
<div class="bg-white dark:bg-darkcard rounded-lg shadow-sm border border-gray-200 dark:border-gray-700 p-4">
|
||||
<div class="text-sm text-gray-500 dark:text-gray-400">Completed</div>
|
||||
<div class="text-2xl font-bold mt-1 text-green-500"><%= stats.done %></div>
|
||||
</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>
|
||||
</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>
|
||||
|
||||
<!-- Task List -->
|
||||
<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>
|
||||
<tr class="border-b border-gray-200 dark:border-gray-700 text-left text-xs text-gray-500 dark:text-gray-400 uppercase tracking-wider">
|
||||
<th class="px-4 py-3 w-16">#</th>
|
||||
<th class="px-4 py-3">Task</th>
|
||||
<th class="px-4 py-3 w-24">Priority</th>
|
||||
<th class="px-4 py-3 w-28">Status</th>
|
||||
<th class="px-4 py-3 w-24">Owner</th>
|
||||
<th class="px-4 py-3 w-32">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-gray-200 dark:divide-gray-700">
|
||||
<% 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';
|
||||
%>
|
||||
<tr class="hover:bg-gray-50 dark:hover:bg-gray-800/50 transition group">
|
||||
<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>
|
||||
<% } %>
|
||||
<% if (task.tags && task.tags.length > 0) { %>
|
||||
<div class="flex gap-1 mt-1">
|
||||
<% task.tags.forEach(tag => { %>
|
||||
<span class="text-[9px] px-1.5 py-0.5 rounded bg-gray-700 text-gray-400"><%= tag %></span>
|
||||
<% }); %>
|
||||
</div>
|
||||
<% } %>
|
||||
</td>
|
||||
<td class="px-4 py-3">
|
||||
<span class="inline-block px-2 py-0.5 text-xs rounded-full font-medium <%= priClass %>"><%= task.priority %></span>
|
||||
</td>
|
||||
<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 => { %>
|
||||
<option value="<%= s %>" <%= task.status === s ? 'selected' : '' %> class="bg-gray-800 text-white"><%= s.replace('_', ' ') %></option>
|
||||
<% }); %>
|
||||
</select>
|
||||
</form>
|
||||
</td>
|
||||
<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 => { %>
|
||||
<option value="<%= o %>" <%= task.owner === o ? 'selected' : '' %> class="bg-gray-800 text-white"><%= o %></option>
|
||||
<% }); %>
|
||||
</select>
|
||||
</form>
|
||||
</td>
|
||||
<td class="px-4 py-3">
|
||||
<% if (task.status !== 'done' && task.status !== 'obsolete') { %>
|
||||
<form method="POST" action="/admin/tasks/update/<%= task.id %>" class="inline">
|
||||
<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>
|
||||
<% } %>
|
||||
</td>
|
||||
</tr>
|
||||
<% }); %>
|
||||
</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
|
||||
</div>
|
||||
|
||||
<!-- Create Task Modal -->
|
||||
<div id="create-modal" style="display:none" class="fixed inset-0 z-50 flex items-center justify-center bg-black/70 backdrop-blur-sm" onclick="if(event.target===this)this.style.display='none'">
|
||||
<div class="bg-white dark:bg-darkcard border border-gray-200 dark:border-gray-700 rounded-lg shadow-2xl w-full max-w-lg p-6">
|
||||
<h2 class="text-xl font-bold mb-4 text-gray-200">New Task</h2>
|
||||
<form method="POST" action="/admin/tasks/create">
|
||||
<div class="space-y-4">
|
||||
<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"
|
||||
placeholder="What needs to be done?">
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-xs text-gray-500 mb-1">Description</label>
|
||||
<textarea name="description" rows="3"
|
||||
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="Optional details..."></textarea>
|
||||
</div>
|
||||
<div class="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label class="block text-xs text-gray-500 mb-1">Priority</label>
|
||||
<select name="priority" 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">
|
||||
<option value="medium" selected>Medium</option>
|
||||
<option value="critical">Critical</option>
|
||||
<option value="high">High</option>
|
||||
<option value="low">Low</option>
|
||||
<option value="wish">Wish List</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-xs text-gray-500 mb-1">Owner</label>
|
||||
<select name="owner" 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">
|
||||
<option value="Michael">Michael</option>
|
||||
<option value="Holly">Holly</option>
|
||||
<option value="Meg">Meg</option>
|
||||
<option value="Trinity">Trinity</option>
|
||||
<option value="unassigned">Unassigned</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex justify-end gap-3 mt-6">
|
||||
<button type="button" onclick="document.getElementById('create-modal').style.display='none'"
|
||||
class="px-4 py-2 text-gray-500 hover:text-gray-300 transition text-sm">Cancel</button>
|
||||
<button type="submit"
|
||||
class="bg-gradient-to-r from-fire to-frost text-white px-6 py-2 rounded-md text-sm font-medium hover:opacity-90 transition">
|
||||
Create Task
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
@@ -76,6 +76,9 @@
|
||||
<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>
|
||||
<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
|
||||
</a>
|
||||
|
||||
Reference in New Issue
Block a user