@hanzlaa/rcode 4.1.2 → 4.3.0

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.
Files changed (67) hide show
  1. package/cli/install.js +176 -13
  2. package/cli/lib/config.cjs +4 -2
  3. package/cli/lib/fsutil.cjs +13 -2
  4. package/cli/lib/homedir.cjs +21 -0
  5. package/cli/lib/schemas.cjs +6 -1
  6. package/cli/nuke.js +13 -8
  7. package/cli/postinstall.js +14 -4
  8. package/cli/rcode-slash-router.cjs +118 -0
  9. package/cli/uninstall.js +59 -1
  10. package/cli/update.js +10 -5
  11. package/dist/rcode.js +234 -230
  12. package/package.json +1 -1
  13. package/server/dashboard.js +26 -7
  14. package/server/lib/api.js +62 -4
  15. package/server/lib/html/client/agents-data.js +22 -18
  16. package/server/lib/html/client/app.js +3 -0
  17. package/server/lib/html/client/components/AgentCard.js +127 -0
  18. package/server/lib/html/client/components/App.js +104 -39
  19. package/server/lib/html/client/components/CommandPalette.js +133 -0
  20. package/server/lib/html/client/components/FileReader.js +116 -0
  21. package/server/lib/html/client/components/FilterChips.js +94 -0
  22. package/server/lib/html/client/components/NotifyCenter.js +117 -0
  23. package/server/lib/html/client/components/OrchPanel.js +80 -52
  24. package/server/lib/html/client/components/PhaseGraph.js +300 -0
  25. package/server/lib/html/client/components/RejectDialog.js +78 -0
  26. package/server/lib/html/client/components/RunnerPicker.js +190 -0
  27. package/server/lib/html/client/components/Sidebar.js +106 -61
  28. package/server/lib/html/client/components/StatusSummaryBar.js +76 -0
  29. package/server/lib/html/client/components/TaskPipeline.js +83 -0
  30. package/server/lib/html/client/components/Topbar.js +86 -39
  31. package/server/lib/html/client/components/dashboard/Blockers.js +57 -0
  32. package/server/lib/html/client/components/dashboard/CompletedTasks.js +47 -0
  33. package/server/lib/html/client/components/dashboard/CurrentPhase.js +107 -0
  34. package/server/lib/html/client/components/dashboard/InProgress.js +72 -0
  35. package/server/lib/html/client/components/dashboard/ProgressDonut.js +101 -0
  36. package/server/lib/html/client/components/dashboard/ProgressTimeline.js +101 -0
  37. package/server/lib/html/client/components/dashboard/ProjectHealth.js +80 -0
  38. package/server/lib/html/client/components/dashboard/RecentDecisions.js +57 -0
  39. package/server/lib/html/client/components/dashboard/Timeline.js +143 -0
  40. package/server/lib/html/client/components/shared.js +47 -11
  41. package/server/lib/html/client/filter-state.js +72 -0
  42. package/server/lib/html/client/icons-client.js +7 -0
  43. package/server/lib/html/client/notify.js +75 -0
  44. package/server/lib/html/client/orchestrator.js +168 -41
  45. package/server/lib/html/client/preact.js +13 -8
  46. package/server/lib/html/client/store.js +70 -6
  47. package/server/lib/html/client/util.js +78 -0
  48. package/server/lib/html/client/vendor/htm.js +1 -0
  49. package/server/lib/html/client/vendor/preact-hooks.js +2 -0
  50. package/server/lib/html/client/vendor/preact.js +2 -0
  51. package/server/lib/html/client/views/AgentsView.js +144 -51
  52. package/server/lib/html/client/views/FilesView.js +20 -103
  53. package/server/lib/html/client/views/KanbanView.js +40 -21
  54. package/server/lib/html/client/views/MemoryView.js +26 -9
  55. package/server/lib/html/client/views/MilestonesView.js +4 -4
  56. package/server/lib/html/client/views/OrchestrationView.js +154 -19
  57. package/server/lib/html/client/views/OverviewView.js +47 -239
  58. package/server/lib/html/client/views/PhasesView.js +50 -6
  59. package/server/lib/html/client/views/RoadmapView.js +6 -3
  60. package/server/lib/html/client/views/SprintsView.js +50 -6
  61. package/server/lib/html/client/views/TasksView.js +4 -3
  62. package/server/lib/html/client.js +21 -4
  63. package/server/lib/html/css.js +2761 -8
  64. package/server/lib/html/icons.js +7 -0
  65. package/server/lib/html/shell.js +10 -3
  66. package/server/lib/scanner.js +376 -39
  67. package/server/orchestrator.js +329 -5
