projectinator 0.3.0 → 0.4.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 (170) hide show
  1. package/README.md +30 -11
  2. package/bin/projectinator.mjs +56 -11
  3. package/dist/bakeoff.js +163 -0
  4. package/dist/bakeoff.js.map +1 -0
  5. package/dist/build-state.js +26 -0
  6. package/dist/build-state.js.map +1 -0
  7. package/dist/burndown.js +19 -0
  8. package/dist/burndown.js.map +1 -0
  9. package/dist/calibration.js +86 -0
  10. package/dist/calibration.js.map +1 -0
  11. package/dist/cli.js +289 -0
  12. package/dist/cli.js.map +1 -0
  13. package/dist/cost.js +35 -0
  14. package/dist/cost.js.map +1 -0
  15. package/dist/council.js +147 -0
  16. package/dist/council.js.map +1 -0
  17. package/dist/demo.js +93 -0
  18. package/dist/demo.js.map +1 -0
  19. package/dist/estimate.js +98 -0
  20. package/dist/estimate.js.map +1 -0
  21. package/dist/executor.js +118 -0
  22. package/dist/executor.js.map +1 -0
  23. package/dist/git.js +82 -0
  24. package/dist/git.js.map +1 -0
  25. package/dist/intake.js +98 -0
  26. package/dist/intake.js.map +1 -0
  27. package/dist/models.js +171 -0
  28. package/dist/models.js.map +1 -0
  29. package/dist/narrate.js +76 -0
  30. package/dist/narrate.js.map +1 -0
  31. package/dist/openrouter.js +112 -0
  32. package/dist/openrouter.js.map +1 -0
  33. package/dist/orchestrator.js +273 -0
  34. package/dist/orchestrator.js.map +1 -0
  35. package/dist/pm.js +245 -0
  36. package/dist/pm.js.map +1 -0
  37. package/dist/preview.js +194 -0
  38. package/dist/preview.js.map +1 -0
  39. package/dist/registry-store.js +38 -0
  40. package/dist/registry-store.js.map +1 -0
  41. package/dist/registry.js +115 -0
  42. package/dist/registry.js.map +1 -0
  43. package/dist/research.js +98 -0
  44. package/dist/research.js.map +1 -0
  45. package/dist/retro.js +83 -0
  46. package/dist/retro.js.map +1 -0
  47. package/dist/roles.js +357 -0
  48. package/dist/roles.js.map +1 -0
  49. package/dist/router.js +95 -0
  50. package/dist/router.js.map +1 -0
  51. package/dist/run-bakeoff.js +68 -0
  52. package/dist/run-bakeoff.js.map +1 -0
  53. package/dist/run-build.js +193 -0
  54. package/dist/run-build.js.map +1 -0
  55. package/{src/run-dev.ts → dist/run-dev.js} +23 -39
  56. package/dist/run-dev.js.map +1 -0
  57. package/{src/run-pm.ts → dist/run-pm.js} +34 -46
  58. package/dist/run-pm.js.map +1 -0
  59. package/{src/run-research.ts → dist/run-research.js} +19 -30
  60. package/dist/run-research.js.map +1 -0
  61. package/{src/run-scout.ts → dist/run-scout.js} +17 -27
  62. package/dist/run-scout.js.map +1 -0
  63. package/dist/run-web.js +93 -0
  64. package/dist/run-web.js.map +1 -0
  65. package/dist/scout.js +81 -0
  66. package/dist/scout.js.map +1 -0
  67. package/dist/session-cost.js +15 -0
  68. package/dist/session-cost.js.map +1 -0
  69. package/dist/stack.js +34 -0
  70. package/dist/stack.js.map +1 -0
  71. package/dist/stuck.js +21 -0
  72. package/dist/stuck.js.map +1 -0
  73. package/dist/tui/App.js +1269 -0
  74. package/dist/tui/App.js.map +1 -0
  75. package/dist/tui/BakeOff.js +95 -0
  76. package/dist/tui/BakeOff.js.map +1 -0
  77. package/dist/tui/BoardEditor.js +183 -0
  78. package/dist/tui/BoardEditor.js.map +1 -0
  79. package/dist/tui/EditableBoard.js +140 -0
  80. package/dist/tui/EditableBoard.js.map +1 -0
  81. package/dist/tui/Frame.js +78 -0
  82. package/dist/tui/Frame.js.map +1 -0
  83. package/dist/tui/Intake.js +59 -0
  84. package/dist/tui/Intake.js.map +1 -0
  85. package/dist/tui/Kanban.js +75 -0
  86. package/dist/tui/Kanban.js.map +1 -0
  87. package/dist/tui/Settings.js +299 -0
  88. package/dist/tui/Settings.js.map +1 -0
  89. package/dist/tui/StackPick.js +44 -0
  90. package/dist/tui/StackPick.js.map +1 -0
  91. package/dist/tui/WebAccounts.js +131 -0
  92. package/dist/tui/WebAccounts.js.map +1 -0
  93. package/dist/tui/components.js +178 -0
  94. package/dist/tui/components.js.map +1 -0
  95. package/dist/tui/config.js +129 -0
  96. package/dist/tui/config.js.map +1 -0
  97. package/dist/tui/deploy.js +114 -0
  98. package/dist/tui/deploy.js.map +1 -0
  99. package/dist/tui/engine.js +710 -0
  100. package/dist/tui/engine.js.map +1 -0
  101. package/dist/tui/notify.js +47 -0
  102. package/dist/tui/notify.js.map +1 -0
  103. package/dist/tui/panels.js +42 -0
  104. package/dist/tui/panels.js.map +1 -0
  105. package/dist/tui/templates.js +106 -0
  106. package/dist/tui/templates.js.map +1 -0
  107. package/dist/tui/theme.js +42 -0
  108. package/dist/tui/theme.js.map +1 -0
  109. package/dist/tui/validate.js +51 -0
  110. package/dist/tui/validate.js.map +1 -0
  111. package/{src/tui.tsx → dist/tui.js} +23 -33
  112. package/dist/tui.js.map +1 -0
  113. package/dist/types.js +19 -0
  114. package/dist/types.js.map +1 -0
  115. package/dist/web/oauth-anthropic.js +179 -0
  116. package/dist/web/oauth-anthropic.js.map +1 -0
  117. package/dist/web/session.js +274 -0
  118. package/dist/web/session.js.map +1 -0
  119. package/package.json +5 -4
  120. package/src/bakeoff.ts +0 -214
  121. package/src/build-state.ts +0 -47
  122. package/src/burndown.ts +0 -35
  123. package/src/calibration.ts +0 -88
  124. package/src/cost.ts +0 -43
  125. package/src/council.ts +0 -175
  126. package/src/demo.ts +0 -106
  127. package/src/estimate.ts +0 -111
  128. package/src/executor.ts +0 -169
  129. package/src/git.ts +0 -72
  130. package/src/intake.ts +0 -125
  131. package/src/models.ts +0 -175
  132. package/src/narrate.ts +0 -87
  133. package/src/openrouter.ts +0 -119
  134. package/src/orchestrator.ts +0 -310
  135. package/src/pm.ts +0 -302
  136. package/src/preview.ts +0 -206
  137. package/src/registry-store.ts +0 -41
  138. package/src/registry.ts +0 -132
  139. package/src/research.ts +0 -123
  140. package/src/retro.ts +0 -99
  141. package/src/roles.ts +0 -401
  142. package/src/router.ts +0 -120
  143. package/src/run-bakeoff.ts +0 -77
  144. package/src/run-build.ts +0 -203
  145. package/src/run-web.ts +0 -87
  146. package/src/scout.ts +0 -121
  147. package/src/session-cost.ts +0 -17
  148. package/src/stack.ts +0 -46
  149. package/src/tui/App.tsx +0 -1760
  150. package/src/tui/BakeOff.tsx +0 -190
  151. package/src/tui/BoardEditor.tsx +0 -260
  152. package/src/tui/EditableBoard.tsx +0 -179
  153. package/src/tui/Frame.tsx +0 -142
  154. package/src/tui/Intake.tsx +0 -111
  155. package/src/tui/Kanban.tsx +0 -158
  156. package/src/tui/Settings.tsx +0 -513
  157. package/src/tui/StackPick.tsx +0 -79
  158. package/src/tui/WebAccounts.tsx +0 -197
  159. package/src/tui/components.tsx +0 -340
  160. package/src/tui/config.ts +0 -150
  161. package/src/tui/deploy.ts +0 -137
  162. package/src/tui/engine.ts +0 -749
  163. package/src/tui/notify.ts +0 -21
  164. package/src/tui/panels.tsx +0 -89
  165. package/src/tui/templates.ts +0 -119
  166. package/src/tui/theme.ts +0 -44
  167. package/src/tui/validate.ts +0 -53
  168. package/src/types.ts +0 -208
  169. package/src/web/oauth-anthropic.ts +0 -206
  170. package/src/web/session.ts +0 -299
