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
@@ -0,0 +1,235 @@
1
+ "use client";
2
+
3
+ import { useEffect, useState, useCallback } from "react";
4
+
5
+ export interface TutorialStep {
6
+ target: string;
7
+ title: string;
8
+ description: string;
9
+ padding?: number;
10
+ }
11
+
12
+ export const TUTORIAL_STEPS: TutorialStep[] = [
13
+ {
14
+ target: "graph",
15
+ title: "The Dependency Graph",
16
+ description:
17
+ "Each circle is a task or issue. The flowing particles between them show the direction of dependencies \u2014 what needs to happen before something else can start. Bigger circles mean more connections.",
18
+ padding: 0,
19
+ },
20
+ {
21
+ target: "layouts",
22
+ title: "Layout Modes",
23
+ description:
24
+ "Switch how the graph is arranged. Force is organic and physics-based. DAG gives you a clean top-down tree. Radial spreads nodes in rings. Cluster groups by project. Spread spaces everything out for screenshots.",
25
+ },
26
+ {
27
+ target: "btn-collapse",
28
+ title: "Collapse / Expand",
29
+ description:
30
+ "Group all epic tasks into a single node, or expand them back out. Great for getting a high-level overview of your project when there are many tasks.",
31
+ },
32
+ {
33
+ target: "btn-clusters",
34
+ title: "Cluster Labels",
35
+ description:
36
+ "Toggle transparent labels that show project prefixes behind groups of related nodes. Helps you see which project each cluster belongs to at a glance.",
37
+ },
38
+ {
39
+ target: "btn-autofit",
40
+ title: "Auto-fit",
41
+ description:
42
+ "When enabled (green), the camera automatically re-centers and zooms to fit all nodes after every update. Turn it off to keep the camera fixed while you explore.",
43
+ },
44
+ {
45
+ target: "btn-pulse",
46
+ title: "Pulse",
47
+ description:
48
+ "Highlights the most recently active node with emerald ripples. Useful for spotting which task just changed status or was just created.",
49
+ },
50
+ {
51
+ target: "legend-stats",
52
+ title: "Project Stats",
53
+ description:
54
+ "A quick summary of your project: total issues, dependency count, and how many separate projects are tracked in this graph.",
55
+ },
56
+ {
57
+ target: "legend-color-mode",
58
+ title: "Color Mode Selector",
59
+ description:
60
+ "Switch what the node fill color represents. Status shows open/closed/blocked. Priority shows urgency. Owner, Assignee, and Prefix color by person or project. The ring around each node always shows which project it belongs to.",
61
+ },
62
+ {
63
+ target: "legend-items",
64
+ title: "Legend",
65
+ description:
66
+ "Shows what each color means for the currently selected color mode. The colored dots match the node fills in the graph above.",
67
+ },
68
+ {
69
+ target: "minimap",
70
+ title: "Minimap",
71
+ description:
72
+ "A bird\u2019s-eye view of your entire graph. Click anywhere to jump there. Drag the edges to resize it.",
73
+ },
74
+ {
75
+ target: "search",
76
+ title: "Search",
77
+ description:
78
+ "Press Cmd+F (or Ctrl+F) to search by name, ID, owner, assignee, or commenter. Arrow keys to navigate, Enter to focus.",
79
+ },
80
+ {
81
+ target: "graph",
82
+ title: "Interacting with Nodes",
83
+ description:
84
+ "Click a node to see details. Hover for a quick tooltip. Right-click for actions like viewing descriptions, commenting, claiming tasks, collapsing epics, or focusing on an epic to isolate its subgraph.",
85
+ padding: 0,
86
+ },
87
+ {
88
+ target: "nav-replay",
89
+ title: "Replay",
90
+ description:
91
+ "Step through your project\u2019s history one event at a time. Watch issues appear and change status as they were created. Use the scrubber to jump to any point in time.",
92
+ },
93
+ {
94
+ target: "nav-comments",
95
+ title: "Comments",
96
+ description:
97
+ "Opens a sidebar showing all conversations across your beads. See who said what, reply to threads, and like comments \u2014 all powered by the AT Protocol.",
98
+ },
99
+ {
100
+ target: "nav-activity",
101
+ title: "Activity",
102
+ description:
103
+ "A real-time feed of changes filtered to only your local issues. See when tasks are created, status changes, priorities shift, and new dependencies are added.",
104
+ },
105
+ {
106
+ target: "nav-learn",
107
+ title: "Learn",
108
+ description:
109
+ "Opens the help panel with keyboard shortcuts, feature documentation, and this interactive tutorial. Click it anytime to come back here.",
110
+ },
111
+ ];
112
+
113
+ interface TutorialOverlayProps {
114
+ step: number | null;
115
+ onNext: () => void;
116
+ onPrev: () => void;
117
+ onEnd: () => void;
118
+ }
119
+
120
+ interface Rect {
121
+ left: number;
122
+ top: number;
123
+ width: number;
124
+ height: number;
125
+ }
126
+
127
+ export function TutorialOverlay({
128
+ step,
129
+ onNext,
130
+ onEnd,
131
+ }: TutorialOverlayProps) {
132
+ const [targetRect, setTargetRect] = useState<Rect | null>(null);
133
+
134
+ const updateRect = useCallback(() => {
135
+ if (step === null) {
136
+ setTargetRect(null);
137
+ return;
138
+ }
139
+ const currentStep = TUTORIAL_STEPS[step];
140
+ if (!currentStep) {
141
+ setTargetRect(null);
142
+ return;
143
+ }
144
+ const el = document.querySelector(
145
+ `[data-tutorial="${currentStep.target}"]`
146
+ );
147
+ if (!el) {
148
+ setTargetRect(null);
149
+ } else {
150
+ const r = el.getBoundingClientRect();
151
+ const padding = currentStep.padding ?? 8;
152
+ setTargetRect({
153
+ left: r.left - padding,
154
+ top: r.top - padding,
155
+ width: r.width + padding * 2,
156
+ height: r.height + padding * 2,
157
+ });
158
+ }
159
+ }, [step]);
160
+
161
+ useEffect(() => {
162
+ updateRect();
163
+ const handleResize = () => updateRect();
164
+ window.addEventListener("resize", handleResize);
165
+ const timer = setTimeout(updateRect, 150);
166
+ return () => {
167
+ window.removeEventListener("resize", handleResize);
168
+ clearTimeout(timer);
169
+ };
170
+ }, [updateRect]);
171
+
172
+ if (step === null) return null;
173
+
174
+ const isLast = step === TUTORIAL_STEPS.length - 1;
175
+
176
+ const handleClick = () => {
177
+ if (isLast) {
178
+ onEnd();
179
+ } else {
180
+ onNext();
181
+ }
182
+ };
183
+
184
+ // The sidebar is z-[60] during tutorial, so it naturally sits above this z-[55] overlay.
185
+ // Clicking the dark area advances/ends the tutorial. The spotlight cutout is visual only.
186
+ return (
187
+ <>
188
+ {/* Full-screen clickable dark overlay */}
189
+ <div
190
+ className="fixed inset-0 z-[55] cursor-pointer"
191
+ onClick={handleClick}
192
+ >
193
+ {/* SVG with mask to punch a transparent hole for the spotlight */}
194
+ <svg className="absolute inset-0 w-full h-full" xmlns="http://www.w3.org/2000/svg">
195
+ <defs>
196
+ <mask id="tutorial-mask">
197
+ <rect width="100%" height="100%" fill="white" />
198
+ {targetRect && (
199
+ <rect
200
+ x={targetRect.left}
201
+ y={targetRect.top}
202
+ width={targetRect.width}
203
+ height={targetRect.height}
204
+ rx={8}
205
+ ry={8}
206
+ fill="black"
207
+ />
208
+ )}
209
+ </mask>
210
+ </defs>
211
+ <rect
212
+ width="100%"
213
+ height="100%"
214
+ fill="rgba(0,0,0,0.45)"
215
+ mask="url(#tutorial-mask)"
216
+ />
217
+ </svg>
218
+ </div>
219
+
220
+ {/* Pulsing ring around spotlight target */}
221
+ {targetRect && (
222
+ <div
223
+ className="fixed rounded-lg ring-2 ring-emerald-400/60 animate-pulse z-[56]"
224
+ style={{
225
+ left: targetRect.left,
226
+ top: targetRect.top,
227
+ width: targetRect.width,
228
+ height: targetRect.height,
229
+ pointerEvents: "none",
230
+ }}
231
+ />
232
+ )}
233
+ </>
234
+ );
235
+ }
@@ -0,0 +1,81 @@
1
+ "use client";
2
+
3
+ import { useEffect, useState, useCallback, useRef } from "react";
4
+ import { fetchBeadsComments } from "@/lib/comments";
5
+ import type { FetchCommentsResult } from "@/lib/comments";
6
+
7
+ // Re-export types so existing imports from this module continue to work
8
+ export type { BeadsComment, BeadsLike } from "@/lib/comments";
9
+
10
+ export interface UseBeadsCommentsResult {
11
+ /** Map from node ID -> threaded comment trees (root comments with nested replies) */
12
+ commentsByNode: Map<string, BeadsComment[]>;
13
+ /** Map from node ID -> total comment count (including replies) */
14
+ commentedNodeIds: Map<string, number>;
15
+ /** Flat list of ALL comments (including replies), newest-first, for the All Comments panel */
16
+ allComments: BeadsComment[];
17
+ isLoading: boolean;
18
+ error: string | null;
19
+ refetch: () => Promise<void>;
20
+ }
21
+
22
+ // Need to import the concrete type for state usage
23
+ import type { BeadsComment } from "@/lib/comments";
24
+
25
+ export function useBeadsComments(): UseBeadsCommentsResult {
26
+ const [commentsByNode, setCommentsByNode] = useState<
27
+ Map<string, BeadsComment[]>
28
+ >(new Map());
29
+ const [commentedNodeIds, setCommentedNodeIds] = useState<
30
+ Map<string, number>
31
+ >(new Map());
32
+ const [allComments, setAllComments] = useState<BeadsComment[]>([]);
33
+ const [isLoading, setIsLoading] = useState(true);
34
+ const [error, setError] = useState<string | null>(null);
35
+ const cancelledRef = useRef(false);
36
+
37
+ const fetchAndProcess = useCallback(async () => {
38
+ try {
39
+ setIsLoading(true);
40
+ setError(null);
41
+
42
+ const result: FetchCommentsResult = await fetchBeadsComments();
43
+
44
+ // Update state (only if not cancelled)
45
+ if (!cancelledRef.current) {
46
+ setCommentsByNode(result.commentsByNode);
47
+ setCommentedNodeIds(result.commentedNodeIds);
48
+ setAllComments(result.allComments);
49
+ }
50
+ } catch (err) {
51
+ if (!cancelledRef.current) {
52
+ const message =
53
+ err instanceof Error ? err.message : "Failed to fetch comments";
54
+ console.error("Failed to fetch beads comments:", err);
55
+ setError(message);
56
+ }
57
+ } finally {
58
+ if (!cancelledRef.current) {
59
+ setIsLoading(false);
60
+ }
61
+ }
62
+ }, []);
63
+
64
+ // Fetch on mount
65
+ useEffect(() => {
66
+ cancelledRef.current = false;
67
+ fetchAndProcess();
68
+ return () => {
69
+ cancelledRef.current = true;
70
+ };
71
+ }, [fetchAndProcess]);
72
+
73
+ return {
74
+ commentsByNode,
75
+ commentedNodeIds,
76
+ allComments,
77
+ isLoading,
78
+ error,
79
+ refetch: fetchAndProcess,
80
+ };
81
+ }
@@ -0,0 +1,19 @@
1
+ "use client";
2
+
3
+ import { useState, useEffect } from "react";
4
+
5
+ const MOBILE_BREAKPOINT = 768; // matches Tailwind md:
6
+
7
+ export function useIsMobile(): boolean {
8
+ const [isMobile, setIsMobile] = useState(false);
9
+
10
+ useEffect(() => {
11
+ const mql = window.matchMedia(`(max-width: ${MOBILE_BREAKPOINT}px)`);
12
+ setIsMobile(mql.matches);
13
+ const handler = (e: MediaQueryListEvent) => setIsMobile(e.matches);
14
+ mql.addEventListener("change", handler);
15
+ return () => mql.removeEventListener("change", handler);
16
+ }, []);
17
+
18
+ return isMobile;
19
+ }
@@ -0,0 +1,377 @@
1
+ /**
2
+ * Activity feed: unified event type and builders for historical + real-time events.
3
+ *
4
+ * Data sources:
5
+ * 1. Issue JSONL timestamps (created_at, closed_at, updated_at)
6
+ * 2. Dependency timestamps (link created_at)
7
+ * 3. ATProto comments, claims, likes (from useBeadsComments)
8
+ * 4. Real-time SSE diffs (BeadsDiff from diffBeadsData)
9
+ */
10
+
11
+ import type { GraphNode, GraphLink } from "./types";
12
+ import type { BeadsDiff, NodeChange } from "./diff-beads";
13
+ import type { BeadsComment } from "@/lib/comments";
14
+
15
+ // ============================================================================
16
+ // Types
17
+ // ============================================================================
18
+
19
+ export type ActivityEventType =
20
+ | "node-created"
21
+ | "node-closed"
22
+ | "node-status-changed"
23
+ | "node-priority-changed"
24
+ | "node-title-changed"
25
+ | "node-owner-changed"
26
+ | "link-added"
27
+ | "link-removed"
28
+ | "comment-added"
29
+ | "reply-added"
30
+ | "task-claimed"
31
+ | "task-unclaimed"
32
+ | "like-added";
33
+
34
+ export interface ActivityActor {
35
+ handle: string;
36
+ avatar?: string;
37
+ did?: string;
38
+ }
39
+
40
+ export interface ActivityEvent {
41
+ /** Unique key for React rendering and deduplication: `${type}:${nodeId}:${time}` */
42
+ id: string;
43
+ type: ActivityEventType;
44
+ /** Unix milliseconds, for sorting */
45
+ time: number;
46
+ /** Which issue this event relates to */
47
+ nodeId: string;
48
+ /** Issue title for display (may be undefined for deleted nodes) */
49
+ nodeTitle?: string;
50
+ /** Who performed the action (for comments, claims, likes) */
51
+ actor?: ActivityActor;
52
+ /** Human-readable detail: e.g. "open -> in_progress", comment text preview, link target */
53
+ detail?: string;
54
+ /** Extra structured context */
55
+ meta?: Record<string, string>;
56
+ }
57
+
58
+ /** Filter category for the UI */
59
+ export type ActivityFilterCategory =
60
+ | "issues"
61
+ | "deps"
62
+ | "comments"
63
+ | "claims"
64
+ | "likes";
65
+
66
+ /** Map event types to filter categories */
67
+ export function getEventCategory(type: ActivityEventType): ActivityFilterCategory {
68
+ switch (type) {
69
+ case "node-created":
70
+ case "node-closed":
71
+ case "node-status-changed":
72
+ case "node-priority-changed":
73
+ case "node-title-changed":
74
+ case "node-owner-changed":
75
+ return "issues";
76
+ case "link-added":
77
+ case "link-removed":
78
+ return "deps";
79
+ case "comment-added":
80
+ case "reply-added":
81
+ return "comments";
82
+ case "task-claimed":
83
+ case "task-unclaimed":
84
+ return "claims";
85
+ case "like-added":
86
+ return "likes";
87
+ }
88
+ }
89
+
90
+ // ============================================================================
91
+ // Historical feed builder
92
+ // ============================================================================
93
+
94
+ /**
95
+ * Build the full historical activity feed from existing data.
96
+ * Called once on load and when allComments changes.
97
+ */
98
+ export function buildHistoricalFeed(
99
+ nodes: GraphNode[],
100
+ links: GraphLink[],
101
+ allComments: BeadsComment[] | null
102
+ ): ActivityEvent[] {
103
+ const events: ActivityEvent[] = [];
104
+ const seen = new Set<string>();
105
+
106
+ function add(event: ActivityEvent) {
107
+ if (seen.has(event.id)) return;
108
+ seen.add(event.id);
109
+ events.push(event);
110
+ }
111
+
112
+ // --- Issue lifecycle events ---
113
+ for (const node of nodes) {
114
+ // Created
115
+ if (node.createdAt) {
116
+ const time = new Date(node.createdAt).getTime();
117
+ if (!isNaN(time)) {
118
+ add({
119
+ id: `node-created:${node.id}:${time}`,
120
+ type: "node-created",
121
+ time,
122
+ nodeId: node.id,
123
+ nodeTitle: node.title,
124
+ detail: node.issueType,
125
+ meta: { issueType: node.issueType, prefix: node.prefix },
126
+ });
127
+ }
128
+ }
129
+
130
+ // Closed
131
+ if (node.closedAt) {
132
+ const time = new Date(node.closedAt).getTime();
133
+ if (!isNaN(time)) {
134
+ add({
135
+ id: `node-closed:${node.id}:${time}`,
136
+ type: "node-closed",
137
+ time,
138
+ nodeId: node.id,
139
+ nodeTitle: node.title,
140
+ detail: node.closeReason || "Closed",
141
+ meta: { prefix: node.prefix },
142
+ });
143
+ }
144
+ }
145
+ }
146
+
147
+ // --- Dependency events ---
148
+ for (const link of links) {
149
+ if (link.createdAt) {
150
+ const time = new Date(link.createdAt).getTime();
151
+ if (!isNaN(time)) {
152
+ const src = typeof link.source === "object" ? (link.source as { id: string }).id : link.source;
153
+ const tgt = typeof link.target === "object" ? (link.target as { id: string }).id : link.target;
154
+ add({
155
+ id: `link-added:${src}->${tgt}:${time}`,
156
+ type: "link-added",
157
+ time,
158
+ nodeId: src,
159
+ detail: `${link.type} ${tgt}`,
160
+ meta: { linkType: link.type, target: tgt },
161
+ });
162
+ }
163
+ }
164
+ }
165
+
166
+ // --- Comment, claim, and like events ---
167
+ if (allComments) {
168
+ for (const comment of allComments) {
169
+ const time = new Date(comment.createdAt).getTime();
170
+ if (isNaN(time)) continue;
171
+
172
+ const actor: ActivityActor = {
173
+ handle: comment.handle,
174
+ avatar: comment.avatar,
175
+ did: comment.did,
176
+ };
177
+
178
+ const isClaim =
179
+ comment.text.startsWith("@") &&
180
+ comment.text.trim().indexOf(" ") === -1;
181
+
182
+ if (isClaim) {
183
+ add({
184
+ id: `task-claimed:${comment.nodeId}:${time}`,
185
+ type: "task-claimed",
186
+ time,
187
+ nodeId: comment.nodeId,
188
+ actor,
189
+ detail: comment.text,
190
+ });
191
+ } else if (comment.replyTo) {
192
+ add({
193
+ id: `reply-added:${comment.nodeId}:${comment.rkey}`,
194
+ type: "reply-added",
195
+ time,
196
+ nodeId: comment.nodeId,
197
+ actor,
198
+ detail:
199
+ comment.text.length > 80
200
+ ? comment.text.slice(0, 80) + "..."
201
+ : comment.text,
202
+ });
203
+ } else {
204
+ add({
205
+ id: `comment-added:${comment.nodeId}:${comment.rkey}`,
206
+ type: "comment-added",
207
+ time,
208
+ nodeId: comment.nodeId,
209
+ actor,
210
+ detail:
211
+ comment.text.length > 80
212
+ ? comment.text.slice(0, 80) + "..."
213
+ : comment.text,
214
+ });
215
+ }
216
+
217
+ // Likes on this comment
218
+ for (const like of comment.likes) {
219
+ const likeTime = new Date(like.createdAt).getTime();
220
+ if (isNaN(likeTime)) continue;
221
+ add({
222
+ id: `like-added:${comment.nodeId}:${like.rkey}`,
223
+ type: "like-added",
224
+ time: likeTime,
225
+ nodeId: comment.nodeId,
226
+ actor: {
227
+ handle: like.handle,
228
+ avatar: like.avatar,
229
+ did: like.did,
230
+ },
231
+ detail: `Liked comment by ${comment.handle}`,
232
+ });
233
+ }
234
+ }
235
+ }
236
+
237
+ // Sort newest-first
238
+ events.sort((a, b) => b.time - a.time);
239
+ return events;
240
+ }
241
+
242
+ // ============================================================================
243
+ // Real-time diff -> events converter
244
+ // ============================================================================
245
+
246
+ /**
247
+ * Convert a BeadsDiff into ActivityEvent items.
248
+ * Called on each SSE message after diffBeadsData().
249
+ */
250
+ export function diffToActivityEvents(
251
+ diff: BeadsDiff,
252
+ nodes: GraphNode[]
253
+ ): ActivityEvent[] {
254
+ const events: ActivityEvent[] = [];
255
+ const now = Date.now();
256
+ const nodeMap = new Map(nodes.map((n) => [n.id, n]));
257
+
258
+ // Added nodes
259
+ for (const id of diff.addedNodeIds) {
260
+ const node = nodeMap.get(id);
261
+ events.push({
262
+ id: `node-created:${id}:${now}`,
263
+ type: "node-created",
264
+ time: now,
265
+ nodeId: id,
266
+ nodeTitle: node?.title,
267
+ detail: node?.issueType || "task",
268
+ meta: node ? { issueType: node.issueType, prefix: node.prefix } : undefined,
269
+ });
270
+ }
271
+
272
+ // Removed nodes
273
+ for (const id of diff.removedNodeIds) {
274
+ events.push({
275
+ id: `node-closed:${id}:${now}`,
276
+ type: "node-closed",
277
+ time: now,
278
+ nodeId: id,
279
+ detail: "Removed",
280
+ });
281
+ }
282
+
283
+ // Changed nodes
284
+ for (const [id, changes] of diff.changedNodes) {
285
+ const node = nodeMap.get(id);
286
+ for (const change of changes) {
287
+ let type: ActivityEventType;
288
+ switch (change.field) {
289
+ case "status":
290
+ type = "node-status-changed";
291
+ break;
292
+ case "priority":
293
+ type = "node-priority-changed";
294
+ break;
295
+ case "title":
296
+ type = "node-title-changed";
297
+ break;
298
+ case "owner":
299
+ type = "node-owner-changed";
300
+ break;
301
+ default:
302
+ type = "node-status-changed"; // fallback
303
+ }
304
+ events.push({
305
+ id: `${type}:${id}:${now}:${change.field}`,
306
+ type,
307
+ time: now,
308
+ nodeId: id,
309
+ nodeTitle: node?.title,
310
+ detail: `${change.from} \u2192 ${change.to}`,
311
+ meta: { field: change.field, from: change.from, to: change.to },
312
+ });
313
+ }
314
+ }
315
+
316
+ // Added links
317
+ for (const key of diff.addedLinkKeys) {
318
+ // key format: "source->target:type"
319
+ const match = key.match(/^(.+)->(.+):(.+)$/);
320
+ if (match) {
321
+ const [, src, tgt, linkType] = match;
322
+ events.push({
323
+ id: `link-added:${key}:${now}`,
324
+ type: "link-added",
325
+ time: now,
326
+ nodeId: src,
327
+ detail: `${linkType} ${tgt}`,
328
+ meta: { linkType, target: tgt },
329
+ });
330
+ }
331
+ }
332
+
333
+ // Removed links
334
+ for (const key of diff.removedLinkKeys) {
335
+ const match = key.match(/^(.+)->(.+):(.+)$/);
336
+ if (match) {
337
+ const [, src, tgt, linkType] = match;
338
+ events.push({
339
+ id: `link-removed:${key}:${now}`,
340
+ type: "link-removed",
341
+ time: now,
342
+ nodeId: src,
343
+ detail: `${linkType} ${tgt}`,
344
+ meta: { linkType, target: tgt },
345
+ });
346
+ }
347
+ }
348
+
349
+ return events;
350
+ }
351
+
352
+ // ============================================================================
353
+ // Feed management helpers
354
+ // ============================================================================
355
+
356
+ /** Maximum events to keep in the feed */
357
+ export const MAX_FEED_SIZE = 200;
358
+
359
+ /**
360
+ * Merge new events into an existing feed, deduplicating by event ID.
361
+ * Returns a new array sorted newest-first, capped at MAX_FEED_SIZE.
362
+ */
363
+ export function mergeFeedEvents(
364
+ existing: ActivityEvent[],
365
+ incoming: ActivityEvent[]
366
+ ): ActivityEvent[] {
367
+ const seen = new Set(existing.map((e) => e.id));
368
+ const merged = [...existing];
369
+ for (const event of incoming) {
370
+ if (!seen.has(event.id)) {
371
+ seen.add(event.id);
372
+ merged.push(event);
373
+ }
374
+ }
375
+ merged.sort((a, b) => b.time - a.time);
376
+ return merged.slice(0, MAX_FEED_SIZE);
377
+ }