projectinator 0.3.1 → 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 (171) hide show
  1. package/README.md +7 -6
  2. package/bin/projectinator.mjs +22 -15
  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/cli.ts +0 -263
  125. package/src/cost.ts +0 -43
  126. package/src/council.ts +0 -175
  127. package/src/demo.ts +0 -106
  128. package/src/estimate.ts +0 -111
  129. package/src/executor.ts +0 -169
  130. package/src/git.ts +0 -72
  131. package/src/intake.ts +0 -125
  132. package/src/models.ts +0 -175
  133. package/src/narrate.ts +0 -87
  134. package/src/openrouter.ts +0 -119
  135. package/src/orchestrator.ts +0 -310
  136. package/src/pm.ts +0 -302
  137. package/src/preview.ts +0 -206
  138. package/src/registry-store.ts +0 -41
  139. package/src/registry.ts +0 -132
  140. package/src/research.ts +0 -123
  141. package/src/retro.ts +0 -99
  142. package/src/roles.ts +0 -401
  143. package/src/router.ts +0 -120
  144. package/src/run-bakeoff.ts +0 -77
  145. package/src/run-build.ts +0 -203
  146. package/src/run-web.ts +0 -87
  147. package/src/scout.ts +0 -121
  148. package/src/session-cost.ts +0 -17
  149. package/src/stack.ts +0 -46
  150. package/src/tui/App.tsx +0 -1760
  151. package/src/tui/BakeOff.tsx +0 -190
  152. package/src/tui/BoardEditor.tsx +0 -260
  153. package/src/tui/EditableBoard.tsx +0 -179
  154. package/src/tui/Frame.tsx +0 -142
  155. package/src/tui/Intake.tsx +0 -111
  156. package/src/tui/Kanban.tsx +0 -158
  157. package/src/tui/Settings.tsx +0 -513
  158. package/src/tui/StackPick.tsx +0 -79
  159. package/src/tui/WebAccounts.tsx +0 -197
  160. package/src/tui/components.tsx +0 -340
  161. package/src/tui/config.ts +0 -150
  162. package/src/tui/deploy.ts +0 -137
  163. package/src/tui/engine.ts +0 -749
  164. package/src/tui/notify.ts +0 -21
  165. package/src/tui/panels.tsx +0 -89
  166. package/src/tui/templates.ts +0 -119
  167. package/src/tui/theme.ts +0 -44
  168. package/src/tui/validate.ts +0 -53
  169. package/src/types.ts +0 -208
  170. package/src/web/oauth-anthropic.ts +0 -206
  171. 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
- }