package/src/tui/Frame.tsx DELETED
@@ -1,142 +0,0 @@
1
- // The persistent app frame: content on top, a status bar pinned to the bottom
2
- // of the viewport on every screen — so the chrome stops jumping between phases.
3
- // (Phase 1 of the TUI redesign; header still lives inside each screen.)
4
-
5
- import React from "react";
6
- import { Box, Text } from "ink";
7
- import { C, useTermRows, useTermCols } from "./components.js";
8
- import { sessionCost } from "../session-cost.js";
9
- import { getPrefs } from "./config.js";
10
-
11
- function truncate(s: string, n: number): string {
12
- return s.length > n ? s.slice(0, n - 1) + "…" : s;
13
- }
14
-
15
- /** A short, human label for the current phase — shown faintly in the status bar. */
16
- const PHASE_LABEL: Record<string, string> = {
17
- setup: "Setup",
18
- home: "Home",
19
- assessing: "Reading your idea",
20
- setCap: "Budget cap",
21
- exportMenu: "Export",
22
- filterEpic: "Filter",
23
- rename: "Rename",
24
- confirmDelete: "Delete",
25
- saveTemplate: "Save template",
26
- addAsset: "Add asset",
27
- error: "Error",
28
- projects: "Projects",
29
- projectActions: "Project",
30
- settings: "Settings",
31
- bakeoff: "Bake-off",
32
- idea: "New build",
33
- change: "Change",
34
- stack: "Stack",
35
- intake: "Intake",
36
- planMode: "Plan",
37
- council: "Council",
38
- approveEpics: "Epics",
39
- planning: "Planning",
40
- plan: "Plan",
41
- board: "Plan board",
42
- editBoard: "Edit board",
43
- kanban: "Board",
44
- building: "Building",
45
- done: "Done",
46
- preview: "Preview",
47
- deployMenu: "Deploy",
48
- deploying: "Deploying",
49
- retro: "Retro",
50
- burndown: "Burndown",
51
- history: "History",
52
- };
53
-
54
- /** Slim top bar (OpenCode-style header): brand ▌ + project · phase on the left,
55
- * a faint tagline on the right, with a subtle rule underneath. Full width. */
56
- export function TopBar({
57
- projectName,
58
- phase,
59
- }: {
60
- projectName?: string;
61
- phase?: string;
62
- }): React.ReactElement {
63
- const cols = useTermCols();
64
- const label = phase ? PHASE_LABEL[phase] ?? phase : undefined;
65
- return (
66
- <Box
67
- width={cols}
68
- borderStyle="single"
69
- borderColor={C.borderSubtle}
70
- borderBottom
71
- borderTop={false}
72
- borderLeft={false}
73
- borderRight={false}
74
- paddingX={1}
75
- justifyContent="space-between"
76
- >
77
- <Box>
78
- <Text color={C.accent} bold>▌ PROJECTINATOR</Text>
79
- {projectName ? <Text color={C.textMuted}>{` ${truncate(projectName, Math.max(12, Math.floor(cols * 0.4)))}`}</Text> : null}
80
- {label ? <Text color={C.textSubtle}>{` · ${label}`}</Text> : null}
81
- </Box>
82
- <Box><Text color={C.textSubtle}>your AI build team</Text></Box>
83
- </Box>
84
- );
85
- }
86
-
87
- /** Bottom status bar: a global shortcut hint on the left, session cost + budget
88
- * cap on the right, with a subtle rule on top. Full width. */
89
- export function StatusBar(): React.ReactElement {
90
- const cols = useTermCols();
91
- const spent = sessionCost();
92
- const cap = getPrefs().budgetCapUSD;
93
- const overHalf = spent >= cap / 2;
94
- return (
95
- <Box
96
- width={cols}
97
- borderStyle="single"
98
- borderColor={C.borderSubtle}
99
- borderTop
100
- borderBottom={false}
101
- borderLeft={false}
102
- borderRight={false}
103
- paddingX={1}
104
- justifyContent="space-between"
105
- >
106
- <Box>
107
- <Text color={C.textSubtle}>q</Text>
108
- <Text color={C.textSubtle}> quit</Text>
109
- </Box>
110
- <Box>
111
- <Text color={C.textSubtle}>session </Text>
112
- <Text color={overHalf ? C.warn : C.accent} bold>{`$${spent.toFixed(2)}`}</Text>
113
- <Text color={C.textSubtle}>{` · cap $${cap}`}</Text>
114
- </Box>
115
- </Box>
116
- );
117
- }
118
-
119
- /** Wraps every screen in the persistent frame: slim top bar, the screen content
120
- * (padded, growing to fill the viewport), and the status bar pinned at the
121
- * bottom edge. Replaces the old per-screen boxed Header. */
122
- export function AppFrame({
123
- children,
124
- projectName,
125
- phase,
126
- }: {
127
- children: React.ReactNode;
128
- projectName?: string;
129
- phase?: string;
130
- }): React.ReactElement {
131
- const rows = useTermRows();
132
- return (
133
- // Fixed height + overflow hidden so overly-tall content clips at the bottom
134
- // instead of pushing the frame past the viewport (which would scroll the top
135
- // bar off and unpin the status bar).
136
- <Box flexDirection="column" height={rows} overflow="hidden">
137
- <TopBar projectName={projectName} phase={phase} />
138
- <Box flexGrow={1} flexDirection="column" paddingX={1} paddingTop={1} overflow="hidden">{children}</Box>
139
- <StatusBar />
140
- </Box>
141
- );
142
- }
@@ -1,111 +0,0 @@
1
- // Intake wizard — walks the PM's clarifying questions one at a time. Single-choice
2
- // questions use the menu; multi-choice use checkboxes. Every question offers
3
- // "Something else…" (free text) and can be skipped.
4
-
5
- import React, { useState } from "react";
6
- import { Box, Text } from "ink";
7
- import { MultiSelect } from "@inkjs/ui";
8
- import { C, Panel, Menu as SelectInput, KeyHint, TextField as TextInput } from "./components.js";
9
- import type { IntakeQuestion } from "../intake.js";
10
-
11
- const OTHER = "__other__";
12
- const SKIP = "__skip__";
13
-
14
- export interface Answer { question: string; answer: string }
15
-
16
- export function Intake({
17
- questions,
18
- onDone,
19
- onCancel,
20
- }: {
21
- questions: IntakeQuestion[];
22
- onDone: (answers: Answer[]) => void;
23
- onCancel: () => void;
24
- }): React.ReactElement {
25
- const [i, setI] = useState(0);
26
- const [answers, setAnswers] = useState<Answer[]>([]);
27
- const [typingPrefix, setTypingPrefix] = useState<string | null>(null); // non-null = capturing free text
28
- const [draft, setDraft] = useState("");
29
-
30
- const q = questions[i]!;
31
-
32
- const advance = (answer: string) => {
33
- const next = [...answers, { question: q.question, answer }];
34
- if (i + 1 < questions.length) {
35
- setAnswers(next);
36
- setI(i + 1);
37
- setTypingPrefix(null);
38
- setDraft("");
39
- } else {
40
- onDone(next);
41
- }
42
- };
43
-
44
- const progress = `Question ${i + 1} of ${questions.length}`;
45
-
46
- // free-text capture (from "Something else…")
47
- if (typingPrefix !== null) {
48
- return (
49
- <Box flexDirection="column">
50
- <Text color={C.dim}>{progress}</Text>
51
- <Text bold wrap="truncate-end">{q.question}</Text>
52
- <Box marginTop={1}>
53
- <Text color={C.accent}>{"› "}</Text>
54
- <TextInput
55
- value={draft}
56
- onChange={setDraft}
57
- onSubmit={() => advance([typingPrefix, draft].filter((s) => s.trim()).join(", "))}
58
- />
59
- </Box>
60
- <Box flexDirection="column" marginTop={1}>
61
- <Text color={C.dim}>Type your answer.</Text>
62
- <KeyHint hints={[{ keys: "Enter", label: "continue" }]} />
63
- </Box>
64
- </Box>
65
- );
66
- }
67
-
68
- // multi-choice: checkboxes + then optional free text if "Something else…" picked
69
- if (q.multi && q.options.length) {
70
- return (
71
- <Box flexDirection="column">
72
- <Text color={C.textSubtle}>{progress}</Text>
73
- <Panel title={q.question}>
74
- <Text color={C.textMuted}>Pick any that apply.</Text>
75
- <MultiSelect
76
- options={[...q.options.map((o) => ({ label: o, value: o })), { label: "Something else…", value: OTHER }]}
77
- onSubmit={(vals: string[]) => {
78
- const picked = vals.filter((v) => v !== OTHER);
79
- if (vals.includes(OTHER)) setTypingPrefix(picked.join(", "));
80
- else advance(picked.join(", "));
81
- }}
82
- />
83
- <Box marginTop={1}><KeyHint hints={[{ keys: "Space", label: "toggle" }, { keys: "Enter", label: "confirm" }]} /></Box>
84
- </Panel>
85
- </Box>
86
- );
87
- }
88
-
89
- // single-choice (or free-text-only when no options)
90
- const items = [
91
- ...q.options.map((o) => ({ label: o, value: o })),
92
- { label: "Something else…", value: OTHER },
93
- { label: "Skip", value: SKIP },
94
- ];
95
- return (
96
- <Box flexDirection="column">
97
- <Text color={C.dim}>{progress}</Text>
98
- <Panel title={q.question}>
99
- <SelectInput
100
- items={items}
101
- onSelect={(it) => {
102
- if (it.value === OTHER) setTypingPrefix("");
103
- else if (it.value === SKIP) advance("");
104
- else advance(it.value);
105
- }}
106
- />
107
- </Panel>
108
- <KeyHint hints={[{ keys: "Esc", label: "cancel & go back" }]} />
109
- </Box>
110
- );
111
- }
@@ -1,158 +0,0 @@
1
- // The Scrum board, as swimlanes. Rows = epics; columns = Backlog · Not Started ·
2
- // In Progress · Done. Used live (during a build) and as the default project view.
3
-
4
- import React from "react";
5
- import { Box, Text } from "ink";
6
- import Spinner from "ink-spinner";
7
- import { Badge } from "@inkjs/ui";
8
- import type { Capability } from "../types.js";
9
- import { C, ROLE_META } from "./components.js";
10
-
11
- export interface BoardTask {
12
- id: string;
13
- capability: Capability;
14
- title: string;
15
- epic?: string;
16
- dependsOn?: string[];
17
- status: "pending" | "running" | "done" | "failed" | "skipped";
18
- cost?: number;
19
- verdict?: "PASS" | "PASS*" | "FAIL";
20
- /** The teammate (model) working this task, when known. */
21
- assignee?: string;
22
- /** Human annotation from the board editor. */
23
- notes?: string;
24
- }
25
-
26
- type Col = "backlog" | "notStarted" | "inProgress" | "done";
27
-
28
- function columnOf(t: BoardTask, done: Set<string>): Col {
29
- if (t.status === "done" || t.status === "skipped") return "done";
30
- if (t.status === "running" || t.status === "failed") return "inProgress";
31
- const ready = (t.dependsOn ?? []).every((d) => done.has(d));
32
- return ready ? "notStarted" : "backlog";
33
- }
34
-
35
- const COLS: { key: Col; label: string; color: string }[] = [
36
- { key: "backlog", label: "BACKLOG", color: C.dim },
37
- { key: "notStarted", label: "NOT STARTED", color: "cyan" },
38
- { key: "inProgress", label: "IN PROGRESS", color: C.accent },
39
- { key: "done", label: "DONE", color: C.good },
40
- ];
41
-
42
- /** Group tasks by epic, preserving first-seen order. */
43
- export function groupByEpic<T extends { epic?: string }>(tasks: T[]): { epic: string; tasks: T[] }[] {
44
- const order: string[] = [];
45
- const map = new Map<string, T[]>();
46
- for (const t of tasks) {
47
- const e = t.epic || "General";
48
- if (!map.has(e)) {
49
- map.set(e, []);
50
- order.push(e);
51
- }
52
- map.get(e)!.push(t);
53
- }
54
- return order.map((epic) => ({ epic, tasks: map.get(epic)! }));
55
- }
56
-
57
- function Card({ t }: { t: BoardTask }): React.ReactElement {
58
- const running = t.status === "running";
59
- const failed = t.status === "failed";
60
- return (
61
- <Box flexDirection="column" marginBottom={1}>
62
- <Box>
63
- {running ? <Text color="cyan"><Spinner type="dots" /> </Text> : failed ? <Text color={C.bad}>✗ </Text> : null}
64
- <Text>{ROLE_META[t.capability].emoji} </Text>
65
- <Text color={C.dim}>{t.id} </Text>
66
- <Text color={C.accent}>{t.capability}</Text>
67
- {t.verdict ? <Text> <Badge color={t.verdict === "FAIL" ? "red" : t.verdict === "PASS*" ? "yellow" : "green"}>{t.verdict}</Badge></Text> : null}
68
- {t.cost ? <Text color={C.dim}> ${t.cost.toFixed(2)}</Text> : null}
69
- </Box>
70
- <Text color={failed ? C.bad : C.text} wrap="truncate-end">{t.title}</Text>
71
- {t.assignee ? <Text color={C.dim}>{t.assignee}</Text> : null}
72
- {t.notes ? <Text color={C.dim} wrap="truncate-end">✎ {t.notes}</Text> : null}
73
- </Box>
74
- );
75
- }
76
-
77
- /** One-line card for the compact (live-build) board. */
78
- function MiniCard({ t }: { t: BoardTask }): React.ReactElement {
79
- const running = t.status === "running";
80
- return (
81
- <Text wrap="truncate-end">
82
- {running ? <Text color="cyan"><Spinner type="dots" /> </Text> : t.status === "failed" ? <Text color={C.bad}>✗ </Text> : <Text>{ROLE_META[t.capability].emoji} </Text>}
83
- <Text color={C.dim}>{t.id} </Text>
84
- <Text color={running ? "cyan" : C.text} wrap="truncate-end">{t.title}</Text>
85
- {t.cost ? <Text color={C.dim}> ${t.cost.toFixed(2)}</Text> : null}
86
- </Text>
87
- );
88
- }
89
-
90
- /** Compact 4-column board (no epic swimlanes, one-line cards, per-column cap) —
91
- * for the live build screen where height must fit the viewport. */
92
- export function Kanban({ tasks, compact, maxPerCol }: { tasks: BoardTask[]; compact?: boolean; maxPerCol?: number }): React.ReactElement {
93
- const done = new Set(tasks.filter((t) => t.status === "done" || t.status === "skipped").map((t) => t.id));
94
- const counts: Record<Col, number> = { backlog: 0, notStarted: 0, inProgress: 0, done: 0 };
95
- for (const t of tasks) counts[columnOf(t, done)]++;
96
-
97
- if (compact) {
98
- const byCol: Record<Col, BoardTask[]> = { backlog: [], notStarted: [], inProgress: [], done: [] };
99
- for (const t of tasks) byCol[columnOf(t, done)].push(t);
100
- const cap = Math.max(2, maxPerCol ?? 6);
101
- return (
102
- <Box flexDirection="column">
103
- <Box>
104
- {COLS.map((col) => (
105
- <Box key={col.key} flexBasis="25%" flexGrow={1} marginRight={1}>
106
- <Text color={col.color} bold>{col.label} <Text color={C.dim}>{counts[col.key]}</Text></Text>
107
- </Box>
108
- ))}
109
- </Box>
110
- <Box marginTop={1}>
111
- {COLS.map((col) => {
112
- const list = byCol[col.key];
113
- return (
114
- <Box key={col.key} flexDirection="column" flexBasis="25%" flexGrow={1} marginRight={1}>
115
- {list.length === 0 ? <Text color={C.dim}>·</Text> : null}
116
- {list.slice(0, cap).map((t) => <MiniCard key={t.id} t={t} />)}
117
- {list.length > cap ? <Text color={C.dim}>+{list.length - cap} more</Text> : null}
118
- </Box>
119
- );
120
- })}
121
- </Box>
122
- </Box>
123
- );
124
- }
125
-
126
- const lanes = groupByEpic(tasks);
127
-
128
- return (
129
- <Box flexDirection="column">
130
- {/* column headers */}
131
- <Box>
132
- <Box width={12} />
133
- {COLS.map((col) => (
134
- <Box key={col.key} flexBasis="25%" flexGrow={1} marginRight={1}>
135
- <Text color={col.color} bold>{col.label} <Text color={C.dim}>{counts[col.key]}</Text></Text>
136
- </Box>
137
- ))}
138
- </Box>
139
- {lanes.map((lane) => {
140
- const byCol: Record<Col, BoardTask[]> = { backlog: [], notStarted: [], inProgress: [], done: [] };
141
- for (const t of lane.tasks) byCol[columnOf(t, done)].push(t);
142
- return (
143
- <Box key={lane.epic} flexDirection="column" marginTop={1}>
144
- <Text color={C.accent}>▊ {lane.epic}</Text>
145
- <Box>
146
- <Box width={12} />
147
- {COLS.map((col) => (
148
- <Box key={col.key} flexDirection="column" flexBasis="25%" flexGrow={1} marginRight={1}>
149
- {byCol[col.key].length === 0 ? <Text color={C.dim}>·</Text> : byCol[col.key].map((t) => <Card key={t.id} t={t} />)}
150
- </Box>
151
- ))}
152
- </Box>
153
- </Box>
154
- );
155
- })}
156
- </Box>
157
- );
158
- }