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,221 @@
1
+ import { NodeOAuthClient } from "@atproto/oauth-client-node";
2
+ import { JoseKey } from "@atproto/jwk-jose";
3
+ import { env } from "../env";
4
+ import { getRawSession } from "../session";
5
+
6
+ const oauthClientKey = "globalOAuthClient";
7
+ // In development, clear cached client on hot reload to pick up config changes
8
+ if (process.env.NODE_ENV !== "production") {
9
+ (global as Record<string, unknown>)[oauthClientKey] = null;
10
+ }
11
+ if (!(global as Record<string, unknown>)[oauthClientKey]) {
12
+ (global as Record<string, unknown>)[oauthClientKey] = null;
13
+ }
14
+
15
+ /**
16
+ * OAuth Client Configuration
17
+ *
18
+ * Supports two modes:
19
+ *
20
+ * 1. CONFIDENTIAL CLIENT (Production)
21
+ * - Requires ATPROTO_JWK_PRIVATE and PUBLIC_URL environment variables
22
+ * - Authenticates to auth servers using private key JWT
23
+ * - Longer session lifetimes
24
+ *
25
+ * 2. PUBLIC CLIENT (Development fallback)
26
+ * - Used when ATPROTO_JWK_PRIVATE is not set
27
+ * - No client authentication
28
+ * - Shorter session lifetimes
29
+ */
30
+
31
+ // ============================================================================
32
+ // In-Memory Store with Cookie Sync
33
+ // ============================================================================
34
+
35
+ const globalStoreKey = "oauthSharedStore";
36
+ if (!(global as Record<string, unknown>)[globalStoreKey]) {
37
+ (global as Record<string, unknown>)[globalStoreKey] = new Map();
38
+ }
39
+ const sharedStore: Map<string, unknown> = (
40
+ global as Record<string, unknown>
41
+ )[globalStoreKey] as Map<string, unknown>;
42
+
43
+ // State store - in-memory only, used during short-lived OAuth flow
44
+ const stateStore = {
45
+ async get(key: string) {
46
+ return sharedStore.get(`state:${key}`);
47
+ },
48
+ async set(key: string, value: unknown) {
49
+ sharedStore.set(`state:${key}`, value);
50
+ },
51
+ async del(key: string) {
52
+ sharedStore.delete(`state:${key}`);
53
+ },
54
+ };
55
+
56
+ // Session store - syncs with cookie for persistence
57
+ const sessionStore = {
58
+ async get(key: string) {
59
+ const memValue = sharedStore.get(`session:${key}`);
60
+ if (memValue) {
61
+ return memValue;
62
+ }
63
+
64
+ try {
65
+ const session = await getRawSession();
66
+ if (session.oauthSession && session.did === key) {
67
+ const parsed = JSON.parse(session.oauthSession);
68
+ sharedStore.set(`session:${key}`, parsed);
69
+ return parsed;
70
+ }
71
+ } catch (err) {
72
+ console.warn("Failed to restore OAuth session from cookie:", err);
73
+ }
74
+
75
+ return undefined;
76
+ },
77
+ async set(key: string, value: unknown) {
78
+ sharedStore.set(`session:${key}`, value);
79
+
80
+ try {
81
+ const session = await getRawSession();
82
+ session.oauthSession = JSON.stringify(value);
83
+ await session.save();
84
+ } catch (err) {
85
+ console.warn("Failed to save OAuth session to cookie:", err);
86
+ }
87
+ },
88
+ async del(key: string) {
89
+ sharedStore.delete(`session:${key}`);
90
+
91
+ try {
92
+ const session = await getRawSession();
93
+ session.oauthSession = undefined;
94
+ await session.save();
95
+ } catch (err) {
96
+ console.warn("Failed to clear OAuth session from cookie:", err);
97
+ }
98
+ },
99
+ };
100
+
101
+ // ============================================================================
102
+ // JWK Keyset Management
103
+ // ============================================================================
104
+
105
+ let cachedKeyset: Awaited<ReturnType<typeof JoseKey.fromImportable>>[] | null =
106
+ null;
107
+
108
+ async function getKeyset() {
109
+ if (cachedKeyset) {
110
+ return cachedKeyset;
111
+ }
112
+
113
+ const jwkPrivate = env.ATPROTO_JWK_PRIVATE;
114
+ if (!jwkPrivate) {
115
+ return null; // Public client mode
116
+ }
117
+
118
+ try {
119
+ const jwk = JSON.parse(jwkPrivate);
120
+ const key = await JoseKey.fromImportable(jwk, jwk.kid || "key-1");
121
+ cachedKeyset = [key];
122
+ return cachedKeyset;
123
+ } catch (err) {
124
+ console.error("Failed to parse ATPROTO_JWK_PRIVATE:", err);
125
+ return null;
126
+ }
127
+ }
128
+
129
+ // ============================================================================
130
+ // OAuth Client Factory
131
+ // ============================================================================
132
+
133
+ export const createClient = async () => {
134
+ const publicUrl = env.PUBLIC_URL;
135
+ // Must use 127.0.0.1 per RFC 8252 for ATProto OAuth localhost development
136
+ const localhostUrl = `http://127.0.0.1:${env.PORT}`;
137
+ const enc = encodeURIComponent;
138
+
139
+ // Detect if we're running on localhost (dev mode)
140
+ const isLocalDev = process.env.NODE_ENV !== "production";
141
+
142
+ // Use localhost URL in development, production URL otherwise
143
+ const url = isLocalDev ? localhostUrl : publicUrl || localhostUrl;
144
+
145
+ let keyset = null;
146
+ try {
147
+ // Only load keyset for production (confidential client)
148
+ if (!isLocalDev && publicUrl) {
149
+ keyset = await getKeyset();
150
+ }
151
+ } catch (err) {
152
+ console.error("Error getting keyset:", err);
153
+ }
154
+
155
+ const isConfidentialClient = keyset !== null && !!publicUrl && !isLocalDev;
156
+
157
+ // Build client metadata based on client type
158
+ const clientMetadata: Record<string, unknown> = {
159
+ client_name: "Beads Map",
160
+ client_uri: url,
161
+ dpop_bound_access_tokens: true,
162
+ grant_types: ["authorization_code", "refresh_token"],
163
+ response_types: ["code"],
164
+ scope: "atproto transition:generic",
165
+ application_type: "web",
166
+ };
167
+
168
+ if (isConfidentialClient) {
169
+ clientMetadata.client_id = `${publicUrl}/api/oauth/client-metadata.json`;
170
+ clientMetadata.redirect_uris = [`${publicUrl}/api/oauth/callback`];
171
+ clientMetadata.token_endpoint_auth_method = "private_key_jwt";
172
+ clientMetadata.token_endpoint_auth_signing_alg = "ES256";
173
+ clientMetadata.jwks_uri = `${publicUrl}/api/oauth/jwks.json`;
174
+ } else {
175
+ clientMetadata.client_id = `http://localhost?redirect_uri=${enc(`${url}/api/oauth/callback`)}&scope=${enc("atproto transition:generic")}`;
176
+ clientMetadata.redirect_uris = [`${url}/api/oauth/callback`];
177
+ clientMetadata.token_endpoint_auth_method = "none";
178
+ }
179
+
180
+ const clientConfig: Record<string, unknown> = {
181
+ clientMetadata,
182
+ stateStore,
183
+ sessionStore,
184
+ };
185
+
186
+ if (keyset) {
187
+ clientConfig.keyset = keyset;
188
+ }
189
+
190
+ return new NodeOAuthClient(
191
+ clientConfig as ConstructorParameters<typeof NodeOAuthClient>[0]
192
+ );
193
+ };
194
+
195
+ export const getGlobalOAuthClient = async () => {
196
+ const currentClient = (global as Record<string, unknown>)[oauthClientKey];
197
+ if (!currentClient) {
198
+ try {
199
+ const newClient = await createClient();
200
+ (global as Record<string, unknown>)[oauthClientKey] = newClient;
201
+ return newClient;
202
+ } catch (err) {
203
+ console.error("Failed to create OAuth client:", err);
204
+ throw err;
205
+ }
206
+ }
207
+ return currentClient as NodeOAuthClient;
208
+ };
209
+
210
+ /**
211
+ * Get the JWKS (public keys) for the confidential client.
212
+ */
213
+ export async function getJwks(): Promise<{ keys: unknown[] } | null> {
214
+ const client = await getGlobalOAuthClient();
215
+
216
+ if ("jwks" in client && client.jwks) {
217
+ return client.jwks as { keys: unknown[] };
218
+ }
219
+
220
+ return null;
221
+ }
package/lib/auth.tsx ADDED
@@ -0,0 +1,159 @@
1
+ "use client";
2
+
3
+ import {
4
+ useState,
5
+ useEffect,
6
+ useCallback,
7
+ createContext,
8
+ useContext,
9
+ } from "react";
10
+
11
+ export interface AuthSession {
12
+ did: string;
13
+ handle: string;
14
+ displayName?: string;
15
+ avatar?: string;
16
+ }
17
+
18
+ export type AuthStatus = "idle" | "authorizing" | "authenticated" | "error";
19
+
20
+ interface AuthState {
21
+ status: AuthStatus;
22
+ session: AuthSession | null;
23
+ error: Error | null;
24
+ isLoading: boolean;
25
+ }
26
+
27
+ const AuthContext = createContext<{
28
+ state: AuthState;
29
+ login: (handle: string) => Promise<void>;
30
+ logout: () => Promise<void>;
31
+ } | null>(null);
32
+
33
+ /**
34
+ * Auth Provider - wraps the app to provide authentication state.
35
+ * Checks session status on mount via /api/status.
36
+ */
37
+ export function AuthProvider({ children }: { children: React.ReactNode }) {
38
+ const [state, setState] = useState<AuthState>({
39
+ status: "idle",
40
+ session: null,
41
+ error: null,
42
+ isLoading: true,
43
+ });
44
+
45
+ // Check session status on mount
46
+ useEffect(() => {
47
+ let cancelled = false;
48
+
49
+ const checkStatus = async () => {
50
+ try {
51
+ const response = await fetch("/api/status");
52
+ if (response.ok) {
53
+ const data = await response.json();
54
+ if (data.did && !cancelled) {
55
+ setState({
56
+ status: "authenticated",
57
+ session: {
58
+ did: data.did,
59
+ handle: data.handle || data.did,
60
+ displayName: data.displayName,
61
+ avatar: data.avatar,
62
+ },
63
+ error: null,
64
+ isLoading: false,
65
+ });
66
+ return;
67
+ }
68
+ }
69
+ } catch (error) {
70
+ console.error("Failed to check auth status:", error);
71
+ }
72
+ if (!cancelled) {
73
+ setState((prev) => ({ ...prev, isLoading: false }));
74
+ }
75
+ };
76
+
77
+ checkStatus();
78
+ return () => {
79
+ cancelled = true;
80
+ };
81
+ }, []);
82
+
83
+ // Login - initiates OAuth flow, redirects to PDS authorization
84
+ const login = useCallback(async (handle: string) => {
85
+ setState((prev) => ({
86
+ ...prev,
87
+ status: "authorizing",
88
+ isLoading: true,
89
+ error: null,
90
+ }));
91
+
92
+ try {
93
+ const normalizedHandle = handle.includes(".")
94
+ ? handle
95
+ : `${handle}.bsky.social`;
96
+
97
+ const response = await fetch("/api/login", {
98
+ method: "POST",
99
+ headers: { "Content-Type": "application/json" },
100
+ body: JSON.stringify({
101
+ handle: normalizedHandle,
102
+ returnTo: window.location.pathname + window.location.search,
103
+ }),
104
+ });
105
+
106
+ if (!response.ok) {
107
+ const data = await response.json();
108
+ throw new Error(data.error || "Login failed");
109
+ }
110
+
111
+ const data = await response.json();
112
+ window.location.href = data.redirectUrl;
113
+ } catch (err) {
114
+ const error = err instanceof Error ? err : new Error("Login failed");
115
+ setState({ status: "error", session: null, error, isLoading: false });
116
+ throw error;
117
+ }
118
+ }, []);
119
+
120
+ // Logout - clears server session
121
+ const logout = useCallback(async () => {
122
+ try {
123
+ await fetch("/api/logout", { method: "POST" });
124
+ } catch (error) {
125
+ console.error("Logout request failed:", error);
126
+ }
127
+ setState({ status: "idle", session: null, error: null, isLoading: false });
128
+ }, []);
129
+
130
+ return (
131
+ <AuthContext.Provider value={{ state, login, logout }}>
132
+ {children}
133
+ </AuthContext.Provider>
134
+ );
135
+ }
136
+
137
+ /**
138
+ * Hook to access auth state and actions.
139
+ * Must be used within an AuthProvider.
140
+ */
141
+ export function useAuth() {
142
+ const context = useContext(AuthContext);
143
+
144
+ if (!context) {
145
+ throw new Error("useAuth must be used within an AuthProvider");
146
+ }
147
+
148
+ const { state, login, logout } = context;
149
+
150
+ return {
151
+ status: state.status,
152
+ session: state.session,
153
+ error: state.error,
154
+ isLoading: state.isLoading,
155
+ isAuthenticated: state.status === "authenticated",
156
+ login,
157
+ logout,
158
+ };
159
+ }
@@ -0,0 +1,125 @@
1
+ import type { BeadsApiResponse, GraphLink } from "./types";
2
+
3
+ export interface NodeChange {
4
+ field: string; // e.g. "status", "priority", "title"
5
+ from: string; // previous value (stringified)
6
+ to: string; // new value (stringified)
7
+ }
8
+
9
+ export interface BeadsDiff {
10
+ addedNodeIds: Set<string>; // IDs of nodes not in old data
11
+ removedNodeIds: Set<string>; // IDs of nodes not in new data
12
+ changedNodes: Map<string, NodeChange[]>; // ID -> list of field changes
13
+ addedLinkKeys: Set<string>; // "source->target:type" keys
14
+ removedLinkKeys: Set<string>; // "source->target:type" keys
15
+ hasChanges: boolean; // true if anything changed at all
16
+ }
17
+
18
+ /**
19
+ * Build a stable key for a link.
20
+ * Links may have string or object source/target (after force-graph mutation).
21
+ */
22
+ export function linkKey(link: GraphLink): string {
23
+ const src =
24
+ typeof link.source === "object"
25
+ ? (link.source as { id: string }).id
26
+ : link.source;
27
+ const tgt =
28
+ typeof link.target === "object"
29
+ ? (link.target as { id: string }).id
30
+ : link.target;
31
+ return `${src}->${tgt}:${link.type}`;
32
+ }
33
+
34
+ /**
35
+ * Compute the diff between old and new beads data.
36
+ * Compares nodes by ID and links by source->target:type key.
37
+ */
38
+ export function diffBeadsData(
39
+ oldData: BeadsApiResponse | null,
40
+ newData: BeadsApiResponse
41
+ ): BeadsDiff {
42
+ // If no old data, everything is "added"
43
+ if (!oldData) {
44
+ return {
45
+ addedNodeIds: new Set(newData.graphData.nodes.map((n) => n.id)),
46
+ removedNodeIds: new Set(),
47
+ changedNodes: new Map(),
48
+ addedLinkKeys: new Set(newData.graphData.links.map(linkKey)),
49
+ removedLinkKeys: new Set(),
50
+ hasChanges: true,
51
+ };
52
+ }
53
+
54
+ const oldNodeMap = new Map(
55
+ oldData.graphData.nodes.map((n) => [n.id, n])
56
+ );
57
+ const newNodeMap = new Map(
58
+ newData.graphData.nodes.map((n) => [n.id, n])
59
+ );
60
+
61
+ // Node diffs
62
+ const addedNodeIds = new Set<string>();
63
+ const removedNodeIds = new Set<string>();
64
+ const changedNodes = new Map<string, NodeChange[]>();
65
+
66
+ for (const [id, node] of newNodeMap) {
67
+ if (!oldNodeMap.has(id)) {
68
+ addedNodeIds.add(id);
69
+ } else {
70
+ const old = oldNodeMap.get(id)!;
71
+ const changes: NodeChange[] = [];
72
+ if (old.status !== node.status) {
73
+ changes.push({ field: "status", from: old.status, to: node.status });
74
+ }
75
+ if (old.priority !== node.priority) {
76
+ changes.push({
77
+ field: "priority",
78
+ from: String(old.priority),
79
+ to: String(node.priority),
80
+ });
81
+ }
82
+ if (old.title !== node.title) {
83
+ changes.push({ field: "title", from: old.title, to: node.title });
84
+ }
85
+ if (changes.length > 0) {
86
+ changedNodes.set(id, changes);
87
+ }
88
+ }
89
+ }
90
+ for (const id of oldNodeMap.keys()) {
91
+ if (!newNodeMap.has(id)) {
92
+ removedNodeIds.add(id);
93
+ }
94
+ }
95
+
96
+ // Link diffs
97
+ const oldLinkKeys = new Set(oldData.graphData.links.map(linkKey));
98
+ const newLinkKeys = new Set(newData.graphData.links.map(linkKey));
99
+
100
+ const addedLinkKeys = new Set<string>();
101
+ const removedLinkKeys = new Set<string>();
102
+
103
+ for (const key of newLinkKeys) {
104
+ if (!oldLinkKeys.has(key)) addedLinkKeys.add(key);
105
+ }
106
+ for (const key of oldLinkKeys) {
107
+ if (!newLinkKeys.has(key)) removedLinkKeys.add(key);
108
+ }
109
+
110
+ const hasChanges =
111
+ addedNodeIds.size > 0 ||
112
+ removedNodeIds.size > 0 ||
113
+ changedNodes.size > 0 ||
114
+ addedLinkKeys.size > 0 ||
115
+ removedLinkKeys.size > 0;
116
+
117
+ return {
118
+ addedNodeIds,
119
+ removedNodeIds,
120
+ changedNodes,
121
+ addedLinkKeys,
122
+ removedLinkKeys,
123
+ hasChanges,
124
+ };
125
+ }