@kitsy/coop-ui 0.0.1 → 1.0.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.
@@ -0,0 +1,133 @@
1
+ import fs from "node:fs";
2
+ import os from "node:os";
3
+ import path from "node:path";
4
+
5
+ import { afterEach, describe, expect, it } from "vitest";
6
+
7
+ import { readCoopSnapshot } from "../snapshot";
8
+
9
+ const tempRoots: string[] = [];
10
+
11
+ function makeRepo(): string {
12
+ const root = fs.mkdtempSync(path.join(os.tmpdir(), "coop-ui-"));
13
+ tempRoots.push(root);
14
+ fs.mkdirSync(path.join(root, ".coop", "tasks"), { recursive: true });
15
+ fs.mkdirSync(path.join(root, ".coop", "deliveries"), { recursive: true });
16
+ fs.mkdirSync(path.join(root, ".coop", "resources"), { recursive: true });
17
+ fs.writeFileSync(path.join(root, ".coop", "config.yml"), "project:\n name: Demo\n", "utf8");
18
+ return root;
19
+ }
20
+
21
+ afterEach(() => {
22
+ while (tempRoots.length > 0) {
23
+ const next = tempRoots.pop();
24
+ if (!next) continue;
25
+ fs.rmSync(next, { recursive: true, force: true });
26
+ }
27
+ });
28
+
29
+ describe("readCoopSnapshot", () => {
30
+ it("builds a dashboard snapshot from .coop index and source files", () => {
31
+ const root = makeRepo();
32
+ fs.writeFileSync(
33
+ path.join(root, ".coop", "tasks", "PM-1.md"),
34
+ [
35
+ "---",
36
+ "id: PM-1",
37
+ "title: API foundation",
38
+ "status: done",
39
+ "type: feature",
40
+ "priority: p1",
41
+ "track: backend",
42
+ "created: 2026-03-01",
43
+ "updated: 2026-03-02",
44
+ "depends_on: []",
45
+ "---",
46
+ "Body"
47
+ ].join("\n"),
48
+ "utf8"
49
+ );
50
+ fs.writeFileSync(
51
+ path.join(root, ".coop", "tasks", "PM-2.md"),
52
+ [
53
+ "---",
54
+ "id: PM-2",
55
+ "title: UI dashboard",
56
+ "status: in_progress",
57
+ "type: feature",
58
+ "priority: p0",
59
+ "track: frontend",
60
+ "created: 2026-03-03",
61
+ "updated: 2026-03-04",
62
+ "depends_on:",
63
+ " - PM-1",
64
+ "estimate:",
65
+ " optimistic_hours: 8",
66
+ " expected_hours: 12",
67
+ " pessimistic_hours: 16",
68
+ "---",
69
+ "Body"
70
+ ].join("\n"),
71
+ "utf8"
72
+ );
73
+ fs.writeFileSync(
74
+ path.join(root, ".coop", "resources", "team.yml"),
75
+ [
76
+ "id: team",
77
+ "type: human",
78
+ "members:",
79
+ " - id: dev1",
80
+ " hours_per_week: 40",
81
+ " - id: dev2",
82
+ " hours_per_week: 40",
83
+ "overhead:",
84
+ " meetings_percent: 10",
85
+ " context_switch_percent: 10"
86
+ ].join("\n"),
87
+ "utf8"
88
+ );
89
+ fs.writeFileSync(
90
+ path.join(root, ".coop", "deliveries", "mvp.yml"),
91
+ [
92
+ "id: MVP",
93
+ "name: MVP",
94
+ "status: committed",
95
+ "target_date: 2026-04-15",
96
+ "capacity_profiles:",
97
+ " - team",
98
+ "scope:",
99
+ " include:",
100
+ " - PM-1",
101
+ " - PM-2",
102
+ " exclude: []"
103
+ ].join("\n"),
104
+ "utf8"
105
+ );
106
+ fs.mkdirSync(path.join(root, ".coop", "runs"), { recursive: true });
107
+ fs.writeFileSync(
108
+ path.join(root, ".coop", "runs", "run-1.yml"),
109
+ [
110
+ "id: run-1",
111
+ "task: PM-1",
112
+ "executor: mock",
113
+ "status: completed",
114
+ "started: 2026-03-01T10:00:00.000Z",
115
+ "completed: 2026-03-01T11:00:00.000Z",
116
+ "steps: []",
117
+ "resources_consumed:",
118
+ " ai_tokens: 0",
119
+ " compute_minutes: 0",
120
+ " file_changes: 0"
121
+ ].join("\n"),
122
+ "utf8"
123
+ );
124
+
125
+ const snapshot = readCoopSnapshot(root, "2026-03-18");
126
+
127
+ expect(snapshot.tasks).toHaveLength(2);
128
+ expect(snapshot.deliveries).toHaveLength(1);
129
+ expect(snapshot.graph.edges).toEqual([{ from: "PM-1", to: "PM-2" }]);
130
+ expect(snapshot.statusCounts.some((entry) => entry.status === "done" && entry.count === 1)).toBe(true);
131
+ expect(snapshot.deliveries[0]?.criticalPath).toContain("PM-2");
132
+ });
133
+ });
@@ -0,0 +1,214 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+
4
+ import {
5
+ IndexManager,
6
+ analyze_feasibility,
7
+ compute_velocity,
8
+ detect_delivery_risks,
9
+ load_completed_runs,
10
+ type Delivery,
11
+ type Task,
12
+ type TaskGraph
13
+ } from "@kitsy/coop-core";
14
+
15
+ import type { UiCapacityProfile, UiDelivery, UiSnapshot, UiTask } from "../types";
16
+
17
+ type TasksIndexRow = {
18
+ readiness?: string;
19
+ depth?: number;
20
+ };
21
+
22
+ type CapacityIndexProfile = {
23
+ id?: string;
24
+ type?: string;
25
+ members?: number;
26
+ total_weekly_hours?: number;
27
+ effective_weekly_hours?: number;
28
+ };
29
+
30
+ function readJsonFile<T>(filePath: string, fallback: T): T {
31
+ if (!fs.existsSync(filePath)) return fallback;
32
+ return JSON.parse(fs.readFileSync(filePath, "utf8")) as T;
33
+ }
34
+
35
+ function percent(part: number, whole: number): number {
36
+ if (whole <= 0) return 0;
37
+ return Number(((part / whole) * 100).toFixed(1));
38
+ }
39
+
40
+ function activeScope(delivery: Delivery, graph: TaskGraph) {
41
+ const include = new Set(delivery.scope.include);
42
+ for (const excluded of delivery.scope.exclude) include.delete(excluded);
43
+ return Array.from(include)
44
+ .map((id) => graph.nodes.get(id))
45
+ .filter((task): task is Task => Boolean(task));
46
+ }
47
+
48
+ function deliveryHealth(status: string, riskCount: number): "healthy" | "warning" | "at-risk" {
49
+ if (status !== "FEASIBLE" || riskCount >= 3) return "at-risk";
50
+ if (riskCount > 0) return "warning";
51
+ return "healthy";
52
+ }
53
+
54
+ function buildBurndown(deliveryTaskIds: Set<string>, runs: ReturnType<typeof load_completed_runs>, currentRemaining: number) {
55
+ const seen = new Set<string>();
56
+ const daily = new Map<string, number>();
57
+
58
+ for (const run of runs) {
59
+ const taskId = String(run.task ?? "");
60
+ if (!deliveryTaskIds.has(taskId) || seen.has(taskId)) continue;
61
+ seen.add(taskId);
62
+ const completed = typeof run.completed === "string" ? run.completed : typeof run.started === "string" ? run.started : null;
63
+ if (!completed) continue;
64
+ const label = completed.slice(0, 10);
65
+ daily.set(label, (daily.get(label) ?? 0) + 1);
66
+ }
67
+
68
+ const total = deliveryTaskIds.size;
69
+ let remaining = total;
70
+ const points = [{ label: "Start", remaining }];
71
+ for (const label of Array.from(daily.keys()).sort((a, b) => a.localeCompare(b))) {
72
+ remaining = Math.max(0, remaining - (daily.get(label) ?? 0));
73
+ points.push({ label, remaining });
74
+ }
75
+ if (points[points.length - 1]?.remaining !== currentRemaining) {
76
+ points.push({ label: "Now", remaining: currentRemaining });
77
+ }
78
+ return points;
79
+ }
80
+
81
+ function loadGraph(coopDir: string): TaskGraph {
82
+ const manager = new IndexManager(coopDir);
83
+ const indexed = manager.load_indexed_graph();
84
+ if (!indexed || manager.is_stale()) {
85
+ return manager.build_full_index();
86
+ }
87
+ return indexed;
88
+ }
89
+
90
+ export function readCoopSnapshot(repoRoot: string, today = new Date().toISOString().slice(0, 10)): UiSnapshot {
91
+ const coopDir = path.join(path.resolve(repoRoot), ".coop");
92
+ if (!fs.existsSync(coopDir)) {
93
+ throw new Error(`Missing .coop directory at ${coopDir}. Run 'coop init' and 'coop index build' first.`);
94
+ }
95
+
96
+ const manager = new IndexManager(coopDir);
97
+ const graph = loadGraph(coopDir);
98
+ const tasksIndex = readJsonFile<Record<string, TasksIndexRow>>(manager.tasksPath, {});
99
+ const capacityIndex = readJsonFile<{ profiles?: Record<string, CapacityIndexProfile> }>(manager.capacityPath, {});
100
+ const runs = load_completed_runs(coopDir);
101
+ const velocity = compute_velocity(runs, 4, { today, graph });
102
+
103
+ const deliveryMembership = new Map<string, string[]>();
104
+ for (const delivery of graph.deliveries.values()) {
105
+ for (const task of activeScope(delivery, graph)) {
106
+ const ids = deliveryMembership.get(task.id) ?? [];
107
+ ids.push(delivery.id);
108
+ deliveryMembership.set(task.id, ids);
109
+ }
110
+ }
111
+
112
+ const tasks: UiTask[] = Array.from(graph.nodes.values())
113
+ .map((task) => ({
114
+ id: task.id,
115
+ title: task.title,
116
+ status: task.status,
117
+ priority: task.priority ?? null,
118
+ track: task.track ?? null,
119
+ assignee: task.assignee ?? null,
120
+ readiness: tasksIndex[task.id]?.readiness ?? "unknown",
121
+ depth: Number(tasksIndex[task.id]?.depth ?? 0),
122
+ dependsOn: [...(graph.forward.get(task.id) ?? new Set<string>())].sort((a, b) => a.localeCompare(b)),
123
+ blocks: [...(graph.reverse.get(task.id) ?? new Set<string>())].sort((a, b) => a.localeCompare(b)),
124
+ deliveryIds: (deliveryMembership.get(task.id) ?? []).sort((a, b) => a.localeCompare(b)),
125
+ riskLevel: task.risk?.level ?? null
126
+ }))
127
+ .sort((a, b) => a.id.localeCompare(b.id));
128
+
129
+ const statusCounts = Array.from(
130
+ tasks.reduce((map, task) => map.set(task.status, (map.get(task.status) ?? 0) + 1), new Map<string, number>()).entries()
131
+ )
132
+ .map(([status, count]) => ({ status, count }))
133
+ .sort((a, b) => a.status.localeCompare(b.status));
134
+
135
+ const deliveries: UiDelivery[] = Array.from(graph.deliveries.values())
136
+ .map((delivery) => {
137
+ const scopedTasks = activeScope(delivery, graph);
138
+ const taskIds = new Set(scopedTasks.map((task) => task.id));
139
+ const completedTasks = scopedTasks.filter((task) => task.status === "done").length;
140
+ const remainingTasks = scopedTasks.filter((task) => task.status !== "done" && task.status !== "canceled").length;
141
+ const feasibility = analyze_feasibility(delivery.id, graph, today);
142
+ const risks = detect_delivery_risks(delivery, graph, velocity, { today });
143
+ return {
144
+ id: delivery.id,
145
+ name: delivery.name,
146
+ status: delivery.status,
147
+ targetDate: delivery.target_date,
148
+ projectedDate: feasibility.summary.dates.projected,
149
+ health: deliveryHealth(feasibility.status, risks.length),
150
+ completionPercent: percent(completedTasks, scopedTasks.length),
151
+ totalTasks: scopedTasks.length,
152
+ completedTasks,
153
+ remainingTasks,
154
+ requiredHours: Number(feasibility.summary.effort_hours.required.toFixed(1)),
155
+ budgetHours:
156
+ Number.isFinite(feasibility.summary.effort_hours.budget) ? Number(feasibility.summary.effort_hours.budget.toFixed(1)) : null,
157
+ riskMessages: risks.map((risk) => risk.message),
158
+ criticalPath: [...feasibility.summary.critical_path],
159
+ utilization: feasibility.simulation.utilization_by_track.map((entry) => ({
160
+ track: entry.track,
161
+ allocatedHours: Number(entry.allocated_hours.toFixed(1)),
162
+ capacityHours: Number(entry.capacity_hours.toFixed(1)),
163
+ utilization: Number((entry.utilization * 100).toFixed(1))
164
+ })),
165
+ burndown: buildBurndown(taskIds, runs, remainingTasks)
166
+ } satisfies UiDelivery;
167
+ })
168
+ .sort((a, b) => a.id.localeCompare(b.id));
169
+
170
+ const capacityProfiles: UiCapacityProfile[] = Object.values(capacityIndex.profiles ?? {})
171
+ .map((profile) => ({
172
+ id: profile.id ?? "unknown",
173
+ type: profile.type ?? "unknown",
174
+ totalWeeklyHours: typeof profile.total_weekly_hours === "number" ? profile.total_weekly_hours : null,
175
+ effectiveWeeklyHours: typeof profile.effective_weekly_hours === "number" ? profile.effective_weekly_hours : null,
176
+ members: typeof profile.members === "number" ? profile.members : null
177
+ }))
178
+ .sort((a, b) => a.id.localeCompare(b.id));
179
+
180
+ const edges = Array.from(graph.forward.entries())
181
+ .flatMap(([taskId, deps]) => Array.from(deps).map((dep) => ({ from: dep, to: taskId })))
182
+ .sort((a, b) => `${a.from}:${a.to}`.localeCompare(`${b.from}:${b.to}`));
183
+
184
+ return {
185
+ generatedAt: new Date().toISOString(),
186
+ repoRoot: path.resolve(repoRoot),
187
+ statusCounts,
188
+ velocity: {
189
+ completedRuns: velocity.completed_runs,
190
+ tasksPerWeek: Number(velocity.tasks_completed_per_week.toFixed(2)),
191
+ hoursPerWeek: Number(velocity.hours_delivered_per_week.toFixed(2)),
192
+ accuracyRatio: Number((velocity.accuracy_ratio ?? 0).toFixed(2)),
193
+ trend: velocity.trend,
194
+ points: velocity.points.map((point) => ({
195
+ label: point.week_start,
196
+ tasksCompleted: point.completed_tasks,
197
+ hoursDelivered: Number(point.delivered_hours.toFixed(2))
198
+ }))
199
+ },
200
+ tasks,
201
+ deliveries,
202
+ graph: {
203
+ nodes: tasks.map((task) => ({
204
+ id: task.id,
205
+ title: task.title,
206
+ status: task.status,
207
+ track: task.track,
208
+ depth: task.depth
209
+ })),
210
+ edges
211
+ },
212
+ capacityProfiles
213
+ };
214
+ }
package/src/styles.css ADDED
@@ -0,0 +1,30 @@
1
+ @tailwind base;
2
+ @tailwind components;
3
+ @tailwind utilities;
4
+
5
+ :root {
6
+ color: #111318;
7
+ background:
8
+ radial-gradient(circle at top left, rgba(217, 107, 43, 0.18), transparent 34%),
9
+ radial-gradient(circle at top right, rgba(41, 89, 74, 0.15), transparent 28%),
10
+ linear-gradient(180deg, #f4efe2 0%, #f7f4eb 100%);
11
+ font-family: "IBM Plex Sans", "Segoe UI", sans-serif;
12
+ }
13
+
14
+ body {
15
+ margin: 0;
16
+ min-width: 320px;
17
+ min-height: 100vh;
18
+ }
19
+
20
+ #root {
21
+ min-height: 100vh;
22
+ }
23
+
24
+ .card-grid {
25
+ grid-template-columns: repeat(auto-fit, minmax(160px, 1fr));
26
+ }
27
+
28
+ .metric-grid {
29
+ grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
30
+ }
package/src/types.ts ADDED
@@ -0,0 +1,82 @@
1
+ export type UiStatusCount = {
2
+ status: string;
3
+ count: number;
4
+ };
5
+
6
+ export type UiVelocity = {
7
+ completedRuns: number;
8
+ tasksPerWeek: number;
9
+ hoursPerWeek: number;
10
+ accuracyRatio: number;
11
+ trend: string;
12
+ points: Array<{ label: string; tasksCompleted: number; hoursDelivered: number }>;
13
+ };
14
+
15
+ export type UiTask = {
16
+ id: string;
17
+ title: string;
18
+ status: string;
19
+ priority: string | null;
20
+ track: string | null;
21
+ assignee: string | null;
22
+ readiness: string;
23
+ depth: number;
24
+ dependsOn: string[];
25
+ blocks: string[];
26
+ deliveryIds: string[];
27
+ riskLevel: string | null;
28
+ };
29
+
30
+ export type UiDelivery = {
31
+ id: string;
32
+ name: string;
33
+ status: string;
34
+ targetDate: string | null;
35
+ projectedDate: string | null;
36
+ health: "healthy" | "warning" | "at-risk";
37
+ completionPercent: number;
38
+ totalTasks: number;
39
+ completedTasks: number;
40
+ remainingTasks: number;
41
+ requiredHours: number;
42
+ budgetHours: number | null;
43
+ riskMessages: string[];
44
+ criticalPath: string[];
45
+ utilization: Array<{ track: string; allocatedHours: number; capacityHours: number; utilization: number }>;
46
+ burndown: Array<{ label: string; remaining: number }>;
47
+ };
48
+
49
+ export type UiGraphNode = {
50
+ id: string;
51
+ title: string;
52
+ status: string;
53
+ track: string | null;
54
+ depth: number;
55
+ };
56
+
57
+ export type UiGraphEdge = {
58
+ from: string;
59
+ to: string;
60
+ };
61
+
62
+ export type UiCapacityProfile = {
63
+ id: string;
64
+ type: string;
65
+ totalWeeklyHours: number | null;
66
+ effectiveWeeklyHours: number | null;
67
+ members: number | null;
68
+ };
69
+
70
+ export type UiSnapshot = {
71
+ generatedAt: string;
72
+ repoRoot: string;
73
+ statusCounts: UiStatusCount[];
74
+ velocity: UiVelocity;
75
+ tasks: UiTask[];
76
+ deliveries: UiDelivery[];
77
+ graph: {
78
+ nodes: UiGraphNode[];
79
+ edges: UiGraphEdge[];
80
+ };
81
+ capacityProfiles: UiCapacityProfile[];
82
+ };
@@ -0,0 +1,25 @@
1
+ import type { Config } from "tailwindcss";
2
+
3
+ export default {
4
+ content: ["./index.html", "./src/**/*.{ts,tsx}"],
5
+ theme: {
6
+ extend: {
7
+ colors: {
8
+ ink: "#111318",
9
+ paper: "#f7f4eb",
10
+ accent: "#d96b2b",
11
+ moss: "#29594a",
12
+ steel: "#44505f",
13
+ sand: "#d6c4a8"
14
+ },
15
+ fontFamily: {
16
+ sans: ['"IBM Plex Sans"', '"Segoe UI"', 'sans-serif'],
17
+ mono: ['"IBM Plex Mono"', '"Cascadia Code"', 'monospace']
18
+ },
19
+ boxShadow: {
20
+ panel: "0 18px 45px rgba(17, 19, 24, 0.12)"
21
+ }
22
+ }
23
+ },
24
+ plugins: []
25
+ } satisfies Config;
package/vite.config.ts ADDED
@@ -0,0 +1,39 @@
1
+ import { defineConfig, type Plugin } from "vite";
2
+ import react from "@vitejs/plugin-react";
3
+
4
+ import { readCoopSnapshot } from "./src/server/snapshot";
5
+
6
+ function coopDataPlugin(): Plugin {
7
+ return {
8
+ name: "coop-data-plugin",
9
+ configureServer(server) {
10
+ server.middlewares.use("/__coop/data", (_req, res) => {
11
+ try {
12
+ const repoRoot = process.env.COOP_REPO_ROOT ?? process.cwd();
13
+ const snapshot = readCoopSnapshot(repoRoot);
14
+ res.statusCode = 200;
15
+ res.setHeader("Content-Type", "application/json");
16
+ res.end(JSON.stringify(snapshot));
17
+ } catch (error) {
18
+ const message = error instanceof Error ? error.message : String(error);
19
+ res.statusCode = 500;
20
+ res.setHeader("Content-Type", "application/json");
21
+ res.end(JSON.stringify({ message }));
22
+ }
23
+ });
24
+ }
25
+ };
26
+ }
27
+
28
+ export default defineConfig({
29
+ plugins: [react(), coopDataPlugin()],
30
+ server: {
31
+ host: process.env.COOP_UI_HOST ?? "127.0.0.1",
32
+ port: Number(process.env.COOP_UI_PORT ?? "4173"),
33
+ open: false
34
+ },
35
+ preview: {
36
+ host: process.env.COOP_UI_HOST ?? "127.0.0.1",
37
+ port: Number(process.env.COOP_UI_PORT ?? "4173")
38
+ }
39
+ });
package/dist/index.cjs DELETED
@@ -1,33 +0,0 @@
1
- "use strict";
2
- var __defProp = Object.defineProperty;
3
- var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
4
- var __getOwnPropNames = Object.getOwnPropertyNames;
5
- var __hasOwnProp = Object.prototype.hasOwnProperty;
6
- var __export = (target, all) => {
7
- for (var name in all)
8
- __defProp(target, name, { get: all[name], enumerable: true });
9
- };
10
- var __copyProps = (to, from, except, desc) => {
11
- if (from && typeof from === "object" || typeof from === "function") {
12
- for (let key of __getOwnPropNames(from))
13
- if (!__hasOwnProp.call(to, key) && key !== except)
14
- __defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
15
- }
16
- return to;
17
- };
18
- var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
19
-
20
- // src/index.ts
21
- var index_exports = {};
22
- __export(index_exports, {
23
- coopUi: () => coopUi
24
- });
25
- module.exports = __toCommonJS(index_exports);
26
- var coopUi = {
27
- package: "@kitsy/coop-ui",
28
- status: "planned"
29
- };
30
- // Annotate the CommonJS export names for ESM import in node:
31
- 0 && (module.exports = {
32
- coopUi
33
- });
package/dist/index.d.cts DELETED
@@ -1,7 +0,0 @@
1
- type CoopUiPlaceholder = {
2
- readonly package: "@kitsy/coop-ui";
3
- readonly status: "planned";
4
- };
5
- declare const coopUi: CoopUiPlaceholder;
6
-
7
- export { type CoopUiPlaceholder, coopUi };
package/dist/index.d.ts DELETED
@@ -1,7 +0,0 @@
1
- type CoopUiPlaceholder = {
2
- readonly package: "@kitsy/coop-ui";
3
- readonly status: "planned";
4
- };
5
- declare const coopUi: CoopUiPlaceholder;
6
-
7
- export { type CoopUiPlaceholder, coopUi };
package/dist/index.js DELETED
@@ -1,8 +0,0 @@
1
- // src/index.ts
2
- var coopUi = {
3
- package: "@kitsy/coop-ui",
4
- status: "planned"
5
- };
6
- export {
7
- coopUi
8
- };
package/tsconfig.json DELETED
@@ -1,7 +0,0 @@
1
- {
2
- "extends": "../../tsconfig.base.json",
3
- "compilerOptions": {
4
- "outDir": "dist"
5
- },
6
- "include": ["src/**/*.ts"]
7
- }