@@ -0,0 +1,107 @@
1
+ /**
2
+ * CurrentPhase — Overview redesign, Row 1 Card 2 (Current Phase stepper).
3
+ *
4
+ * Reads `currentPhase { id, name, status, next, startedDaysAgo, currentTask,
5
+ * milestones[{name,state}] }` from the store (null when the project has no
6
+ * phases; a legacy plain string from old state.json is tolerated). Renders a
7
+ * rocket tile, the phase name with a status pill ("Up Next" when nothing is
8
+ * active and this is the upcoming phase), the in-flight sprint goal as
9
+ * subtitle, a completion line, then a horizontal milestone stepper built from
10
+ * the phase's real sprints.
11
+ *
12
+ * Honest states: null phase → "No active phase" + command hint; a phase with
13
+ * no sprints → "No sprints planned yet" instead of a fabricated stepper.
14
+ * Pure: reads props/store only — never fetches. See DATA-CONTRACT.md.
15
+ */
16
+
17
+ import { html } from '../../preact.js';
18
+ import { useStore } from '../../store.js';
19
+
20
+ // Humanise a free-form status into a short pill label.
21
+ function statusLabel(status) {
22
+ if (!status) return 'Planned';
23
+ return String(status)
24
+ .replace(/[_-]+/g, ' ')
25
+ .replace(/\b\w/g, c => c.toUpperCase());
26
+ }
27
+
28
+ // Subtitle derived from the real phase status — never claims "active" for a
29
+ // phase that isn't. "executing" is what /rcode-execute writes mid-phase.
30
+ function statusSubtitle(status) {
31
+ const s = String(status || '').toLowerCase();
32
+ if (/complete|done/.test(s)) return 'Completed phase';
33
+ if (/active|progress|executing/.test(s)) return 'Active development phase';
34
+ return 'Not started yet';
35
+ }
36
+
37
+ export function CurrentPhase() {
38
+ const S = useStore();
39
+ const cp = S.currentPhase;
40
+ // Tolerate the legacy plain-string shape from old state.json snapshots.
41
+ const phase = cp == null ? null : (typeof cp === 'object' ? cp : { name: String(cp), status: '' });
42
+
43
+ if (!phase || !phase.name) {
44
+ return html`
45
+ <section class="dash-card cp-card">
46
+ <p class="dash-card-title">Current Phase</p>
47
+ <div class="dash-empty">
48
+ <span>No active phase</span>
49
+ <code class="dash-empty-hint">/rcode-plan</code>
50
+ </div>
51
+ </section>
52
+ `;
53
+ }
54
+
55
+ const milestones = Array.isArray(phase.milestones) ? phase.milestones : [];
56
+ const done = milestones.filter(m => m.state === 'done').length;
57
+ const pct = milestones.length
58
+ ? Math.round((done / milestones.length) * 100)
59
+ : null;
60
+ const days = phase.startedDaysAgo;
61
+
62
+ return html`
63
+ <section class="dash-card cp-card">
64
+ <p class="dash-card-title">Current Phase</p>
65
+
66
+ <div class="cp-head">
67
+ <div class="cp-rocket" aria-hidden="true">${phase.next ? '🧭' : '🚀'}</div>
68
+ <div class="cp-headtext">
69
+ <div class="cp-titlerow">
70
+ <span class="cp-name">${phase.name}</span>
71
+ <span class=${'cp-pill' + (phase.next ? ' cp-pill--next' : '')}>
72
+ ● ${phase.next ? 'Up Next' : statusLabel(phase.status)}
73
+ </span>
74
+ </div>
75
+ <p class="cp-sub">${phase.next
76
+ ? 'No phase is active yet — this one is next in line'
77
+ : (phase.currentTask || statusSubtitle(phase.status))}</p>
78
+ </div>
79
+ </div>
80
+
81
+ ${pct != null ? html`
82
+ <p class="cp-progress">
83
+ ${days != null ? html`${days === 0 ? 'Started today' : `Started ${days} day${days === 1 ? '' : 's'} ago`}<span class="cp-dot">•</span>` : null}
84
+ <span class="cp-pct">${done}/${milestones.length} sprints done<span class="cp-dot">•</span>${pct}% complete</span>
85
+ </p>
86
+ ` : null}
87
+
88
+ ${milestones.length
89
+ ? html`
90
+ <ol class="cp-stepper">
91
+ ${milestones.map((m, i) => html`
92
+ <li class=${'cp-step cp-' + (m.state || 'todo')} key=${i}>
93
+ <span class="cp-node">${m.state === 'done' ? '✓' : ''}</span>
94
+ <span class="cp-label">${m.name}</span>
95
+ </li>
96
+ `)}
97
+ </ol>
98
+ `
99
+ : html`
100
+ <div class="dash-empty">
101
+ <span>No sprints planned yet</span>
102
+ <code class="dash-empty-hint">/rcode-sprint-planning</code>
103
+ </div>
104
+ `}
105
+ </section>
106
+ `;
107
+ }
@@ -0,0 +1,72 @@
1
+ /**
2
+ * InProgress — Overview redesign, Row 2 Card 2.
3
+ *
4
+ * "In Progress" card with a "View all" link top-right and a list of rows.
5
+ *
6
+ * Two sources, live first:
7
+ * 1. Live orchestrator sessions (store.activeSessions, status==='running') —
8
+ * pulsing dot, title from storyId (or the command for cmd-* runs),
9
+ * elapsed time since startTime; clicking opens the session's orchestrator
10
+ * panel (existing openOrchPanel mechanism).
11
+ * 2. Scanned tasks from `tasks.inProgress[{ title, pct }]` (pct null → the
12
+ * percent pill is omitted).
13
+ *
14
+ * Pure — never fetches; the 4s session poll keeps the store fresh. An absent
15
+ * or empty slice renders an honest "Nothing in progress" state — never sample
16
+ * data. See .planning/campaign/DATA-CONTRACT.md.
17
+ */
18
+
19
+ import { html } from '../../preact.js';
20
+ import { useStore } from '../../store.js';
21
+ import { orchElapsed, rowLink } from '../../util.js';
22
+ import { openOrchPanel } from '../../orchestrator.js';
23
+ import { pressable } from '../shared.js';
24
+ import { TaskPipeline } from '../TaskPipeline.js';
25
+
26
+ /** Display title for a live session row — command runs show the command. */
27
+ function sessionTitle(s) {
28
+ if (String(s.storyId || '').startsWith('cmd-')) return s.cmd || s.storyId;
29
+ return s.storyId;
30
+ }
31
+
32
+ function LiveRow({ session: s }) {
33
+ return html`
34
+ <li class="ip-row ip-live-row" title=${'Open session ' + s.storyId}
35
+ ...${pressable(() => openOrchPanel(s.storyId))}>
36
+ <span class="live-dot"></span>
37
+ <span class="ip-title ip-live-title">${sessionTitle(s)}</span>
38
+ <span class="ip-live-elapsed">${orchElapsed(s.startTime)}</span>
39
+ </li>
40
+ `;
41
+ }
42
+
43
+ export function InProgress() {
44
+ const S = useStore();
45
+ const items = (S.tasks && Array.isArray(S.tasks.inProgress)) ? S.tasks.inProgress : [];
46
+ const live = (S.activeSessions || []).filter(s => s.status === 'running' || s.status === 'blocked');
47
+
48
+ return html`
49
+ <section class="dash-card ip-card">
50
+ <div class="ip-head">
51
+ <p class="dash-card-title">In Progress</p>
52
+ <button class="ip-viewall" onClick=${() => { location.hash = 'tasks'; }}>
53
+ View all
54
+ </button>
55
+ </div>
56
+ ${live.length === 0 && items.length === 0
57
+ ? html`<p class="dash-card-sub">Nothing in progress</p>`
58
+ : html`
59
+ <ul class="ip-list">
60
+ ${live.map(s => html`<${LiveRow} key=${'live-' + s.storyId} session=${s}/>`)}
61
+ ${items.map((t, i) => html`
62
+ <li class="ip-row ovr-link" key=${t.title + i} ...${rowLink('tasks')}>
63
+ <span class="ip-title">${t.title}</span>
64
+ <${TaskPipeline} task=${t} mini=${true}/>
65
+ ${Number.isFinite(t.pct) ? html`<span class="ip-badge">${t.pct}%</span>` : null}
66
+ </li>
67
+ `)}
68
+ </ul>
69
+ `}
70
+ </section>
71
+ `;
72
+ }
@@ -0,0 +1,101 @@
1
+ /**
2
+ * ProgressDonut — Overview redesign, Row 1 Card 1 (Project Progress donut).
3
+ *
4
+ * Reads the `progress { completed, inProgress, notStarted, total, pct }` slice
5
+ * from the store and renders a teal-gradient donut ring with the percentage
6
+ * centered, a colored-dot legend, and a thin segmented progress bar.
7
+ * See .planning/campaign/DATA-CONTRACT.md. Reads store only — no fetch.
8
+ */
9
+
10
+ import { html } from '../../preact.js';
11
+ import { useStore } from '../../store.js';
12
+
13
+ function pctOf(n, total) {
14
+ return total > 0 ? Math.round((n / total) * 100) : 0;
15
+ }
16
+
17
+ export function ProgressDonut() {
18
+ const S = useStore();
19
+ const p = S.progress;
20
+ // Live orchestrator sessions (derived map, refreshed by the 4s poll).
21
+ const liveCount = Object.keys(S.runningByStory || {}).length;
22
+
23
+ // No tracked work yet (or no project) — honest empty state, no sample donut.
24
+ if (!p || !p.total) {
25
+ return html`
26
+ <section class="dash-card donut-card">
27
+ <p class="dash-card-title">Project Progress</p>
28
+ <div class="dash-empty">
29
+ <span>No tasks tracked yet</span>
30
+ <code class="dash-empty-hint">/rcode-plan</code>
31
+ </div>
32
+ </section>
33
+ `;
34
+ }
35
+ const completed = p.completed ?? 0;
36
+ const inProgress = p.inProgress ?? 0;
37
+ const notStarted = p.notStarted ?? 0;
38
+ const total = p.total ?? (completed + inProgress + notStarted);
39
+ const pct = p.pct ?? pctOf(completed, total);
40
+
41
+ // Donut geometry — track + teal-gradient arc for the completed percentage.
42
+ const r = 52;
43
+ const c = 2 * Math.PI * r;
44
+ const offset = c - (pct / 100) * c;
45
+
46
+ const legend = [
47
+ { label: 'Completed', cls: 'donut-dot--done', pct: pctOf(completed, total) },
48
+ { label: 'In Progress', cls: 'donut-dot--prog', pct: pctOf(inProgress, total) },
49
+ { label: 'Not Started', cls: 'donut-dot--idle', pct: pctOf(notStarted, total) },
50
+ ];
51
+
52
+ // Segmented bar widths — teal (completed) then purple (in progress).
53
+ const tealW = pctOf(completed, total);
54
+ const purpleW = pctOf(inProgress, total);
55
+
56
+ return html`
57
+ <section class="dash-card donut-card">
58
+ <p class="dash-card-title">Project Progress</p>
59
+ <div class="donut-body">
60
+ <div class="donut-ring">
61
+ <svg width="132" height="132" viewBox="0 0 132 132">
62
+ <defs>
63
+ <linearGradient id="donutTeal" x1="0" y1="0" x2="1" y2="1">
64
+ <stop offset="0%" stop-color="var(--dash-teal)"/>
65
+ <stop offset="100%" stop-color="var(--dash-purple)"/>
66
+ </linearGradient>
67
+ </defs>
68
+ <circle cx="66" cy="66" r=${r} fill="none"
69
+ stroke="var(--dash-border)" stroke-width="10"/>
70
+ <circle cx="66" cy="66" r=${r} fill="none"
71
+ stroke="url(#donutTeal)" stroke-width="10" stroke-linecap="round"
72
+ stroke-dasharray=${c} stroke-dashoffset=${offset}
73
+ transform="rotate(-90 66 66)"/>
74
+ </svg>
75
+ <div class="donut-center">
76
+ <span class="donut-pct">${pct}%</span>
77
+ <span class="donut-pct-label">complete</span>
78
+ </div>
79
+ </div>
80
+ <ul class="donut-legend">
81
+ ${legend.map(item => html`
82
+ <li class="donut-legend-row" key=${item.label}>
83
+ <span class=${'donut-dot ' + item.cls}></span>
84
+ <span class="donut-legend-label">${item.label}</span>
85
+ <span class="donut-legend-pct">${item.pct}%</span>
86
+ </li>
87
+ `)}
88
+ </ul>
89
+ </div>
90
+ <p class="donut-summary">
91
+ <strong>${completed}/${total}</strong> tasks completed
92
+ ${liveCount > 0 ? html` <span class="donut-live">· ${liveCount} running now</span>` : null}
93
+ </p>
94
+ <svg class="donut-bar" width="100%" height="6" viewBox="0 0 100 6" preserveAspectRatio="none">
95
+ <rect x="0" y="0" width="100" height="6" rx="3" fill="var(--dash-border)"/>
96
+ <rect x="0" y="0" width=${tealW} height="6" fill="var(--dash-teal)"/>
97
+ <rect x=${tealW} y="0" width=${purpleW} height="6" fill="var(--dash-purple)"/>
98
+ </svg>
99
+ </section>
100
+ `;
101
+ }
@@ -0,0 +1,101 @@
1
+ /**
2
+ * ProgressTimeline — Overview redesign, Row 3 Card 2 (horizontal phases).
3
+ *
4
+ * "Progress Timeline" card + "View full timeline" link. A horizontal track with
5
+ * date ticks across the top and one segment per phase (Planning, Design,
6
+ * Development, Testing, Launch). Each segment shows its date range and a state
7
+ * badge: Completed → green, In Progress → purple, Upcoming → gray.
8
+ * Reads `phases[{ name, range, state }]` from the store. An empty array is
9
+ * real data — rendered as an honest "No phases yet" state with a command hint,
10
+ * never substituted with sample phases.
11
+ * See .planning/campaign/DATA-CONTRACT.md. Reads props/store only — no fetch.
12
+ */
13
+
14
+ import { html } from '../../preact.js';
15
+ import { useStore } from '../../store.js';
16
+ import { rowLink } from '../../util.js';
17
+
18
+ // Map phase state → label + badge/segment modifier.
19
+ function stateMeta(state) {
20
+ const s = String(state || '').toLowerCase();
21
+ if (s === 'done') return { label: 'Completed', mod: 'pt-seg--done' };
22
+ if (s === 'active') return { label: 'In Progress', mod: 'pt-seg--active' };
23
+ return { label: 'Upcoming', mod: 'pt-seg--todo' };
24
+ }
25
+
26
+ // Max segments shown at once — the mockup track is designed for ~5; real
27
+ // projects can have 20+ phases, which would cram into unreadable slivers.
28
+ const MAX_SEGMENTS = 6;
29
+
30
+ /** Window of at most MAX_SEGMENTS phases centered on the active one (or the
31
+ * done/todo boundary when nothing is active), so the card shows where the
32
+ * project currently is. "View full timeline" covers the rest. */
33
+ function visibleWindow(phases) {
34
+ if (phases.length <= MAX_SEGMENTS) return phases;
35
+ let center = phases.findIndex(p => String(p.state).toLowerCase() === 'active');
36
+ if (center === -1) {
37
+ const firstTodo = phases.findIndex(p => String(p.state).toLowerCase() !== 'done');
38
+ center = firstTodo === -1 ? phases.length - 1 : firstTodo;
39
+ }
40
+ let start = Math.max(0, center - Math.floor(MAX_SEGMENTS / 2));
41
+ start = Math.min(start, phases.length - MAX_SEGMENTS);
42
+ return phases.slice(start, start + MAX_SEGMENTS);
43
+ }
44
+
45
+ export function ProgressTimeline() {
46
+ const S = useStore();
47
+ const all = Array.isArray(S.phases) ? S.phases : [];
48
+
49
+ if (!all.length) {
50
+ return html`
51
+ <section class="dash-card">
52
+ <div class="pt-head">
53
+ <p class="dash-card-title">Progress Timeline</p>
54
+ </div>
55
+ <div class="dash-empty">
56
+ <span>No phases yet</span>
57
+ <code class="dash-empty-hint">/rcode-plan</code>
58
+ </div>
59
+ </section>
60
+ `;
61
+ }
62
+
63
+ const phases = visibleWindow(all);
64
+
65
+ // Date ticks across the top — the start of each visible phase range, plus
66
+ // the end of the last range, so the axis brackets the visible window.
67
+ const ticks = phases.map(p => String(p.range || '').split('–')[0].trim());
68
+ const lastEnd = String(phases[phases.length - 1]?.range || '').split('–')[1];
69
+ if (lastEnd) ticks.push(lastEnd.trim());
70
+
71
+ return html`
72
+ <section class="dash-card">
73
+ <div class="pt-head">
74
+ <p class="dash-card-title">Progress Timeline</p>
75
+ <button class="pt-viewall" onClick=${() => { location.hash = 'phases'; }}>
76
+ View full timeline
77
+ </button>
78
+ </div>
79
+
80
+ <div class="pt-ticks">
81
+ ${ticks.map((t, i) => html`<span class="pt-tick" key=${'tick' + i}>${t}</span>`)}
82
+ </div>
83
+
84
+ <div class="pt-track">
85
+ ${phases.map((p, i) => {
86
+ const m = stateMeta(p.state);
87
+ // Segment deep-links to its phase detail; fall back to the list
88
+ // when the phase has no id.
89
+ const target = p.id != null ? 'phases/' + p.id : 'phases';
90
+ return html`
91
+ <div class=${'pt-seg ovr-link ' + m.mod} key=${p.name + i} ...${rowLink(target)}>
92
+ <span class="pt-seg-name">${p.name}</span>
93
+ <span class="pt-seg-range">${p.range || ''}</span>
94
+ <span class="pt-seg-badge">${m.label}</span>
95
+ </div>
96
+ `;
97
+ })}
98
+ </div>
99
+ </section>
100
+ `;
101
+ }
@@ -0,0 +1,80 @@
1
+ /**
2
+ * ProjectHealth — progress mini-card used by the Sidebar.
3
+ *
4
+ * Reads `health { pct, label, points[] }` from the store. The scanner now
5
+ * emits real values only: pct is the story-completion percentage (null when
6
+ * nothing is tracked yet — shown as "—"), label is the real blocker count or
7
+ * "Not started", and points exist only when the project has recorded
8
+ * velocity_history. No sample fallback, no invented composite score.
9
+ * See .planning/campaign/DATA-CONTRACT.md. Reads store only — no fetch.
10
+ *
11
+ * No inline style= attributes — all styling via .phealth-* classes in css.js.
12
+ */
13
+
14
+ import { html } from '../../preact.js';
15
+ import { useStore } from '../../store.js';
16
+
17
+ /**
18
+ * Tone class — driven by the real blocker count, not an invented score.
19
+ * Neutral when nothing is tracked yet.
20
+ */
21
+ function healthTone(pct, blockerCount) {
22
+ if (pct == null) return 'phealth--none';
23
+ if (blockerCount >= 2) return 'phealth--risk';
24
+ if (blockerCount === 1) return 'phealth--warn';
25
+ return 'phealth--good';
26
+ }
27
+
28
+ /**
29
+ * Build an SVG polyline `points` string for the sparkline from a value series.
30
+ * Maps values into a 0..W × 0..H box, flipping Y so higher values sit higher.
31
+ */
32
+ function sparkPoints(points, w, h) {
33
+ if (!points.length) return '';
34
+ const values = points.map(p => Number(p.value) || 0);
35
+ const min = Math.min(...values);
36
+ const max = Math.max(...values);
37
+ const span = max - min || 1;
38
+ const step = points.length > 1 ? w / (points.length - 1) : 0;
39
+ return values
40
+ .map((v, i) => {
41
+ const x = i * step;
42
+ const y = h - ((v - min) / span) * h;
43
+ return `${x.toFixed(1)},${y.toFixed(1)}`;
44
+ })
45
+ .join(' ');
46
+ }
47
+
48
+ export function ProjectHealth() {
49
+ const S = useStore();
50
+ const health = (S && S.health) || {};
51
+ const blockerCount = Array.isArray(S.blockers) ? S.blockers.length : 0;
52
+ const hasPct = typeof health.pct === 'number';
53
+ const pct = hasPct ? Math.max(0, Math.min(100, Math.round(health.pct))) : null;
54
+ const label = health.label || (hasPct ? '' : 'Not tracked');
55
+ const points = Array.isArray(health.points) ? health.points : [];
56
+ const tone = healthTone(pct, blockerCount);
57
+
58
+ const W = 180;
59
+ const H = 36;
60
+ // Sparkline only from real recorded velocity — 2+ points or nothing.
61
+ const line = points.length >= 2 ? sparkPoints(points, W, H) : '';
62
+ // Close the polygon down to the baseline for a soft area fill.
63
+ const area = line ? `0,${H} ${line} ${W},${H}` : '';
64
+
65
+ return html`
66
+ <section class=${'phealth ' + tone}>
67
+ <p class="phealth-title">Progress</p>
68
+ <div class="phealth-head">
69
+ <span class="phealth-pct">${pct != null ? pct : '—'}${pct != null ? html`<span class="phealth-pct-sign">%</span>` : null}</span>
70
+ <span class="phealth-label">${label}</span>
71
+ </div>
72
+ ${line ? html`
73
+ <svg class="phealth-spark" viewBox=${'0 0 ' + W + ' ' + H} preserveAspectRatio="none" aria-hidden="true">
74
+ <polygon class="phealth-spark-area" points=${area} />
75
+ <polyline class="phealth-spark-line" points=${line} />
76
+ </svg>
77
+ ` : null}
78
+ </section>
79
+ `;
80
+ }
@@ -0,0 +1,57 @@
1
+ /**
2
+ * RecentDecisions — Overview redesign, Row 3 Card 1 (Recent Decisions list).
3
+ *
4
+ * "Recent Decisions" card + "View all" link. Each row is a decision title with
5
+ * a status badge (only when the decision actually records a status — no
6
+ * default "Approved") and the date on the right. An empty array renders an
7
+ * honest "No decisions recorded yet" state, never sample data.
8
+ * See .planning/campaign/DATA-CONTRACT.md. Reads props/store only — no fetch.
9
+ */
10
+
11
+ import { html } from '../../preact.js';
12
+ import { useStore } from '../../store.js';
13
+ import { humanDate, rowLink } from '../../util.js';
14
+
15
+ // Map a free-form status string to a badge modifier class.
16
+ function statusClass(status) {
17
+ const s = String(status || '').toLowerCase();
18
+ if (s === 'approved') return 'rd-badge--approved';
19
+ if (s === 'rejected') return 'rd-badge--rejected';
20
+ return 'rd-badge--proposed';
21
+ }
22
+
23
+ export function RecentDecisions() {
24
+ const S = useStore();
25
+ const decisions = Array.isArray(S.decisions) ? S.decisions : [];
26
+
27
+ return html`
28
+ <section class="dash-card">
29
+ <div class="rd-head">
30
+ <p class="dash-card-title">Recent Decisions</p>
31
+ <button class="rd-viewall" onClick=${() => { location.hash = 'decisions'; }}>
32
+ View all
33
+ </button>
34
+ </div>
35
+ ${decisions.length === 0
36
+ ? html`
37
+ <div class="dash-empty">
38
+ <span>No decisions recorded yet</span>
39
+ <code class="dash-empty-hint">/rcode-council</code>
40
+ </div>
41
+ `
42
+ : html`
43
+ <ul class="rd-list">
44
+ ${decisions.map((d, i) => html`
45
+ <li class="rd-row ovr-link" key=${d.title + i} ...${rowLink('decisions')}>
46
+ <span class="rd-title">${d.title}</span>
47
+ ${d.status
48
+ ? html`<span class=${'rd-badge ' + statusClass(d.status)}>${d.status}</span>`
49
+ : html`<span class="rd-status-none">—</span>`}
50
+ <span class="rd-date">${humanDate(d.date) || ''}</span>
51
+ </li>
52
+ `)}
53
+ </ul>
54
+ `}
55
+ </section>
56
+ `;
57
+ }
@@ -0,0 +1,143 @@
1
+ /**
2
+ * Timeline — Overview redesign, Row 1 Card 3.
3
+ *
4
+ * Target-launch card. With a configured launch date: countdown + a velocity
5
+ * sparkline drawn only from real recorded `velocity_history` + the real
6
+ * open-blocker count. Without one, the dead space becomes a "Milestone
7
+ * outlook": current milestone name, phases done/total, latest recorded
8
+ * velocity, and a hint that launch_date in .rcode/config.yaml enables the
9
+ * countdown — never a projected/invented date.
10
+ *
11
+ * Reads the `timeline { launchDate, onTrack, points[] }` slice plus
12
+ * `blockers`, `milestone`, and `phases` from the store. See DATA-CONTRACT.md.
13
+ * No fetch.
14
+ */
15
+
16
+ import { html } from '../../preact.js';
17
+ import { useStore } from '../../store.js';
18
+
19
+ // SVG viewBox geometry for the sparkline.
20
+ const VW = 280;
21
+ const VH = 88;
22
+ const PAD_X = 8;
23
+ const PAD_Y = 12;
24
+
25
+ /** Days from today until the launch date (>= 0; null when unparseable). */
26
+ function daysUntil(launchDate) {
27
+ const target = new Date(launchDate);
28
+ if (isNaN(target.getTime())) return null;
29
+ const now = new Date();
30
+ const ms = target.setHours(0, 0, 0, 0) - now.setHours(0, 0, 0, 0);
31
+ return Math.max(0, Math.round(ms / 86400000));
32
+ }
33
+
34
+ /** Human display of an ISO date, falling back to the raw string. */
35
+ function displayDate(launchDate) {
36
+ const d = new Date(launchDate);
37
+ if (isNaN(d.getTime())) return launchDate;
38
+ return d.toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' });
39
+ }
40
+
41
+ /** Map a value series to evenly-spaced SVG coordinates (y inverted). */
42
+ function toCoords(points) {
43
+ const values = points.map(p => Number(p.value) || 0);
44
+ const max = Math.max(...values, 1);
45
+ const min = Math.min(...values, 0);
46
+ const span = max - min || 1;
47
+ const innerW = VW - PAD_X * 2;
48
+ const innerH = VH - PAD_Y * 2;
49
+ const step = points.length > 1 ? innerW / (points.length - 1) : 0;
50
+ return values.map((v, i) => ({
51
+ x: PAD_X + step * i,
52
+ y: PAD_Y + innerH - ((v - min) / span) * innerH,
53
+ }));
54
+ }
55
+
56
+ export function Timeline() {
57
+ const S = useStore();
58
+ const timeline = (S && S.timeline) || {};
59
+ const points = Array.isArray(timeline.points) ? timeline.points : [];
60
+ const blockers = Array.isArray(S.blockers) ? S.blockers : [];
61
+
62
+ const hasDate = !!timeline.launchDate;
63
+ const days = hasDate ? daysUntil(timeline.launchDate) : null;
64
+ const daysLine = days == null ? 'Launch scheduled' : `In ${days} day${days === 1 ? '' : 's'}`;
65
+
66
+ let chart = null;
67
+ if (points.length >= 2) {
68
+ const coords = toCoords(points);
69
+ const linePath = coords.map((c, i) => `${i === 0 ? 'M' : 'L'} ${c.x.toFixed(1)} ${c.y.toFixed(1)}`).join(' ');
70
+ const areaPath = `${linePath} L ${coords[coords.length - 1].x.toFixed(1)} ${VH - PAD_Y} L ${coords[0].x.toFixed(1)} ${VH - PAD_Y} Z`;
71
+ chart = html`
72
+ <svg class="tl-chart" viewBox=${`0 0 ${VW} ${VH}`} preserveAspectRatio="none" role="img"
73
+ aria-label="Recorded sprint velocity trend">
74
+ <defs>
75
+ <linearGradient id="tlFill" x1="0" y1="0" x2="0" y2="1">
76
+ <stop offset="0%" stop-color="var(--dash-teal)" stop-opacity="0.22"/>
77
+ <stop offset="100%" stop-color="var(--dash-teal)" stop-opacity="0"/>
78
+ </linearGradient>
79
+ </defs>
80
+ <path class="tl-area" d=${areaPath} fill="url(#tlFill)"/>
81
+ <path class="tl-line" d=${linePath} fill="none" stroke="var(--dash-teal)"
82
+ stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
83
+ ${coords.map(c => html`
84
+ <circle class="tl-dot" cx=${c.x.toFixed(1)} cy=${c.y.toFixed(1)} r="3"
85
+ fill="var(--dash-bg)" stroke="var(--dash-teal)" stroke-width="2"/>
86
+ `)}
87
+ </svg>
88
+ `;
89
+ } else {
90
+ chart = html`<div class="tl-nochart">${points.length === 1 ? 'Not enough velocity history to chart yet' : 'No velocity history recorded'}</div>`;
91
+ }
92
+
93
+ const blockerNote = blockers.length
94
+ ? `${blockers.length} open blocker${blockers.length === 1 ? '' : 's'}`
95
+ : 'No open blockers';
96
+
97
+ const footer = html`
98
+ <div class="tl-footer">
99
+ <span class=${'tl-status' + (blockers.length ? ' tl-status-risk' : '')}>
100
+ <span class="tl-dot-badge"></span>${blockerNote}
101
+ </span>
102
+ </div>
103
+ `;
104
+
105
+ if (!hasDate) {
106
+ // No launch date configured — show a milestone outlook built from real
107
+ // store data instead of a dead "—" date.
108
+ const phases = Array.isArray(S.phases) ? S.phases : [];
109
+ const phasesDone = phases.filter(p => String(p.state).toLowerCase() === 'done').length;
110
+ const latest = points.length ? points[points.length - 1] : null;
111
+ return html`
112
+ <section class="dash-card tl-card">
113
+ <p class="dash-card-sub tl-label">Milestone Outlook</p>
114
+ <p class="tl-outlook-name">${S.milestone || 'No milestone set'}</p>
115
+ <ul class="tl-outlook">
116
+ <li class="tl-outlook-row">
117
+ <span class="tl-outlook-key">Phases</span>
118
+ <span class="tl-outlook-val">${phases.length ? `${phasesDone}/${phases.length} done` : 'None planned'}</span>
119
+ </li>
120
+ <li class="tl-outlook-row">
121
+ <span class="tl-outlook-key">Latest velocity</span>
122
+ <span class="tl-outlook-val">${latest ? `${latest.value} pts (${latest.label})` : 'Not recorded'}</span>
123
+ </li>
124
+ </ul>
125
+ ${chart}
126
+ <p class="tl-outlook-hint">Set <code>launch_date</code> in <code>.rcode/config.yaml</code> to track a launch countdown</p>
127
+ ${footer}
128
+ </section>
129
+ `;
130
+ }
131
+
132
+ return html`
133
+ <section class="dash-card tl-card">
134
+ <p class="dash-card-sub tl-label">Target Launch</p>
135
+ <p class="tl-date">${displayDate(timeline.launchDate)}</p>
136
+ <p class="tl-days">${daysLine}</p>
137
+
138
+ ${chart}
139
+
140
+ ${footer}
141
+ </section>
142
+ `;
143
+ }