feat: add status history tracking for velocity statistics

- Add StatusHistoryEntry type to track status transitions with timestamps
- Update parser to read status_history from task frontmatter
- Update serializer to write status_history to task frontmatter
- Record status changes in backlog.ts for both task creation and updates
- Add velocity statistics panel to aggregator UI with:
  - Tasks completed in last 7 days
  - Average cycle time (To Do -> Done)
  - Average time to start (To Do -> In Progress)
  - Weekly throughput chart with 4-week visualization

🤖 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-04 03:11:47 -08:00
parent 5fdcd5a273
commit a1987d4ed5
6 changed files with 272 additions and 3 deletions

View File

@ -306,6 +306,91 @@
from { transform: translateX(100%); opacity: 0; } from { transform: translateX(100%); opacity: 0; }
to { transform: translateX(0); opacity: 1; } to { transform: translateX(0); opacity: 1; }
} }
/* Velocity Statistics Panel */
.stats-panel {
background: #1e293b;
border-radius: 0.5rem;
padding: 1rem;
margin: 0 2rem;
display: none;
}
.stats-panel.visible { display: block; }
.stats-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 1rem;
}
.stats-header h3 { font-size: 1rem; font-weight: 600; }
.stats-toggle {
padding: 0.5rem 1rem;
border-radius: 0.375rem;
border: 1px solid #334155;
background: #0f172a;
color: #e2e8f0;
cursor: pointer;
font-size: 0.875rem;
}
.stats-toggle.active { background: #3b82f6; border-color: #3b82f6; }
.stats-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: 1rem;
}
.stat-card {
background: #0f172a;
border-radius: 0.375rem;
padding: 1rem;
text-align: center;
}
.stat-value {
font-size: 2rem;
font-weight: 700;
color: #3b82f6;
}
.stat-label {
font-size: 0.75rem;
color: #94a3b8;
margin-top: 0.25rem;
}
.stat-trend { font-size: 0.875rem; margin-top: 0.5rem; }
.stat-trend.positive { color: #22c55e; }
.stat-trend.negative { color: #ef4444; }
.velocity-chart {
margin-top: 1rem;
background: #0f172a;
border-radius: 0.375rem;
padding: 1rem;
}
.velocity-bar-container {
display: flex;
align-items: center;
gap: 0.5rem;
margin: 0.5rem 0;
}
.velocity-label {
font-size: 0.75rem;
color: #94a3b8;
min-width: 100px;
text-align: right;
}
.velocity-bar-bg {
flex: 1;
height: 20px;
background: #334155;
border-radius: 0.25rem;
overflow: hidden;
}
.velocity-bar {
height: 100%;
background: linear-gradient(90deg, #3b82f6, #8b5cf6);
transition: width 0.3s ease;
}
.velocity-value {
font-size: 0.75rem;
color: #e2e8f0;
min-width: 60px;
}
</style> </style>
</head> </head>
<body> <body>
@ -320,7 +405,37 @@
<div class="filter-bar" id="filter-bar"> <div class="filter-bar" id="filter-bar">
<input type="text" id="filter" class="filter-input" placeholder="Filter tasks..."> <input type="text" id="filter" class="filter-input" placeholder="Filter tasks...">
<button class="project-btn active" id="all-projects">All Projects</button> <button class="project-btn active" id="all-projects">All Projects</button>
<button class="action-btn" id="new-task-btn" style="margin-left: auto;">+ New Task</button> <button class="stats-toggle" id="stats-toggle" style="margin-left: auto;">📊 Velocity</button>
<button class="action-btn" id="new-task-btn">+ New Task</button>
</div>
<!-- Velocity Statistics Panel -->
<div class="stats-panel" id="stats-panel">
<div class="stats-header">
<h3>Pipeline Velocity</h3>
</div>
<div class="stats-grid">
<div class="stat-card">
<div class="stat-value" id="stat-completed-7d">0</div>
<div class="stat-label">Completed (7 days)</div>
</div>
<div class="stat-card">
<div class="stat-value" id="stat-avg-cycle">-</div>
<div class="stat-label">Avg Cycle Time</div>
</div>
<div class="stat-card">
<div class="stat-value" id="stat-avg-progress">-</div>
<div class="stat-label">Avg Time to Start</div>
</div>
<div class="stat-card">
<div class="stat-value" id="stat-throughput">0</div>
<div class="stat-label">Weekly Throughput</div>
</div>
</div>
<div class="velocity-chart">
<div style="font-size:0.875rem;font-weight:600;margin-bottom:0.75rem;">Recent Activity</div>
<div id="velocity-bars"></div>
</div>
</div> </div>
<div class="board" id="board"> <div class="board" id="board">
@ -894,6 +1009,127 @@
renderTasks(); renderTasks();
}; };
// Velocity Statistics Toggle
document.getElementById('stats-toggle').onclick = () => {
const panel = document.getElementById('stats-panel');
const toggle = document.getElementById('stats-toggle');
panel.classList.toggle('visible');
toggle.classList.toggle('active');
if (panel.classList.contains('visible')) {
calculateVelocityStats();
}
};
function parseTimestamp(ts) {
if (!ts) return null;
// Handle "YYYY-MM-DD HH:mm" format
const date = new Date(ts.replace(' ', 'T'));
return isNaN(date.getTime()) ? null : date;
}
function formatDuration(ms) {
if (!ms || ms <= 0) return '-';
const hours = ms / (1000 * 60 * 60);
if (hours < 24) return `${Math.round(hours)}h`;
const days = hours / 24;
if (days < 7) return `${days.toFixed(1)}d`;
const weeks = days / 7;
return `${weeks.toFixed(1)}w`;
}
function calculateVelocityStats() {
const now = new Date();
const sevenDaysAgo = new Date(now - 7 * 24 * 60 * 60 * 1000);
const fourWeeksAgo = new Date(now - 28 * 24 * 60 * 60 * 1000);
let completedLast7Days = 0;
let cycleTimes = [];
let timesToStart = [];
let weeklyCompletions = [0, 0, 0, 0]; // Last 4 weeks
const filteredTasks = selectedProject
? tasks.filter(t => t.projectName === selectedProject)
: tasks;
filteredTasks.forEach(task => {
const history = task.statusHistory || [];
if (history.length === 0) return;
// Find status transitions
let todoTime = null;
let inProgressTime = null;
let doneTime = null;
history.forEach(entry => {
const ts = parseTimestamp(entry.timestamp);
if (!ts) return;
const status = entry.status.toLowerCase();
if (status === 'to do' && !todoTime) todoTime = ts;
if (status === 'in progress' && !inProgressTime) inProgressTime = ts;
if (status === 'done' && !doneTime) doneTime = ts;
});
// Count completions in last 7 days
if (doneTime && doneTime >= sevenDaysAgo) {
completedLast7Days++;
}
// Track weekly completions
if (doneTime && doneTime >= fourWeeksAgo) {
const weekIndex = Math.floor((now - doneTime) / (7 * 24 * 60 * 60 * 1000));
if (weekIndex >= 0 && weekIndex < 4) {
weeklyCompletions[weekIndex]++;
}
}
// Calculate cycle time (To Do -> Done)
if (todoTime && doneTime && doneTime > todoTime) {
cycleTimes.push(doneTime - todoTime);
}
// Calculate time to start (To Do -> In Progress)
if (todoTime && inProgressTime && inProgressTime > todoTime) {
timesToStart.push(inProgressTime - todoTime);
}
});
// Update stats display
document.getElementById('stat-completed-7d').textContent = completedLast7Days;
document.getElementById('stat-avg-cycle').textContent =
cycleTimes.length > 0
? formatDuration(cycleTimes.reduce((a, b) => a + b, 0) / cycleTimes.length)
: '-';
document.getElementById('stat-avg-progress').textContent =
timesToStart.length > 0
? formatDuration(timesToStart.reduce((a, b) => a + b, 0) / timesToStart.length)
: '-';
document.getElementById('stat-throughput').textContent =
(weeklyCompletions.reduce((a, b) => a + b, 0) / 4).toFixed(1);
// Render weekly velocity bars
const maxWeekly = Math.max(...weeklyCompletions, 1);
const barsContainer = document.getElementById('velocity-bars');
const weekLabels = ['This week', 'Last week', '2 weeks ago', '3 weeks ago'];
barsContainer.innerHTML = weeklyCompletions.map((count, i) => `
<div class="velocity-bar-container">
<span class="velocity-label">${weekLabels[i]}</span>
<div class="velocity-bar-bg">
<div class="velocity-bar" style="width:${(count / maxWeekly) * 100}%"></div>
</div>
<span class="velocity-value">${count} done</span>
</div>
`).join('');
}
// Recalculate stats when data updates
const originalRenderTasks = renderTasks;
renderTasks = function() {
originalRenderTasks();
if (document.getElementById('stats-panel').classList.contains('visible')) {
calculateVelocityStats();
}
};
// Initial connection // Initial connection
connectWebSocket(); connectWebSocket();
</script> </script>

View File

@ -9,6 +9,7 @@ import type {
Document, Document,
SearchFilters, SearchFilters,
Sequence, Sequence,
StatusHistoryEntry,
Task, Task,
TaskCreateInput, TaskCreateInput,
TaskListFilter, TaskListFilter,
@ -492,6 +493,9 @@ export class Core {
.filter((criterion) => criterion.text.length > 0) .filter((criterion) => criterion.text.length > 0)
: []; : [];
// Record initial status in history for velocity tracking
const statusHistory: StatusHistoryEntry[] = status ? [{ status, timestamp: createdDate }] : [];
const task: Task = { const task: Task = {
id, id,
title: input.title.trim(), title: input.title.trim(),
@ -507,6 +511,7 @@ export class Core {
...(typeof input.implementationPlan === "string" && { implementationPlan: input.implementationPlan }), ...(typeof input.implementationPlan === "string" && { implementationPlan: input.implementationPlan }),
...(typeof input.implementationNotes === "string" && { implementationNotes: input.implementationNotes }), ...(typeof input.implementationNotes === "string" && { implementationNotes: input.implementationNotes }),
...(acceptanceCriteriaItems.length > 0 && { acceptanceCriteriaItems }), ...(acceptanceCriteriaItems.length > 0 && { acceptanceCriteriaItems }),
...(statusHistory.length > 0 && { statusHistory }),
}; };
const isDraft = (status || "").toLowerCase() === "draft"; const isDraft = (status || "").toLowerCase() === "draft";
@ -617,6 +622,10 @@ export class Core {
input.status.trim().toLowerCase() === "draft" ? "Draft" : await this.requireCanonicalStatus(input.status); input.status.trim().toLowerCase() === "draft" ? "Draft" : await this.requireCanonicalStatus(input.status);
if ((task.status ?? "") !== canonicalStatus) { if ((task.status ?? "") !== canonicalStatus) {
task.status = canonicalStatus; task.status = canonicalStatus;
// Record status change timestamp for velocity tracking
const timestamp = new Date().toISOString().slice(0, 16).replace("T", " ");
const entry: StatusHistoryEntry = { status: canonicalStatus, timestamp };
task.statusHistory = [...(task.statusHistory ?? []), entry];
mutated = true; mutated = true;
} }
} }

View File

@ -1,5 +1,5 @@
import matter from "gray-matter"; import matter from "gray-matter";
import type { AcceptanceCriterion, Decision, Document, ParsedMarkdown, Task } from "../types/index.ts"; import type { AcceptanceCriterion, Decision, Document, ParsedMarkdown, StatusHistoryEntry, Task } from "../types/index.ts";
import { AcceptanceCriteriaManager, extractStructuredSection, STRUCTURED_SECTION_KEYS } from "./structured-sections.ts"; import { AcceptanceCriteriaManager, extractStructuredSection, STRUCTURED_SECTION_KEYS } from "./structured-sections.ts";
function preprocessFrontmatter(frontmatter: string): string { function preprocessFrontmatter(frontmatter: string): string {
@ -86,6 +86,20 @@ function normalizeDate(value: unknown): string {
return str; return str;
} }
function parseStatusHistory(value: unknown): StatusHistoryEntry[] | undefined {
if (!value || !Array.isArray(value)) return undefined;
const entries: StatusHistoryEntry[] = [];
for (const item of value) {
if (item && typeof item === "object" && "status" in item && "timestamp" in item) {
entries.push({
status: String(item.status),
timestamp: normalizeDate(item.timestamp),
});
}
}
return entries.length > 0 ? entries : undefined;
}
export function parseMarkdown(content: string): ParsedMarkdown { export function parseMarkdown(content: string): ParsedMarkdown {
// Updated regex to handle both Windows (\r\n) and Unix (\n) line endings // Updated regex to handle both Windows (\r\n) and Unix (\n) line endings
const fmRegex = /^---\r?\n([\s\S]*?)\r?\n---/; const fmRegex = /^---\r?\n([\s\S]*?)\r?\n---/;
@ -147,6 +161,7 @@ export function parseTask(content: string): Task {
priority: validatedPriority, priority: validatedPriority,
ordinal: frontmatter.ordinal !== undefined ? Number(frontmatter.ordinal) : undefined, ordinal: frontmatter.ordinal !== undefined ? Number(frontmatter.ordinal) : undefined,
onStatusChange: frontmatter.onStatusChange ? String(frontmatter.onStatusChange) : undefined, onStatusChange: frontmatter.onStatusChange ? String(frontmatter.onStatusChange) : undefined,
statusHistory: parseStatusHistory(frontmatter.status_history),
}; };
} }

View File

@ -21,6 +21,7 @@ export function serializeTask(task: Task): string {
...(task.priority && { priority: task.priority }), ...(task.priority && { priority: task.priority }),
...(task.ordinal !== undefined && { ordinal: task.ordinal }), ...(task.ordinal !== undefined && { ordinal: task.ordinal }),
...(task.onStatusChange && { onStatusChange: task.onStatusChange }), ...(task.onStatusChange && { onStatusChange: task.onStatusChange }),
...(task.statusHistory && task.statusHistory.length > 0 && { status_history: task.statusHistory }),
}; };
let contentBody = task.rawContent ?? ""; let contentBody = task.rawContent ?? "";

View File

@ -1,5 +1,11 @@
export type TaskStatus = string; export type TaskStatus = string;
/** Records when a task transitioned to a specific status */
export interface StatusHistoryEntry {
status: string;
timestamp: string; // ISO format: "YYYY-MM-DD HH:mm"
}
// Structured Acceptance Criterion (domain-level) // Structured Acceptance Criterion (domain-level)
export interface AcceptanceCriterion { export interface AcceptanceCriterion {
index: number; // 1-based index: number; // 1-based
@ -40,6 +46,8 @@ export interface Task {
source?: "local" | "remote" | "completed" | "local-branch"; source?: "local" | "remote" | "completed" | "local-branch";
/** Optional per-task callback command to run on status change (overrides global config) */ /** Optional per-task callback command to run on status change (overrides global config) */
onStatusChange?: string; onStatusChange?: string;
/** History of status transitions with timestamps for velocity tracking */
statusHistory?: StatusHistoryEntry[];
} }
/** /**

File diff suppressed because one or more lines are too long