beads-map 0.1.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 (142) hide show
  1. package/.next/BUILD_ID +1 -0
  2. package/.next/app-build-manifest.json +27 -0
  3. package/.next/app-path-routes-manifest.json +1 -0
  4. package/.next/build-manifest.json +32 -0
  5. package/.next/export-marker.json +1 -0
  6. package/.next/images-manifest.json +1 -0
  7. package/.next/next-minimal-server.js.nft.json +1 -0
  8. package/.next/next-server.js.nft.json +1 -0
  9. package/.next/package.json +1 -0
  10. package/.next/prerender-manifest.json +1 -0
  11. package/.next/react-loadable-manifest.json +8 -0
  12. package/.next/required-server-files.json +1 -0
  13. package/.next/routes-manifest.json +1 -0
  14. package/.next/server/app/_not-found/page.js +1 -0
  15. package/.next/server/app/_not-found/page.js.nft.json +1 -0
  16. package/.next/server/app/_not-found/page_client-reference-manifest.js +1 -0
  17. package/.next/server/app/_not-found.html +1 -0
  18. package/.next/server/app/_not-found.meta +6 -0
  19. package/.next/server/app/_not-found.rsc +10 -0
  20. package/.next/server/app/api/beads/route.js +8 -0
  21. package/.next/server/app/api/beads/route.js.nft.json +1 -0
  22. package/.next/server/app/api/beads/stream/route.js +10 -0
  23. package/.next/server/app/api/beads/stream/route.js.nft.json +1 -0
  24. package/.next/server/app/api/beads.body +1 -0
  25. package/.next/server/app/api/beads.meta +1 -0
  26. package/.next/server/app/api/config/route.js +8 -0
  27. package/.next/server/app/api/config/route.js.nft.json +1 -0
  28. package/.next/server/app/api/config.body +1 -0
  29. package/.next/server/app/api/config.meta +1 -0
  30. package/.next/server/app/api/login/route.js +1 -0
  31. package/.next/server/app/api/login/route.js.nft.json +1 -0
  32. package/.next/server/app/api/logout/route.js +1 -0
  33. package/.next/server/app/api/logout/route.js.nft.json +1 -0
  34. package/.next/server/app/api/oauth/callback/route.js +1 -0
  35. package/.next/server/app/api/oauth/callback/route.js.nft.json +1 -0
  36. package/.next/server/app/api/oauth/client-metadata.json/route.js +1 -0
  37. package/.next/server/app/api/oauth/client-metadata.json/route.js.nft.json +1 -0
  38. package/.next/server/app/api/oauth/jwks.json/route.js +1 -0
  39. package/.next/server/app/api/oauth/jwks.json/route.js.nft.json +1 -0
  40. package/.next/server/app/api/records/route.js +1 -0
  41. package/.next/server/app/api/records/route.js.nft.json +1 -0
  42. package/.next/server/app/api/status/route.js +1 -0
  43. package/.next/server/app/api/status/route.js.nft.json +1 -0
  44. package/.next/server/app/index.html +1 -0
  45. package/.next/server/app/index.meta +5 -0
  46. package/.next/server/app/index.rsc +8 -0
  47. package/.next/server/app/page.js +24 -0
  48. package/.next/server/app/page.js.nft.json +1 -0
  49. package/.next/server/app/page_client-reference-manifest.js +1 -0
  50. package/.next/server/app-paths-manifest.json +14 -0
  51. package/.next/server/chunks/247.js +12 -0
  52. package/.next/server/chunks/251.js +2 -0
  53. package/.next/server/chunks/29.js +1 -0
  54. package/.next/server/chunks/343.js +1 -0
  55. package/.next/server/chunks/533.js +38 -0
  56. package/.next/server/chunks/590.js +6 -0
  57. package/.next/server/chunks/615.js +15 -0
  58. package/.next/server/chunks/696.js +25 -0
  59. package/.next/server/chunks/719.js +2 -0
  60. package/.next/server/chunks/739.js +1 -0
  61. package/.next/server/chunks/font-manifest.json +1 -0
  62. package/.next/server/font-manifest.json +1 -0
  63. package/.next/server/functions-config-manifest.json +1 -0
  64. package/.next/server/interception-route-rewrite-manifest.js +1 -0
  65. package/.next/server/middleware-build-manifest.js +1 -0
  66. package/.next/server/middleware-manifest.json +6 -0
  67. package/.next/server/middleware-react-loadable-manifest.js +1 -0
  68. package/.next/server/next-font-manifest.js +1 -0
  69. package/.next/server/next-font-manifest.json +1 -0
  70. package/.next/server/pages/404.html +1 -0
  71. package/.next/server/pages/500.html +1 -0
  72. package/.next/server/pages/_app.js +1 -0
  73. package/.next/server/pages/_app.js.nft.json +1 -0
  74. package/.next/server/pages/_document.js +1 -0
  75. package/.next/server/pages/_document.js.nft.json +1 -0
  76. package/.next/server/pages/_error.js +1 -0
  77. package/.next/server/pages/_error.js.nft.json +1 -0
  78. package/.next/server/pages-manifest.json +1 -0
  79. package/.next/server/server-reference-manifest.js +1 -0
  80. package/.next/server/server-reference-manifest.json +1 -0
  81. package/.next/server/webpack-runtime.js +1 -0
  82. package/.next/static/99eOjoTtoO32H-c1faxZ5/_buildManifest.js +1 -0
  83. package/.next/static/99eOjoTtoO32H-c1faxZ5/_ssgManifest.js +1 -0
  84. package/.next/static/chunks/149.a3e3a5dc03e21086.js +1 -0
  85. package/.next/static/chunks/2200cc46-7c93a0e00b0bb825.js +1 -0
  86. package/.next/static/chunks/666-fb778298a77f3754.js +1 -0
  87. package/.next/static/chunks/945-bf736d0119e7437b.js +2 -0
  88. package/.next/static/chunks/app/_not-found/page-b568fd9238f85f27.js +1 -0
  89. package/.next/static/chunks/app/layout-13e3cdaaa416edb6.js +1 -0
  90. package/.next/static/chunks/app/page-49d569c912d5af9d.js +1 -0
  91. package/.next/static/chunks/framework-6e06c675866dc992.js +1 -0
  92. package/.next/static/chunks/main-62aa0e18004db880.js +1 -0
  93. package/.next/static/chunks/main-app-8b0c4a1007dbb7f4.js +1 -0
  94. package/.next/static/chunks/pages/_app-0c3037849002a4aa.js +1 -0
  95. package/.next/static/chunks/pages/_error-a647cd2c75dc4dc7.js +1 -0
  96. package/.next/static/chunks/polyfills-42372ed130431b0a.js +1 -0
  97. package/.next/static/chunks/webpack-c8b9ebfd35ae1d92.js +1 -0
  98. package/.next/static/css/10ef08b24212fe36.css +3 -0
  99. package/README.md +243 -0
  100. package/app/api/beads/route.ts +27 -0
  101. package/app/api/beads/stream/route.ts +83 -0
  102. package/app/api/config/route.ts +46 -0
  103. package/app/api/login/route.ts +42 -0
  104. package/app/api/logout/route.ts +14 -0
  105. package/app/api/oauth/callback/route.ts +94 -0
  106. package/app/api/oauth/client-metadata.json/route.ts +33 -0
  107. package/app/api/oauth/jwks.json/route.ts +32 -0
  108. package/app/api/records/route.ts +168 -0
  109. package/app/api/status/route.ts +25 -0
  110. package/app/globals.css +192 -0
  111. package/app/layout.tsx +30 -0
  112. package/app/page.tsx +1151 -0
  113. package/bin/beads-map.mjs +175 -0
  114. package/components/AllCommentsPanel.tsx +265 -0
  115. package/components/AuthButton.tsx +197 -0
  116. package/components/BeadsGraph.tsx +1539 -0
  117. package/components/CommentTooltip.tsx +310 -0
  118. package/components/GraphStats.tsx +121 -0
  119. package/components/HeartIcon.tsx +33 -0
  120. package/components/NodeDetail.tsx +741 -0
  121. package/components/StatusLegend.tsx +99 -0
  122. package/components/TimelineBar.tsx +116 -0
  123. package/hooks/useBeadsComments.ts +412 -0
  124. package/lib/agent.ts +29 -0
  125. package/lib/auth/client.ts +221 -0
  126. package/lib/auth.tsx +159 -0
  127. package/lib/diff-beads.ts +125 -0
  128. package/lib/discover.ts +228 -0
  129. package/lib/env.ts +28 -0
  130. package/lib/parse-beads.ts +232 -0
  131. package/lib/session.ts +52 -0
  132. package/lib/timeline.ts +138 -0
  133. package/lib/types.ts +202 -0
  134. package/lib/utils.ts +25 -0
  135. package/lib/watch-beads.ts +97 -0
  136. package/next.config.mjs +4 -0
  137. package/package.json +75 -0
  138. package/postcss.config.mjs +9 -0
  139. package/public/image.png +0 -0
  140. package/scripts/generate-jwk.js +38 -0
  141. package/tailwind.config.ts +41 -0
  142. package/tsconfig.json +24 -0
