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; }
|
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>
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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 ?? "";
|
||||||
|
|
|
||||||
|
|
@ -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
Loading…
Reference in New Issue