Use vanilla JS for aggregator frontend (no React bundling needed)

The HTML import with React/TSX doesn't work well in Docker production
builds. Replace with a self-contained HTML file with vanilla JS that
works reliably across all environments.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Jeff Emmett 2025-12-03 21:01:56 -08:00
parent 24757f5031
commit a3ad292b81
2 changed files with 325 additions and 5 deletions

View File

@ -16,10 +16,14 @@ import { parseTask } from "../markdown/parser.ts";
import type { Task } from "../types/index.ts";
import { sortByTaskId } from "../utils/task-sorting.ts";
// @ts-expect-error - Bun HTML import
import indexHtml from "./web/index.html";
// @ts-expect-error - Bun file import
import favicon from "../web/favicon.png" with { type: "file" };
import { dirname, join as pathJoin } from "node:path";
import { fileURLToPath } from "node:url";
// Get the directory of this file
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
interface ProjectConfig {
path: string;
@ -87,7 +91,16 @@ export class BacklogAggregator {
port: this.config.port,
development: process.env.NODE_ENV === "development",
routes: {
"/": indexHtml,
"/": async () => {
const htmlPath = pathJoin(__dirname, "web", "index.html");
const htmlFile = Bun.file(htmlPath);
return new Response(htmlFile, { headers: { "Content-Type": "text/html" } });
},
"/app.tsx": async () => {
const tsxPath = pathJoin(__dirname, "web", "app.tsx");
const tsxFile = Bun.file(tsxPath);
return new Response(tsxFile, { headers: { "Content-Type": "application/javascript" } });
},
"/api/projects": {
GET: async () => this.handleGetProjects(),
},

View File

@ -5,9 +5,316 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Backlog Aggregator - Real-time Task View</title>
<link rel="icon" type="image/png" href="/favicon.png">
<script type="module" src="./app.tsx"></script>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
font-family: system-ui, -apple-system, sans-serif;
background: #0f172a;
color: #e2e8f0;
min-height: 100vh;
}
.header {
padding: 1rem 2rem;
background: #1e293b;
border-bottom: 1px solid #334155;
display: flex;
justify-content: space-between;
align-items: center;
}
.header h1 { font-size: 1.5rem; font-weight: 600; }
.header-left { display: flex; align-items: center; gap: 1rem; }
.status-badge {
padding: 0.25rem 0.5rem;
border-radius: 9999px;
font-size: 0.75rem;
background: #22c55e;
color: white;
}
.status-badge.offline { background: #ef4444; }
.stats { font-size: 0.875rem; color: #94a3b8; }
.filter-bar {
padding: 1rem 2rem;
background: #1e293b;
display: flex;
gap: 0.5rem;
flex-wrap: wrap;
align-items: center;
}
.filter-input {
padding: 0.5rem 1rem;
border-radius: 0.375rem;
border: 1px solid #334155;
background: #0f172a;
color: #e2e8f0;
min-width: 200px;
}
.project-btn {
padding: 0.5rem 1rem;
border-radius: 0.375rem;
border: 1px solid #334155;
background: #0f172a;
color: #e2e8f0;
cursor: pointer;
display: flex;
align-items: center;
gap: 0.5rem;
}
.project-btn.active { border-width: 2px; border-color: #3b82f6; background: #1e40af; }
.project-dot {
width: 0.75rem;
height: 0.75rem;
border-radius: 50%;
}
.board {
padding: 2rem;
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 1.5rem;
min-height: calc(100vh - 180px);
}
.column {
background: #1e293b;
border-radius: 0.5rem;
padding: 1rem;
display: flex;
flex-direction: column;
}
.column-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 1rem;
padding-bottom: 0.5rem;
border-bottom: 1px solid #334155;
}
.column-header h2 { font-size: 1rem; font-weight: 600; }
.task-count {
background: #334155;
padding: 0.25rem 0.5rem;
border-radius: 9999px;
font-size: 0.75rem;
}
.tasks { display: flex; flex-direction: column; gap: 0.75rem; flex: 1; overflow-y: auto; }
.task-card {
background: #0f172a;
border-radius: 0.375rem;
padding: 0.75rem;
border-left: 4px solid #3b82f6;
cursor: pointer;
transition: transform 0.1s, box-shadow 0.1s;
}
.task-card:hover {
transform: translateY(-1px);
box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.3);
}
.task-header { display: flex; justify-content: space-between; align-items: flex-start; gap: 0.5rem; }
.task-title { font-size: 0.875rem; font-weight: 500; margin-bottom: 0.25rem; }
.task-meta { font-size: 0.75rem; color: #64748b; }
.priority-badge {
padding: 0.125rem 0.375rem;
border-radius: 0.25rem;
font-size: 0.625rem;
font-weight: 500;
text-transform: uppercase;
}
.priority-high { background: rgba(239, 68, 68, 0.2); color: #ef4444; }
.priority-medium { background: rgba(245, 158, 11, 0.2); color: #f59e0b; }
.priority-low { background: rgba(34, 197, 94, 0.2); color: #22c55e; }
.labels { display: flex; gap: 0.25rem; flex-wrap: wrap; margin-top: 0.5rem; }
.label {
padding: 0.125rem 0.375rem;
border-radius: 0.25rem;
font-size: 0.625rem;
background: #334155;
color: #94a3b8;
}
.assignees { margin-top: 0.5rem; font-size: 0.75rem; color: #64748b; }
.empty-state { color: #64748b; text-align: center; padding: 2rem; font-size: 0.875rem; }
.task-description {
margin-top: 0.75rem;
padding-top: 0.75rem;
border-top: 1px solid #334155;
font-size: 0.8rem;
color: #94a3b8;
white-space: pre-wrap;
display: none;
}
.task-card.expanded .task-description { display: block; }
</style>
</head>
<body>
<div id="root"></div>
<header class="header">
<div class="header-left">
<h1>Backlog Aggregator</h1>
<span id="status-badge" class="status-badge">Connecting...</span>
</div>
<div class="stats" id="stats">Loading...</div>
</header>
<div class="filter-bar" id="filter-bar">
<input type="text" id="filter" class="filter-input" placeholder="Filter tasks...">
<button class="project-btn active" id="all-projects">All Projects</button>
</div>
<div class="board" id="board">
<div class="column" data-status="To Do">
<div class="column-header">
<h2>To Do</h2>
<span class="task-count" id="count-todo">0</span>
</div>
<div class="tasks" id="tasks-todo"></div>
</div>
<div class="column" data-status="In Progress">
<div class="column-header">
<h2>In Progress</h2>
<span class="task-count" id="count-progress">0</span>
</div>
<div class="tasks" id="tasks-progress"></div>
</div>
<div class="column" data-status="Done">
<div class="column-header">
<h2>Done</h2>
<span class="task-count" id="count-done">0</span>
</div>
<div class="tasks" id="tasks-done"></div>
</div>
</div>
<script>
let projects = [];
let tasks = [];
let selectedProject = null;
let ws = null;
function connectWebSocket() {
const protocol = location.protocol === 'https:' ? 'wss:' : 'ws:';
ws = new WebSocket(`${protocol}//${location.host}`);
ws.onopen = () => {
document.getElementById('status-badge').textContent = 'Live';
document.getElementById('status-badge').classList.remove('offline');
};
ws.onclose = () => {
document.getElementById('status-badge').textContent = 'Reconnecting...';
document.getElementById('status-badge').classList.add('offline');
setTimeout(connectWebSocket, 3000);
};
ws.onmessage = (event) => {
try {
const data = JSON.parse(event.data);
if (data.type === 'init' || data.type === 'update') {
projects = data.projects || [];
tasks = data.tasks || [];
renderProjects();
renderTasks();
}
} catch (e) { console.error('Parse error:', e); }
};
}
function renderProjects() {
const filterBar = document.getElementById('filter-bar');
// Remove old project buttons except filter and all-projects
const oldBtns = filterBar.querySelectorAll('.project-btn:not(#all-projects)');
oldBtns.forEach(btn => btn.remove());
projects.forEach(p => {
const btn = document.createElement('button');
btn.className = 'project-btn';
btn.innerHTML = `<span class="project-dot" style="background:${p.color}"></span>${p.name}`;
btn.onclick = () => {
selectedProject = p.name;
document.querySelectorAll('.project-btn').forEach(b => b.classList.remove('active'));
btn.classList.add('active');
renderTasks();
};
filterBar.appendChild(btn);
});
document.getElementById('stats').textContent =
`${projects.length} projects | ${tasks.length} tasks | Updated ${new Date().toLocaleTimeString()}`;
}
function renderTasks() {
const filter = document.getElementById('filter').value.toLowerCase();
const statusMap = {
'to do': 'todo',
'in progress': 'progress',
'done': 'done'
};
// Clear all columns
['todo', 'progress', 'done'].forEach(id => {
document.getElementById(`tasks-${id}`).innerHTML = '';
});
let counts = { todo: 0, progress: 0, done: 0 };
tasks.forEach(task => {
if (selectedProject && task.projectName !== selectedProject) return;
if (filter && !task.title.toLowerCase().includes(filter) &&
!task.projectName.toLowerCase().includes(filter)) return;
const statusKey = statusMap[task.status.toLowerCase()] || 'todo';
counts[statusKey]++;
const card = document.createElement('div');
card.className = 'task-card';
card.style.borderLeftColor = task.projectColor;
card.onclick = () => card.classList.toggle('expanded');
let priorityClass = '';
if (task.priority) {
priorityClass = `priority-${task.priority.toLowerCase()}`;
}
card.innerHTML = `
<div class="task-header">
<div>
<div class="task-title">${escapeHtml(task.title)}</div>
<div class="task-meta">${task.id} | ${task.projectName}</div>
</div>
${task.priority ? `<span class="priority-badge ${priorityClass}">${task.priority}</span>` : ''}
</div>
${task.labels && task.labels.length > 0 ? `
<div class="labels">
${task.labels.map(l => `<span class="label">${escapeHtml(l)}</span>`).join('')}
</div>
` : ''}
${task.assignee && task.assignee.length > 0 ? `
<div class="assignees">${task.assignee.join(', ')}</div>
` : ''}
${task.description ? `
<div class="task-description">${escapeHtml(task.description.slice(0, 500))}${task.description.length > 500 ? '...' : ''}</div>
` : ''}
`;
document.getElementById(`tasks-${statusKey}`).appendChild(card);
});
document.getElementById('count-todo').textContent = counts.todo;
document.getElementById('count-progress').textContent = counts.progress;
document.getElementById('count-done').textContent = counts.done;
}
function escapeHtml(text) {
const div = document.createElement('div');
div.textContent = text;
return div.innerHTML;
}
document.getElementById('filter').addEventListener('input', renderTasks);
document.getElementById('all-projects').onclick = () => {
selectedProject = null;
document.querySelectorAll('.project-btn').forEach(b => b.classList.remove('active'));
document.getElementById('all-projects').classList.add('active');
renderTasks();
};
// Initial connection
connectWebSocket();
</script>
</body>
</html>