super-backlog 1.3.0 → 1.3.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -97,7 +97,7 @@ The router is fully owned by super-backlog and removed by `sbl uninstall`. See t
97
97
 
98
98
  ## Project Dashboard
99
99
 
100
- `sbl dashboard` starts a local hub that serves a dark, HTS-style cockpit rendered from your Backlog data in seven sections — Board & Quick Actions, Status (donut), Milestones, Tasks (sortable/filterable table; click a row to open a modal detail view with acceptance criteria and dependencies), Feature Cycle (pipeline stepper plus an Up Next / Blocked flow view from task dependencies), Activity (30-day sparkline), and Decisions & Docs. Glossary tooltips explain domain terms inline; extend or override them project-wide via `backlog/docs/glossary.md` (`## Term` heading plus the text below it). No CDNs, no external fontsworks offline when served locally. Bookmark `http://127.0.0.1:6428/p/<project_name>/`. The hub watches `backlog/`, regenerates on change, and serves on port `6428`; connected browser tabs reload automatically via Server-Sent Events. A second repo's `sbl dashboard` attaches to the same hub. `Ctrl+C` in the hub terminal stops all projects.
100
+ `sbl dashboard` starts a local hub that serves an HTS-style cockpit (light/dark theme toggle) rendered from your Backlog data in eight sections — Board & Quick Actions, Status (KPI tiles, donut, and an aging strip for open tasks), Feature Cycle (pipeline stepper plus an Up Next / Blocked flow view from task dependencies), Milestones, Drafts (click a card for details), Tasks (sortable/filterable table; click a row to open a modal detail view with acceptance criteria and dependencies), Activity (a 26-week calendar heatmap; click a day for its tasks), and Decisions & Docs. Glossary tooltips explain domain terms inline; extend or override them project-wide via `backlog/docs/glossary.md` (`## Term` heading plus the text below it). Typefaces load from Google Fonts with full system fallbacks the one external resource; everything else is inline, and the dashboard still renders offline. Bookmark `http://127.0.0.1:6428/p/<project_name>/`. The hub watches `backlog/`, regenerates on change, and serves on port `6428`; connected browser tabs reload automatically via Server-Sent Events. A second repo's `sbl dashboard` attaches to the same hub. `Ctrl+C` in the hub terminal stops all projects.
101
101
 
102
102
  ### Keeping it fresh
103
103
 
@@ -5,6 +5,7 @@ import { basename, join } from 'node:path';
5
5
  import { resolveBacklogBin, runCapture } from '../lib/run.js';
6
6
  import { isNewerVersion } from '../lib/version-check.js';
7
7
  import { readSimpleKeys } from '../lib/yamlmini.js';
8
+ import { computeKpis } from './metrics.js';
8
9
  function isRecord(v) {
9
10
  return typeof v === 'object' && v !== null && !Array.isArray(v);
10
11
  }
