heartbeads 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 (205) hide show
  1. package/.next/BUILD_ID +1 -0
  2. package/.next/app-build-manifest.json +49 -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 +9 -0
  20. package/.next/server/app/api/auth/route.js +1 -0
  21. package/.next/server/app/api/auth/route.js.nft.json +1 -0
  22. package/.next/server/app/api/beads/route.js +8 -0
  23. package/.next/server/app/api/beads/route.js.nft.json +1 -0
  24. package/.next/server/app/api/beads/stream/route.js +10 -0
  25. package/.next/server/app/api/beads/stream/route.js.nft.json +1 -0
  26. package/.next/server/app/api/beads.body +1 -0
  27. package/.next/server/app/api/beads.meta +1 -0
  28. package/.next/server/app/api/config/route.js +8 -0
  29. package/.next/server/app/api/config/route.js.nft.json +1 -0
  30. package/.next/server/app/api/docs/page.js +120 -0
  31. package/.next/server/app/api/docs/page.js.nft.json +1 -0
  32. package/.next/server/app/api/docs/page_client-reference-manifest.js +1 -0
  33. package/.next/server/app/api/docs.html +120 -0
  34. package/.next/server/app/api/docs.meta +5 -0
  35. package/.next/server/app/api/docs.rsc +70 -0
  36. package/.next/server/app/api/login/route.js +1 -0
  37. package/.next/server/app/api/login/route.js.nft.json +1 -0
  38. package/.next/server/app/api/logout/route.js +1 -0
  39. package/.next/server/app/api/logout/route.js.nft.json +1 -0
  40. package/.next/server/app/api/oauth/callback/route.js +1 -0
  41. package/.next/server/app/api/oauth/callback/route.js.nft.json +1 -0
  42. package/.next/server/app/api/oauth/client-metadata.json/route.js +1 -0
  43. package/.next/server/app/api/oauth/client-metadata.json/route.js.nft.json +1 -0
  44. package/.next/server/app/api/oauth/jwks.json/route.js +1 -0
  45. package/.next/server/app/api/oauth/jwks.json/route.js.nft.json +1 -0
  46. package/.next/server/app/api/records/route.js +1 -0
  47. package/.next/server/app/api/records/route.js.nft.json +1 -0
  48. package/.next/server/app/api/status/route.js +1 -0
  49. package/.next/server/app/api/status/route.js.nft.json +1 -0
  50. package/.next/server/app/api/v1/graph/route.js +1 -0
  51. package/.next/server/app/api/v1/graph/route.js.nft.json +1 -0
  52. package/.next/server/app/api/v1/issues/[id]/route.js +1 -0
  53. package/.next/server/app/api/v1/issues/[id]/route.js.nft.json +1 -0
  54. package/.next/server/app/api/v1/ready/route.js +1 -0
  55. package/.next/server/app/api/v1/ready/route.js.nft.json +1 -0
  56. package/.next/server/app/index.html +1 -0
  57. package/.next/server/app/index.meta +5 -0
  58. package/.next/server/app/index.rsc +9 -0
  59. package/.next/server/app/login/page.js +1 -0
  60. package/.next/server/app/login/page.js.nft.json +1 -0
  61. package/.next/server/app/login/page_client-reference-manifest.js +1 -0
  62. package/.next/server/app/login.html +1 -0
  63. package/.next/server/app/login.meta +5 -0
  64. package/.next/server/app/login.rsc +9 -0
  65. package/.next/server/app/opengraph-image.png/route.js +1 -0
  66. package/.next/server/app/opengraph-image.png/route.js.nft.json +1 -0
  67. package/.next/server/app/opengraph-image.png.body +0 -0
  68. package/.next/server/app/opengraph-image.png.meta +1 -0
  69. package/.next/server/app/page.js +24 -0
  70. package/.next/server/app/page.js.nft.json +1 -0
  71. package/.next/server/app/page_client-reference-manifest.js +1 -0
  72. package/.next/server/app/twitter-image.png/route.js +1 -0
  73. package/.next/server/app/twitter-image.png/route.js.nft.json +1 -0
  74. package/.next/server/app/twitter-image.png.body +0 -0
  75. package/.next/server/app/twitter-image.png.meta +1 -0
  76. package/.next/server/app-paths-manifest.json +22 -0
  77. package/.next/server/chunks/247.js +12 -0
  78. package/.next/server/chunks/29.js +1 -0
  79. package/.next/server/chunks/343.js +1 -0
  80. package/.next/server/chunks/460.js +12 -0
  81. package/.next/server/chunks/533.js +38 -0
  82. package/.next/server/chunks/542.js +27 -0
  83. package/.next/server/chunks/590.js +6 -0
  84. package/.next/server/chunks/615.js +15 -0
  85. package/.next/server/chunks/696.js +25 -0
  86. package/.next/server/chunks/719.js +2 -0
  87. package/.next/server/chunks/739.js +1 -0
  88. package/.next/server/chunks/950.js +2 -0
  89. package/.next/server/chunks/font-manifest.json +1 -0
  90. package/.next/server/edge-runtime-webpack.js +2 -0
  91. package/.next/server/edge-runtime-webpack.js.map +1 -0
  92. package/.next/server/font-manifest.json +1 -0
  93. package/.next/server/functions-config-manifest.json +1 -0
  94. package/.next/server/interception-route-rewrite-manifest.js +1 -0
  95. package/.next/server/middleware-build-manifest.js +1 -0
  96. package/.next/server/middleware-manifest.json +32 -0
  97. package/.next/server/middleware-react-loadable-manifest.js +1 -0
  98. package/.next/server/middleware.js +14 -0
  99. package/.next/server/middleware.js.map +1 -0
  100. package/.next/server/next-font-manifest.js +1 -0
  101. package/.next/server/next-font-manifest.json +1 -0
  102. package/.next/server/pages/404.html +1 -0
  103. package/.next/server/pages/500.html +1 -0
  104. package/.next/server/pages/_app.js +1 -0
  105. package/.next/server/pages/_app.js.nft.json +1 -0
  106. package/.next/server/pages/_document.js +1 -0
  107. package/.next/server/pages/_document.js.nft.json +1 -0
  108. package/.next/server/pages/_error.js +1 -0
  109. package/.next/server/pages/_error.js.nft.json +1 -0
  110. package/.next/server/pages-manifest.json +1 -0
  111. package/.next/server/server-reference-manifest.js +1 -0
  112. package/.next/server/server-reference-manifest.json +1 -0
  113. package/.next/server/webpack-runtime.js +1 -0
  114. package/.next/static/chunks/149.a3e3a5dc03e21086.js +1 -0
  115. package/.next/static/chunks/2200cc46-7c93a0e00b0bb825.js +1 -0
  116. package/.next/static/chunks/788-aa413085174e935a.js +1 -0
  117. package/.next/static/chunks/945-3ff1d381a0af1ecd.js +2 -0
  118. package/.next/static/chunks/971-bb44d52bcd9ee2a9.js +1 -0
  119. package/.next/static/chunks/app/_not-found/page-200b7a7a6cfc29df.js +1 -0
  120. package/.next/static/chunks/app/api/docs/page-1dc18f40154cdce6.js +1 -0
  121. package/.next/static/chunks/app/layout-13e3cdaaa416edb6.js +1 -0
  122. package/.next/static/chunks/app/login/page-60d930d64f021753.js +1 -0
  123. package/.next/static/chunks/app/not-found-ae1139bed2018dd8.js +1 -0
  124. package/.next/static/chunks/app/page-583300dd8af66e5a.js +1 -0
  125. package/.next/static/chunks/framework-6e06c675866dc992.js +1 -0
  126. package/.next/static/chunks/main-app-8b0c4a1007dbb7f4.js +1 -0
  127. package/.next/static/chunks/main-e680fb049d7426e1.js +1 -0
  128. package/.next/static/chunks/pages/_app-0c3037849002a4aa.js +1 -0
  129. package/.next/static/chunks/pages/_error-a647cd2c75dc4dc7.js +1 -0
  130. package/.next/static/chunks/polyfills-42372ed130431b0a.js +1 -0
  131. package/.next/static/chunks/webpack-117444a4bfe51057.js +1 -0
  132. package/.next/static/css/8c1b520a38ba4ccd.css +3 -0
  133. package/.next/static/vFM69sDrBUf_9ULwPmVAE/_buildManifest.js +1 -0
  134. package/.next/static/vFM69sDrBUf_9ULwPmVAE/_ssgManifest.js +1 -0
  135. package/README.md +389 -0
  136. package/app/api/auth/route.ts +103 -0
  137. package/app/api/beads/route.ts +27 -0
  138. package/app/api/beads/stream/route.ts +83 -0
  139. package/app/api/config/route.ts +48 -0
  140. package/app/api/docs/page.tsx +497 -0
  141. package/app/api/login/route.ts +42 -0
  142. package/app/api/logout/route.ts +14 -0
  143. package/app/api/oauth/callback/route.ts +97 -0
  144. package/app/api/oauth/client-metadata.json/route.ts +33 -0
  145. package/app/api/oauth/jwks.json/route.ts +32 -0
  146. package/app/api/records/route.ts +168 -0
  147. package/app/api/status/route.ts +25 -0
  148. package/app/api/v1/graph/route.ts +251 -0
  149. package/app/api/v1/issues/[id]/route.ts +158 -0
  150. package/app/api/v1/ready/route.ts +229 -0
  151. package/app/globals.css +230 -0
  152. package/app/layout.tsx +51 -0
  153. package/app/login/page.tsx +164 -0
  154. package/app/not-found.tsx +91 -0
  155. package/app/opengraph-image.png +0 -0
  156. package/app/page.tsx +2041 -0
  157. package/app/twitter-image.png +0 -0
  158. package/bin/heartbeads.mjs +225 -0
  159. package/components/ActivityItem.tsx +326 -0
  160. package/components/ActivityOverlay.tsx +125 -0
  161. package/components/ActivityPanel.tsx +345 -0
  162. package/components/AllCommentsPanel.tsx +270 -0
  163. package/components/AuthButton.tsx +202 -0
  164. package/components/BeadTooltip.tsx +246 -0
  165. package/components/BeadsGraph.tsx +2493 -0
  166. package/components/BeadsLogo.tsx +94 -0
  167. package/components/CommentTooltip.tsx +338 -0
  168. package/components/ContextMenu.tsx +272 -0
  169. package/components/DescriptionModal.tsx +595 -0
  170. package/components/GraphStats.tsx +121 -0
  171. package/components/HeartIcon.tsx +33 -0
  172. package/components/HelpPanel.tsx +339 -0
  173. package/components/MobileActionSheet.tsx +255 -0
  174. package/components/NodeDetail.tsx +793 -0
  175. package/components/SettingsModal.tsx +315 -0
  176. package/components/StatusLegend.tsx +99 -0
  177. package/components/TimelineBar.tsx +116 -0
  178. package/components/TutorialOverlay.tsx +235 -0
  179. package/hooks/useBeadsComments.ts +81 -0
  180. package/hooks/useIsMobile.ts +19 -0
  181. package/lib/activity.ts +377 -0
  182. package/lib/agent.ts +29 -0
  183. package/lib/api-helpers.ts +46 -0
  184. package/lib/auth/client.ts +221 -0
  185. package/lib/auth.tsx +159 -0
  186. package/lib/comments.ts +413 -0
  187. package/lib/diff-beads.ts +128 -0
  188. package/lib/discover.ts +228 -0
  189. package/lib/env.ts +33 -0
  190. package/lib/gate.ts +55 -0
  191. package/lib/parse-beads.ts +234 -0
  192. package/lib/session.ts +52 -0
  193. package/lib/settings.ts +42 -0
  194. package/lib/timeline.ts +138 -0
  195. package/lib/tts.ts +397 -0
  196. package/lib/types.ts +271 -0
  197. package/lib/utils.ts +48 -0
  198. package/lib/watch-beads.ts +97 -0
  199. package/next.config.mjs +4 -0
  200. package/package.json +81 -0
  201. package/postcss.config.mjs +9 -0
  202. package/public/image.png +0 -0
  203. package/scripts/generate-jwk.js +38 -0
  204. package/tailwind.config.ts +41 -0
  205. package/tsconfig.json +24 -0
