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:
parent
5fdcd5a273
commit
a1987d4ed5
|
|
@ -306,6 +306,91 @@
|
|||
from { transform: translateX(100%); opacity: 0; }
|
||||
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>
|
||||
</head>
|
||||
<body>
|
||||
|
|
@ -320,7 +405,37 @@
|
|||
<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>
|
||||
<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 class="board" id="board">
|
||||
|
|
@ -894,6 +1009,127 @@
|
|||
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
|
||||
connectWebSocket();
|
||||
</script>
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@ import type {
|
|||
Document,
|
||||
SearchFilters,
|
||||
Sequence,
|
||||
StatusHistoryEntry,
|
||||
Task,
|
||||
TaskCreateInput,
|
||||
TaskListFilter,
|
||||
|
|
@ -492,6 +493,9 @@ export class Core {
|
|||
.filter((criterion) => criterion.text.length > 0)
|
||||
: [];
|
||||
|
||||
// Record initial status in history for velocity tracking
|
||||
const statusHistory: StatusHistoryEntry[] = status ? [{ status, timestamp: createdDate }] : [];
|
||||
|
||||
const task: Task = {
|
||||
id,
|
||||
title: input.title.trim(),
|
||||
|
|
@ -507,6 +511,7 @@ export class Core {
|
|||
...(typeof input.implementationPlan === "string" && { implementationPlan: input.implementationPlan }),
|
||||
...(typeof input.implementationNotes === "string" && { implementationNotes: input.implementationNotes }),
|
||||
...(acceptanceCriteriaItems.length > 0 && { acceptanceCriteriaItems }),
|
||||
...(statusHistory.length > 0 && { statusHistory }),
|
||||
};
|
||||
|
||||
const isDraft = (status || "").toLowerCase() === "draft";
|
||||
|
|
@ -617,6 +622,10 @@ export class Core {
|
|||
input.status.trim().toLowerCase() === "draft" ? "Draft" : await this.requireCanonicalStatus(input.status);
|
||||
if ((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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
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";
|
||||
|
||||
function preprocessFrontmatter(frontmatter: string): string {
|
||||
|
|
@ -86,6 +86,20 @@ function normalizeDate(value: unknown): string {
|
|||
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 {
|
||||
// Updated regex to handle both Windows (\r\n) and Unix (\n) line endings
|
||||
const fmRegex = /^---\r?\n([\s\S]*?)\r?\n---/;
|
||||
|
|
@ -147,6 +161,7 @@ export function parseTask(content: string): Task {
|
|||
priority: validatedPriority,
|
||||
ordinal: frontmatter.ordinal !== undefined ? Number(frontmatter.ordinal) : undefined,
|
||||
onStatusChange: frontmatter.onStatusChange ? String(frontmatter.onStatusChange) : undefined,
|
||||
statusHistory: parseStatusHistory(frontmatter.status_history),
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -21,6 +21,7 @@ export function serializeTask(task: Task): string {
|
|||
...(task.priority && { priority: task.priority }),
|
||||
...(task.ordinal !== undefined && { ordinal: task.ordinal }),
|
||||
...(task.onStatusChange && { onStatusChange: task.onStatusChange }),
|
||||
...(task.statusHistory && task.statusHistory.length > 0 && { status_history: task.statusHistory }),
|
||||
};
|
||||
|
||||
let contentBody = task.rawContent ?? "";
|
||||
|
|
|
|||
|
|
@ -1,5 +1,11 @@
|
|||
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)
|
||||
export interface AcceptanceCriterion {
|
||||
index: number; // 1-based
|
||||
|
|
@ -40,6 +46,8 @@ export interface Task {
|
|||
source?: "local" | "remote" | "completed" | "local-branch";
|
||||
/** Optional per-task callback command to run on status change (overrides global config) */
|
||||
onStatusChange?: string;
|
||||
/** History of status transitions with timestamps for velocity tracking */
|
||||
statusHistory?: StatusHistoryEntry[];
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
File diff suppressed because one or more lines are too long
Loading…
Reference in New Issue