@@ -70,6 +71,7 @@ export function normalizeTasks(rawTasks) {
70
71
  status: asString(t['status']) ?? 'Unknown',
71
72
  priority: asString(t['priority']),
72
73
  assignee: firstAssignee(t),
74
+ created: asString(t['createdAt']) ?? asString(t['created_at']) ?? asString(t['created']),
73
75
  updated: asString(t['updatedAt']) ?? asString(t['updated_at']) ?? asString(t['updated']),
74
76
  milestone: asString(t['milestone']),
75
77
  description: asString(t['description']),
@@ -140,20 +142,24 @@ function shiftDay(day, deltaDays) {
140
142
  const [y, mo, d] = day.split('-').map(Number);
141
143
  return new Date(Date.UTC(y, mo - 1, d) + deltaDays * 86400000).toISOString().slice(0, 10);
142
144
  }
143
- /** Bucket tasks into exactly 30 UTC daily buckets ending at `today`, oldest first. */
145
+ export const ACTIVITY_DAYS = 182;
146
+ /** Bucket tasks into exactly ACTIVITY_DAYS UTC daily buckets ending at `today`, oldest first. */
144
147
  export function computeActivity(rawTasks, today) {
145
- const counts = new Map();
148
+ const byDay = new Map();
146
149
  for (const t of rawTasks) {
147
- const day = isoDay(asString(t['updated_at']) ?? asString(t['updated'])) ??
148
- isoDay(asString(t['created_at'])) ??
150
+ const day = isoDay(asString(t['updatedAt']) ?? asString(t['updated_at']) ?? asString(t['updated'])) ??
151
+ isoDay(asString(t['createdAt']) ?? asString(t['created_at'])) ??
149
152
  today;
150
- counts.set(day, (counts.get(day) ?? 0) + 1);
153
+ const ids = byDay.get(day) ?? [];
154
+ ids.push(asString(t['id']) ?? '');
155
+ byDay.set(day, ids);
151
156
  }
152
- const start = shiftDay(today, -29);
157
+ const start = shiftDay(today, -(ACTIVITY_DAYS - 1));
153
158
  const out = [];
154
- for (let i = 0; i < 30; i++) {
159
+ for (let i = 0; i < ACTIVITY_DAYS; i++) {
155
160
  const date = shiftDay(start, i);
156
- out.push({ date, count: counts.get(date) ?? 0 });
161
+ const ids = byDay.get(date) ?? [];
162
+ out.push({ date, count: ids.length, ids });
157
163
  }
158
164
  return out;
159
165
  }
@@ -230,13 +236,33 @@ function readProjectGlossary(cwd) {
230
236
  }
231
237
  }
232
238
  function readDraftFile(path) {
233
- const keys = readSimpleKeys(path, ['id', 'title', 'status']);
239
+ const keys = readSimpleKeys(path, [
240
+ 'id', 'title', 'status', 'priority', 'assignee',
241
+ 'created_date', 'updated_date', 'created', 'updated',
242
+ ]);
234
243
  const id = asString(keys.id);
235
244
  const title = asString(keys.title);
236
245
  const status = asString(keys.status);
237
246
  if (!id || !title || !status)
238
247
  return null;
239
- return { id, title, status };
248
+ let detail = { acs: [] };
249
+ try {
250
+ detail = parseTaskFile(readFileSync(path, 'utf8'));
251
+ }
252
+ catch {
253
+ // keys-only draft when the file cannot be re-read
254
+ }
255
+ return {
256
+ id,
257
+ title,
258
+ status,
259
+ description: detail.description,
260
+ priority: asString(keys.priority),
261
+ assignee: asString(keys.assignee),
262
+ created: asString(keys['created_date']) ?? asString(keys['created']),
263
+ updated: asString(keys['updated_date']) ?? asString(keys['updated']),
264
+ acs: detail.acs,
265
+ };
240
266
  }
241
267
  export function readDrafts(cwd) {
242
268
  const draftsDir = join(cwd, 'backlog', 'drafts');
@@ -353,6 +379,7 @@ export function collectDashboardData(cwd, opts) {
353
379
  const today = opts.today && /^\d{4}-\d{2}-\d{2}$/.test(opts.today.trim())
354
380
  ? opts.today.trim()
355
381
  : new Date().toISOString().slice(0, 10);
382
+ const activity = computeActivity([], today);
356
383
  const base = {
357
384
  project: readProjectIdentity(cwd),
358
385
  generatedAt: new Date().toISOString(),
@@ -363,8 +390,9 @@ export function collectDashboardData(cwd, opts) {
363
390
  tasks: [],
364
391
  deps: [],
365
392
  drafts: readDrafts(cwd),
366
- activity: computeActivity([], today),
393
+ activity,
367
394
  glossary: mergeGlossary(readProjectGlossary(cwd)),
395
+ kpis: computeKpis([], [], activity, today),
368
396
  source: 'fallback-empty',
369
397
  };
370
398
  try {
@@ -376,13 +404,16 @@ export function collectDashboardData(cwd, opts) {
376
404
  return base;
377
405
  const rawTasks = parseTasksJson(res.stdout);
378
406
  const tasks = enrichTasksFromFiles(cwd, normalizeTasks(rawTasks));
407
+ const deps = computeDeps(rawTasks);
408
+ const taskActivity = computeActivity(rawTasks, today);
379
409
  return {
380
410
  ...base,
381
411
  tasks,
382
412
  statuses: computeStatuses(tasks),
383
413
  milestones: computeMilestones(tasks),
384
- deps: computeDeps(rawTasks),
385
- activity: computeActivity(rawTasks, today),
414
+ deps,
415
+ activity: taskActivity,
416
+ kpis: computeKpis(tasks, deps, taskActivity, today),
386
417
  source: 'backlog-json',
387
418
  };
388
419
  }
@@ -0,0 +1,97 @@
1
+ function isDone(status) {
2
+ const s = status.toLowerCase();
3
+ return s === 'done' || s === 'complete' || s === 'completed';
4
+ }
5
+ function isWip(status) {
6
+ const s = status.toLowerCase();
7
+ return s.includes('progress') || s.includes('review');
8
+ }
9
+ function utcDay(value) {
10
+ if (!value)
11
+ return null;
12
+ const m = /^(\d{4})-(\d{2})-(\d{2})/.exec(value.trim());
13
+ if (m)
14
+ return Date.UTC(Number(m[1]), Number(m[2]) - 1, Number(m[3]));
15
+ const t = Date.parse(value);
16
+ if (Number.isNaN(t))
17
+ return null;
18
+ const d = new Date(t);
19
+ return Date.UTC(d.getUTCFullYear(), d.getUTCMonth(), d.getUTCDate());
20
+ }
21
+ const DAY_MS = 86_400_000;
22
+ export function computeKpis(tasks, deps, activity, today) {
23
+ const todayMs = utcDay(today) ?? Date.UTC(1970, 0, 1);
24
+ const total = tasks.length;
25
+ const doneTasks = tasks.filter((t) => isDone(t.status));
26
+ const done = doneTasks.length;
27
+ const open = total - done;
28
+ const progressPct = total > 0 ? Math.round((done / total) * 100) : 0;
29
+ const doneInWindow = (fromDaysAgo, toDaysAgo) => doneTasks.filter((t) => {
30
+ const d = utcDay(t.updated);
31
+ if (d === null)
32
+ return false;
33
+ const age = (todayMs - d) / DAY_MS;
34
+ return age >= toDaysAgo && age < fromDaysAgo;
35
+ }).length;
36
+ const velocity7 = doneInWindow(7, 0);
37
+ const velocityPrev7 = doneInWindow(14, 7);
38
+ let forecastDate = null;
39
+ if (velocity7 > 0 && open > 0) {
40
+ const daysNeeded = Math.ceil((open / velocity7) * 7);
41
+ forecastDate = new Date(todayMs + daysNeeded * DAY_MS).toISOString().slice(0, 10);
42
+ }
43
+ const byId = new Map(tasks.map((t) => [t.id, t]));
44
+ const unresolved = new Set();
45
+ for (const dep of deps) {
46
+ const from = byId.get(dep.from);
47
+ const to = byId.get(dep.to);
48
+ if (from && !isDone(from.status) && to && !isDone(to.status))
49
+ unresolved.add(dep.from);
50
+ }
51
+ const openTasks = tasks.filter((t) => !isDone(t.status));
52
+ const wip = openTasks.filter((t) => isWip(t.status)).length;
53
+ const blocked = openTasks.filter((t) => t.status.toLowerCase().includes('block') || unresolved.has(t.id)).length;
54
+ const ages = [];
55
+ for (const t of openTasks) {
56
+ const d = utcDay(t.created) ?? utcDay(t.updated);
57
+ if (d === null)
58
+ continue;
59
+ ages.push({ id: t.id, days: Math.max(0, Math.round((todayMs - d) / DAY_MS)) });
60
+ }
61
+ ages.sort((a, b) => b.days - a.days);
62
+ const oldest = ages[0] ?? null;
63
+ let medianOpenAgeDays = null;
64
+ if (ages.length > 0) {
65
+ const mid = Math.floor(ages.length / 2);
66
+ medianOpenAgeDays =
67
+ ages.length % 2 === 1 ? ages[mid].days : Math.round((ages[mid - 1].days + ages[mid].days) / 2);
68
+ }
69
+ const activityTotal30 = activity.slice(-30).reduce((sum, b) => sum + b.count, 0);
70
+ const windowTotal = activity.reduce((sum, b) => sum + b.count, 0);
71
+ const activityAvgPerWeek = activity.length > 0 ? Math.round((windowTotal / (activity.length / 7)) * 10) / 10 : 0;
72
+ const perWeekday = [0, 0, 0, 0, 0, 0, 0];
73
+ for (const b of activity) {
74
+ const d = utcDay(b.date);
75
+ if (d !== null)
76
+ perWeekday[new Date(d).getUTCDay()] += b.count;
77
+ }
78
+ const maxWeekday = Math.max(...perWeekday);
79
+ const busiestWeekday = maxWeekday > 0 ? perWeekday.indexOf(maxWeekday) : null;
80
+ let streakDays = 0;
81
+ let i = activity.length - 1;
82
+ if (i >= 0 && activity[i].count === 0)
83
+ i--; // today may still be empty
84
+ while (i >= 0 && activity[i].count > 0) {
85
+ streakDays++;
86
+ i--;
87
+ }
88
+ return {
89
+ total, done, open, progressPct,
90
+ velocity7, velocityPrev7, forecastDate,
91
+ wip, blocked,
92
+ oldestOpenId: oldest ? oldest.id : null,
93
+ oldestOpenDays: oldest ? oldest.days : null,
94
+ medianOpenAgeDays,
95
+ activityTotal30, activityAvgPerWeek, busiestWeekday, streakDays,
96
+ };
97
+ }
@@ -5,6 +5,9 @@
5
5
  <meta name="viewport" content="width=device-width, initial-scale=1">
6
6
  <title>__PROJECT_NAME__ &middot; Project Dashboard</title>
7
7
  <link rel="icon" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'%3E%3Ccircle cx='50' cy='50' r='42' fill='%235cc8ff'/%3E%3C/svg%3E">
8
+ <link rel="preconnect" href="https://fonts.googleapis.com">
9
+ <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
10
+ <link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;500;700&family=Plus+Jakarta+Sans:wght@400;500;600;700;800&display=swap">
8
11
  <style>
9
12
  :root {
10
13
  --bg:#0a0e16;
@@ -16,7 +19,7 @@
16
19
  --line-strong:#2c3b57;
17
20
  --text:#e8edf6;
18
21
  --muted:#8fa0ba;
19
- --dim:#5d6d88;
22
+ --dim:#72839f;
20
23
  --accent:#5cc8ff;
21
24
  --accent-dim:#234257;
22
25
  --ok:#3ecf8e;
@@ -25,27 +28,59 @@
25
28
  --warn-bg:#33260f;
26
29
  --danger:#ff7a7a;
27
30
  --danger-bg:#331718;
28
- --mono:"Cascadia Code",Consolas,"Courier New",monospace;
29
- }
30
- @font-face {
31
- font-family: 'Inter';
32
- src: local('Inter'), local('Inter-Regular');
33
- font-weight: 400; font-style: normal;
34
- }
35
- @font-face {
36
- font-family: 'Inter';
37
- src: local('Inter Medium'), local('Inter-Medium');
38
- font-weight: 500; font-style: normal;
39
- }
40
- @font-face {
41
- font-family: 'Inter';
42
- src: local('Inter SemiBold'), local('Inter-SemiBold');
43
- font-weight: 600; font-style: normal;
31
+ --sans:"Plus Jakarta Sans","Segoe UI",system-ui,sans-serif;
32
+ --mono:"JetBrains Mono",Consolas,"Courier New",monospace;
33
+ --ok-line:#2b5642;
34
+ --accent-bg:#10202e;
35
+ --accent-line:#274a63;
36
+ --warn-line:#5c4520;
37
+ --danger-line:#5c2c2c;
38
+ --backdrop:rgba(4,7,12,.65);
39
+ --tip-bg:rgba(13,19,32,.97);
40
+ --shadow-dialog:0 24px 80px rgba(0,0,0,.55);
41
+ --shadow-tip:0 10px 28px rgba(0,0,0,.5);
42
+ --glow-accent:rgba(92,200,255,.6);
43
+ --glow-warn:rgba(255,180,84,.25);
44
+ --glow-warn-soft:rgba(255,180,84,.3);
45
+ --focus-ring:rgba(92,200,255,.15);
46
+ }
47
+ :root[data-theme="light"] {
48
+ --bg:#f3f6fb;
49
+ --bg-glow-1: rgba(11,116,181,.05);
50
+ --bg-glow-2: rgba(23,122,78,.04);
51
+ --surface:#ffffff;
52
+ --surface-2:#e9eef7;
53
+ --line:#d7dfeb;
54
+ --line-strong:#b6c3d8;
55
+ --text:#17202f;
56
+ --muted:#4c5d77;
57
+ --dim:#5a6881;
58
+ --accent:#0a6ca8;
59
+ --accent-dim:#bcdcf0;
60
+ --ok:#16764b;
61
+ --ok-bg:#dff3e8;
62
+ --warn:#8a5a00;
63
+ --warn-bg:#fbecd2;
64
+ --danger:#b83a3a;
65
+ --danger-bg:#fbe3e3;
66
+ --ok-line:#9fd4b8;
67
+ --accent-bg:#e2f0f9;
68
+ --accent-line:#a8cfe6;
69
+ --warn-line:#e3c68e;
70
+ --danger-line:#eab5b5;
71
+ --backdrop:rgba(23,32,47,.45);
72
+ --tip-bg:rgba(255,255,255,.98);
73
+ --shadow-dialog:0 24px 80px rgba(23,32,47,.25);
74
+ --shadow-tip:0 10px 28px rgba(23,32,47,.18);
75
+ --glow-accent:rgba(11,116,181,.35);
76
+ --glow-warn:rgba(138,90,0,.2);
77
+ --glow-warn-soft:rgba(138,90,0,.25);
78
+ --focus-ring:rgba(11,116,181,.18);
44
79
  }
45
80
  * { box-sizing: border-box; margin: 0; padding: 0; }
46
81
  html { scroll-behavior: smooth; }
47
82
  body {
48
- font-family: Inter, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
83
+ font-family: var(--sans);
49
84
  background:
50
85
  radial-gradient(1100px 500px at 85% -10%, var(--bg-glow-1), transparent 60%),
51
86
  radial-gradient(900px 500px at -10% 30%, var(--bg-glow-2), transparent 55%),
@@ -66,8 +101,15 @@
66
101
 
67
102
  /* ---------- Sidebar ---------- */
68
103
  .brand { display: flex; align-items: center; gap: 10px; margin-bottom: 6px; }
69
- .brand-glyph { color: var(--accent); font-size: .8rem; line-height: 1; text-shadow: 0 0 12px rgba(92,200,255,.6); }
104
+ .brand-glyph { color: var(--accent); font-size: .8rem; line-height: 1; text-shadow: 0 0 12px var(--glow-accent); }
70
105
  .brand b { font-size: .98rem; letter-spacing: .4px; overflow-wrap: anywhere; }
106
+ .theme-toggle {
107
+ margin-left: auto; width: 28px; height: 28px; border-radius: 50%;
108
+ font: inherit; font-size: .9rem; line-height: 1; cursor: pointer;
109
+ color: var(--muted); background: var(--surface-2); border: 1px solid var(--line-strong);
110
+ }
111
+ .theme-toggle:hover { color: var(--accent); border-color: var(--accent); }
112
+ .theme-toggle:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
71
113
  .kicker {
72
114
  color: var(--dim); font-size: .72rem; letter-spacing: 1.6px;
73
115
  text-transform: uppercase; margin-bottom: 22px;
@@ -88,10 +130,10 @@
88
130
  white-space: nowrap; cursor: pointer;
89
131
  }
90
132
  .pill .n { color: inherit; font-weight: 700; margin-right: 4px; }
91
- .pill[data-tone="ok"] { color: var(--ok); border-color: #2b5642; background: var(--ok-bg); }
92
- .pill[data-tone="accent"] { color: var(--accent); border-color: #274a63; background: #10202e; }
93
- .pill[data-tone="warn"] { color: var(--warn); border-color: #5c4520; background: var(--warn-bg); }
94
- .pill[data-tone="danger"] { color: var(--danger); border-color: #5c2c2c; background: var(--danger-bg); }
133
+ .pill[data-tone="ok"] { color: var(--ok); border-color: var(--ok-line); background: var(--ok-bg); }
134
+ .pill[data-tone="accent"] { color: var(--accent); border-color: var(--accent-line); background: var(--accent-bg); }
135
+ .pill[data-tone="warn"] { color: var(--warn); border-color: var(--warn-line); background: var(--warn-bg); }
136
+ .pill[data-tone="danger"] { color: var(--danger); border-color: var(--danger-line); background: var(--danger-bg); }
95
137
  .pill:hover { filter: brightness(1.25); }
96
138
  .pill.active { outline: 2px solid var(--accent); outline-offset: 1px; }
97
139
  .pill-empty { color: var(--dim); font-size: .78rem; }
@@ -101,9 +143,9 @@
101
143
  .term:hover { color: var(--accent); border-color: var(--accent); }
102
144
  #sbl-tip {
103
145
  position: fixed; z-index: 70; max-width: 340px; padding: 8px 12px;
104
- background: rgba(13,19,32,.97); border: 1px solid var(--line-strong); border-radius: 8px;
146
+ background: var(--tip-bg); border: 1px solid var(--line-strong); border-radius: 8px;
105
147
  font-size: .78rem; line-height: 1.45; color: var(--text);
106
- box-shadow: 0 10px 28px rgba(0,0,0,.5); pointer-events: none;
148
+ box-shadow: var(--shadow-tip); pointer-events: none;
107
149
  }
108
150
 
109
151
  /* ---------- Detail panel ---------- */
@@ -112,10 +154,10 @@
112
154
  width: min(720px, 92vw); max-height: 85vh; overflow-y: auto; overflow-x: hidden; padding: 0;
113
155
  border: 1px solid var(--line-strong); border-radius: 14px;
114
156
  background: var(--surface); color: var(--text);
115
- box-shadow: 0 24px 80px rgba(0,0,0,.55);
157
+ box-shadow: var(--shadow-dialog);
116
158
  }
117
159
  #task-dialog::backdrop {
118
- background: rgba(4,7,12,.65);
160
+ background: var(--backdrop);
119
161
  backdrop-filter: blur(2px);
120
162
  }
121
163
  #task-dialog[open] { animation: sbl-dialog-in .18s ease-out; }
@@ -130,10 +172,10 @@
130
172
  display: none; flex-direction: column;
131
173
  border: 1px solid var(--line-strong); border-radius: 14px;
132
174
  background: var(--surface); color: var(--text);
133
- box-shadow: 0 24px 80px rgba(0,0,0,.55);
175
+ box-shadow: var(--shadow-dialog);
134
176
  }
135
177
  #backlog-dialog[open] { display: flex; animation: sbl-dialog-in .18s ease-out; }
136
- #backlog-dialog::backdrop { background: rgba(4,7,12,.65); backdrop-filter: blur(2px); }
178
+ #backlog-dialog::backdrop { background: var(--backdrop); backdrop-filter: blur(2px); }
137
179
  @media (prefers-reduced-motion: reduce) {
138
180
  #backlog-dialog[open] { animation: none; }
139
181
  }
@@ -164,10 +206,10 @@
164
206
  }
165
207
  .detail-id { font-family: var(--mono); color: var(--accent); font-weight: 700; }
166
208
  .status-chip { font-family: var(--mono); font-size: .68rem; padding: 2px 10px; border-radius: 999px; border: 1px solid var(--line-strong); background: var(--surface-2); color: var(--muted); white-space: nowrap; }
167
- .status-chip[data-tone="ok"] { color: var(--ok); border-color: #2b5642; background: var(--ok-bg); }
168
- .status-chip[data-tone="accent"] { color: var(--accent); border-color: #274a63; background: #10202e; }
169
- .status-chip[data-tone="warn"] { color: var(--warn); border-color: #5c4520; background: var(--warn-bg); }
170
- .status-chip[data-tone="danger"] { color: var(--danger); border-color: #5c2c2c; background: var(--danger-bg); }
209
+ .status-chip[data-tone="ok"] { color: var(--ok); border-color: var(--ok-line); background: var(--ok-bg); }
210
+ .status-chip[data-tone="accent"] { color: var(--accent); border-color: var(--accent-line); background: var(--accent-bg); }
211
+ .status-chip[data-tone="warn"] { color: var(--warn); border-color: var(--warn-line); background: var(--warn-bg); }
212
+ .status-chip[data-tone="danger"] { color: var(--danger); border-color: var(--danger-line); background: var(--danger-bg); }
171
213
  .detail-close { margin-left: auto; background: none; border: none; color: var(--dim); font-size: 1.3rem; cursor: pointer; line-height: 1; }
172
214
  .detail-close:hover { color: var(--danger); }
173
215
  .detail-title { font-size: 1.3rem; font-weight: 700; line-height: 1.3; margin-bottom: 10px; overflow-wrap: anywhere; }
@@ -197,7 +239,7 @@
197
239
  .detail-cmd { margin-top: 18px; }
198
240
  .dep-link {
199
241
  font-family: var(--mono); font-size: .74rem; padding: 2px 10px; border-radius: 999px;
200
- border: 1px solid #274a63; background: #10202e; color: var(--accent); cursor: pointer;
242
+ border: 1px solid var(--accent-line); background: var(--accent-bg); color: var(--accent); cursor: pointer;
201
243
  }
202
244
  .dep-link:hover { filter: brightness(1.25); }
203
245
 
@@ -213,7 +255,7 @@
213
255
  background: linear-gradient(180deg, var(--accent), transparent 130%);
214
256
  -webkit-background-clip: text; background-clip: text; color: transparent;
215
257
  }
216
- .sec-head h2 { font-size: 1.15rem; letter-spacing: .5px; }
258
+ .sec-head h2 { font-size: 1.15rem; letter-spacing: .5px; font-weight: 700; }
217
259
  .sec-head .tagline { color: var(--dim); font-size: .82rem; margin-left: auto; text-align: right; }
218
260
  .mount { min-height: 24px; }
219
261
 
@@ -228,12 +270,11 @@
228
270
  }
229
271
  .cmd-btn:hover { background: var(--surface-2); border-color: var(--accent); }
230
272
  .cmd-btn:active { transform: translateY(1px); }
231
- .cmd-title { font-size: .95rem; font-weight: 600; color: var(--text); }
273
+ .cmd-title { font-size: .95rem; font-weight: 700; color: var(--text); }
232
274
  .cmd-btn:hover .cmd-title { color: var(--accent); }
233
275
  .cmd-line { font-family: var(--mono); font-size: .72rem; color: var(--dim); }
234
276
  .hint { color: var(--dim); font-size: .8rem; margin-top: 12px; }
235
277
  .drafts-list { list-style: none; margin: 0; padding: 0; display: grid; gap: 8px; }
236
- .drafts-list li { background: var(--surface); border: 1px solid var(--line); border-radius: 8px; padding: 8px 12px; font-size: .88rem; }
237
278
  code.inline {
238
279
  font-family: var(--mono); font-size: .85em;
239
280
  background: var(--surface-2); border: 1px solid var(--line);
@@ -246,7 +287,7 @@
246
287
  background: var(--surface-2); color: var(--text); border: 1px solid var(--line-strong);
247
288
  border-radius: 999px; padding: 7px 16px; min-width: 260px; font: inherit;
248
289
  }
249
- input[type="search"]:focus { outline: none; border-color: var(--accent-dim); box-shadow: 0 0 0 2px rgba(92,200,255,.15); }
290
+ input[type="search"]:focus { outline: none; border-color: var(--accent-dim); box-shadow: 0 0 0 2px var(--focus-ring); }
250
291
  .table-wrap { overflow-x: auto; background: var(--surface); border: 1px solid var(--line); border-radius: 12px; }
251
292
  table { width: 100%; border-collapse: collapse; font-size: .89rem; }
252
293
  th, td { text-align: left; padding: 8px 14px; border-bottom: 1px solid var(--line); vertical-align: top; white-space: nowrap; }
@@ -262,7 +303,8 @@
262
303
  .task-row:hover td { background: var(--surface-2); }
263
304
  .cell-id { color: var(--accent); font-family: var(--mono); font-variant-numeric: tabular-nums; }
264
305
  .cell-title { font-weight: 400; white-space: normal; min-width: 220px; }
265
- .cell-updated { font-family: var(--mono); color: var(--muted); }
306
+ .cell-updated { font-family: var(--mono); color: var(--muted); text-align: center; }
307
+ th[data-key="updated"] { text-align: center; }
266
308
 
267
309
  /* ---------- Footer ---------- */
268
310
  .footer {
@@ -281,6 +323,30 @@
281
323
  .legend-list li:hover { color: var(--text); }
282
324
  .legend-list .swatch { width: 10px; height: 10px; border-radius: 3px; flex: none; }
283
325
 
326
+ /* ---------- KPI tiles ---------- */
327
+ .kpi-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(190px, 1fr)); gap: 12px; margin-bottom: 22px; }
328
+ .kpi {
329
+ display: block; text-align: left; font: inherit; color: inherit; cursor: default;
330
+ background: var(--surface); border: 1px solid var(--line); border-radius: 12px; padding: 14px 16px;
331
+ }
332
+ .kpi-big { font-family: var(--mono); font-size: 1.7rem; font-weight: 700; line-height: 1.1; font-variant-numeric: tabular-nums; }
333
+ .kpi-cap { display: block; color: var(--dim); font-size: .66rem; letter-spacing: 1.4px; text-transform: uppercase; margin-top: 4px; }
334
+ .kpi-sub { display: block; font-size: .78rem; color: var(--muted); margin-top: 8px; }
335
+ .kpi-sub[data-tone="ok"] { color: var(--ok); }
336
+ .kpi-sub[data-tone="danger"] { color: var(--danger); }
337
+ .kpi-link { font: inherit; background: none; border: none; padding: 0; color: var(--accent); cursor: pointer; font-family: var(--mono); font-size: .78rem; }
338
+ .kpi-link:hover { filter: brightness(1.25); }
339
+ .kpi-special-btn { display: block; width: 100%; text-align: left; font: inherit; color: inherit; background: none; border: none; border-radius: 8px; padding: 2px 4px; margin: -2px -4px; cursor: pointer; }
340
+ .kpi-special-btn:hover { background: var(--surface-2); }
341
+ .kpi-special-btn:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
342
+ .kpi-special-btn[aria-pressed="true"] { background: var(--accent-bg); }
343
+ /* ---------- Aging strip ---------- */
344
+ .aging-lane-label { fill: var(--text); font-size: 12px; font-family: var(--mono); }
345
+ .aging-axis { fill: var(--dim); font-size: 10px; font-family: var(--mono); }
346
+ .aging-track { stroke: var(--line); }
347
+ .age-dot { cursor: pointer; }
348
+ .age-dot:hover { opacity: .8; }
349
+
284
350
  /* ---------- Milestone bars ---------- */
285
351
  .bars text { font-family: var(--mono); }
286
352
  .bar-label { fill: var(--text); font-size: 12.5px; }
@@ -289,16 +355,17 @@
289
355
  .bar-fill { fill: var(--accent); }
290
356
  .bar-fill.complete { fill: var(--ok); }
291
357
 
292
- /* ---------- Sparkline ---------- */
293
- .spark-line {
294
- fill: none; stroke: var(--accent); stroke-width: 2;
295
- stroke-linejoin: round; stroke-linecap: round;
296
- filter: drop-shadow(0 0 6px rgba(92,200,255,.4));
297
- }
298
- .spark-area { fill: rgba(92,200,255,.09); stroke: none; }
299
- .spark-dot { fill: var(--bg); stroke: var(--dim); stroke-width: 1; }
300
- .spark-dot.nonzero { fill: var(--accent); stroke: var(--accent); }
301
- .spark-axis { fill: var(--dim); font-size: 10px; font-family: var(--mono); }
358
+ /* ---------- Activity heatmap ---------- */
359
+ .hm-cell { cursor: pointer; }
360
+ .hm-cell:hover { stroke: var(--accent); stroke-width: 1; }
361
+ .hm-0 { fill: var(--surface-2); }
362
+ .hm-1 { fill: var(--accent); opacity: .3; }
363
+ .hm-2 { fill: var(--accent); opacity: .55; }
364
+ .hm-3 { fill: var(--accent); opacity: .8; }
365
+ .hm-4 { fill: var(--accent); }
366
+ .hm-axis { fill: var(--dim); font-size: 9px; font-family: var(--mono); }
367
+ #day-panel { margin-top: 16px; }
368
+ #day-panel h4 { font-family: var(--mono); }
302
369
 
303
370
  /* ---------- Pipeline stepper ---------- */
304
371
  .stepper { display: grid; grid-template-columns: repeat(9, minmax(0, 1fr)); gap: 2px; margin: 4px 0 6px; }
@@ -322,7 +389,7 @@
322
389
  border-radius: 50%; font-family: var(--mono); font-size: .9rem; font-weight: 700;
323
390
  background: var(--surface-2); border: 2px solid var(--line-strong); color: var(--muted);
324
391
  }
325
- .step.gate .step-num { border-color: var(--warn); color: var(--warn); background: var(--warn-bg); box-shadow: 0 0 14px rgba(255,180,84,.25); }
392
+ .step.gate .step-num { border-color: var(--warn); color: var(--warn); background: var(--warn-bg); box-shadow: 0 0 14px var(--glow-warn); }
326
393
  .step-label {
327
394
  display: block; font-size: .8rem; font-weight: 600; color: var(--text); line-height: 1.3;
328
395
  overflow-wrap: break-word;
@@ -367,25 +434,18 @@
367
434
  color: var(--warn); background: var(--warn-bg); border: 1px solid var(--warn);
368
435
  border-radius: 999px; padding: 2px 9px; transition: box-shadow .15s ease;
369
436
  }
370
- .update-badge:hover { box-shadow: 0 0 10px rgba(255,180,84,.3); }
437
+ .update-badge:hover { box-shadow: 0 0 10px var(--glow-warn-soft); }
371
438
  .update-badge:focus-visible { outline: 2px solid var(--warn); outline-offset: 2px; }
372
439
  .update-badge .cmd-title { font-size: inherit; font-weight: 600; color: inherit; }
373
- .side-models {
374
- display: block; margin: 4px 0 0; padding: 2px 0; cursor: pointer; text-align: left;
375
- font: inherit; font-family: var(--mono); font-size: .78rem; color: var(--muted);
376
- background: none; border: none;
377
- }
378
- .side-models:hover { color: var(--accent); }
379
- .side-models:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
380
440
 
381
441
  /* ---------- Models modal ---------- */
382
442
  #models-dialog {
383
443
  width: min(560px, 92vw); max-height: 80vh; overflow-y: auto; overflow-x: hidden; padding: 0;
384
444
  border: 1px solid var(--line-strong); border-radius: 14px;
385
445
  background: var(--surface); color: var(--text);
386
- box-shadow: 0 24px 80px rgba(0,0,0,.55);
446
+ box-shadow: var(--shadow-dialog);
387
447
  }
388
- #models-dialog::backdrop { background: rgba(4,7,12,.65); backdrop-filter: blur(2px); }
448
+ #models-dialog::backdrop { background: var(--backdrop); backdrop-filter: blur(2px); }
389
449
  #models-dialog[open] { animation: sbl-dialog-in .18s ease-out; }
390
450
  @media (prefers-reduced-motion: reduce) {
391
451
  #models-dialog[open] { animation: none; }
@@ -426,23 +486,35 @@
426
486
  main { padding: 24px 20px 40px; }
427
487
  }
428
488
  </style>
489
+ <script>
490
+ (function () {
491
+ var theme = null;
492
+ try { theme = localStorage.getItem('sbl-theme'); } catch (e) { theme = null; }
493
+ if (theme !== 'light' && theme !== 'dark') {
494
+ theme = window.matchMedia && window.matchMedia('(prefers-color-scheme: light)').matches ? 'light' : 'dark';
495
+ }
496
+ document.documentElement.setAttribute('data-theme', theme);
497
+ })();
498
+ </script>
429
499
  </head>
430
500
  <body>
431
501
  <div class="layout">
432
502
 
433
503
  <aside class="sbl-side">
434
- <div class="brand"><span class="brand-glyph">●</span><b>__PROJECT_NAME__</b></div>
504
+ <div class="brand"><span class="brand-glyph">●</span><b>__PROJECT_NAME__</b>
505
+ <button type="button" id="theme-toggle" class="theme-toggle" aria-label="Switch color theme">◐</button>
506
+ </div>
435
507
  <div class="kicker">SUPERPOWERS × BACKLOG.MD</div>
436
508
  <div class="side-version" id="side-version"><span>v__KIT_VERSION__</span></div>
437
- <button type="button" id="models-btn" class="side-models">model router</button>
438
509
  <nav aria-label="Dashboard sections">
439
510
  <a href="#sec-01"><span class="n">01</span>Board &amp; Quick Actions</a>
440
511
  <a href="#sec-02"><span class="n">02</span>Status</a>
441
- <a href="#sec-03"><span class="n">03</span>Milestones</a>
442
- <a href="#sec-04"><span class="n">04</span>Tasks</a>
443
- <a href="#sec-05"><span class="n">05</span>Feature Cycle</a>
444
- <a href="#sec-06"><span class="n">06</span>Activity</a>
445
- <a href="#sec-07"><span class="n">07</span>Decisions &amp; Docs</a>
512
+ <a href="#sec-03"><span class="n">03</span>Feature Cycle</a>
513
+ <a href="#sec-04"><span class="n">04</span>Milestones</a>
514
+ <a href="#sec-05"><span class="n">05</span>Drafts</a>
515
+ <a href="#sec-06"><span class="n">06</span>Tasks</a>
516
+ <a href="#sec-07"><span class="n">07</span>Activity</a>
517
+ <a href="#sec-08"><span class="n">08</span>Decisions &amp; Docs</a>
446
518
  </nav>
447
519
  <div class="legend">
448
520
  <h4>Status</h4>
@@ -459,27 +531,42 @@
459
531
  <div id="quickactions" class="mount">
460
532
  <div class="cmd-row" id="cmd-buttons">
461
533
  <button type="button" class="cmd-btn" id="backlog-btn"><span class="cmd-title">Backlog</span><span class="cmd-line">board &middot; tasks &middot; docs &middot; decisions</span></button>
534
+ <button type="button" class="cmd-btn" id="models-btn"><span class="cmd-title">Model Router</span><span class="cmd-line">workhorse &middot; budget &middot; tiers</span></button>
462
535
  </div>
463
536
  <p class="hint">Opens the Backlog.md browser in an overlay &mdash; served locally per project, started on demand.</p>
464
537
  </div>
465
- <div id="drafts" class="mount">
466
- <h3 class="sub-head">Drafts</h3>
467
- <ul id="drafts-list" class="drafts-list"></ul>
468
- </div>
469
538
  </section>
470
539
 
471
540
  <section id="sec-02">
472
- <div class="sec-head"><span class="sec-num">02</span><h2>Status</h2><span class="tagline">tasks per status</span></div>
541
+ <div class="sec-head"><span class="sec-num">02</span><h2>Status</h2><span class="tagline">progress &middot; velocity &middot; wip &middot; age</span></div>
542
+ <div id="kpis" class="mount"></div>
473
543
  <div id="donut" class="mount"></div>
544
+ <h3 class="sub-head">Aging &mdash; open tasks by days in the backlog</h3>
545
+ <div id="aging" class="mount"></div>
474
546
  </section>
475
547
 
476
548
  <section id="sec-03">
477
- <div class="sec-head"><span class="sec-num">03</span><h2>Milestones</h2><span class="tagline">done / total per <span class="term" data-term="Milestone">Milestone</span></span></div>
478
- <div id="bars" class="mount"></div>
549
+ <div class="sec-head"><span class="sec-num">03</span><h2>Feature Cycle</h2><span class="tagline">idea &rarr; merge &middot; every <span class="term" data-term="Review Gate">Review Gate</span> included &middot; click a step for details</span></div>
550
+ <div id="stepper" class="mount"></div>
551
+ <div id="phase-detail" hidden></div>
552
+ <h3 class="sub-head">Flow</h3>
553
+ <div id="depgraph" class="mount"></div>
479
554
  </section>
480
555
 
481
556
  <section id="sec-04">
482
- <div class="sec-head"><span class="sec-num">04</span><h2>Tasks</h2><span class="tagline">sort &middot; filter &middot; click row for details</span></div>
557
+ <div class="sec-head"><span class="sec-num">04</span><h2>Milestones</h2><span class="tagline">done / total per <span class="term" data-term="Milestone">Milestone</span></span></div>
558
+ <div id="bars" class="mount"></div>
559
+ </section>
560
+
561
+ <section id="sec-05">
562
+ <div class="sec-head"><span class="sec-num">05</span><h2>Drafts</h2><span class="tagline">ideas before they enter the backlog &middot; click a card for details</span></div>
563
+ <div id="drafts" class="mount">
564
+ <ul id="drafts-list" class="drafts-list"></ul>
565
+ </div>
566
+ </section>
567
+
568
+ <section id="sec-06">
569
+ <div class="sec-head"><span class="sec-num">06</span><h2>Tasks</h2><span class="tagline">sort &middot; filter &middot; click row for details</span></div>
483
570
  <div id="tasks" class="mount">
484
571
  <div class="toolbar">
485
572
  <input id="taskfilter" type="search" placeholder="Filter tasks&hellip;" aria-label="Filter tasks">
@@ -503,21 +590,15 @@
503
590
  </div>
504
591
  </section>
505
592
 
506
- <section id="sec-05">
507
- <div class="sec-head"><span class="sec-num">05</span><h2>Feature Cycle</h2><span class="tagline">idea &rarr; merge &middot; every <span class="term" data-term="Review Gate">Review Gate</span> included &middot; click a step for details</span></div>
508
- <div id="stepper" class="mount"></div>
509
- <div id="phase-detail" hidden></div>
510
- <h3 class="sub-head">Flow</h3>
511
- <div id="depgraph" class="mount"></div>
512
- </section>
513
-
514
- <section id="sec-06">
515
- <div class="sec-head"><span class="sec-num">06</span><h2>Activity</h2><span class="tagline">last 30 days</span></div>
516
- <div id="spark" class="mount"></div>
593
+ <section id="sec-07">
594
+ <div class="sec-head"><span class="sec-num">07</span><h2>Activity</h2><span class="tagline">last 26 weeks &middot; click a day for its tasks</span></div>
595
+ <div id="activity-kpis" class="mount"></div>
596
+ <div id="heatmap" class="mount"></div>
597
+ <div id="day-panel" class="flow-block" hidden></div>
517
598
  </section>
518
599
 
519
- <section id="sec-07">
520
- <div class="sec-head"><span class="sec-num">07</span><h2>Decisions &amp; Docs</h2><span class="tagline">decisions &middot; docs &middot; glossary</span></div>
600
+ <section id="sec-08">
601
+ <div class="sec-head"><span class="sec-num">08</span><h2>Decisions &amp; Docs</h2><span class="tagline">decisions &middot; docs &middot; glossary</span></div>
521
602
  <div id="docs" class="mount"></div>
522
603
  <p class="hint">The <span class="term" data-term="TDD">TDD</span> loop and the <span class="term" data-term="DoD">DoD</span> ship as built-in glossary terms &mdash; extend them via <code class="inline">backlog/docs/glossary.md</code>.</p>
523
604
  </section>
@@ -584,7 +665,7 @@
584
665
  }
585
666
 
586
667
  var KEYS = ['id', 'title', 'status', 'milestone', 'priority', 'assignee', 'updated'];
587
- var state = { key: 'id', dir: 1, query: '', status: null, hoverStatus: null };
668
+ var state = { key: 'id', dir: 1, query: '', status: null, hoverStatus: null, special: null };
588
669
  function field(task, key) {
589
670
  var v = task[key];
590
671
  return v === undefined || v === null ? '' : String(v);
@@ -595,6 +676,8 @@
595
676
  function matches(task) {
596
677
  var eff = activeStatus();
597
678
  if (eff && field(task, 'status').toLowerCase() !== eff.toLowerCase()) return false;
679
+ if (state.special === 'wip' && !isWipStatus(task.status)) return false;
680
+ if (state.special === 'blocked' && !isBlockedTask(task)) return false;
598
681
  if (!state.query) return true;
599
682
  var hit = KEYS.some(function (k) {
600
683
  return field(task, k).toLowerCase().indexOf(state.query) !== -1;
@@ -603,13 +686,68 @@
603
686
  return ac.text.toLowerCase().indexOf(state.query) !== -1;
604
687
  });
605
688
  }
689
+ function isWipStatus(status) {
690
+ var s = String(status || '').toLowerCase();
691
+ return s.indexOf('progress') !== -1 || s.indexOf('review') !== -1;
692
+ }
693
+ function isBlockedTask(task) {
694
+ var s = String(task.status || '').toLowerCase();
695
+ if (isDoneStatus(task.status)) return false;
696
+ if (s.indexOf('block') !== -1) return true;
697
+ if (!depsOut) return false;
698
+ var blockers = depsOut[task.id] || [];
699
+ for (var i = 0; i < blockers.length; i++) {
700
+ var dep = findTask(blockers[i]);
701
+ if (dep && !isDoneStatus(dep.status)) return true;
702
+ }
703
+ return false;
704
+ }
705
+ function setSpecialFilter(kind) {
706
+ state.special = state.special === kind ? null : kind;
707
+ state.status = null;
708
+ document.querySelectorAll('#pills .pill').forEach(function (pill) { pill.classList.remove('active'); });
709
+ document.querySelectorAll('[data-special]').forEach(function (tile) {
710
+ tile.setAttribute('aria-pressed', String(state.special === tile.getAttribute('data-special')));
711
+ });
712
+ renderTasks();
713
+ }
606
714
  var tbody = $('#task-rows');
715
+ function compareTasks(a, b, key) {
716
+ if (key === 'updated') {
717
+ var ta = Date.parse(field(a, key)) || 0;
718
+ var tb = Date.parse(field(b, key)) || 0;
719
+ if (ta !== tb) return ta - tb;
720
+ }
721
+ return field(a, key).localeCompare(field(b, key), undefined, { numeric: true, sensitivity: 'base' });
722
+ }
723
+ var relFmt = window.Intl && Intl.RelativeTimeFormat ? new Intl.RelativeTimeFormat(undefined, { numeric: 'auto' }) : null;
724
+ var exactFmt = window.Intl && Intl.DateTimeFormat ? new Intl.DateTimeFormat(undefined, { dateStyle: 'medium', timeStyle: 'short' }) : null;
725
+ var REL_UNITS = [['year', 31536000], ['month', 2592000], ['week', 604800], ['day', 86400], ['hour', 3600], ['minute', 60]];
726
+ function parseWhen(value) {
727
+ if (!value) return null;
728
+ /* date-only strings get a local noon so the day never shifts across timezones */
729
+ var t = Date.parse(/^\d{4}-\d{2}-\d{2}$/.test(value) ? value + 'T12:00:00' : value);
730
+ return isNaN(t) ? null : t;
731
+ }
732
+ function formatRelative(value) {
733
+ var t = parseWhen(value);
734
+ if (t === null || !relFmt) return value;
735
+ var diff = (t - Date.now()) / 1000;
736
+ for (var i = 0; i < REL_UNITS.length; i++) {
737
+ if (Math.abs(diff) >= REL_UNITS[i][1] || i === REL_UNITS.length - 1) {
738
+ return relFmt.format(Math.round(diff / REL_UNITS[i][1]), REL_UNITS[i][0]);
739
+ }
740
+ }
741
+ return value;
742
+ }
743
+ function formatExact(value) {
744
+ var t = parseWhen(value);
745
+ return t === null || !exactFmt ? '' : exactFmt.format(new Date(t));
746
+ }
607
747
  function renderTasks() {
608
748
  var rows = data.tasks
609
749
  .filter(matches)
610
- .sort(function (a, b) {
611
- return field(a, state.key).localeCompare(field(b, state.key)) * state.dir;
612
- });
750
+ .sort(function (a, b) { return compareTasks(a, b, state.key) * state.dir; });
613
751
  tbody.textContent = '';
614
752
  if (rows.length === 0) {
615
753
  var emptyTr = el('tr');
@@ -623,7 +761,19 @@
623
761
  var tr = el('tr', 'task-row');
624
762
  tr.setAttribute('data-task', task.id);
625
763
  KEYS.forEach(function (k) {
626
- tr.appendChild(el('td', 'cell-' + k, field(task, k)));
764
+ var td = el('td', 'cell-' + k);
765
+ if (k === 'status') {
766
+ var chip = el('span', 'status-chip', field(task, k));
767
+ chip.setAttribute('data-tone', toneOf(task.status));
768
+ td.appendChild(chip);
769
+ } else if (k === 'updated') {
770
+ td.textContent = formatRelative(field(task, k));
771
+ var exact = formatExact(field(task, k));
772
+ if (exact) td.setAttribute('data-tip', exact);
773
+ } else {
774
+ td.textContent = field(task, k);
775
+ }
776
+ tr.appendChild(td);
627
777
  });
628
778
  tr.addEventListener('click', function () { openDetail(task.id); });
629
779
  tbody.appendChild(tr);
@@ -657,9 +807,13 @@
657
807
  }
658
808
  function setStatusFilter(status) {
659
809
  state.status = status || null;
810
+ state.special = null;
660
811
  document.querySelectorAll('#pills .pill').forEach(function (pill) {
661
812
  pill.classList.toggle('active', !!state.status && pill.getAttribute('data-status') === state.status);
662
813
  });
814
+ document.querySelectorAll('[data-special]').forEach(function (tile) {
815
+ tile.setAttribute('aria-pressed', 'false');
816
+ });
663
817
  renderTasks();
664
818
  }
665
819
  document.querySelectorAll('#pills .pill').forEach(function (pill) {
@@ -719,6 +873,24 @@
719
873
  });
720
874
  }
721
875
 
876
+ /* ---------- Theme toggle ---------- */
877
+ var themeToggle = document.getElementById('theme-toggle');
878
+ function syncThemeToggle() {
879
+ if (!themeToggle) return;
880
+ var mode = document.documentElement.getAttribute('data-theme');
881
+ themeToggle.textContent = mode === 'light' ? '☾' : '☀';
882
+ themeToggle.setAttribute('aria-label', mode === 'light' ? 'Switch to dark theme' : 'Switch to light theme');
883
+ }
884
+ if (themeToggle) {
885
+ themeToggle.addEventListener('click', function () {
886
+ var next = document.documentElement.getAttribute('data-theme') === 'light' ? 'dark' : 'light';
887
+ document.documentElement.setAttribute('data-theme', next);
888
+ try { localStorage.setItem('sbl-theme', next); } catch (e) { /* private mode */ }
889
+ syncThemeToggle();
890
+ });
891
+ syncThemeToggle();
892
+ }
893
+
722
894
  /* ---------- Models modal ---------- */
723
895
  var modelsBtn = document.getElementById('models-btn');
724
896
  var modelsDialog = document.getElementById('models-dialog');
@@ -802,16 +974,6 @@
802
974
  });
803
975
  }
804
976
 
805
- /* ---------- Drafts ---------- */
806
- function renderDrafts(drafts) {
807
- var list = document.getElementById('drafts-list');
808
- if (!list) return;
809
- list.textContent = '';
810
- if (!drafts || drafts.length === 0) { list.appendChild(el('li', '', 'No drafts.')); return; }
811
- drafts.forEach(function (d) { list.appendChild(el('li', '', d.id + ' \u2014 ' + d.title)); });
812
- }
813
- renderDrafts(data.drafts);
814
-
815
977
  /* ---------- Shared diagram helpers ---------- */
816
978
  var SVGNS = 'http://www.w3.org/2000/svg';
817
979
  var TONE_VAR = { ok: 'var(--ok)', accent: 'var(--accent)', warn: 'var(--warn)', danger: 'var(--danger)', dim: 'var(--dim)' };
@@ -896,6 +1058,123 @@
896
1058
  mount.appendChild(wrap);
897
1059
  }