@@ -0,0 +1,228 @@
1
+ import { existsSync, readFileSync, realpathSync, statSync } from "fs";
2
+ import { join, resolve, dirname, basename, parse as pathParse } from "path";
3
+ import { execSync } from "child_process";
4
+ import { parse as parseYaml } from "yaml";
5
+
6
+ export interface BeadsDiscovery {
7
+ beadsDir: string; // Absolute path to the .beads/ directory
8
+ repoRoot: string; // Parent directory of .beads/
9
+ repoName: string; // Directory name of repoRoot (e.g. 'my-project')
10
+ issuePrefix?: string; // From config.yaml issue-prefix field, if set
11
+ additionalRepos: number; // Count of repos.additional entries (0 for single-repo)
12
+ }
13
+
14
+ /**
15
+ * Read config.yaml from a .beads directory and extract metadata.
16
+ */
17
+ function readBeadsConfig(beadsDir: string): {
18
+ issuePrefix?: string;
19
+ additionalRepos: number;
20
+ } {
21
+ const configPath = join(beadsDir, "config.yaml");
22
+ try {
23
+ if (!existsSync(configPath)) return { additionalRepos: 0 };
24
+ const content = readFileSync(configPath, "utf-8");
25
+ const config = parseYaml(content);
26
+
27
+ const issuePrefix = config?.["issue-prefix"] || undefined;
28
+ const additional = config?.repos?.additional;
29
+ const additionalRepos = Array.isArray(additional) ? additional.length : 0;
30
+
31
+ return { issuePrefix, additionalRepos };
32
+ } catch {
33
+ return { additionalRepos: 0 };
34
+ }
35
+ }
36
+
37
+ /**
38
+ * Auto-discover the .beads/ directory by walking up from a starting directory,
39
+ * similar to how git finds .git/ or how bd itself finds .beads/.
40
+ *
41
+ * Resolution order:
42
+ * 1. BEADS_DIR environment variable (if set)
43
+ * 2. Walk up from startDir (or process.cwd()) looking for .beads/issues.jsonl
44
+ *
45
+ * @param startDir - Directory to start searching from (defaults to process.cwd())
46
+ * @throws Error if no .beads/ directory is found
47
+ */
48
+ export function discoverBeadsDir(startDir?: string): BeadsDiscovery {
49
+ // 1. Check BEADS_DIR env var
50
+ const envDir = process.env.BEADS_DIR;
51
+ if (envDir) {
52
+ const resolved = resolve(envDir);
53
+
54
+ // Accept both /path/to/.beads and /path/to/repo (auto-append .beads/)
55
+ let beadsDir: string;
56
+ if (basename(resolved) === ".beads") {
57
+ beadsDir = resolved;
58
+ } else if (existsSync(join(resolved, ".beads"))) {
59
+ beadsDir = join(resolved, ".beads");
60
+ } else {
61
+ // Treat as .beads dir directly even if it doesn't exist yet
62
+ beadsDir = resolved;
63
+ }
64
+
65
+ try {
66
+ beadsDir = realpathSync(beadsDir);
67
+ } catch {
68
+ // realpathSync fails if path doesn't exist — use as-is
69
+ }
70
+
71
+ const repoRoot = dirname(beadsDir);
72
+ const repoName = basename(repoRoot);
73
+ const configData = readBeadsConfig(beadsDir);
74
+
75
+ return {
76
+ beadsDir,
77
+ repoRoot,
78
+ repoName,
79
+ ...configData,
80
+ };
81
+ }
82
+
83
+ // 2. Walk up directory tree from startDir or cwd
84
+ let current = resolve(startDir || process.cwd());
85
+ const { root } = pathParse(current);
86
+
87
+ while (true) {
88
+ const candidate = join(current, ".beads");
89
+
90
+ // Check for .beads/ directory (with or without issues.jsonl — empty projects are valid)
91
+ if (existsSync(candidate) && statSync(candidate).isDirectory()) {
92
+ let beadsDir: string;
93
+ try {
94
+ beadsDir = realpathSync(candidate);
95
+ } catch {
96
+ beadsDir = candidate;
97
+ }
98
+
99
+ const repoRoot = dirname(beadsDir);
100
+ const repoName = basename(repoRoot);
101
+ const configData = readBeadsConfig(beadsDir);
102
+
103
+ return {
104
+ beadsDir,
105
+ repoRoot,
106
+ repoName,
107
+ ...configData,
108
+ };
109
+ }
110
+
111
+ // Reached filesystem root without finding .beads/
112
+ if (current === root) {
113
+ throw new Error(
114
+ `No .beads/ directory found.\n` +
115
+ `Searched from: ${resolve(startDir || process.cwd())}\n\n` +
116
+ `To initialize beads in your project, run:\n` +
117
+ ` bd init\n\n` +
118
+ `Or set the BEADS_DIR environment variable:\n` +
119
+ ` BEADS_DIR=/path/to/project/.beads beads-map`
120
+ );
121
+ }
122
+
123
+ // Move up one level
124
+ current = dirname(current);
125
+ }
126
+ }
127
+
128
+ /**
129
+ * Convert a raw git remote URL to a browser-friendly HTTPS URL.
130
+ * Handles: git@github.com:Org/repo.git, https://github.com/Org/repo.git, etc.
131
+ */
132
+ function gitUrlToHttps(rawUrl: string): string | null {
133
+ let url = rawUrl.trim().replace(/\.git$/, "");
134
+
135
+ // SSH format: git@github.com:Org/repo
136
+ const sshMatch = url.match(/^git@([^:]+):(.+)$/);
137
+ if (sshMatch) {
138
+ return `https://${sshMatch[1]}/${sshMatch[2]}`;
139
+ }
140
+
141
+ // Already HTTPS
142
+ if (url.startsWith("https://") || url.startsWith("http://")) {
143
+ return url;
144
+ }
145
+
146
+ return null;
147
+ }
148
+
149
+ /**
150
+ * Get the git remote origin URL for a directory, if it's a git repo.
151
+ */
152
+ function getGitRemoteUrl(dir: string): string | null {
153
+ try {
154
+ const raw = execSync("git config --get remote.origin.url", {
155
+ cwd: dir,
156
+ encoding: "utf-8",
157
+ timeout: 3000,
158
+ stdio: ["pipe", "pipe", "pipe"],
159
+ });
160
+ return gitUrlToHttps(raw);
161
+ } catch {
162
+ return null;
163
+ }
164
+ }
165
+
166
+ /**
167
+ * Build a mapping of repo prefix → GitHub HTTPS URL for all discovered repos.
168
+ * Uses the primary repo + any additional repos from config.yaml.
169
+ */
170
+ export function getRepoUrls(beadsDir: string): Record<string, string> {
171
+ const urls: Record<string, string> = {};
172
+
173
+ // Primary repo
174
+ const repoRoot = dirname(beadsDir);
175
+ const primaryUrl = getGitRemoteUrl(repoRoot);
176
+
177
+ // Read config to get issue-prefix and additional repos
178
+ const configPath = join(beadsDir, "config.yaml");
179
+ let issuePrefix: string | undefined;
180
+ let additionalPaths: string[] = [];
181
+
182
+ try {
183
+ if (existsSync(configPath)) {
184
+ const content = readFileSync(configPath, "utf-8");
185
+ const config = parseYaml(content);
186
+ issuePrefix = config?.["issue-prefix"] || undefined;
187
+ const additional = config?.repos?.additional;
188
+ if (Array.isArray(additional)) {
189
+ additionalPaths = additional
190
+ .map((p: string) => resolve(repoRoot, p))
191
+ .filter((p: string) => existsSync(join(p, ".beads")));
192
+ }
193
+ }
194
+ } catch {
195
+ // config.yaml unreadable
196
+ }
197
+
198
+ // Primary repo: use issue-prefix or directory name as key
199
+ const primaryPrefix = issuePrefix || basename(repoRoot);
200
+ if (primaryUrl) {
201
+ urls[primaryPrefix] = primaryUrl;
202
+ }
203
+
204
+ // Additional repos
205
+ for (const repoPath of additionalPaths) {
206
+ const url = getGitRemoteUrl(repoPath);
207
+ if (!url) continue;
208
+
209
+ // Read that repo's .beads/config.yaml for its issue-prefix
210
+ const subConfigPath = join(repoPath, ".beads", "config.yaml");
211
+ let prefix = basename(repoPath); // fallback: directory name
212
+ try {
213
+ if (existsSync(subConfigPath)) {
214
+ const content = readFileSync(subConfigPath, "utf-8");
215
+ const config = parseYaml(content);
216
+ if (config?.["issue-prefix"]) {
217
+ prefix = config["issue-prefix"];
218
+ }
219
+ }
220
+ } catch {
221
+ // use directory name fallback
222
+ }
223
+
224
+ urls[prefix] = url;
225
+ }
226
+
227
+ return urls;
228
+ }
package/lib/env.ts ADDED
@@ -0,0 +1,28 @@
1
+ /**
2
+ * Environment variable validation and defaults for ATProto OAuth.
3
+ * Allows missing values in dev mode (defaults kick in).
4
+ */
5
+ export const env = {
6
+ /** iron-session encryption key (32+ chars) */
7
+ get COOKIE_SECRET(): string {
8
+ return (
9
+ process.env.COOKIE_SECRET ||
10
+ "development-secret-at-least-32-chars!!"
11
+ );
12
+ },
13
+
14
+ /** App's public URL (empty = localhost dev mode with public OAuth client) */
15
+ get PUBLIC_URL(): string {
16
+ return process.env.PUBLIC_URL || "";
17
+ },
18
+
19
+ /** Dev server port */
20
+ get PORT(): number {
21
+ return parseInt(process.env.PORT || "3000", 10);
22
+ },
23
+
24
+ /** ES256 JWK private key JSON (empty = public client mode) */
25
+ get ATPROTO_JWK_PRIVATE(): string {
26
+ return process.env.ATPROTO_JWK_PRIVATE || "";
27
+ },
28
+ };
@@ -0,0 +1,232 @@
1
+ import { readFileSync, existsSync } from "fs";
2
+ import { join, resolve } from "path";
3
+ import { parse as parseYaml } from "yaml";
4
+ import { discoverBeadsDir } from "./discover";
5
+ import type {
6
+ BeadIssue,
7
+ BeadDependency,
8
+ GraphNode,
9
+ GraphLink,
10
+ GraphData,
11
+ BeadsApiResponse,
12
+ } from "./types";
13
+
14
+ /**
15
+ * Extract the prefix from a bead ID (e.g. "my-app-5gw" -> "my-app")
16
+ */
17
+ export function extractPrefix(id: string): string {
18
+ const lastDash = id.lastIndexOf("-");
19
+ if (lastDash === -1) return id;
20
+ return id.substring(0, lastDash);
21
+ }
22
+
23
+ /**
24
+ * Read repos.additional paths from .beads/config.yaml
25
+ */
26
+ export function getAdditionalRepoPaths(beadsDir: string): string[] {
27
+ const configPath = join(beadsDir, "config.yaml");
28
+ try {
29
+ const content = readFileSync(configPath, "utf-8");
30
+ const config = parseYaml(content);
31
+ const additional = config?.repos?.additional;
32
+ if (Array.isArray(additional)) {
33
+ return additional
34
+ .map((p: string) => resolve(beadsDir, "..", p))
35
+ .filter((p: string) => existsSync(join(p, ".beads", "issues.jsonl")));
36
+ }
37
+ } catch (err) {
38
+ console.error("Failed to read config.yaml:", err);
39
+ }
40
+ return [];
41
+ }
42
+
43
+ /**
44
+ * Parse a single issues.jsonl file and return issues
45
+ */
46
+ function parseJsonlFile(filePath: string): BeadIssue[] {
47
+ try {
48
+ const content = readFileSync(filePath, "utf-8");
49
+ const lines = content.split("\n").filter((line) => line.trim().length > 0);
50
+ return lines.map((line) => JSON.parse(line) as BeadIssue);
51
+ } catch (err) {
52
+ console.error(`Failed to parse ${filePath}:`, err);
53
+ return [];
54
+ }
55
+ }
56
+
57
+ /**
58
+ * Parse issues.jsonl from the primary .beads directory and all additional repos
59
+ */
60
+ export function parseIssuesJsonl(beadsDir?: string): BeadIssue[] {
61
+ const dir = beadsDir || discoverBeadsDir().beadsDir;
62
+
63
+ // Load primary repo issues
64
+ const primaryPath = join(dir, "issues.jsonl");
65
+ const issues = parseJsonlFile(primaryPath);
66
+
67
+ // Load additional repo issues from config.yaml
68
+ const additionalRepos = getAdditionalRepoPaths(dir);
69
+ for (const repoPath of additionalRepos) {
70
+ const repoJsonl = join(repoPath, ".beads", "issues.jsonl");
71
+ const repoIssues = parseJsonlFile(repoJsonl);
72
+ issues.push(...repoIssues);
73
+ }
74
+
75
+ // Deduplicate by issue ID (primary repo wins) and filter out tombstones.
76
+ // bd delete marks issues with status "tombstone" rather than removing them
77
+ // from the JSONL file — we must exclude these from the graph.
78
+ const seen = new Set<string>();
79
+ return issues.filter((issue) => {
80
+ if (seen.has(issue.id)) return false;
81
+ seen.add(issue.id);
82
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
83
+ if ((issue as any).status === "tombstone") return false;
84
+ return true;
85
+ });
86
+ }
87
+
88
+ /**
89
+ * Extract all dependencies from issues (they're embedded in each issue)
90
+ */
91
+ export function extractDependencies(issues: BeadIssue[]): BeadDependency[] {
92
+ const deps: BeadDependency[] = [];
93
+ const seen = new Set<string>();
94
+
95
+ for (const issue of issues) {
96
+ if (issue.dependencies) {
97
+ for (const dep of issue.dependencies) {
98
+ const key = `${dep.issue_id}->${dep.depends_on_id}:${dep.type}`;
99
+ if (!seen.has(key)) {
100
+ seen.add(key);
101
+ deps.push(dep);
102
+ }
103
+ }
104
+ }
105
+ }
106
+
107
+ return deps;
108
+ }
109
+
110
+ /**
111
+ * Build graph data from issues and dependencies
112
+ */
113
+ export function buildGraphData(
114
+ issues: BeadIssue[],
115
+ dependencies: BeadDependency[]
116
+ ): GraphData {
117
+ const issueMap = new Map(issues.map((i) => [i.id, i]));
118
+
119
+ // Count blockers and dependents for each issue
120
+ const blockerCounts = new Map<string, string[]>(); // issue -> issues it blocks
121
+ const dependentCounts = new Map<string, string[]>(); // issue -> issues that block it
122
+
123
+ for (const dep of dependencies) {
124
+ if (dep.type === "blocks" || dep.type === "parent-child") {
125
+ // For blocks: depends_on_id blocks issue_id
126
+ // For parent-child: depends_on_id is parent of issue_id
127
+ // Both count as connections for node sizing
128
+ if (!blockerCounts.has(dep.depends_on_id)) {
129
+ blockerCounts.set(dep.depends_on_id, []);
130
+ }
131
+ blockerCounts.get(dep.depends_on_id)!.push(dep.issue_id);
132
+
133
+ if (!dependentCounts.has(dep.issue_id)) {
134
+ dependentCounts.set(dep.issue_id, []);
135
+ }
136
+ dependentCounts.get(dep.issue_id)!.push(dep.depends_on_id);
137
+ }
138
+ }
139
+
140
+ const nodes: GraphNode[] = issues.map((issue) => ({
141
+ id: issue.id,
142
+ title: issue.title,
143
+ description: issue.description,
144
+ status: issue.status,
145
+ priority: issue.priority,
146
+ issueType: issue.issue_type,
147
+ owner: issue.owner,
148
+ createdAt: issue.created_at,
149
+ updatedAt: issue.updated_at,
150
+ closedAt: issue.closed_at,
151
+ closeReason: issue.close_reason,
152
+ prefix: extractPrefix(issue.id),
153
+
154
+ blockerCount: blockerCounts.get(issue.id)?.length || 0,
155
+ dependentCount: dependentCounts.get(issue.id)?.length || 0,
156
+ blockerIds: blockerCounts.get(issue.id) || [],
157
+ dependentIds: dependentCounts.get(issue.id) || [],
158
+ }));
159
+
160
+ // Build links: include blocking AND parent-child dependencies where both nodes exist
161
+ const links: GraphLink[] = dependencies
162
+ .filter(
163
+ (d) =>
164
+ (d.type === "blocks" || d.type === "parent-child") &&
165
+ issueMap.has(d.issue_id) &&
166
+ issueMap.has(d.depends_on_id)
167
+ )
168
+ .map((d) => ({
169
+ // For both types: depends_on_id is the "upstream" node (blocker or parent)
170
+ // blocks: blocker -> blocked
171
+ // parent-child: parent -> child
172
+ source: d.depends_on_id,
173
+ target: d.issue_id,
174
+ type: d.type,
175
+ createdAt: d.created_at,
176
+ }));
177
+
178
+ return { nodes, links };
179
+ }
180
+
181
+ /**
182
+ * Compute stats from issues and dependencies
183
+ */
184
+ export function computeStats(
185
+ issues: BeadIssue[],
186
+ dependencies: BeadDependency[],
187
+ graphData: GraphData
188
+ ) {
189
+ const prefixes = [...new Set(issues.map((i) => extractPrefix(i.id)))];
190
+
191
+ // An issue is "actionable" if it's open and has no open blockers
192
+ const openIssueIds = new Set(
193
+ issues.filter((i) => i.status !== "closed").map((i) => i.id)
194
+ );
195
+
196
+ const blockedByOpen = new Set<string>();
197
+ for (const dep of dependencies) {
198
+ if (dep.type === "blocks" && openIssueIds.has(dep.depends_on_id)) {
199
+ // issue_id is blocked by depends_on_id which is still open
200
+ blockedByOpen.add(dep.issue_id);
201
+ }
202
+ }
203
+
204
+ const actionable = issues.filter(
205
+ (i) =>
206
+ (i.status === "open" || i.status === "in_progress") &&
207
+ !blockedByOpen.has(i.id)
208
+ ).length;
209
+
210
+ return {
211
+ total: issues.length,
212
+ open: issues.filter((i) => i.status === "open").length,
213
+ inProgress: issues.filter((i) => i.status === "in_progress").length,
214
+ blocked: issues.filter((i) => i.status === "blocked").length,
215
+ closed: issues.filter((i) => i.status === "closed").length,
216
+ actionable,
217
+ edges: graphData.links.length,
218
+ prefixes,
219
+ };
220
+ }
221
+
222
+ /**
223
+ * Full pipeline: parse, extract, build, compute
224
+ */
225
+ export function loadBeadsData(beadsDir?: string): BeadsApiResponse {
226
+ const issues = parseIssuesJsonl(beadsDir);
227
+ const dependencies = extractDependencies(issues);
228
+ const graphData = buildGraphData(issues, dependencies);
229
+ const stats = computeStats(issues, dependencies, graphData);
230
+
231
+ return { issues, dependencies, graphData, stats };
232
+ }
package/lib/session.ts ADDED
@@ -0,0 +1,52 @@
1
+ import { env } from "./env";
2
+ import { getIronSession, type SessionOptions } from "iron-session";
3
+ import { cookies } from "next/headers";
4
+
5
+ /**
6
+ * Session data stored in the encrypted cookie.
7
+ */
8
+ export interface Session {
9
+ did?: string;
10
+ handle?: string;
11
+ displayName?: string;
12
+ avatar?: string;
13
+ returnTo?: string;
14
+ // OAuth session data (serialized) - persisted across serverless invocations
15
+ oauthSession?: string;
16
+ }
17
+
18
+ const isProduction = process.env.NODE_ENV === "production";
19
+
20
+ const sessionOptions: SessionOptions = {
21
+ cookieName: "beads_map_sid",
22
+ password: env.COOKIE_SECRET,
23
+ cookieOptions: {
24
+ secure: isProduction,
25
+ maxAge: 60 * 60 * 24 * 30, // 30 days
26
+ },
27
+ };
28
+
29
+ /**
30
+ * Get the current user's session from their encrypted cookie.
31
+ */
32
+ export async function getSession(): Promise<Session> {
33
+ const cookieStore = await cookies();
34
+ const session = await getIronSession<Session>(cookieStore, sessionOptions);
35
+ return session;
36
+ }
37
+
38
+ /**
39
+ * Get the raw iron-session object for direct manipulation (save/destroy).
40
+ */
41
+ export async function getRawSession() {
42
+ const cookieStore = await cookies();
43
+ return await getIronSession<Session>(cookieStore, sessionOptions);
44
+ }
45
+
46
+ /**
47
+ * Clear the current user's session.
48
+ */
49
+ export async function clearSession(): Promise<void> {
50
+ const session = await getRawSession();
51
+ session.destroy();
52
+ }
@@ -0,0 +1,138 @@
1
+ import type { GraphNode, GraphLink } from "./types";
2
+
3
+ // ============================================================================
4
+ // Timeline event types
5
+ // ============================================================================
6
+
7
+ export type TimelineEventType = "node-created" | "node-closed";
8
+
9
+ export interface TimelineEvent {
10
+ time: number; // unix ms
11
+ type: TimelineEventType;
12
+ id: string; // node ID or "source->target"
13
+ }
14
+
15
+ export interface TimelineRange {
16
+ events: TimelineEvent[];
17
+ minTime: number; // earliest event (unix ms)
18
+ maxTime: number; // latest event (unix ms)
19
+ }
20
+
21
+ // ============================================================================
22
+ // Event extraction
23
+ // ============================================================================
24
+
25
+ /**
26
+ * Extract all temporal events from nodes and links, sorted chronologically.
27
+ *
28
+ * Events:
29
+ * - node-created: from node.createdAt
30
+ * - node-closed: from node.closedAt (if present)
31
+ * - link-created: from link.createdAt (if present)
32
+ *
33
+ * Nodes/links missing timestamps are skipped.
34
+ */
35
+ export function buildTimelineEvents(
36
+ nodes: GraphNode[],
37
+ links: GraphLink[]
38
+ ): TimelineRange {
39
+ const events: TimelineEvent[] = [];
40
+
41
+ for (const node of nodes) {
42
+ const createdMs = new Date(node.createdAt).getTime();
43
+ if (!isNaN(createdMs)) {
44
+ events.push({ time: createdMs, type: "node-created", id: node.id });
45
+ }
46
+ if (node.closedAt) {
47
+ const closedMs = new Date(node.closedAt).getTime();
48
+ if (!isNaN(closedMs)) {
49
+ events.push({ time: closedMs, type: "node-closed", id: node.id });
50
+ }
51
+ }
52
+ }
53
+
54
+ events.sort((a, b) => a.time - b.time);
55
+
56
+ const minTime = events.length > 0 ? events[0].time : Date.now();
57
+ const maxTime =
58
+ events.length > 0 ? events[events.length - 1].time : Date.now();
59
+
60
+ return { events, minTime, maxTime };
61
+ }
62
+
63
+ // ============================================================================
64
+ // Time filtering
65
+ // ============================================================================
66
+
67
+ /**
68
+ * Filter nodes and links to only include items visible at `currentTime`.
69
+ *
70
+ * Node visibility: createdAt <= currentTime.
71
+ * Node status override: if closedAt <= currentTime, force status to "closed".
72
+ * Link visibility: both endpoints visible AND link.createdAt <= currentTime.
73
+ * If link has no createdAt, it appears when both endpoints are visible.
74
+ *
75
+ * Returns shallow copies when status is overridden, original objects otherwise
76
+ * (preserves x/y positions from force simulation).
77
+ */
78
+ export function filterDataAtTime(
79
+ allNodes: GraphNode[],
80
+ allLinks: GraphLink[],
81
+ currentTime: number
82
+ ): { nodes: GraphNode[]; links: GraphLink[] } {
83
+ const visibleNodeIds = new Set<string>();
84
+ const nodes: GraphNode[] = [];
85
+
86
+ for (const node of allNodes) {
87
+ const createdMs = new Date(node.createdAt).getTime();
88
+ if (isNaN(createdMs) || createdMs > currentTime) continue;
89
+
90
+ visibleNodeIds.add(node.id);
91
+
92
+ // Determine correct status at this point in time
93
+ let status = node.status;
94
+ if (node.closedAt) {
95
+ const closedMs = new Date(node.closedAt).getTime();
96
+ if (!isNaN(closedMs) && closedMs <= currentTime) {
97
+ status = "closed";
98
+ } else if (node.status === "closed") {
99
+ // Node is closed in current data but we're before closedAt — show as open
100
+ status = "open";
101
+ }
102
+ }
103
+
104
+ if (status !== node.status) {
105
+ nodes.push({ ...node, status } as GraphNode);
106
+ } else {
107
+ nodes.push(node);
108
+ }
109
+ }
110
+
111
+ // Filter visible links
112
+ const links: GraphLink[] = [];
113
+ for (const link of allLinks) {
114
+ const src =
115
+ typeof link.source === "object"
116
+ ? (link.source as { id: string }).id
117
+ : link.source;
118
+ const tgt =
119
+ typeof link.target === "object"
120
+ ? (link.target as { id: string }).id
121
+ : link.target;
122
+
123
+ // Both endpoints must be visible — link appears when both nodes are on canvas
124
+ if (!visibleNodeIds.has(src) || !visibleNodeIds.has(tgt)) continue;
125
+
126
+ // Normalize source/target to string IDs — d3-force mutates link objects
127
+ // in-place replacing strings with object refs to the main graph's nodes.
128
+ // We must return fresh objects with string IDs so ForceGraph2D resolves
129
+ // them against the timeline's node array, not the main graph's.
130
+ links.push({
131
+ ...link,
132
+ source: src,
133
+ target: tgt,
134
+ });
135
+ }
136
+
137
+ return { nodes, links };
138
+ }