package/lib/types.ts ADDED
@@ -0,0 +1,271 @@
1
+ // ============================================================================
2
+ // Core data types matching .beads/issues.jsonl schema
3
+ // ============================================================================
4
+
5
+ export interface BeadDependency {
6
+ issue_id: string;
7
+ depends_on_id: string;
8
+ type: "blocks" | "parent-child" | "relates_to";
9
+ created_at: string;
10
+ created_by?: string;
11
+ metadata?: string;
12
+ }
13
+
14
+ export interface BeadIssue {
15
+ id: string;
16
+ title: string;
17
+ description?: string;
18
+ status: "open" | "in_progress" | "blocked" | "deferred" | "closed";
19
+ priority: number; // 0 (critical) to 4 (backlog)
20
+ issue_type: "task" | "bug" | "feature" | "chore" | "epic";
21
+ owner?: string;
22
+ assignee?: string;
23
+ labels?: string[];
24
+ created_at: string;
25
+ created_by?: string;
26
+ updated_at: string;
27
+ closed_at?: string;
28
+ close_reason?: string;
29
+ dependencies?: BeadDependency[];
30
+ }
31
+
32
+ // ============================================================================
33
+ // Graph data types for visualization
34
+ // ============================================================================
35
+
36
+ export interface GraphNode {
37
+ [key: string]: unknown; // index signature for react-force-graph compatibility
38
+ id: string;
39
+ title: string;
40
+ description?: string;
41
+ status: BeadIssue["status"];
42
+ priority: number;
43
+ issueType: BeadIssue["issue_type"];
44
+ owner?: string;
45
+ assignee?: string;
46
+ createdBy?: string;
47
+ createdAt: string;
48
+ updatedAt: string;
49
+ closedAt?: string;
50
+ closeReason?: string;
51
+ prefix: string; // e.g. "my-app", "backend", "frontend"
52
+
53
+ // Computed
54
+ blockerCount: number; // issues this blocks
55
+ dependentCount: number; // issues that depend on this
56
+ blockerIds: string[]; // IDs of issues this blocks
57
+ dependentIds: string[]; // IDs of issues blocking this
58
+
59
+ // Force-graph position (set by simulation)
60
+ x?: number;
61
+ y?: number;
62
+ fx?: number;
63
+ fy?: number;
64
+
65
+ // Animation metadata (set by live-update merge logic, consumed by paintNode)
66
+ _spawnTime?: number; // Date.now() when this node first appeared (for pop-in animation)
67
+ _removeTime?: number; // Date.now() when this node was marked for removal (for shrink-out)
68
+ _changedAt?: number; // Date.now() when status/priority changed (for ripple animation)
69
+ _prevStatus?: string; // Previous status value before the change (for color transition)
70
+ }
71
+
72
+ // ============================================================================
73
+ // Color mode for legend/node fill switching
74
+ // ============================================================================
75
+
76
+ export type ColorMode = "status" | "priority" | "owner" | "assignee" | "prefix";
77
+
78
+ export const COLOR_MODE_LABELS: Record<ColorMode, string> = {
79
+ status: "Status",
80
+ priority: "Priority",
81
+ owner: "Owner",
82
+ assignee: "Assignee",
83
+ prefix: "Prefix",
84
+ };
85
+
86
+ export interface GraphLink {
87
+ source: string;
88
+ target: string;
89
+ type: "blocks" | "parent-child" | "relates_to";
90
+ createdAt?: string; // ISO 8601 from BeadDependency.created_at (for timeline replay)
91
+
92
+ // Animation metadata (set by live-update merge logic, consumed by paintLink)
93
+ _spawnTime?: number; // Date.now() when this link first appeared (for fade-in animation)
94
+ _removeTime?: number; // Date.now() when this link was marked for removal (for fade-out)
95
+ }
96
+
97
+ export interface GraphData {
98
+ nodes: GraphNode[];
99
+ links: GraphLink[];
100
+ }
101
+
102
+ export interface BeadsApiResponse {
103
+ issues: BeadIssue[];
104
+ dependencies: BeadDependency[];
105
+ graphData: GraphData;
106
+ stats: {
107
+ total: number;
108
+ open: number;
109
+ inProgress: number;
110
+ blocked: number;
111
+ closed: number;
112
+ actionable: number;
113
+ edges: number;
114
+ prefixes: string[];
115
+ };
116
+ }
117
+
118
+ // ============================================================================
119
+ // Status and priority display helpers
120
+ // ============================================================================
121
+
122
+ export const STATUS_COLORS: Record<string, string> = {
123
+ open: "#10b981", // emerald-500
124
+ in_progress: "#f59e0b", // amber-500
125
+ blocked: "#ef4444", // red-500
126
+ deferred: "#8b5cf6", // violet-500
127
+ closed: "#a1a1aa", // zinc-400
128
+ };
129
+
130
+ export const STATUS_LABELS: Record<string, string> = {
131
+ open: "Open",
132
+ in_progress: "In Progress",
133
+ blocked: "Blocked",
134
+ deferred: "Deferred",
135
+ closed: "Closed",
136
+ };
137
+
138
+ export const PRIORITY_LABELS: Record<number, string> = {
139
+ 0: "P0 - Critical",
140
+ 1: "P1 - High",
141
+ 2: "P2 - Medium",
142
+ 3: "P3 - Low",
143
+ 4: "P4 - Backlog",
144
+ };
145
+
146
+ export const PRIORITY_COLORS: Record<number, string> = {
147
+ 0: "#ef4444", // red
148
+ 1: "#f97316", // orange
149
+ 2: "#3b82f6", // blue
150
+ 3: "#a1a1aa", // zinc
151
+ 4: "#d4d4d8", // zinc-300
152
+ };
153
+
154
+ // ============================================================================
155
+ // Catppuccin Latte accent palette
156
+ // ============================================================================
157
+
158
+ /** Catppuccin Latte accent colors — 14 visually distinct, reordered for max contrast between adjacent indices */
159
+ export const CATPPUCCIN_ACCENTS: string[] = [
160
+ "#d20f39", // Red
161
+ "#179299", // Teal
162
+ "#fe640b", // Peach
163
+ "#1e66f5", // Blue
164
+ "#40a02b", // Green
165
+ "#8839ef", // Mauve
166
+ "#df8e1d", // Yellow
167
+ "#209fb5", // Sapphire
168
+ "#ea76cb", // Pink
169
+ "#04a5e5", // Sky
170
+ "#e64553", // Maroon
171
+ "#7287fd", // Lavender
172
+ "#dd7878", // Flamingo
173
+ "#dc8a78", // Rosewater
174
+ ];
175
+
176
+ export const CATPPUCCIN_ACCENT_NAMES: string[] = [
177
+ "Red", "Teal", "Peach", "Blue", "Green", "Mauve", "Yellow",
178
+ "Sapphire", "Pink", "Sky", "Maroon", "Lavender", "Flamingo", "Rosewater",
179
+ ];
180
+
181
+ /** Catppuccin Latte Surface2 — used for "unassigned" / unknown values */
182
+ export const CATPPUCCIN_UNASSIGNED = "#acb0be";
183
+
184
+ export const TYPE_ICONS: Record<string, string> = {
185
+ bug: "\uD83D\uDC1B",
186
+ feature: "\u2728",
187
+ task: "\uD83D\uDCDD",
188
+ epic: "\uD83C\uDFAF",
189
+ chore: "\uD83D\uDD27",
190
+ };
191
+
192
+ // ============================================================================
193
+ // Dynamic prefix label and color generation
194
+ // ============================================================================
195
+
196
+ /**
197
+ * Generate a deterministic color from a string using a hash-based hue.
198
+ * Uses FNV-1a for better distribution across the hue wheel.
199
+ */
200
+ function hashColor(str: string): string {
201
+ let hash = 2166136261; // FNV offset basis
202
+ for (let i = 0; i < str.length; i++) {
203
+ hash ^= str.charCodeAt(i);
204
+ hash = (hash * 16777619) >>> 0; // FNV prime, keep as uint32
205
+ }
206
+ const hue = hash % 360;
207
+ return `hsl(${hue}, 65%, 55%)`;
208
+ }
209
+
210
+ /**
211
+ * Humanize a prefix string into a display label.
212
+ * "my-lib" -> "My Lib", "backend" -> "Backend"
213
+ */
214
+ function humanizePrefix(prefix: string): string {
215
+ return prefix
216
+ .split(/[-_]/)
217
+ .map((w) => w.charAt(0).toUpperCase() + w.slice(1))
218
+ .join(" ");
219
+ }
220
+
221
+ /**
222
+ * Get a display label for a prefix. Returns a human-readable name
223
+ * auto-generated from the prefix string.
224
+ */
225
+ export function getPrefixLabel(prefix: string): string {
226
+ return humanizePrefix(prefix);
227
+ }
228
+
229
+ /**
230
+ * Get a color for a prefix. Generates a deterministic color
231
+ * from the prefix string using hash-based hue.
232
+ */
233
+ export function getPrefixColor(prefix: string): string {
234
+ return hashColor(prefix);
235
+ }
236
+
237
+ /**
238
+ * Deterministically map a person handle/name to a Catppuccin Latte accent color.
239
+ * Uses FNV-1a hash modulo 14 to index into CATPPUCCIN_ACCENTS.
240
+ * Returns CATPPUCCIN_UNASSIGNED for undefined/null/empty strings.
241
+ */
242
+ export function getPersonColor(person: string | undefined): string {
243
+ if (!person) return CATPPUCCIN_UNASSIGNED;
244
+ let hash = 2166136261; // FNV offset basis
245
+ for (let i = 0; i < person.length; i++) {
246
+ hash ^= person.charCodeAt(i);
247
+ hash = (hash * 16777619) >>> 0;
248
+ }
249
+ return CATPPUCCIN_ACCENTS[hash % CATPPUCCIN_ACCENTS.length];
250
+ }
251
+
252
+ /**
253
+ * Deterministically map a prefix string to a Catppuccin Latte accent color.
254
+ * Same approach as getPersonColor but for project prefixes.
255
+ */
256
+ export function getCatppuccinPrefixColor(prefix: string): string {
257
+ return getPersonColor(prefix);
258
+ }
259
+
260
+ // Backward-compatible exports — delegates to dynamic functions
261
+ // so existing code using PREFIX_COLORS[prefix] || fallback still works.
262
+ // Prefer using getPrefixColor() / getPrefixLabel() directly.
263
+ export const PREFIX_LABELS = new Proxy({} as Record<string, string>, {
264
+ get: (_target, prop: string) => getPrefixLabel(prop),
265
+ has: () => true,
266
+ });
267
+
268
+ export const PREFIX_COLORS = new Proxy({} as Record<string, string>, {
269
+ get: (_target, prop: string) => getPrefixColor(prop),
270
+ has: () => true,
271
+ });
package/lib/utils.ts ADDED
@@ -0,0 +1,48 @@
1
+ /**
2
+ * Shared utilities for heartbeads.
3
+ */
4
+
5
+ import { getPrefixLabel } from "@/lib/types";
6
+ import type { GraphNode } from "@/lib/types";
7
+
8
+ /**
9
+ * Formats an ISO date string as a human-readable relative time.
10
+ * - < 60s: "just now"
11
+ * - < 60m: "Xm ago"
12
+ * - < 24h: "Xh ago"
13
+ * - < 7d: "Xd ago"
14
+ * - else: "Mon DD" (e.g. "Feb 10")
15
+ */
16
+ export function formatRelativeTime(isoString: string): string {
17
+ const date = new Date(isoString);
18
+ const now = new Date();
19
+ const seconds = Math.floor((now.getTime() - date.getTime()) / 1000);
20
+ if (seconds < 60) return "just now";
21
+ const minutes = Math.floor(seconds / 60);
22
+ if (minutes < 60) return `${minutes}m ago`;
23
+ const hours = Math.floor(minutes / 60);
24
+ if (hours < 24) return `${hours}h ago`;
25
+ const days = Math.floor(hours / 24);
26
+ if (days < 7) return `${days}d ago`;
27
+ return date.toLocaleDateString("en-US", { month: "short", day: "numeric" });
28
+ }
29
+
30
+ /**
31
+ * Build a copy-pasteable text for a node's description with metadata header.
32
+ * Format:
33
+ * [Project Name] issue-id
34
+ * https://github.com/org/repo (if available)
35
+ *
36
+ * <description>
37
+ */
38
+ export function buildDescriptionCopyText(
39
+ node: GraphNode,
40
+ repoUrl?: string,
41
+ ): string {
42
+ const lines: string[] = [];
43
+ lines.push(`[${getPrefixLabel(node.prefix)}] ${node.id}`);
44
+ if (repoUrl) lines.push(repoUrl);
45
+ lines.push("");
46
+ lines.push(node.description || "");
47
+ return lines.join("\n");
48
+ }
@@ -0,0 +1,97 @@
1
+ import { watch, existsSync } from "fs";
2
+ import { join, dirname, basename } from "path";
3
+ import { getAdditionalRepoPaths } from "./parse-beads";
4
+
5
+ /**
6
+ * Get all issues.jsonl file paths that should be watched.
7
+ * Returns the primary path plus any additional repo paths from config.yaml.
8
+ */
9
+ export function getWatchPaths(beadsDir: string): string[] {
10
+ const paths: string[] = [];
11
+
12
+ // Primary JSONL
13
+ const primary = join(beadsDir, "issues.jsonl");
14
+ if (existsSync(primary)) paths.push(primary);
15
+
16
+ // Additional repo JSONLs
17
+ const additionalRepos = getAdditionalRepoPaths(beadsDir);
18
+ for (const repoPath of additionalRepos) {
19
+ const jsonlPath = join(repoPath, ".beads", "issues.jsonl");
20
+ if (existsSync(jsonlPath)) paths.push(jsonlPath);
21
+ }
22
+
23
+ return paths;
24
+ }
25
+
26
+ /**
27
+ * Watch all issues.jsonl files for a beads project.
28
+ * Discovers files from the primary .beads dir and config.yaml repos.additional.
29
+ * Debounces rapid changes (bd often writes multiple times per command).
30
+ *
31
+ * @param beadsDir - Absolute path to the primary .beads/ directory
32
+ * @param onChange - Callback fired when any watched file changes (after debounce)
33
+ * @param debounceMs - Debounce interval in milliseconds (default: 300)
34
+ * @returns Cleanup function that closes all watchers
35
+ */
36
+ export function watchBeadsFiles(
37
+ beadsDir: string,
38
+ onChange: () => void,
39
+ debounceMs = 300
40
+ ): () => void {
41
+ const filePaths = getWatchPaths(beadsDir);
42
+ let timer: ReturnType<typeof setTimeout> | null = null;
43
+ const watchers: ReturnType<typeof watch>[] = [];
44
+
45
+ // Deduplicate directories — multiple files may share the same .beads/ dir
46
+ const dirToFiles = new Map<string, Set<string>>();
47
+ for (const filePath of filePaths) {
48
+ const dir = dirname(filePath);
49
+ const file = basename(filePath);
50
+ if (!dirToFiles.has(dir)) dirToFiles.set(dir, new Set());
51
+ dirToFiles.get(dir)!.add(file);
52
+ }
53
+
54
+ console.log(
55
+ `[heartbeads] Watching ${filePaths.length} file(s) in ${dirToFiles.size} dir(s) for changes`
56
+ );
57
+
58
+ const debouncedOnChange = () => {
59
+ if (timer) clearTimeout(timer);
60
+ timer = setTimeout(() => {
61
+ console.log("[heartbeads] File change detected, pushing update");
62
+ onChange();
63
+ }, debounceMs);
64
+ };
65
+
66
+ // Watch directories instead of individual files.
67
+ // This is far more reliable on macOS: fs.watch on a file breaks when the
68
+ // file is atomically replaced (write-tmp + rename), which is how bd and
69
+ // many editors write. Watching the directory catches renames reliably.
70
+ for (const [dir, fileNames] of dirToFiles) {
71
+ try {
72
+ const watcher = watch(dir, { persistent: true }, (_eventType, filename) => {
73
+ // Filter: only react to changes to our target files
74
+ if (filename && fileNames.has(filename)) {
75
+ debouncedOnChange();
76
+ }
77
+ });
78
+ watchers.push(watcher);
79
+ console.log(`[heartbeads] Watching dir: ${dir} for [${[...fileNames].join(", ")}]`);
80
+ } catch (err) {
81
+ console.warn(`[heartbeads] Failed to watch ${dir}:`, err);
82
+ }
83
+ }
84
+
85
+ if (filePaths.length === 0) {
86
+ console.warn("[heartbeads] No issues.jsonl files found to watch");
87
+ }
88
+
89
+ // Return cleanup function
90
+ return () => {
91
+ if (timer) clearTimeout(timer);
92
+ for (const w of watchers) {
93
+ w.close();
94
+ }
95
+ console.log("[heartbeads] File watchers closed");
96
+ };
97
+ }
@@ -0,0 +1,4 @@
1
+ /** @type {import('next').NextConfig} */
2
+ const nextConfig = {};
3
+
4
+ export default nextConfig;
package/package.json ADDED
@@ -0,0 +1,81 @@
1
+ {
2
+ "name": "heartbeads",
3
+ "version": "0.4.0",
4
+ "description": "Interactive dependency graph viewer for beads (bd) issues",
5
+ "keywords": [
6
+ "beads",
7
+ "bd",
8
+ "issue-tracking",
9
+ "dependency-graph",
10
+ "visualization"
11
+ ],
12
+ "repository": {
13
+ "type": "git",
14
+ "url": "https://github.com/GainForest/heartbeads.git"
15
+ },
16
+ "license": "MIT",
17
+ "bin": {
18
+ "heartbeads": "./bin/heartbeads.mjs"
19
+ },
20
+ "files": [
21
+ "app",
22
+ "components",
23
+ "hooks",
24
+ "lib",
25
+ "bin",
26
+ "scripts",
27
+ "public",
28
+ ".next/server",
29
+ ".next/static",
30
+ ".next/BUILD_ID",
31
+ ".next/build-manifest.json",
32
+ ".next/app-build-manifest.json",
33
+ ".next/app-path-routes-manifest.json",
34
+ ".next/export-marker.json",
35
+ ".next/images-manifest.json",
36
+ ".next/package.json",
37
+ ".next/prerender-manifest.json",
38
+ ".next/react-loadable-manifest.json",
39
+ ".next/required-server-files.json",
40
+ ".next/routes-manifest.json",
41
+ ".next/next-minimal-server.js.nft.json",
42
+ ".next/next-server.js.nft.json",
43
+ "next.config.mjs",
44
+ "tailwind.config.ts",
45
+ "tsconfig.json",
46
+ "postcss.config.mjs",
47
+ "package.json"
48
+ ],
49
+ "scripts": {
50
+ "dev": "next dev",
51
+ "build": "next build",
52
+ "start": "next start",
53
+ "lint": "next lint",
54
+ "prepublishOnly": "next build"
55
+ },
56
+ "dependencies": {
57
+ "@atproto/api": "^0.18.20",
58
+ "@atproto/jwk-jose": "^0.1.11",
59
+ "@atproto/oauth-client-node": "^0.3.16",
60
+ "@atproto/syntax": "^0.4.3",
61
+ "@types/d3-force": "^3.0.10",
62
+ "d3-force": "^3.0.0",
63
+ "iron-session": "^8.0.4",
64
+ "next": "^14.2.21",
65
+ "react": "^18.3.1",
66
+ "react-dom": "^18.3.1",
67
+ "react-force-graph-2d": "^1.25.7",
68
+ "react-markdown": "^10.1.0",
69
+ "remark-gfm": "^4.0.1",
70
+ "yaml": "^2.8.2"
71
+ },
72
+ "devDependencies": {
73
+ "@types/node": "^20.17.0",
74
+ "@types/react": "^18.3.12",
75
+ "@types/react-dom": "^18.3.1",
76
+ "autoprefixer": "^10.4.20",
77
+ "postcss": "^8.4.49",
78
+ "tailwindcss": "^3.4.16",
79
+ "typescript": "^5.7.2"
80
+ }
81
+ }
@@ -0,0 +1,9 @@
1
+ /** @type {import('postcss-load-config').Config} */
2
+ const config = {
3
+ plugins: {
4
+ tailwindcss: {},
5
+ autoprefixer: {},
6
+ },
7
+ };
8
+
9
+ export default config;
Binary file
@@ -0,0 +1,38 @@
1
+ #!/usr/bin/env node
2
+
3
+ /**
4
+ * Generate an ES256 JWK private key for ATProto OAuth confidential client authentication.
5
+ *
6
+ * Usage: node scripts/generate-jwk.js
7
+ *
8
+ * Copy the output and add it to your .env file as ATPROTO_JWK_PRIVATE
9
+ */
10
+
11
+ const crypto = require('crypto')
12
+
13
+ async function generateJWK() {
14
+ // Generate an EC key pair using P-256 curve (required for ES256)
15
+ const { privateKey } = await crypto.subtle.generateKey(
16
+ {
17
+ name: 'ECDSA',
18
+ namedCurve: 'P-256',
19
+ },
20
+ true, // extractable
21
+ ['sign', 'verify']
22
+ )
23
+
24
+ // Export as JWK
25
+ const jwk = await crypto.subtle.exportKey('jwk', privateKey)
26
+
27
+ // Add key ID and algorithm
28
+ jwk.kid = `key-${Date.now()}`
29
+ jwk.alg = 'ES256'
30
+ jwk.use = 'sig'
31
+
32
+ console.log('\n=== ES256 Private Key (JWK) ===\n')
33
+ console.log('Add this to your .env file as ATPROTO_JWK_PRIVATE:\n')
34
+ console.log(JSON.stringify(jwk))
35
+ console.log('\n')
36
+ }
37
+
38
+ generateJWK().catch(console.error)
@@ -0,0 +1,41 @@
1
+ import type { Config } from "tailwindcss";
2
+
3
+ const config: Config = {
4
+ content: [
5
+ "./app/**/*.{js,ts,jsx,tsx,mdx}",
6
+ "./components/**/*.{js,ts,jsx,tsx,mdx}",
7
+ ],
8
+ theme: {
9
+ extend: {
10
+ fontFamily: {
11
+ sans: [
12
+ "Inter",
13
+ "system-ui",
14
+ "-apple-system",
15
+ "BlinkMacSystemFont",
16
+ "Segoe UI",
17
+ "Roboto",
18
+ "sans-serif",
19
+ ],
20
+ mono: ["JetBrains Mono", "Fira Code", "monospace"],
21
+ },
22
+ colors: {
23
+ beads: {
24
+ 50: "#ecfdf5",
25
+ 100: "#d1fae5",
26
+ 200: "#a7f3d0",
27
+ 300: "#6ee7b7",
28
+ 400: "#34d399",
29
+ 500: "#10b981",
30
+ 600: "#059669",
31
+ 700: "#047857",
32
+ 800: "#065f46",
33
+ 900: "#064e3b",
34
+ },
35
+ },
36
+ },
37
+ },
38
+ plugins: [],
39
+ };
40
+
41
+ export default config;
package/tsconfig.json ADDED
@@ -0,0 +1,24 @@
1
+ {
2
+ "compilerOptions": {
3
+ "lib": ["dom", "dom.iterable", "esnext"],
4
+ "allowJs": true,
5
+ "skipLibCheck": true,
6
+ "strict": true,
7
+ "noEmit": true,
8
+ "target": "es2017",
9
+ "downlevelIteration": true,
10
+ "esModuleInterop": true,
11
+ "module": "esnext",
12
+ "moduleResolution": "bundler",
13
+ "resolveJsonModule": true,
14
+ "isolatedModules": true,
15
+ "jsx": "preserve",
16
+ "incremental": true,
17
+ "plugins": [{ "name": "next" }],
18
+ "paths": {
19
+ "@/*": ["./*"]
20
+ }
21
+ },
22
+ "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
23
+ "exclude": ["node_modules"]
24
+ }