898
1060
 
1061
+ var APPROX_TIP = 'Approximation from created/updated timestamps — Backlog.md keeps no status history.';
1062
+ function kpiTile(opts) {
1063
+ var node = el('div', 'kpi');
1064
+ node.setAttribute('data-tip', APPROX_TIP);
1065
+ node.appendChild(el('span', 'kpi-big', opts.big));
1066
+ node.appendChild(el('span', 'kpi-cap', opts.cap));
1067
+ if (opts.sub) {
1068
+ var sub = el('span', 'kpi-sub', opts.sub);
1069
+ if (opts.tone) sub.setAttribute('data-tone', opts.tone);
1070
+ node.appendChild(sub);
1071
+ }
1072
+ return node;
1073
+ }
1074
+ function wipBlockedTile(k) {
1075
+ var node = el('div', 'kpi');
1076
+ node.setAttribute('data-tip', APPROX_TIP);
1077
+ var wipBtn = el('button', 'kpi-special-btn');
1078
+ wipBtn.type = 'button';
1079
+ wipBtn.setAttribute('data-special', 'wip');
1080
+ wipBtn.setAttribute('aria-pressed', 'false');
1081
+ wipBtn.addEventListener('click', function () { setSpecialFilter('wip'); });
1082
+ wipBtn.appendChild(el('span', 'kpi-big', String(k.wip)));
1083
+ wipBtn.appendChild(el('span', 'kpi-cap', 'in progress'));
1084
+ node.appendChild(wipBtn);
1085
+ var blockedBtn = el('button', 'kpi-special-btn');
1086
+ blockedBtn.type = 'button';
1087
+ blockedBtn.setAttribute('data-special', 'blocked');
1088
+ blockedBtn.setAttribute('aria-pressed', 'false');
1089
+ blockedBtn.addEventListener('click', function () { setSpecialFilter('blocked'); });
1090
+ var sub = el('span', 'kpi-sub', k.blocked + ' blocked · click to filter');
1091
+ if (k.blocked > 0) sub.setAttribute('data-tone', 'danger');
1092
+ blockedBtn.appendChild(sub);
1093
+ node.appendChild(blockedBtn);
1094
+ return node;
1095
+ }
1096
+ function renderKpis(mount, k) {
1097
+ mount.textContent = '';
1098
+ if (!k) return;
1099
+ var grid = el('div', 'kpi-grid');
1100
+ grid.appendChild(kpiTile({
1101
+ big: k.progressPct + '%', cap: 'complete',
1102
+ sub: k.done + ' / ' + k.total + ' done' + (k.forecastDate ? ' · at this pace done ~' + k.forecastDate : ''),
1103
+ }));
1104
+ var delta = k.velocity7 - k.velocityPrev7;
1105
+ grid.appendChild(kpiTile({
1106
+ big: String(k.velocity7), cap: 'done / last 7 days',
1107
+ sub: delta === 0 ? '± 0 vs previous week' : (delta > 0 ? '▲ +' : '▼ ') + delta + ' vs previous week',
1108
+ tone: delta > 0 ? 'ok' : delta < 0 ? 'danger' : undefined,
1109
+ }));
1110
+ grid.appendChild(wipBlockedTile(k));
1111
+ var age = kpiTile({
1112
+ big: k.oldestOpenDays === null ? '—' : k.oldestOpenDays + 'd', cap: 'oldest open task',
1113
+ sub: k.medianOpenAgeDays === null ? '' : 'median ' + k.medianOpenAgeDays + 'd open',
1114
+ });
1115
+ if (k.oldestOpenId) {
1116
+ var link = el('button', 'kpi-link', k.oldestOpenId);
1117
+ link.type = 'button';
1118
+ link.addEventListener('click', function () { openDetail(k.oldestOpenId); });
1119
+ age.appendChild(link);
1120
+ }
1121
+ grid.appendChild(age);
1122
+ mount.appendChild(grid);
1123
+ }
1124
+ function ageInDays(task) {
1125
+ var raw = task.created || task.updated;
1126
+ if (!raw) return null;
1127
+ var t = Date.parse(/^\d{4}-\d{2}-\d{2}$/.test(raw) ? raw + 'T12:00:00' : raw);
1128
+ if (isNaN(t)) return null;
1129
+ return Math.max(0, Math.round((Date.now() - t) / 86400000));
1130
+ }
1131
+ var STALE_DAYS = 14;
1132
+ function renderAging(mount, tasks) {
1133
+ mount.textContent = '';
1134
+ var lanes = [];
1135
+ var byStatus = {};
1136
+ tasks.forEach(function (t) {
1137
+ if (isDoneStatus(t.status)) return;
1138
+ var days = ageInDays(t);
1139
+ if (days === null) return;
1140
+ if (!byStatus[t.status]) { byStatus[t.status] = []; lanes.push(t.status); }
1141
+ byStatus[t.status].push({ task: t, days: days });
1142
+ });
1143
+ if (lanes.length === 0) {
1144
+ mount.appendChild(el('p', 'hint', 'No datable open tasks.'));
1145
+ return;
1146
+ }
1147
+ var maxDays = 1;
1148
+ lanes.forEach(function (s) { byStatus[s].forEach(function (e) { if (e.days > maxDays) maxDays = e.days; }); });
1149
+ var labelW = 130, w = 780, laneH = 34, padR = 20;
1150
+ var h = lanes.length * laneH + 24;
1151
+ var svg = svgEl('svg', { viewBox: '0 0 ' + w + ' ' + h, width: '100%', 'class': 'aging', role: 'img', 'aria-label': 'Open tasks by age in days' });
1152
+ lanes.forEach(function (s, i) {
1153
+ var y = i * laneH + 22;
1154
+ var lbl = svgEl('text', { x: labelW - 12, y: y + 4, 'text-anchor': 'end', 'class': 'aging-lane-label' });
1155
+ lbl.textContent = s;
1156
+ svg.appendChild(lbl);
1157
+ svg.appendChild(svgEl('line', { x1: labelW, y1: y, x2: w - padR, y2: y, 'class': 'aging-track' }));
1158
+ byStatus[s].forEach(function (e) {
1159
+ var cx = labelW + (w - labelW - padR) * e.days / maxDays;
1160
+ var dot = svgEl('circle', {
1161
+ cx: cx.toFixed(1), cy: y, r: 6, 'class': 'age-dot',
1162
+ fill: e.days >= STALE_DAYS ? 'var(--warn)' : TONE_VAR[toneOf(s)]
1163
+ });
1164
+ attachTitle(dot, e.task.id + ' · ' + e.task.title + ' · ' + e.days + 'd');
1165
+ dot.addEventListener('click', function () { openDetail(e.task.id); });
1166
+ svg.appendChild(dot);
1167
+ });
1168
+ });
1169
+ var axis0 = svgEl('text', { x: labelW, y: h - 4, 'class': 'aging-axis' });
1170
+ axis0.textContent = '0d';
1171
+ var axisMax = svgEl('text', { x: w - padR, y: h - 4, 'text-anchor': 'end', 'class': 'aging-axis' });
1172
+ axisMax.textContent = maxDays + 'd';
1173
+ svg.appendChild(axis0);
1174
+ svg.appendChild(axisMax);
1175
+ mount.appendChild(svg);
1176
+ }
1177
+
899
1178
  function renderBars(mount, milestones) {
900
1179
  mount.textContent = '';
901
1180
  if (milestones.length === 0) {
@@ -931,44 +1210,95 @@
931
1210
  mount.appendChild(svg);
932
1211
  }
933
1212
 
934
- function renderSparkline(mount, activity) {
1213
+ function weekdayName(idx, style) {
1214
+ /* 2024-01-07 was a Sunday; offset from it gives any weekday */
1215
+ var ref = new Date(Date.UTC(2024, 0, 7 + idx));
1216
+ try {
1217
+ return new Intl.DateTimeFormat(undefined, { weekday: style, timeZone: 'UTC' }).format(ref);
1218
+ } catch (e) {
1219
+ return ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'][idx];
1220
+ }
1221
+ }
1222
+ function renderActivityKpis(mount, k) {
935
1223
  mount.textContent = '';
936
- if (!activity || activity.length < 2) {
1224
+ if (!k) return;
1225
+ var grid = el('div', 'kpi-grid');
1226
+ grid.appendChild(kpiTile({ big: String(k.activityTotal30), cap: 'tasks touched / 30 days' }));
1227
+ grid.appendChild(kpiTile({ big: String(k.activityAvgPerWeek), cap: 'avg per week (26w)' }));
1228
+ grid.appendChild(kpiTile({
1229
+ big: k.busiestWeekday === null ? '—' : weekdayName(k.busiestWeekday, 'long'),
1230
+ cap: 'most active day'
1231
+ }));
1232
+ grid.appendChild(kpiTile({ big: k.streakDays + 'd', cap: 'current streak' }));
1233
+ mount.appendChild(grid);
1234
+ }
1235
+ function renderDayPanel(panel, bucket) {
1236
+ panel.textContent = '';
1237
+ panel.hidden = false;
1238
+ var head = el('h4', '', bucket.date + ' — ' + bucket.count + ' task' + (bucket.count === 1 ? '' : 's'));
1239
+ panel.appendChild(head);
1240
+ var list = el('ul', 'flow-list');
1241
+ var shown = 0;
1242
+ bucket.ids.forEach(function (id) {
1243
+ var t = findTask(id);
1244
+ if (!t) return;
1245
+ shown++;
1246
+ var li = el('li', 'flow-card');
1247
+ var h = el('div', 'flow-card-head');
1248
+ h.appendChild(el('span', 'flow-card-id', t.id));
1249
+ var chip = el('span', 'status-chip', t.status);
1250
+ chip.setAttribute('data-tone', toneOf(t.status));
1251
+ h.appendChild(chip);
1252
+ li.appendChild(h);
1253
+ li.appendChild(el('div', 'flow-card-title', t.title));
1254
+ li.addEventListener('click', function () { openDetail(t.id); });
1255
+ list.appendChild(li);
1256
+ });
1257
+ if (shown === 0) panel.appendChild(el('p', 'flow-empty', 'No task details for this day.'));
1258
+ else panel.appendChild(list);
1259
+ }
1260
+ function renderHeatmap(mount, activity) {
1261
+ mount.textContent = '';
1262
+ if (!activity || activity.length === 0) {
937
1263
  mount.appendChild(el('p', 'hint', 'No activity data.'));
938
1264
  return;
939
1265
  }
940
- var w = 780, h = 150, padL = 14, padR = 14, padT = 18, padB = 26;
1266
+ var panel = document.getElementById('day-panel');
1267
+ var cell = 12, gap = 3, padL = 34, padT = 16;
1268
+ var first = new Date(activity[0].date + 'T00:00:00Z');
1269
+ var offset = first.getUTCDay();
1270
+ var cols = Math.ceil((activity.length + offset) / 7);
1271
+ var w = padL + cols * (cell + gap);
1272
+ var h = padT + 7 * (cell + gap) + 14;
941
1273
  var max = 1;
942
1274
  activity.forEach(function (b) { if (b.count > max) max = b.count; });
943
- function px(i) { return padL + (w - padL - padR) * i / (activity.length - 1); }
944
- function py(c) { return padT + (h - padT - padB) * (1 - c / max); }
945
- var svg = svgEl('svg', {
946
- viewBox: '0 0 ' + w + ' ' + h, width: '100%', 'class': 'spark',
947
- role: 'img', 'aria-label': 'Task activity over the last 30 days'
948
- });
949
- var coords = activity.map(function (b, i) {
950
- return px(i).toFixed(1) + ',' + py(b.count).toFixed(1);
1275
+ var svg = svgEl('svg', { viewBox: '0 0 ' + w + ' ' + h, width: '100%', 'class': 'heatmap', role: 'img', 'aria-label': 'Daily task activity, last 26 weeks' });
1276
+ [1, 3, 5].forEach(function (row) {
1277
+ var lbl = svgEl('text', { x: padL - 6, y: padT + row * (cell + gap) + cell - 2, 'text-anchor': 'end', 'class': 'hm-axis' });
1278
+ lbl.textContent = weekdayName(row, 'short');
1279
+ svg.appendChild(lbl);
951
1280
  });
952
- var areaD = 'M' + px(0).toFixed(1) + ',' + (h - padB) +
953
- ' L ' + coords.join(' L ') +
954
- ' L ' + px(activity.length - 1).toFixed(1) + ',' + (h - padB) + ' Z';
955
- svg.appendChild(svgEl('path', { d: areaD, 'class': 'spark-area' }));
956
- svg.appendChild(svgEl('polyline', { points: coords.join(' '), 'class': 'spark-line' }));
1281
+ var lastMonth = '';
957
1282
  activity.forEach(function (b, i) {
958
- var dot = svgEl('circle', {
959
- cx: px(i).toFixed(1), cy: py(b.count).toFixed(1),
960
- r: b.count > 0 ? 3.5 : 2,
961
- 'class': b.count > 0 ? 'spark-dot nonzero' : 'spark-dot'
1283
+ var pos = i + offset;
1284
+ var col = Math.floor(pos / 7), row = pos % 7;
1285
+ var level = b.count === 0 ? 0 : Math.max(1, Math.ceil((b.count / max) * 4));
1286
+ var rect = svgEl('rect', {
1287
+ x: padL + col * (cell + gap), y: padT + row * (cell + gap),
1288
+ width: cell, height: cell, rx: 2.5,
1289
+ 'class': 'hm-cell hm-' + level, 'data-date': b.date
962
1290
  });
963
- attachTitle(dot, b.date + ': ' + b.count);
964
- svg.appendChild(dot);
1291
+ attachTitle(rect, b.date + ': ' + b.count);
1292
+ rect.addEventListener('click', function () { if (panel) renderDayPanel(panel, b); });
1293
+ svg.appendChild(rect);
1294
+ var month = b.date.slice(0, 7);
1295
+ if (row === 0 && month !== lastMonth) {
1296
+ lastMonth = month;
1297
+ var m = svgEl('text', { x: padL + col * (cell + gap), y: padT - 5, 'class': 'hm-axis' });
1298
+ m.textContent = b.date.slice(5, 7);
1299
+ svg.appendChild(m);
1300
+ }
965
1301
  });
966
- var first = svgEl('text', { x: padL, y: h - 7, 'class': 'spark-axis' });
967
- first.textContent = activity[0].date;
968
- var last = svgEl('text', { x: w - padR, y: h - 7, 'text-anchor': 'end', 'class': 'spark-axis' });
969
- last.textContent = activity[activity.length - 1].date;
970
- svg.appendChild(first);
971
- svg.appendChild(last);
972
1302
  mount.appendChild(svg);
973
1303
  }
974
1304
 
@@ -1039,9 +1369,12 @@
1039
1369
  phases = [];
1040
1370
  }
1041
1371
  if ($('#donut')) renderDonut($('#donut'), data.statuses);
1372
+ if ($('#kpis')) renderKpis($('#kpis'), data.kpis);
1373
+ if ($('#aging')) renderAging($('#aging'), data.tasks);
1042
1374
  if ($('#bars')) renderBars($('#bars'), data.milestones);
1043
1375
  if ($('#stepper')) renderStepper($('#stepper'), phases);
1044
- if ($('#spark')) renderSparkline($('#spark'), data.activity);
1376
+ if ($('#activity-kpis')) renderActivityKpis($('#activity-kpis'), data.kpis);
1377
+ if ($('#heatmap')) renderHeatmap($('#heatmap'), data.activity);
1045
1378
 
1046
1379
  /* ---------- Glossary tooltips ---------- */
1047
1380
  var glossary = {};
@@ -1244,6 +1577,76 @@
1244
1577
  window.__sblOpenDetail = openDetail;
1245
1578
  if (dialog) dialog.addEventListener('click', function (ev) { if (ev.target === dialog) closeDetail(); });
1246
1579
 
1580
+ /* ---------- Drafts ---------- */
1581
+ function draftMetaGrid(d) {
1582
+ var grid = el('div', 'detail-meta');
1583
+ function cell(label, value, tone) {
1584
+ var c = el('div', 'meta-cell');
1585
+ c.appendChild(el('span', 'meta-label', label));
1586
+ var v = el('span', 'meta-value' + (value ? '' : ' empty'), value || '—');
1587
+ if (value && tone) v.setAttribute('data-tone', tone);
1588
+ c.appendChild(v);
1589
+ return c;
1590
+ }
1591
+ grid.appendChild(cell('Priority', d.priority, priorityTone(d.priority)));
1592
+ grid.appendChild(cell('Assignee', d.assignee));
1593
+ grid.appendChild(cell('Created', d.created));
1594
+ grid.appendChild(cell('Updated', d.updated));
1595
+ return grid;
1596
+ }
1597
+ function openDraftDetail(d) {
1598
+ if (!dialog) return;
1599
+ var content = el('div', 'dialog-content');
1600
+ var head = el('div', 'detail-head');
1601
+ head.appendChild(el('span', 'detail-id', d.id));
1602
+ var chip = el('span', 'status-chip', d.status);
1603
+ chip.setAttribute('data-tone', toneOf(d.status));
1604
+ head.appendChild(chip);
1605
+ var closeBtn = el('button', 'detail-close', '×');
1606
+ closeBtn.type = 'button';
1607
+ closeBtn.setAttribute('aria-label', 'Close details');
1608
+ closeBtn.addEventListener('click', closeDetail);
1609
+ head.appendChild(closeBtn);
1610
+ content.appendChild(head);
1611
+ var title = el('h3', 'detail-title', d.title);
1612
+ title.id = 'detail-title-h';
1613
+ dialog.setAttribute('aria-labelledby', 'detail-title-h');
1614
+ content.appendChild(title);
1615
+ if (d.description) content.appendChild(descParagraphs(d.description));
1616
+ else content.appendChild(el('p', 'detail-desc', 'No description.'));
1617
+ content.appendChild(draftMetaGrid(d));
1618
+ if (d.acs && d.acs.length > 0) content.appendChild(acSection(d));
1619
+ var cmd = el('button', 'phase-cmd detail-cmd');
1620
+ cmd.type = 'button';
1621
+ var cmdLine = 'backlog draft promote ' + d.id.replace(/^task-/i, '');
1622
+ cmd.appendChild(el('span', 'cmd-line', cmdLine));
1623
+ cmd.appendChild(el('span', 'cmd-title', 'copy'));
1624
+ cmd.addEventListener('click', function () { copyCommand(cmd, cmdLine); });
1625
+ content.appendChild(cmd);
1626
+ dialog.textContent = '';
1627
+ dialog.appendChild(content);
1628
+ dialog.showModal();
1629
+ }
1630
+ function renderDrafts(drafts) {
1631
+ var list = document.getElementById('drafts-list');
1632
+ if (!list) return;
1633
+ list.textContent = '';
1634
+ if (!drafts || drafts.length === 0) { list.appendChild(el('li', '', 'No drafts.')); return; }
1635
+ drafts.forEach(function (d) {
1636
+ var li = el('li', 'flow-card');
1637
+ var head = el('div', 'flow-card-head');
1638
+ head.appendChild(el('span', 'flow-card-id', d.id));
1639
+ var chip = el('span', 'status-chip', d.status);
1640
+ chip.setAttribute('data-tone', toneOf(d.status));
1641
+ head.appendChild(chip);
1642
+ li.appendChild(head);
1643
+ li.appendChild(el('div', 'flow-card-title', d.title));
1644
+ li.addEventListener('click', function () { openDraftDetail(d); });
1645
+ list.appendChild(li);
1646
+ });
1647
+ }
1648
+ renderDrafts(data.drafts);
1649
+
1247
1650
  /* ---------- Keyboard shortcuts ---------- */
1248
1651
  document.addEventListener('keydown', function (ev) {
1249
1652
  if (ev.key === 'Escape') {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "super-backlog",
3
- "version": "1.3.0",
3
+ "version": "1.3.1",
4
4
  "description": "One command to equip any project with Backlog.md + Superpowers, plus a Project Dashboard.",
5
5
  "license": "MIT",
6
6
  "repository": {