From 7fe10544a11456b645b8ad0ee339bd6f69d0079e Mon Sep 17 00:00:00 2001 From: Gustavo Henrique Santos Souza de Miranda Date: Fri, 23 Jan 2026 21:03:17 -0300 Subject: [PATCH 01/20] feat: [US-047] - Live cursor positions on canvas Co-Authored-By: Claude Opus 4.5 --- .../editor/[projectId]/FlowchartEditor.tsx | 49 +++++++- src/app/editor/[projectId]/page.tsx | 112 ++++++++---------- src/components/editor/RemoteCursors.tsx | 95 +++++++++++++++ src/lib/collaboration/realtime.ts | 36 ++++++ 4 files changed, 225 insertions(+), 67 deletions(-) create mode 100644 src/components/editor/RemoteCursors.tsx diff --git a/src/app/editor/[projectId]/FlowchartEditor.tsx b/src/app/editor/[projectId]/FlowchartEditor.tsx index 9f938d5..f4cb1d7 100644 --- a/src/app/editor/[projectId]/FlowchartEditor.tsx +++ b/src/app/editor/[projectId]/FlowchartEditor.tsx @@ -33,7 +33,8 @@ import ProjectSettingsModal from '@/components/editor/ProjectSettingsModal' import ConditionEditor from '@/components/editor/ConditionEditor' import ExportValidationModal, { type ValidationIssue } from '@/components/editor/ExportValidationModal' import { EditorProvider } from '@/components/editor/EditorContext' -import { RealtimeConnection, type ConnectionState, type PresenceUser } from '@/lib/collaboration/realtime' +import { RealtimeConnection, type ConnectionState, type PresenceUser, type RemoteCursor } from '@/lib/collaboration/realtime' +import RemoteCursors from '@/components/editor/RemoteCursors' import { CRDTManager } from '@/lib/collaboration/crdt' import ShareModal from '@/components/editor/ShareModal' import type { FlowchartData, FlowchartNode, FlowchartEdge, Character, Variable, Condition } from '@/types/flowchart' @@ -508,9 +509,11 @@ function FlowchartEditorInner({ projectId, projectName, userId, userDisplayName, const [warningNodeIds, setWarningNodeIds] = useState>(new Set()) const [connectionState, setConnectionState] = useState('disconnected') const [presenceUsers, setPresenceUsers] = useState([]) + const [remoteCursors, setRemoteCursors] = useState([]) const realtimeRef = useRef(null) const crdtRef = useRef(null) const isRemoteUpdateRef = useRef(false) + const cursorThrottleRef = useRef(0) // Initialize CRDT manager and connect to Supabase Realtime channel on mount useEffect(() => { @@ -553,6 +556,17 @@ function FlowchartEditorInner({ projectId, projectName, userId, userDisplayName, const connection = new RealtimeConnection(projectId, userId, userDisplayName, { onConnectionStateChange: setConnectionState, onPresenceSync: setPresenceUsers, + onCursorUpdate: (cursor) => { + setRemoteCursors((prev) => { + const existing = prev.findIndex((c) => c.userId === cursor.userId) + if (existing >= 0) { + const updated = [...prev] + updated[existing] = cursor + return updated + } + return [...prev, cursor] + }) + }, onChannelSubscribed: (channel) => { crdtManager.connectChannel(channel) }, @@ -600,6 +614,36 @@ function FlowchartEditorInner({ projectId, projectName, userId, userDisplayName, crdtRef.current?.updateEdges(edgesForCRDT) }, [edgesForCRDT]) + // Broadcast cursor position on mouse move (throttled to 50ms) + const handleMouseMove = useCallback( + (event: React.MouseEvent) => { + const now = Date.now() + if (now - cursorThrottleRef.current < 50) return + cursorThrottleRef.current = now + + const connection = realtimeRef.current + if (!connection) return + + // Convert screen position to flow coordinates + const bounds = event.currentTarget.getBoundingClientRect() + const screenX = event.clientX - bounds.left + const screenY = event.clientY - bounds.top + const flowPosition = screenToFlowPosition({ x: screenX, y: screenY }) + + connection.broadcastCursor(flowPosition) + }, + [screenToFlowPosition] + ) + + // Remove cursors for users who leave + useEffect(() => { + setRemoteCursors((prev) => + prev.filter((cursor) => + presenceUsers.some((u) => u.userId === cursor.userId) + ) + ) + }, [presenceUsers]) + const handleAddCharacter = useCallback( (name: string, color: string): string => { const id = nanoid() @@ -1210,7 +1254,7 @@ function FlowchartEditorInner({ projectId, projectName, userId, userDisplayName, connectionState={connectionState} presenceUsers={presenceUsers} /> -
+
+
{contextMenu && ( -
-
-
- - +
+
+ - - - + + + + +
@@ -131,32 +114,31 @@ export default async function EditorPage({ params }: PageProps) {
- - -
-
- ) } - const flowchartData = (project.flowchart_data || { - nodes: [], - edges: [], - }) as FlowchartData + const rawData = project.flowchart_data || {} + const flowchartData: FlowchartData = { + nodes: rawData.nodes || [], + edges: rawData.edges || [], + characters: rawData.characters || [], + variables: rawData.variables || [], + } + + // Migration flag: if the raw data doesn't have characters/variables arrays, + // the project was created before these features existed and may need auto-migration + const needsMigration = !rawData.characters && !rawData.variables return ( ) } diff --git a/src/components/editor/RemoteCursors.tsx b/src/components/editor/RemoteCursors.tsx new file mode 100644 index 0000000..86825dd --- /dev/null +++ b/src/components/editor/RemoteCursors.tsx @@ -0,0 +1,95 @@ +'use client' + +import { useEffect, useState } from 'react' +import { useViewport } from 'reactflow' +import type { RemoteCursor } from '@/lib/collaboration/realtime' + +type RemoteCursorsProps = { + cursors: RemoteCursor[] +} + +const FADE_TIMEOUT_MS = 5000 + +// Generate a consistent color from a user ID hash (same algorithm as PresenceAvatars) +function getUserColor(userId: string): string { + let hash = 0 + for (let i = 0; i < userId.length; i++) { + hash = ((hash << 5) - hash + userId.charCodeAt(i)) | 0 + } + const colors = [ + '#EF4444', '#F97316', '#F59E0B', '#10B981', + '#3B82F6', '#8B5CF6', '#EC4899', '#14B8A6', + '#6366F1', '#F43F5E', '#84CC16', '#06B6D4', + ] + return colors[Math.abs(hash) % colors.length] +} + +export default function RemoteCursors({ cursors }: RemoteCursorsProps) { + const viewport = useViewport() + const [now, setNow] = useState(() => Date.now()) + + // Tick every second to update fade states + useEffect(() => { + const timer = setInterval(() => setNow(Date.now()), 1000) + return () => clearInterval(timer) + }, []) + + return ( +
+ {cursors.map((cursor) => { + const timeSinceUpdate = now - cursor.lastUpdated + const isFaded = timeSinceUpdate > FADE_TIMEOUT_MS + if (isFaded) return null + + const opacity = timeSinceUpdate > FADE_TIMEOUT_MS - 1000 + ? Math.max(0, 1 - (timeSinceUpdate - (FADE_TIMEOUT_MS - 1000)) / 1000) + : 1 + + // Convert flow coordinates to screen coordinates + const screenX = cursor.position.x * viewport.zoom + viewport.x + const screenY = cursor.position.y * viewport.zoom + viewport.y + + const color = getUserColor(cursor.userId) + + return ( +
+ {/* Cursor arrow SVG */} + + + + + {/* User name label */} +
+ {cursor.displayName} +
+
+ ) + })} +
+ ) +} diff --git a/src/lib/collaboration/realtime.ts b/src/lib/collaboration/realtime.ts index 0c8fe84..d65d860 100644 --- a/src/lib/collaboration/realtime.ts +++ b/src/lib/collaboration/realtime.ts @@ -8,10 +8,23 @@ export type PresenceUser = { displayName: string } +export type CursorPosition = { + x: number + y: number +} + +export type RemoteCursor = { + userId: string + displayName: string + position: CursorPosition + lastUpdated: number +} + type RealtimeCallbacks = { onConnectionStateChange: (state: ConnectionState) => void onPresenceSync?: (users: PresenceUser[]) => void onChannelSubscribed?: (channel: RealtimeChannel) => void + onCursorUpdate?: (cursor: RemoteCursor) => void } const HEARTBEAT_INTERVAL_MS = 30_000 @@ -63,6 +76,15 @@ export class RealtimeConnection { this.callbacks.onPresenceSync?.(users) } }) + .on('broadcast', { event: 'cursor' }, ({ payload }) => { + if (payload.userId === this.userId) return + this.callbacks.onCursorUpdate?.({ + userId: payload.userId, + displayName: payload.displayName, + position: { x: payload.x, y: payload.y }, + lastUpdated: Date.now(), + }) + }) .subscribe(async (status) => { if (this.isDestroyed) return @@ -107,6 +129,20 @@ export class RealtimeConnection { return this.channel } + broadcastCursor(position: CursorPosition): void { + if (!this.channel) return + this.channel.send({ + type: 'broadcast', + event: 'cursor', + payload: { + userId: this.userId, + displayName: this.displayName, + x: position.x, + y: position.y, + }, + }) + } + private async createSession(): Promise { try { await this.supabase.from('collaboration_sessions').upsert( From 841a44112abf99d722cff6c671144cf37ed592fb Mon Sep 17 00:00:00 2001 From: Gustavo Henrique Santos Souza de Miranda Date: Fri, 23 Jan 2026 21:04:09 -0300 Subject: [PATCH 02/20] feat: [US-047] - Live cursor positions on canvas Co-Authored-By: Claude Opus 4.5 --- prd.json | 2 +- progress.txt | 20 ++++++++++++++++++++ 2 files changed, 21 insertions(+), 1 deletion(-) diff --git a/prd.json b/prd.json index 378ea27..14f135d 100644 --- a/prd.json +++ b/prd.json @@ -324,7 +324,7 @@ "Verify in browser using dev-browser skill" ], "priority": 18, - "passes": false, + "passes": true, "notes": "Dependencies: US-045, US-046" }, { diff --git a/progress.txt b/progress.txt index 023908d..5bfbce3 100644 --- a/progress.txt +++ b/progress.txt @@ -60,6 +60,8 @@ - `CRDTManager` at `src/lib/collaboration/crdt.ts` wraps a Yjs Y.Doc with Y.Map for nodes and edges. Connects to Supabase Realtime channel for broadcasting updates. - CRDT sync pattern: local React Flow changes → `updateNodes`/`updateEdges` on CRDTManager → Yjs broadcasts to channel; remote broadcasts → Yjs applies update → callbacks set React Flow state. Use `isRemoteUpdateRef` to prevent echo loops. - For Supabase Realtime broadcast of binary data (Yjs updates), convert `Uint8Array` → `Array.from()` for JSON payload, and `new Uint8Array()` on receive. +- For ephemeral real-time data (cursors, typing indicators), use Supabase Realtime broadcast (`channel.send({ type: 'broadcast', event, payload })`) + `.on('broadcast', { event }, callback)` — not persistence-backed +- `RemoteCursors` at `src/components/editor/RemoteCursors.tsx` renders collaborator cursors on canvas. Uses `useViewport()` to transform flow→screen coordinates. Throttle broadcasts to 50ms via timestamp ref. --- @@ -396,3 +398,21 @@ - After successful password update, sign out the user and redirect to login with success message (same as reset-password page) - The modal coexists with the existing /reset-password page - both handle recovery tokens but in different UX patterns --- + +## 2026-01-23 - US-047 +- What was implemented: Live cursor positions on canvas showing collaborators' mouse positions in real-time +- Files changed: + - `src/lib/collaboration/realtime.ts` - Added `CursorPosition`, `RemoteCursor` types, `onCursorUpdate` callback to `RealtimeCallbacks`, broadcast listener for 'cursor' events, and `broadcastCursor()` method + - `src/components/editor/RemoteCursors.tsx` - New component: renders colored arrow cursors with user name labels, smooth position interpolation via CSS transition, 5-second fade-out for inactive cursors, flow-to-screen coordinate transformation using React Flow viewport + - `src/app/editor/[projectId]/FlowchartEditor.tsx` - Added `remoteCursors` state, `cursorThrottleRef` for 50ms throttling, `handleMouseMove` that converts screen→flow coordinates and broadcasts via RealtimeConnection, cleanup of cursors for disconnected users, rendering of RemoteCursors overlay + - `src/app/editor/[projectId]/page.tsx` - Fixed broken JSX structure (malformed HTML nesting and dead code after return) +- **Learnings for future iterations:** + - `screenToFlowPosition` from `useReactFlow()` converts screen-relative mouse coordinates to flow coordinates; for the reverse (rendering cursors), multiply by viewport.zoom and add viewport offset + - Cursor broadcast uses Supabase Realtime broadcast (not presence) for efficiency: `channel.send({ type: 'broadcast', event: 'cursor', payload })`. Broadcast is fire-and-forget (no persistence). + - React Compiler lint treats `Date.now()` as an impure function call — use `useState(() => Date.now())` lazy initializer pattern instead of `useState(Date.now())` + - Throttling mouse events uses a ref storing the last broadcast timestamp (`cursorThrottleRef`), checked at the start of the handler before computing flow position + - Remote cursors are removed when their user disconnects (filtered by `presenceUsers` list changes) + - CSS `transition: transform 80ms linear` provides smooth interpolation between position updates without needing requestAnimationFrame + - The `page.tsx` had a corrupted structure with unclosed tags and dead code — likely from a failed merge. Fixed by restructuring the error/not-found case into a proper early return + - No browser testing tools are available; manual verification is needed. +--- From ccb05e3a3e358360cbf6da46a8acf7c4d0d2432e Mon Sep 17 00:00:00 2001 From: Gustavo Henrique Santos Souza de Miranda Date: Fri, 23 Jan 2026 21:08:05 -0300 Subject: [PATCH 03/20] feat: [US-050] - Join/leave notifications Co-Authored-By: Claude Opus 4.5 --- prd.json | 2 +- progress.txt | 16 +++++++ .../editor/[projectId]/FlowchartEditor.tsx | 38 ++++++++++++++++ src/components/editor/CollaborationToast.tsx | 43 +++++++++++++++++++ src/lib/collaboration/realtime.ts | 24 +++++++++++ 5 files changed, 122 insertions(+), 1 deletion(-) create mode 100644 src/components/editor/CollaborationToast.tsx diff --git a/prd.json b/prd.json index 14f135d..42f2047 100644 --- a/prd.json +++ b/prd.json @@ -341,7 +341,7 @@ "Verify in browser using dev-browser skill" ], "priority": 19, - "passes": false, + "passes": true, "notes": "Dependencies: US-045, US-046" }, { diff --git a/progress.txt b/progress.txt index 5bfbce3..cb9e998 100644 --- a/progress.txt +++ b/progress.txt @@ -62,6 +62,8 @@ - For Supabase Realtime broadcast of binary data (Yjs updates), convert `Uint8Array` → `Array.from()` for JSON payload, and `new Uint8Array()` on receive. - For ephemeral real-time data (cursors, typing indicators), use Supabase Realtime broadcast (`channel.send({ type: 'broadcast', event, payload })`) + `.on('broadcast', { event }, callback)` — not persistence-backed - `RemoteCursors` at `src/components/editor/RemoteCursors.tsx` renders collaborator cursors on canvas. Uses `useViewport()` to transform flow→screen coordinates. Throttle broadcasts to 50ms via timestamp ref. +- Supabase Realtime presence events: `sync` (full state), `join` (arrivals with `newPresences` array), `leave` (departures with `leftPresences` array). Filter `this.userId` to skip own events. +- `CollaborationToast` at `src/components/editor/CollaborationToast.tsx` shows join/leave notifications (bottom-left, auto-dismiss 3s). Uses `getUserColor(userId)` for accent color dot. --- @@ -416,3 +418,17 @@ - The `page.tsx` had a corrupted structure with unclosed tags and dead code — likely from a failed merge. Fixed by restructuring the error/not-found case into a proper early return - No browser testing tools are available; manual verification is needed. --- + +## 2026-01-23 - US-050 +- What was implemented: Join/leave toast notifications when collaborators connect or disconnect from the editing session +- Files changed: + - `src/lib/collaboration/realtime.ts` - Added `onPresenceJoin` and `onPresenceLeave` callbacks to `RealtimeCallbacks` type; added Supabase Realtime `presence.join` and `presence.leave` event listeners that filter out own user and invoke callbacks + - `src/components/editor/CollaborationToast.tsx` - New component: renders a compact toast notification with user's presence color dot, "[Name] joined" or "[Name] left" message, auto-dismisses after 3 seconds + - `src/app/editor/[projectId]/FlowchartEditor.tsx` - Added `getUserColor` helper (same hash logic as PresenceAvatars), `collaborationNotifications` state, `onPresenceJoin`/`onPresenceLeave` handlers on RealtimeConnection, `handleDismissNotification` callback, and rendering of CollaborationToast list in bottom-left corner +- **Learnings for future iterations:** + - Supabase Realtime presence has three event types: `sync` (full state), `join` (new arrivals), and `leave` (departures). Each provides an array of presences (`newPresences`/`leftPresences`). Use all three for different purposes. + - The `join` event fires for each newly tracked presence. It includes the presence payload (userId, displayName) that was passed to `channel.track()`. + - Collaboration notifications are positioned `bottom-left` (`left-4`) to avoid overlapping with the existing Toast component which is `bottom-right` (`right-4`). + - The `getUserColor` function is duplicated from PresenceAvatars to avoid circular imports. Both use the same hash-to-color-index algorithm with the same RANDOM_COLORS palette for consistency. + - No browser testing tools are available; manual verification is needed. +--- diff --git a/src/app/editor/[projectId]/FlowchartEditor.tsx b/src/app/editor/[projectId]/FlowchartEditor.tsx index f4cb1d7..c353a3c 100644 --- a/src/app/editor/[projectId]/FlowchartEditor.tsx +++ b/src/app/editor/[projectId]/FlowchartEditor.tsx @@ -37,6 +37,7 @@ import { RealtimeConnection, type ConnectionState, type PresenceUser, type Remot import RemoteCursors from '@/components/editor/RemoteCursors' import { CRDTManager } from '@/lib/collaboration/crdt' import ShareModal from '@/components/editor/ShareModal' +import CollaborationToast, { type CollaborationNotification } from '@/components/editor/CollaborationToast' import type { FlowchartData, FlowchartNode, FlowchartEdge, Character, Variable, Condition } from '@/types/flowchart' // LocalStorage key prefix for draft saves @@ -303,6 +304,15 @@ function randomHexColor(): string { return RANDOM_COLORS[Math.floor(Math.random() * RANDOM_COLORS.length)] } +// Generate a consistent color from a user ID hash (same logic as PresenceAvatars) +function getUserColor(userId: string): string { + let hash = 0 + for (let i = 0; i < userId.length; i++) { + hash = ((hash << 5) - hash + userId.charCodeAt(i)) | 0 + } + return RANDOM_COLORS[Math.abs(hash) % RANDOM_COLORS.length] +} + // Compute auto-migration of existing free-text values to character/variable definitions function computeMigration(initialData: FlowchartData, shouldMigrate: boolean) { if (!shouldMigrate) { @@ -514,6 +524,7 @@ function FlowchartEditorInner({ projectId, projectName, userId, userDisplayName, const crdtRef = useRef(null) const isRemoteUpdateRef = useRef(false) const cursorThrottleRef = useRef(0) + const [collaborationNotifications, setCollaborationNotifications] = useState([]) // Initialize CRDT manager and connect to Supabase Realtime channel on mount useEffect(() => { @@ -556,6 +567,18 @@ function FlowchartEditorInner({ projectId, projectName, userId, userDisplayName, const connection = new RealtimeConnection(projectId, userId, userDisplayName, { onConnectionStateChange: setConnectionState, onPresenceSync: setPresenceUsers, + onPresenceJoin: (user) => { + setCollaborationNotifications((prev) => [ + ...prev, + { id: nanoid(), displayName: user.displayName, type: 'join', color: getUserColor(user.userId) }, + ]) + }, + onPresenceLeave: (user) => { + setCollaborationNotifications((prev) => [ + ...prev, + { id: nanoid(), displayName: user.displayName, type: 'leave', color: getUserColor(user.userId) }, + ]) + }, onCursorUpdate: (cursor) => { setRemoteCursors((prev) => { const existing = prev.findIndex((c) => c.userId === cursor.userId) @@ -644,6 +667,10 @@ function FlowchartEditorInner({ projectId, projectName, userId, userDisplayName, ) }, [presenceUsers]) + const handleDismissNotification = useCallback((id: string) => { + setCollaborationNotifications((prev) => prev.filter((n) => n.id !== id)) + }, []) + const handleAddCharacter = useCallback( (name: string, color: string): string => { const id = nanoid() @@ -1333,6 +1360,17 @@ function FlowchartEditorInner({ projectId, projectName, userId, userDisplayName, onClose={() => setToastMessage(null)} /> )} + {collaborationNotifications.length > 0 && ( +
+ {collaborationNotifications.map((notification) => ( + + ))} +
+ )}
) diff --git a/src/components/editor/CollaborationToast.tsx b/src/components/editor/CollaborationToast.tsx new file mode 100644 index 0000000..3885d9c --- /dev/null +++ b/src/components/editor/CollaborationToast.tsx @@ -0,0 +1,43 @@ +'use client' + +import { useEffect } from 'react' + +export type CollaborationNotification = { + id: string + displayName: string + type: 'join' | 'leave' + color: string +} + +type CollaborationToastProps = { + notification: CollaborationNotification + onDismiss: (id: string) => void +} + +export default function CollaborationToast({ notification, onDismiss }: CollaborationToastProps) { + useEffect(() => { + const timer = setTimeout(() => { + onDismiss(notification.id) + }, 3000) + + return () => clearTimeout(timer) + }, [notification.id, onDismiss]) + + const message = notification.type === 'join' + ? `${notification.displayName} joined` + : `${notification.displayName} left` + + return ( +
+
+
+ {message} +
+
+ ) +} diff --git a/src/lib/collaboration/realtime.ts b/src/lib/collaboration/realtime.ts index d65d860..92815b0 100644 --- a/src/lib/collaboration/realtime.ts +++ b/src/lib/collaboration/realtime.ts @@ -23,6 +23,8 @@ export type RemoteCursor = { type RealtimeCallbacks = { onConnectionStateChange: (state: ConnectionState) => void onPresenceSync?: (users: PresenceUser[]) => void + onPresenceJoin?: (user: PresenceUser) => void + onPresenceLeave?: (user: PresenceUser) => void onChannelSubscribed?: (channel: RealtimeChannel) => void onCursorUpdate?: (cursor: RemoteCursor) => void } @@ -76,6 +78,28 @@ export class RealtimeConnection { this.callbacks.onPresenceSync?.(users) } }) + .on('presence', { event: 'join' }, ({ newPresences }) => { + for (const presence of newPresences) { + const p = presence as { userId?: string; displayName?: string } + if (p.userId && p.userId !== this.userId) { + this.callbacks.onPresenceJoin?.({ + userId: p.userId, + displayName: p.displayName || 'Anonymous', + }) + } + } + }) + .on('presence', { event: 'leave' }, ({ leftPresences }) => { + for (const presence of leftPresences) { + const p = presence as { userId?: string; displayName?: string } + if (p.userId && p.userId !== this.userId) { + this.callbacks.onPresenceLeave?.({ + userId: p.userId, + displayName: p.displayName || 'Anonymous', + }) + } + } + }) .on('broadcast', { event: 'cursor' }, ({ payload }) => { if (payload.userId === this.userId) return this.callbacks.onCursorUpdate?.({ From 5b84170a281db3a18ae0da725b08e1fa4906731b Mon Sep 17 00:00:00 2001 From: Gustavo Henrique Santos Souza de Miranda Date: Sat, 24 Jan 2026 02:38:05 -0300 Subject: [PATCH 04/20] fix: allow profile lookup for sharing by adding RLS policy The profiles table RLS policies only allowed users to view their own profile, causing the share feature to fail when searching for users by email. Added a policy allowing any authenticated user to read profiles. Co-Authored-By: Claude Opus 4.5 --- ...20260124100000_allow_profile_lookup_for_sharing.sql | 10 ++++++++++ 1 file changed, 10 insertions(+) create mode 100644 supabase/migrations/20260124100000_allow_profile_lookup_for_sharing.sql diff --git a/supabase/migrations/20260124100000_allow_profile_lookup_for_sharing.sql b/supabase/migrations/20260124100000_allow_profile_lookup_for_sharing.sql new file mode 100644 index 0000000..ca85885 --- /dev/null +++ b/supabase/migrations/20260124100000_allow_profile_lookup_for_sharing.sql @@ -0,0 +1,10 @@ +-- Migration: Allow authenticated users to look up profiles for sharing +-- Problem: The profiles RLS policies only allow users to see their own profile +-- (or admins to see all). This prevents the sharing feature from finding +-- users by email since the query is blocked by RLS. +-- Solution: Add a SELECT policy allowing any authenticated user to view profiles. + +CREATE POLICY "Authenticated users can view profiles" + ON profiles + FOR SELECT + USING (auth.uid() IS NOT NULL); From 4a85d7a64bc35005dde9a41f80bcdfb3ed725f75 Mon Sep 17 00:00:00 2001 From: Gustavo Henrique Santos Souza de Miranda Date: Sat, 24 Jan 2026 02:43:06 -0300 Subject: [PATCH 05/20] feat: [US-049] - Node editing lock indicators Co-Authored-By: Claude Opus 4.5 --- .../editor/[projectId]/FlowchartEditor.tsx | 92 ++++++++++++++++++- src/components/editor/EditorContext.tsx | 9 ++ src/components/editor/NodeLockIndicator.tsx | 24 +++++ src/components/editor/nodes/ChoiceNode.tsx | 26 +++++- src/components/editor/nodes/DialogueNode.tsx | 24 ++++- src/components/editor/nodes/VariableNode.tsx | 24 ++++- src/lib/collaboration/realtime.ts | 36 ++++++++ 7 files changed, 225 insertions(+), 10 deletions(-) create mode 100644 src/components/editor/NodeLockIndicator.tsx diff --git a/src/app/editor/[projectId]/FlowchartEditor.tsx b/src/app/editor/[projectId]/FlowchartEditor.tsx index c353a3c..83657a7 100644 --- a/src/app/editor/[projectId]/FlowchartEditor.tsx +++ b/src/app/editor/[projectId]/FlowchartEditor.tsx @@ -33,7 +33,8 @@ import ProjectSettingsModal from '@/components/editor/ProjectSettingsModal' import ConditionEditor from '@/components/editor/ConditionEditor' import ExportValidationModal, { type ValidationIssue } from '@/components/editor/ExportValidationModal' import { EditorProvider } from '@/components/editor/EditorContext' -import { RealtimeConnection, type ConnectionState, type PresenceUser, type RemoteCursor } from '@/lib/collaboration/realtime' +import { RealtimeConnection, type ConnectionState, type PresenceUser, type RemoteCursor, type NodeLock } from '@/lib/collaboration/realtime' +import type { NodeLockInfo } from '@/components/editor/EditorContext' import RemoteCursors from '@/components/editor/RemoteCursors' import { CRDTManager } from '@/lib/collaboration/crdt' import ShareModal from '@/components/editor/ShareModal' @@ -525,6 +526,9 @@ function FlowchartEditorInner({ projectId, projectName, userId, userDisplayName, const isRemoteUpdateRef = useRef(false) const cursorThrottleRef = useRef(0) const [collaborationNotifications, setCollaborationNotifications] = useState([]) + const [nodeLocks, setNodeLocks] = useState>(new Map()) + const localLockRef = useRef(null) // node ID currently locked by this user + const lockExpiryTimerRef = useRef | null>(null) // Initialize CRDT manager and connect to Supabase Realtime channel on mount useEffect(() => { @@ -590,6 +594,22 @@ function FlowchartEditorInner({ projectId, projectName, userId, userDisplayName, return [...prev, cursor] }) }, + onNodeLockUpdate: (lock: NodeLock | null, lockUserId: string) => { + setNodeLocks((prev) => { + const next = new Map(prev) + if (lock) { + next.set(lock.nodeId, { ...lock, color: getUserColor(lock.userId) }) + } else { + // Remove any lock held by this user + for (const [nodeId, info] of next) { + if (info.userId === lockUserId) { + next.delete(nodeId) + } + } + } + return next + }) + }, onChannelSubscribed: (channel) => { crdtManager.connectChannel(channel) }, @@ -598,6 +618,11 @@ function FlowchartEditorInner({ projectId, projectName, userId, userDisplayName, connection.connect() return () => { + // Release any held lock before disconnecting + if (localLockRef.current) { + connection.broadcastNodeLock(null) + localLockRef.current = null + } connection.disconnect() realtimeRef.current = null crdtManager.destroy() @@ -658,19 +683,70 @@ function FlowchartEditorInner({ projectId, projectName, userId, userDisplayName, [screenToFlowPosition] ) - // Remove cursors for users who leave + // Remove cursors and locks for users who leave useEffect(() => { setRemoteCursors((prev) => prev.filter((cursor) => presenceUsers.some((u) => u.userId === cursor.userId) ) ) + setNodeLocks((prev) => { + const next = new Map(prev) + let changed = false + for (const [nodeId, info] of next) { + if (!presenceUsers.some((u) => u.userId === info.userId)) { + next.delete(nodeId) + changed = true + } + } + return changed ? next : prev + }) }, [presenceUsers]) + // Lock auto-expiry: expire locks after 60 seconds of inactivity + useEffect(() => { + const LOCK_EXPIRY_MS = 60_000 + lockExpiryTimerRef.current = setInterval(() => { + const now = Date.now() + setNodeLocks((prev) => { + const next = new Map(prev) + let changed = false + for (const [nodeId, info] of next) { + if (now - info.lockedAt > LOCK_EXPIRY_MS) { + next.delete(nodeId) + changed = true + } + } + return changed ? next : prev + }) + }, 5000) // Check every 5 seconds + + return () => { + if (lockExpiryTimerRef.current) { + clearInterval(lockExpiryTimerRef.current) + lockExpiryTimerRef.current = null + } + } + }, []) + const handleDismissNotification = useCallback((id: string) => { setCollaborationNotifications((prev) => prev.filter((n) => n.id !== id)) }, []) + const handleNodeFocus = useCallback((nodeId: string) => { + // Broadcast lock for this node + localLockRef.current = nodeId + realtimeRef.current?.broadcastNodeLock(nodeId) + }, []) + + const handleNodeBlur = useCallback(() => { + // Release lock + if (localLockRef.current) { + localLockRef.current = null + realtimeRef.current?.broadcastNodeLock(null) + } + }, []) + const handleAddCharacter = useCallback( (name: string, color: string): string => { const id = nanoid() @@ -692,8 +768,16 @@ function FlowchartEditorInner({ projectId, projectName, userId, userDisplayName, ) const editorContextValue = useMemo( - () => ({ characters, onAddCharacter: handleAddCharacter, variables, onAddVariable: handleAddVariableDefinition }), - [characters, handleAddCharacter, variables, handleAddVariableDefinition] + () => ({ + characters, + onAddCharacter: handleAddCharacter, + variables, + onAddVariable: handleAddVariableDefinition, + nodeLocks, + onNodeFocus: handleNodeFocus, + onNodeBlur: handleNodeBlur, + }), + [characters, handleAddCharacter, variables, handleAddVariableDefinition, nodeLocks, handleNodeFocus, handleNodeBlur] ) const getCharacterUsageCount = useCallback( diff --git a/src/components/editor/EditorContext.tsx b/src/components/editor/EditorContext.tsx index 39a921d..177c119 100644 --- a/src/components/editor/EditorContext.tsx +++ b/src/components/editor/EditorContext.tsx @@ -2,12 +2,18 @@ import { createContext, useContext } from 'react' import type { Character, Variable } from '@/types/flowchart' +import type { NodeLock } from '@/lib/collaboration/realtime' + +export type NodeLockInfo = NodeLock & { color: string } type EditorContextValue = { characters: Character[] onAddCharacter: (name: string, color: string) => string // returns new character id variables: Variable[] onAddVariable: (name: string, type: 'numeric' | 'string' | 'boolean', initialValue: number | string | boolean) => string // returns new variable id + nodeLocks: Map // nodeId -> lock info + onNodeFocus: (nodeId: string) => void + onNodeBlur: () => void } const EditorContext = createContext({ @@ -15,6 +21,9 @@ const EditorContext = createContext({ onAddCharacter: () => '', variables: [], onAddVariable: () => '', + nodeLocks: new Map(), + onNodeFocus: () => {}, + onNodeBlur: () => {}, }) export const EditorProvider = EditorContext.Provider diff --git a/src/components/editor/NodeLockIndicator.tsx b/src/components/editor/NodeLockIndicator.tsx new file mode 100644 index 0000000..8cde517 --- /dev/null +++ b/src/components/editor/NodeLockIndicator.tsx @@ -0,0 +1,24 @@ +'use client' + +import type { NodeLock } from '@/lib/collaboration/realtime' + +type NodeLockIndicatorProps = { + lock: NodeLock + color: string +} + +export default function NodeLockIndicator({ lock, color }: NodeLockIndicatorProps) { + return ( +
+
+ {lock.displayName} +
+
+ ) +} diff --git a/src/components/editor/nodes/ChoiceNode.tsx b/src/components/editor/nodes/ChoiceNode.tsx index 47f54e4..523ffac 100644 --- a/src/components/editor/nodes/ChoiceNode.tsx +++ b/src/components/editor/nodes/ChoiceNode.tsx @@ -5,6 +5,7 @@ import { Handle, Position, NodeProps, useReactFlow } from 'reactflow' import { nanoid } from 'nanoid' import { useEditorContext } from '@/components/editor/EditorContext' import OptionConditionEditor from '@/components/editor/OptionConditionEditor' +import NodeLockIndicator from '@/components/editor/NodeLockIndicator' import type { Condition } from '@/types/flowchart' type ChoiceOption = { @@ -23,9 +24,12 @@ const MAX_OPTIONS = 6 export default function ChoiceNode({ id, data }: NodeProps) { const { setNodes } = useReactFlow() - const { variables } = useEditorContext() // Puxa as variáveis globais para validar condições + const { variables, nodeLocks, onNodeFocus, onNodeBlur } = useEditorContext() const [editingConditionOptionId, setEditingConditionOptionId] = useState(null) + const lockInfo = nodeLocks.get(id) + const isLockedByOther = !!lockInfo + // --- Handlers de Atualização --- const updatePrompt = useCallback( @@ -152,8 +156,26 @@ export default function ChoiceNode({ id, data }: NodeProps) { [variables] ) + const handleFocus = useCallback(() => { + onNodeFocus(id) + }, [id, onNodeFocus]) + return ( -
+
+ {isLockedByOther && ( + + )} + {isLockedByOther && ( +
+ + Being edited by {lockInfo.displayName} + +
+ )}
diff --git a/src/components/editor/nodes/DialogueNode.tsx b/src/components/editor/nodes/DialogueNode.tsx index 10afe97..0ae4956 100644 --- a/src/components/editor/nodes/DialogueNode.tsx +++ b/src/components/editor/nodes/DialogueNode.tsx @@ -5,6 +5,7 @@ import { Handle, Position, NodeProps, useReactFlow } from 'reactflow' import Combobox from '@/components/editor/Combobox' import type { ComboboxItem } from '@/components/editor/Combobox' import { useEditorContext } from '@/components/editor/EditorContext' +import NodeLockIndicator from '@/components/editor/NodeLockIndicator' type DialogueNodeData = { speaker?: string @@ -24,7 +25,10 @@ function randomColor(): string { export default function DialogueNode({ id, data }: NodeProps) { const { setNodes } = useReactFlow() - const { characters, onAddCharacter } = useEditorContext() + const { characters, onAddCharacter, nodeLocks, onNodeFocus, onNodeBlur } = useEditorContext() + + const lockInfo = nodeLocks.get(id) + const isLockedByOther = !!lockInfo const [showAddForm, setShowAddForm] = useState(false) const [newName, setNewName] = useState('') @@ -96,14 +100,30 @@ export default function DialogueNode({ id, data }: NodeProps) [updateNodeData] ) + const handleFocus = useCallback(() => { + onNodeFocus(id) + }, [id, onNodeFocus]) + return (
+ {isLockedByOther && ( + + )} + {isLockedByOther && ( +
+ + Being edited by {lockInfo.displayName} + +
+ )} ) { const { setNodes } = useReactFlow() - const { variables, onAddVariable } = useEditorContext() + const { variables, onAddVariable, nodeLocks, onNodeFocus, onNodeBlur } = useEditorContext() + + const lockInfo = nodeLocks.get(id) + const isLockedByOther = !!lockInfo const [showAddForm, setShowAddForm] = useState(false) const [newName, setNewName] = useState('') @@ -109,14 +113,30 @@ export default function VariableNode({ id, data }: NodeProps) // Filter operations based on selected variable type const isNumeric = !selectedVariable || selectedVariable.type === 'numeric' + const handleFocus = useCallback(() => { + onNodeFocus(id) + }, [id, onNodeFocus]) + return (
+ {isLockedByOther && ( + + )} + {isLockedByOther && ( +
+ + Being edited by {lockInfo.displayName} + +
+ )} void onPresenceSync?: (users: PresenceUser[]) => void @@ -27,6 +34,7 @@ type RealtimeCallbacks = { onPresenceLeave?: (user: PresenceUser) => void onChannelSubscribed?: (channel: RealtimeChannel) => void onCursorUpdate?: (cursor: RemoteCursor) => void + onNodeLockUpdate?: (lock: NodeLock | null, userId: string) => void } const HEARTBEAT_INTERVAL_MS = 30_000 @@ -109,6 +117,20 @@ export class RealtimeConnection { lastUpdated: Date.now(), }) }) + .on('broadcast', { event: 'node-lock' }, ({ payload }) => { + if (payload.userId === this.userId) return + if (payload.nodeId) { + this.callbacks.onNodeLockUpdate?.({ + nodeId: payload.nodeId, + userId: payload.userId, + displayName: payload.displayName, + lockedAt: payload.lockedAt, + }, payload.userId) + } else { + // null nodeId means unlock + this.callbacks.onNodeLockUpdate?.(null, payload.userId) + } + }) .subscribe(async (status) => { if (this.isDestroyed) return @@ -167,6 +189,20 @@ export class RealtimeConnection { }) } + broadcastNodeLock(nodeId: string | null): void { + if (!this.channel) return + this.channel.send({ + type: 'broadcast', + event: 'node-lock', + payload: { + userId: this.userId, + displayName: this.displayName, + nodeId, + lockedAt: Date.now(), + }, + }) + } + private async createSession(): Promise { try { await this.supabase.from('collaboration_sessions').upsert( From 6ef5cfc7fabfbc8f29c8799737892b3e2d69b248 Mon Sep 17 00:00:00 2001 From: Gustavo Henrique Santos Souza de Miranda Date: Sat, 24 Jan 2026 02:46:52 -0300 Subject: [PATCH 06/20] feat: [US-051] - Audit trail recording Co-Authored-By: Claude Opus 4.5 --- .../editor/[projectId]/FlowchartEditor.tsx | 11 + src/lib/collaboration/auditTrail.ts | 218 ++++++++++++++++++ 2 files changed, 229 insertions(+) create mode 100644 src/lib/collaboration/auditTrail.ts diff --git a/src/app/editor/[projectId]/FlowchartEditor.tsx b/src/app/editor/[projectId]/FlowchartEditor.tsx index 83657a7..a5bd2b8 100644 --- a/src/app/editor/[projectId]/FlowchartEditor.tsx +++ b/src/app/editor/[projectId]/FlowchartEditor.tsx @@ -37,6 +37,7 @@ import { RealtimeConnection, type ConnectionState, type PresenceUser, type Remot import type { NodeLockInfo } from '@/components/editor/EditorContext' import RemoteCursors from '@/components/editor/RemoteCursors' import { CRDTManager } from '@/lib/collaboration/crdt' +import { AuditTrailRecorder } from '@/lib/collaboration/auditTrail' import ShareModal from '@/components/editor/ShareModal' import CollaborationToast, { type CollaborationNotification } from '@/components/editor/CollaborationToast' import type { FlowchartData, FlowchartNode, FlowchartEdge, Character, Variable, Condition } from '@/types/flowchart' @@ -523,6 +524,7 @@ function FlowchartEditorInner({ projectId, projectName, userId, userDisplayName, const [remoteCursors, setRemoteCursors] = useState([]) const realtimeRef = useRef(null) const crdtRef = useRef(null) + const auditRef = useRef(null) const isRemoteUpdateRef = useRef(false) const cursorThrottleRef = useRef(0) const [collaborationNotifications, setCollaborationNotifications] = useState([]) @@ -568,6 +570,11 @@ function FlowchartEditorInner({ projectId, projectName, userId, userDisplayName, crdtManager.initializeFromData(migratedData.nodes, migratedData.edges) crdtRef.current = crdtManager + // Initialize audit trail recorder + const auditRecorder = new AuditTrailRecorder(projectId, userId) + auditRecorder.initialize(migratedData.nodes, migratedData.edges) + auditRef.current = auditRecorder + const connection = new RealtimeConnection(projectId, userId, userDisplayName, { onConnectionStateChange: setConnectionState, onPresenceSync: setPresenceUsers, @@ -627,6 +634,8 @@ function FlowchartEditorInner({ projectId, projectName, userId, userDisplayName, realtimeRef.current = null crdtManager.destroy() crdtRef.current = null + auditRecorder.destroy() + auditRef.current = null } // eslint-disable-next-line react-hooks/exhaustive-deps }, [projectId, userId, userDisplayName]) @@ -655,11 +664,13 @@ function FlowchartEditorInner({ projectId, projectName, userId, userDisplayName, useEffect(() => { if (isRemoteUpdateRef.current) return crdtRef.current?.updateNodes(nodesForCRDT) + auditRef.current?.recordNodeChanges(nodesForCRDT) }, [nodesForCRDT]) useEffect(() => { if (isRemoteUpdateRef.current) return crdtRef.current?.updateEdges(edgesForCRDT) + auditRef.current?.recordEdgeChanges(edgesForCRDT) }, [edgesForCRDT]) // Broadcast cursor position on mouse move (throttled to 50ms) diff --git a/src/lib/collaboration/auditTrail.ts b/src/lib/collaboration/auditTrail.ts new file mode 100644 index 0000000..f141a30 --- /dev/null +++ b/src/lib/collaboration/auditTrail.ts @@ -0,0 +1,218 @@ +import { createClient } from '@/lib/supabase/client' +import type { FlowchartNode, FlowchartEdge } from '@/types/flowchart' + +export type AuditActionType = + | 'node_add' + | 'node_update' + | 'node_delete' + | 'edge_add' + | 'edge_update' + | 'edge_delete' + +type PendingAuditEntry = { + actionType: AuditActionType + entityId: string + previousState: unknown | null + newState: unknown | null + timer: ReturnType +} + +const DEBOUNCE_MS = 1000 + +export class AuditTrailRecorder { + private projectId: string + private userId: string + private previousNodes: Map // node ID -> JSON string + private previousEdges: Map // edge ID -> JSON string + private pending: Map // entityId -> pending entry + private isDestroyed = false + + constructor(projectId: string, userId: string) { + this.projectId = projectId + this.userId = userId + this.previousNodes = new Map() + this.previousEdges = new Map() + this.pending = new Map() + } + + /** Initialize with the current state (no audit entries are created for initial state) */ + initialize(nodes: FlowchartNode[], edges: FlowchartEdge[]): void { + this.previousNodes.clear() + this.previousEdges.clear() + nodes.forEach((node) => { + this.previousNodes.set(node.id, JSON.stringify(node)) + }) + edges.forEach((edge) => { + this.previousEdges.set(edge.id, JSON.stringify(edge)) + }) + } + + /** Record changes by diffing current state against previous state */ + recordNodeChanges(currentNodes: FlowchartNode[]): void { + if (this.isDestroyed) return + + const currentMap = new Map() + currentNodes.forEach((node) => { + currentMap.set(node.id, JSON.stringify(node)) + }) + + // Detect additions and updates + for (const [id, serialized] of currentMap) { + const previous = this.previousNodes.get(id) + if (!previous) { + // Node was added + this.scheduleWrite(id, 'node_add', null, JSON.parse(serialized)) + } else if (previous !== serialized) { + // Node was updated + this.scheduleWrite(id, 'node_update', JSON.parse(previous), JSON.parse(serialized)) + } + } + + // Detect deletions + for (const [id, serialized] of this.previousNodes) { + if (!currentMap.has(id)) { + this.scheduleWrite(id, 'node_delete', JSON.parse(serialized), null) + } + } + + // Update previous state + this.previousNodes = currentMap + } + + /** Record edge changes by diffing current state against previous state */ + recordEdgeChanges(currentEdges: FlowchartEdge[]): void { + if (this.isDestroyed) return + + const currentMap = new Map() + currentEdges.forEach((edge) => { + currentMap.set(edge.id, JSON.stringify(edge)) + }) + + // Detect additions and updates + for (const [id, serialized] of currentMap) { + const previous = this.previousEdges.get(id) + if (!previous) { + // Edge was added + this.scheduleWrite(id, 'edge_add', null, JSON.parse(serialized)) + } else if (previous !== serialized) { + // Edge was updated + this.scheduleWrite(id, 'edge_update', JSON.parse(previous), JSON.parse(serialized)) + } + } + + // Detect deletions + for (const [id, serialized] of this.previousEdges) { + if (!currentMap.has(id)) { + this.scheduleWrite(id, 'edge_delete', JSON.parse(serialized), null) + } + } + + // Update previous state + this.previousEdges = currentMap + } + + /** Clean up resources */ + destroy(): void { + this.isDestroyed = true + // Flush all pending writes immediately + for (const [entityId, entry] of this.pending) { + clearTimeout(entry.timer) + this.writeEntry(entry) + this.pending.delete(entityId) + } + } + + private scheduleWrite( + entityId: string, + actionType: AuditActionType, + previousState: unknown | null, + newState: unknown | null + ): void { + // If there's already a pending write for this entity, cancel it and update + const existing = this.pending.get(entityId) + if (existing) { + clearTimeout(existing.timer) + // For debounced updates, keep the original previousState but use latest newState + // For type changes (e.g., add then delete within 1s), use the new action type + const mergedPreviousState = existing.actionType === 'node_add' || existing.actionType === 'edge_add' + ? null // If it was just added, there's no real previous state + : existing.previousState + const mergedActionType = this.mergeActionTypes(existing.actionType, actionType) + + // If an entity was added and then deleted within the debounce window, skip entirely + if (mergedActionType === null) { + this.pending.delete(entityId) + return + } + + const entry: PendingAuditEntry = { + actionType: mergedActionType, + entityId, + previousState: mergedPreviousState, + newState, + timer: setTimeout(() => { + this.pending.delete(entityId) + this.writeEntry(entry) + }, DEBOUNCE_MS), + } + this.pending.set(entityId, entry) + } else { + const entry: PendingAuditEntry = { + actionType, + entityId, + previousState, + newState, + timer: setTimeout(() => { + this.pending.delete(entityId) + this.writeEntry(entry) + }, DEBOUNCE_MS), + } + this.pending.set(entityId, entry) + } + } + + /** Merge two sequential action types for the same entity */ + private mergeActionTypes( + first: AuditActionType, + second: AuditActionType + ): AuditActionType | null { + // add + delete = no-op (entity never really existed) + if ( + (first === 'node_add' && second === 'node_delete') || + (first === 'edge_add' && second === 'edge_delete') + ) { + return null + } + // add + update = still an add (with the updated state) + if ( + (first === 'node_add' && second === 'node_update') || + (first === 'edge_add' && second === 'edge_update') + ) { + return first + } + // For all other cases, use the second (latest) action type + return second + } + + /** Fire-and-forget write to Supabase */ + private writeEntry(entry: PendingAuditEntry): void { + if (this.isDestroyed) return + + const supabase = createClient() + supabase + .from('audit_trail') + .insert({ + project_id: this.projectId, + user_id: this.userId, + action_type: entry.actionType, + entity_id: entry.entityId, + previous_state: entry.previousState, + new_state: entry.newState, + }) + .then(({ error }) => { + if (error) { + console.error('[AuditTrail] Failed to write audit entry:', error) + } + }) + } +} From 6c4a3ba2b7ee0789f096cfc094243eb5e8055012 Mon Sep 17 00:00:00 2001 From: Gustavo Henrique Santos Souza de Miranda Date: Sat, 24 Jan 2026 02:47:41 -0300 Subject: [PATCH 07/20] docs: update PRD and progress for US-051 Co-Authored-By: Claude Opus 4.5 --- prd.json | 4 ++-- progress.txt | 38 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 40 insertions(+), 2 deletions(-) diff --git a/prd.json b/prd.json index 42f2047..211c373 100644 --- a/prd.json +++ b/prd.json @@ -359,7 +359,7 @@ "Verify in browser using dev-browser skill" ], "priority": 20, - "passes": false, + "passes": true, "notes": "Dependencies: US-045, US-048" }, { @@ -376,7 +376,7 @@ "Typecheck passes" ], "priority": 21, - "passes": false, + "passes": true, "notes": "Dependencies: US-043, US-048" }, { diff --git a/progress.txt b/progress.txt index cb9e998..811e328 100644 --- a/progress.txt +++ b/progress.txt @@ -64,6 +64,9 @@ - `RemoteCursors` at `src/components/editor/RemoteCursors.tsx` renders collaborator cursors on canvas. Uses `useViewport()` to transform flow→screen coordinates. Throttle broadcasts to 50ms via timestamp ref. - Supabase Realtime presence events: `sync` (full state), `join` (arrivals with `newPresences` array), `leave` (departures with `leftPresences` array). Filter `this.userId` to skip own events. - `CollaborationToast` at `src/components/editor/CollaborationToast.tsx` shows join/leave notifications (bottom-left, auto-dismiss 3s). Uses `getUserColor(userId)` for accent color dot. +- Node lock indicators use `EditorContext` (`nodeLocks` Map, `onNodeFocus`, `onNodeBlur`). Each node component checks `nodeLocks.get(id)` for lock state and renders `NodeLockIndicator` + overlay if locked by another user. +- For ephemeral lock state (node editing locks), broadcast via `node-lock` event with `{ nodeId, userId, displayName, lockedAt }`. Send `nodeId: null` to release. +- `AuditTrailRecorder` at `src/lib/collaboration/auditTrail.ts` records node/edge changes to `audit_trail` table. Uses state diffing (previous vs current Maps), 1-second per-entity debounce, and fire-and-forget Supabase inserts. Only records local changes (guarded by `isRemoteUpdateRef` in FlowchartEditor). --- @@ -432,3 +435,38 @@ - The `getUserColor` function is duplicated from PresenceAvatars to avoid circular imports. Both use the same hash-to-color-index algorithm with the same RANDOM_COLORS palette for consistency. - No browser testing tools are available; manual verification is needed. --- + +## 2026-01-24 - US-049 +- What was implemented: Node editing lock indicators that show when another collaborator is editing a node +- Files changed: + - `src/lib/collaboration/realtime.ts` - Added `NodeLock` type, `onNodeLockUpdate` callback to `RealtimeCallbacks`, `node-lock` broadcast listener, and `broadcastNodeLock()` method + - `src/components/editor/NodeLockIndicator.tsx` - New component: renders a colored border and name label overlay on locked nodes + - `src/components/editor/EditorContext.tsx` - Extended context with `NodeLockInfo` type, `nodeLocks` (Map), `onNodeFocus`, and `onNodeBlur` callbacks + - `src/components/editor/nodes/DialogueNode.tsx` - Added lock detection, `NodeLockIndicator` rendering, "Being edited by [name]" overlay, `onFocus`/`onBlur` handlers + - `src/components/editor/nodes/VariableNode.tsx` - Same lock indicator pattern as DialogueNode + - `src/components/editor/nodes/ChoiceNode.tsx` - Same lock indicator pattern as DialogueNode + - `src/app/editor/[projectId]/FlowchartEditor.tsx` - Added `nodeLocks` state (Map), `localLockRef` for tracking own lock, `handleNodeFocus`/`handleNodeBlur` callbacks, `onNodeLockUpdate` handler in RealtimeConnection, lock expiry timer (60s check every 5s), lock cleanup on user leave and component unmount, extended `editorContextValue` with lock state +- **Learnings for future iterations:** + - Node lock uses Supabase Realtime broadcast (like cursors) — ephemeral, not persisted to DB. Event name: `node-lock`. + - Lock broadcasting uses `nodeId: string | null` pattern: non-null to acquire lock, `null` to release. The receiving side maps userId to their current lock. + - Lock expiry uses a 60-second timeout checked every 5 seconds via `setInterval`. The `lockedAt` timestamp is broadcast with the lock payload. + - Each node component accesses lock state via `EditorContext` (`nodeLocks` Map). The `NodeLockInfo` type extends `NodeLock` with a `color` field derived from `getUserColor()`. + - `onFocus`/`onBlur` on the node container div fires when any child input gains/loses focus (focus event bubbles), which naturally maps to "user is editing this node". + - Lock release on disconnect: broadcast `null` lock before calling `connection.disconnect()` in the cleanup return of the mount effect. + - Locks from disconnected users are cleaned up in the same effect that removes cursors (filtered by `presenceUsers` list). + - No browser testing tools are available; manual verification is needed. +--- + +## 2026-01-24 - US-051 +- What was implemented: Audit trail recording that writes all node/edge add/update/delete operations to the `audit_trail` table with debouncing and fire-and-forget semantics +- Files changed: + - `src/lib/collaboration/auditTrail.ts` - New `AuditTrailRecorder` class: tracks previous node/edge state, diffs against current state to detect add/update/delete operations, debounces writes per entity (1 second), merges rapid sequential actions (e.g., add+update=add, add+delete=no-op), fire-and-forget Supabase inserts with error logging, flush on destroy + - `src/app/editor/[projectId]/FlowchartEditor.tsx` - Added `auditRef` (AuditTrailRecorder), initialized in mount effect alongside CRDT manager, records node/edge changes in the CRDT sync effects (only for local changes, skipped for remote updates), destroyed on unmount +- **Learnings for future iterations:** + - The audit recorder piggybacks on the same CRDT sync effects that already compute `nodesForCRDT`/`edgesForCRDT` — this avoids duplicating the React Flow → FlowchartNode/Edge conversion. + - The `isRemoteUpdateRef` guard in the sync effects ensures audit entries are only created for local user actions, not for changes received from other collaborators (those users' own recorders will handle their audit entries). + - Debouncing per entity (1 second) prevents rapid edits (e.g., typing in a text field) from flooding the audit table. The merge logic handles transient states (add+delete within 1s = skip). + - The `destroy()` method flushes pending entries synchronously on unmount, ensuring in-flight edits aren't lost when navigating away. + - Supabase `.insert().then()` pattern provides fire-and-forget writes with error logging — the async operation doesn't block the editing flow. + - No browser testing needed — this is a developer/infrastructure story with no UI changes. +--- From f06a30b2bf4f7ca952f58b25564371cd2005f8e7 Mon Sep 17 00:00:00 2001 From: Gustavo Henrique Santos Souza de Miranda Date: Sat, 24 Jan 2026 02:53:31 -0300 Subject: [PATCH 08/20] feat: [US-052] - Activity history sidebar Co-Authored-By: Claude Opus 4.5 --- prd.json | 2 +- progress.txt | 17 + .../editor/[projectId]/FlowchartEditor.tsx | 30 ++ .../editor/ActivityHistorySidebar.tsx | 326 ++++++++++++++++++ src/components/editor/Toolbar.tsx | 8 + 5 files changed, 382 insertions(+), 1 deletion(-) create mode 100644 src/components/editor/ActivityHistorySidebar.tsx diff --git a/prd.json b/prd.json index 211c373..23905f1 100644 --- a/prd.json +++ b/prd.json @@ -394,7 +394,7 @@ "Verify in browser using dev-browser skill" ], "priority": 22, - "passes": false, + "passes": true, "notes": "Dependencies: US-051" }, { diff --git a/progress.txt b/progress.txt index 811e328..047319a 100644 --- a/progress.txt +++ b/progress.txt @@ -67,6 +67,8 @@ - Node lock indicators use `EditorContext` (`nodeLocks` Map, `onNodeFocus`, `onNodeBlur`). Each node component checks `nodeLocks.get(id)` for lock state and renders `NodeLockIndicator` + overlay if locked by another user. - For ephemeral lock state (node editing locks), broadcast via `node-lock` event with `{ nodeId, userId, displayName, lockedAt }`. Send `nodeId: null` to release. - `AuditTrailRecorder` at `src/lib/collaboration/auditTrail.ts` records node/edge changes to `audit_trail` table. Uses state diffing (previous vs current Maps), 1-second per-entity debounce, and fire-and-forget Supabase inserts. Only records local changes (guarded by `isRemoteUpdateRef` in FlowchartEditor). +- `ActivityHistorySidebar` at `src/components/editor/ActivityHistorySidebar.tsx` displays audit trail entries in a right sidebar. Rendered inside the canvas `relative flex-1` container. Toggle via `showHistory` state in FlowchartEditor. +- For async data fetching in components with React Compiler, use a pure fetch function returning `{ data, error, hasMore }` result object, then handle setState in the `.then()` callback with an abort/mount guard — never call setState-containing functions directly inside useEffect. --- @@ -470,3 +472,18 @@ - Supabase `.insert().then()` pattern provides fire-and-forget writes with error logging — the async operation doesn't block the editing flow. - No browser testing needed — this is a developer/infrastructure story with no UI changes. --- + +## 2026-01-24 - US-052 +- What was implemented: Activity history sidebar that displays audit trail entries, grouped by time period, with entity selection on click +- Files changed: + - `src/components/editor/ActivityHistorySidebar.tsx` - New component: right sidebar panel showing chronological audit trail entries, grouped by Today/Yesterday/Earlier, with user color accents, entity descriptions, paginated loading (20 per page), and click-to-select entity on canvas + - `src/components/editor/Toolbar.tsx` - Added `onHistory` prop and "History" button in the right toolbar section + - `src/app/editor/[projectId]/FlowchartEditor.tsx` - Added `showHistory` state, `handleHistorySelectEntity` callback (selects nodes/edges on canvas), `ActivityHistorySidebar` import and rendering inside the canvas area, `onHistory` toggle prop on Toolbar +- **Learnings for future iterations:** + - React Compiler lint (`react-hooks/set-state-in-effect`) treats any function that calls setState as problematic when invoked inside useEffect — even if the setState is in an async `.then()` callback. To avoid this, extract data fetching into a pure function that returns a result object, then handle setState only in the `.then()` callback after checking a mounted/aborted guard. + - For right sidebar panels overlaying the canvas, use `absolute right-0 top-0 z-40 h-full w-80` inside the `relative flex-1` canvas container. This keeps the sidebar within the canvas area without affecting the toolbar. + - The `audit_trail` table has an index on `(project_id, created_at DESC)` which makes paginated queries efficient. Use `.range(offset, offset + PAGE_SIZE - 1)` for Supabase pagination. + - Entity descriptions are derived from `new_state` (for adds/updates) or `previous_state` (for deletes). The state contains the full node/edge data including `type`, `data.speaker`, `data.question`, `data.variableName`. + - Deleted entities (`action_type.endsWith('_delete')`) cannot be selected on canvas since they no longer exist — render those entries as disabled (no click handler, reduced opacity). + - No browser testing tools are available; manual verification is needed. +--- diff --git a/src/app/editor/[projectId]/FlowchartEditor.tsx b/src/app/editor/[projectId]/FlowchartEditor.tsx index a5bd2b8..0370e4e 100644 --- a/src/app/editor/[projectId]/FlowchartEditor.tsx +++ b/src/app/editor/[projectId]/FlowchartEditor.tsx @@ -39,6 +39,7 @@ import RemoteCursors from '@/components/editor/RemoteCursors' import { CRDTManager } from '@/lib/collaboration/crdt' import { AuditTrailRecorder } from '@/lib/collaboration/auditTrail' import ShareModal from '@/components/editor/ShareModal' +import ActivityHistorySidebar from '@/components/editor/ActivityHistorySidebar' import CollaborationToast, { type CollaborationNotification } from '@/components/editor/CollaborationToast' import type { FlowchartData, FlowchartNode, FlowchartEdge, Character, Variable, Condition } from '@/types/flowchart' @@ -515,6 +516,7 @@ function FlowchartEditorInner({ projectId, projectName, userId, userDisplayName, const [variables, setVariables] = useState(migratedData.variables) const [showSettings, setShowSettings] = useState(false) const [showShare, setShowShare] = useState(false) + const [showHistory, setShowHistory] = useState(false) const [selectedEdgeId, setSelectedEdgeId] = useState(null) const [toastMessage, setToastMessage] = useState(migratedData.toastMessage) const [validationIssues, setValidationIssues] = useState(null) @@ -744,6 +746,26 @@ function FlowchartEditorInner({ projectId, projectName, userId, userDisplayName, setCollaborationNotifications((prev) => prev.filter((n) => n.id !== id)) }, []) + const handleHistorySelectEntity = useCallback((entityId: string, actionType: string) => { + if (actionType.startsWith('node_')) { + // Select the node on the canvas + setNodes((nds) => + nds.map((n) => ({ ...n, selected: n.id === entityId })) + ) + setEdges((eds) => + eds.map((e) => ({ ...e, selected: false })) + ) + } else if (actionType.startsWith('edge_')) { + // Select the edge on the canvas + setEdges((eds) => + eds.map((e) => ({ ...e, selected: e.id === entityId })) + ) + setNodes((nds) => + nds.map((n) => ({ ...n, selected: false })) + ) + } + }, [setNodes, setEdges]) + const handleNodeFocus = useCallback((nodeId: string) => { // Broadcast lock for this node localLockRef.current = nodeId @@ -1373,6 +1395,7 @@ function FlowchartEditorInner({ projectId, projectName, userId, userDisplayName, onImport={handleImport} onProjectSettings={() => setShowSettings(true)} onShare={() => setShowShare(true)} + onHistory={() => setShowHistory((v) => !v)} connectionState={connectionState} presenceUsers={presenceUsers} /> @@ -1398,6 +1421,13 @@ function FlowchartEditorInner({ projectId, projectName, userId, userDisplayName, + {showHistory && ( + setShowHistory(false)} + onSelectEntity={handleHistorySelectEntity} + /> + )}
{contextMenu && ( | null + new_state: Record | null + created_at: string + user_display_name?: string +} + +type ActivityHistorySidebarProps = { + projectId: string + onClose: () => void + onSelectEntity: (entityId: string, actionType: string) => void +} + +const ACTION_LABELS: Record = { + node_add: 'Added node', + node_update: 'Updated node', + node_delete: 'Deleted node', + edge_add: 'Added edge', + edge_update: 'Updated edge', + edge_delete: 'Deleted edge', +} + +const ACTION_ICONS: Record = { + node_add: '+', + node_update: '~', + node_delete: '-', + edge_add: '+', + edge_update: '~', + edge_delete: '-', +} + +const ACTION_COLORS: Record = { + node_add: 'text-green-500', + node_update: 'text-blue-500', + node_delete: 'text-red-500', + edge_add: 'text-green-500', + edge_update: 'text-blue-500', + edge_delete: 'text-red-500', +} + +// Same hash logic as PresenceAvatars and FlowchartEditor +const PRESENCE_COLORS = [ + '#EF4444', '#F97316', '#F59E0B', '#10B981', + '#3B82F6', '#8B5CF6', '#EC4899', '#14B8A6', + '#6366F1', '#F43F5E', '#84CC16', '#06B6D4', +] + +function getUserColor(userId: string): string { + let hash = 0 + for (let i = 0; i < userId.length; i++) { + hash = ((hash << 5) - hash + userId.charCodeAt(i)) | 0 + } + return PRESENCE_COLORS[Math.abs(hash) % PRESENCE_COLORS.length] +} + +function getEntityDescription(entry: AuditEntry): string { + const state = entry.new_state || entry.previous_state + if (!state) return entry.entity_id.slice(0, 8) + + if (entry.action_type.startsWith('node_')) { + const type = (state as Record).type as string | undefined + const data = (state as Record).data as Record | undefined + if (type === 'dialogue' && data) { + const speaker = (data.speaker as string) || (data.characterId as string) || '' + return speaker ? `Dialogue (${speaker})` : 'Dialogue node' + } + if (type === 'choice' && data) { + const question = (data.question as string) || '' + return question ? `Choice: "${question.slice(0, 20)}${question.length > 20 ? '…' : ''}"` : 'Choice node' + } + if (type === 'variable' && data) { + const name = (data.variableName as string) || '' + return name ? `Variable: ${name}` : 'Variable node' + } + return `${type || 'Unknown'} node` + } + + // Edge entries + return 'Connection' +} + +function formatTime(dateStr: string): string { + const date = new Date(dateStr) + return date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }) +} + +function getTimePeriod(dateStr: string): 'Today' | 'Yesterday' | 'Earlier' { + const date = new Date(dateStr) + const now = new Date() + const today = new Date(now.getFullYear(), now.getMonth(), now.getDate()) + const yesterday = new Date(today) + yesterday.setDate(yesterday.getDate() - 1) + + if (date >= today) return 'Today' + if (date >= yesterday) return 'Yesterday' + return 'Earlier' +} + +type GroupedEntries = { + period: 'Today' | 'Yesterday' | 'Earlier' + entries: AuditEntry[] +} + +function groupByPeriod(entries: AuditEntry[]): GroupedEntries[] { + const groups: Record = {} + const order: ('Today' | 'Yesterday' | 'Earlier')[] = ['Today', 'Yesterday', 'Earlier'] + + for (const entry of entries) { + const period = getTimePeriod(entry.created_at) + if (!groups[period]) groups[period] = [] + groups[period].push(entry) + } + + return order + .filter((p) => groups[p] && groups[p].length > 0) + .map((p) => ({ period: p, entries: groups[p] })) +} + +export default function ActivityHistorySidebar({ + projectId, + onClose, + onSelectEntity, +}: ActivityHistorySidebarProps) { + const [entries, setEntries] = useState([]) + const [loading, setLoading] = useState(true) + const [loadingMore, setLoadingMore] = useState(false) + const [hasMore, setHasMore] = useState(true) + const [error, setError] = useState(null) + const mountedRef = useRef(true) + + const fetchEntriesRaw = useCallback(async (offset: number): Promise<{ entries: AuditEntry[]; hasMore: boolean; error?: string }> => { + const supabase = createClient() + const { data, error: fetchError } = await supabase + .from('audit_trail') + .select('id, project_id, user_id, action_type, entity_id, previous_state, new_state, created_at') + .eq('project_id', projectId) + .order('created_at', { ascending: false }) + .range(offset, offset + PAGE_SIZE - 1) + + if (fetchError) { + return { entries: [], hasMore: false, error: fetchError.message } + } + + const moreAvailable = !!data && data.length >= PAGE_SIZE + + // Fetch user display names for the entries + if (data && data.length > 0) { + const userIds = [...new Set(data.map((e) => e.user_id))] + const { data: profiles } = await supabase + .from('profiles') + .select('id, display_name') + .in('id', userIds) + + const nameMap = new Map() + if (profiles) { + for (const p of profiles) { + nameMap.set(p.id, p.display_name || 'Unknown') + } + } + + return { + entries: data.map((e) => ({ + ...e, + user_display_name: nameMap.get(e.user_id) || 'Unknown', + })), + hasMore: moreAvailable, + } + } + + return { entries: data || [], hasMore: moreAvailable } + }, [projectId]) + + useEffect(() => { + mountedRef.current = true + const controller = new AbortController() + fetchEntriesRaw(0).then((result) => { + if (!controller.signal.aborted && mountedRef.current) { + if (result.error) { + setError(result.error) + } else { + setEntries(result.entries) + setHasMore(result.hasMore) + } + setLoading(false) + } + }) + return () => { + controller.abort() + mountedRef.current = false + } + }, [fetchEntriesRaw]) + + const handleLoadMore = async () => { + setLoadingMore(true) + const result = await fetchEntriesRaw(entries.length) + if (result.error) { + setError(result.error) + } else { + setEntries((prev) => [...prev, ...result.entries]) + setHasMore(result.hasMore) + } + setLoadingMore(false) + } + + const grouped = groupByPeriod(entries) + + return ( +
+
+

+ Activity History +

+ +
+ +
+ {loading && ( +
+ + + + +
+ )} + + {error && ( +
+ Failed to load history: {error} +
+ )} + + {!loading && !error && entries.length === 0 && ( +
+ No activity recorded yet +
+ )} + + {!loading && grouped.map((group) => ( +
+
+
+ {group.period} +
+
+
+ {group.entries.map((entry) => { + const userColor = getUserColor(entry.user_id) + const isDeleted = entry.action_type.endsWith('_delete') + return ( + + ) + })} +
+
+ ))} + + {!loading && hasMore && entries.length > 0 && ( +
+ +
+ )} +
+
+ ) +} diff --git a/src/components/editor/Toolbar.tsx b/src/components/editor/Toolbar.tsx index 213db07..56b9f9f 100644 --- a/src/components/editor/Toolbar.tsx +++ b/src/components/editor/Toolbar.tsx @@ -15,6 +15,7 @@ type ToolbarProps = { onImport: () => void onProjectSettings: () => void onShare: () => void + onHistory: () => void connectionState?: ConnectionState presenceUsers?: PresenceUser[] } @@ -44,6 +45,7 @@ export default function Toolbar({ onImport, onProjectSettings, onShare, + onHistory, connectionState, presenceUsers, }: ToolbarProps) { @@ -108,6 +110,12 @@ export default function Toolbar({ > Share +
diff --git a/src/components/editor/ActivityHistorySidebar.tsx b/src/components/editor/ActivityHistorySidebar.tsx index c7ae703..3649428 100644 --- a/src/components/editor/ActivityHistorySidebar.tsx +++ b/src/components/editor/ActivityHistorySidebar.tsx @@ -2,10 +2,11 @@ import { useCallback, useEffect, useRef, useState } from 'react' import { createClient } from '@/lib/supabase/client' +import RevertConfirmDialog from './RevertConfirmDialog' const PAGE_SIZE = 20 -type AuditEntry = { +export type AuditEntry = { id: string project_id: string user_id: string @@ -21,6 +22,7 @@ type ActivityHistorySidebarProps = { projectId: string onClose: () => void onSelectEntity: (entityId: string, actionType: string) => void + onRevert: (entry: AuditEntry) => void } const ACTION_LABELS: Record = { @@ -132,12 +134,14 @@ export default function ActivityHistorySidebar({ projectId, onClose, onSelectEntity, + onRevert, }: ActivityHistorySidebarProps) { const [entries, setEntries] = useState([]) const [loading, setLoading] = useState(true) const [loadingMore, setLoadingMore] = useState(false) const [hasMore, setHasMore] = useState(true) const [error, setError] = useState(null) + const [revertEntry, setRevertEntry] = useState(null) const mountedRef = useRef(true) const fetchEntriesRaw = useCallback(async (offset: number): Promise<{ entries: AuditEntry[]; hasMore: boolean; error?: string }> => { @@ -267,13 +271,11 @@ export default function ActivityHistorySidebar({ const userColor = getUserColor(entry.user_id) const isDeleted = entry.action_type.endsWith('_delete') return ( -
{getEntityDescription(entry)}
-
- {entry.user_display_name} - · - {formatTime(entry.created_at)} +
+
+ {entry.user_display_name} + · + {formatTime(entry.created_at)} +
+
- +
) })}
@@ -321,6 +339,19 @@ export default function ActivityHistorySidebar({
)} + {revertEntry && ( + { + onRevert(revertEntry) + setRevertEntry(null) + }} + onCancel={() => setRevertEntry(null)} + /> + )} ) } diff --git a/src/components/editor/RevertConfirmDialog.tsx b/src/components/editor/RevertConfirmDialog.tsx new file mode 100644 index 0000000..54cdc1f --- /dev/null +++ b/src/components/editor/RevertConfirmDialog.tsx @@ -0,0 +1,162 @@ +'use client' + +type RevertConfirmDialogProps = { + actionType: string + entityDescription: string + previousState: Record | null + newState: Record | null + onConfirm: () => void + onCancel: () => void +} + +const ACTION_LABELS: Record = { + node_add: 'node addition', + node_update: 'node update', + node_delete: 'node deletion', + edge_add: 'edge addition', + edge_update: 'edge update', + edge_delete: 'edge deletion', +} + +function formatStatePreview(state: Record | null): string { + if (!state) return '(none)' + + const type = state.type as string | undefined + const data = state.data as Record | undefined + + if (type === 'dialogue' && data) { + const speaker = (data.speaker as string) || '(no speaker)' + const text = (data.text as string) || '(no text)' + return `Dialogue: ${speaker}\n"${text.slice(0, 80)}${text.length > 80 ? '…' : ''}"` + } + if (type === 'choice' && data) { + const prompt = (data.prompt as string) || '(no prompt)' + const options = (data.options as { label: string }[]) || [] + const optionLabels = options.map((o) => o.label || '(empty)').join(', ') + return `Choice: ${prompt.slice(0, 60)}${prompt.length > 60 ? '…' : ''}\nOptions: ${optionLabels}` + } + if (type === 'variable' && data) { + const name = (data.variableName as string) || '(unnamed)' + const op = (data.operation as string) || 'set' + const val = data.value ?? 0 + return `Variable: ${name} ${op} ${val}` + } + + // Edge state + if (state.source && state.target) { + const condition = (state.data as Record | undefined)?.condition as Record | undefined + if (condition) { + return `Edge: ${state.source} → ${state.target}\nCondition: ${condition.variableName} ${condition.operator} ${condition.value}` + } + return `Edge: ${state.source} → ${state.target}` + } + + return JSON.stringify(state, null, 2).slice(0, 200) +} + +function getRevertDescription(actionType: string): string { + switch (actionType) { + case 'node_add': + return 'This will delete the node that was added.' + case 'node_update': + return 'This will restore the node to its previous state.' + case 'node_delete': + return 'This will re-create the deleted node.' + case 'edge_add': + return 'This will delete the edge that was added.' + case 'edge_update': + return 'This will restore the edge to its previous state.' + case 'edge_delete': + return 'This will re-create the deleted edge.' + default: + return 'This will undo the change.' + } +} + +export default function RevertConfirmDialog({ + actionType, + entityDescription, + previousState, + newState, + onConfirm, + onCancel, +}: RevertConfirmDialogProps) { + const label = ACTION_LABELS[actionType] || actionType + const description = getRevertDescription(actionType) + + // For revert, the "before" is the current state (newState) and "after" is what we're restoring to (previousState) + const isAddition = actionType.endsWith('_add') + const isDeletion = actionType.endsWith('_delete') + + return ( +
+
e.stopPropagation()} + > +
+

+ Revert {label} +

+

+ {entityDescription} +

+
+ +
+

+ {description} +

+ +
+ {!isAddition && ( +
+
+ Current State +
+
+                  {formatStatePreview(newState)}
+                
+
+ )} + {!isDeletion && ( +
+
+ {isAddition ? 'Will be removed' : 'Restored State'} +
+
+                  {isAddition ? formatStatePreview(newState) : formatStatePreview(previousState)}
+                
+
+ )} + {isDeletion && ( +
+
+ Will be restored +
+
+                  {formatStatePreview(previousState)}
+                
+
+ )} +
+
+ +
+ + +
+
+
+ ) +} From c9f523113741e45c4c15ba25378d3cb816b8e350 Mon Sep 17 00:00:00 2001 From: Gustavo Henrique Santos Souza de Miranda Date: Sat, 24 Jan 2026 02:58:14 -0300 Subject: [PATCH 10/20] docs: update PRD and progress for US-053 Co-Authored-By: Claude Opus 4.5 --- prd.json | 2 +- progress.txt | 20 +++++++++++++++++++- 2 files changed, 20 insertions(+), 2 deletions(-) diff --git a/prd.json b/prd.json index 23905f1..a49d175 100644 --- a/prd.json +++ b/prd.json @@ -414,7 +414,7 @@ "Verify in browser using dev-browser skill" ], "priority": 23, - "passes": false, + "passes": true, "notes": "Dependencies: US-052, US-048" } ] diff --git a/progress.txt b/progress.txt index 047319a..2b4b295 100644 --- a/progress.txt +++ b/progress.txt @@ -67,7 +67,8 @@ - Node lock indicators use `EditorContext` (`nodeLocks` Map, `onNodeFocus`, `onNodeBlur`). Each node component checks `nodeLocks.get(id)` for lock state and renders `NodeLockIndicator` + overlay if locked by another user. - For ephemeral lock state (node editing locks), broadcast via `node-lock` event with `{ nodeId, userId, displayName, lockedAt }`. Send `nodeId: null` to release. - `AuditTrailRecorder` at `src/lib/collaboration/auditTrail.ts` records node/edge changes to `audit_trail` table. Uses state diffing (previous vs current Maps), 1-second per-entity debounce, and fire-and-forget Supabase inserts. Only records local changes (guarded by `isRemoteUpdateRef` in FlowchartEditor). -- `ActivityHistorySidebar` at `src/components/editor/ActivityHistorySidebar.tsx` displays audit trail entries in a right sidebar. Rendered inside the canvas `relative flex-1` container. Toggle via `showHistory` state in FlowchartEditor. +- `ActivityHistorySidebar` at `src/components/editor/ActivityHistorySidebar.tsx` displays audit trail entries in a right sidebar. Rendered inside the canvas `relative flex-1` container. Toggle via `showHistory` state in FlowchartEditor. Exports `AuditEntry` type for consumers. +- To prevent double audit recording when programmatically changing nodes/edges (e.g., revert), set a ref guard (`isRevertingRef`) before `setNodes`/`setEdges` and clear it with `setTimeout(() => ..., 0)`. Check the guard in the CRDT sync effects before calling `auditRef.current.recordNodeChanges()`. - For async data fetching in components with React Compiler, use a pure fetch function returning `{ data, error, hasMore }` result object, then handle setState in the `.then()` callback with an abort/mount guard — never call setState-containing functions directly inside useEffect. --- @@ -487,3 +488,20 @@ - Deleted entities (`action_type.endsWith('_delete')`) cannot be selected on canvas since they no longer exist — render those entries as disabled (no click handler, reduced opacity). - No browser testing tools are available; manual verification is needed. --- + +## 2026-01-24 - US-053 +- What was implemented: Revert changes from audit trail — each history entry has a Revert button, confirmation dialog with before/after preview, and revert logic that reverses node/edge add/update/delete operations +- Files changed: + - `src/components/editor/RevertConfirmDialog.tsx` - New component: confirmation dialog showing action description, before/after state previews, with Cancel and Revert buttons + - `src/components/editor/ActivityHistorySidebar.tsx` - Added Revert button (shows on hover per entry), `revertEntry` state, `RevertConfirmDialog` rendering, exported `AuditEntry` type, added `onRevert` prop + - `src/app/editor/[projectId]/FlowchartEditor.tsx` - Added `handleRevertEntry` callback implementing all revert cases (node add→delete, node update→restore, node delete→recreate, same for edges), `isRevertingRef` to prevent double audit recording, `getRevertActionType` helper, explicit audit trail write for the revert itself, toast notification on success +- **Learnings for future iterations:** + - Reverting uses the same `setNodes`/`setEdges` state setters, so CRDT sync happens automatically through the existing effects that watch `nodesForCRDT`/`edgesForCRDT` — no explicit CRDT call needed. + - To prevent double audit recording (once from the sync effect, once from the explicit revert write), use an `isRevertingRef` guard set synchronously before `setNodes`/`setEdges` and cleared with `setTimeout(() => ..., 0)` after React processes the state updates. + - The revert audit entry uses inverse action types: reverting `node_add` records `node_delete`, reverting `node_delete` records `node_add`, `node_update` stays `node_update`. + - The `previous_state` and `new_state` are swapped for the revert audit entry: what was `new_state` (the current state being reverted) becomes `previous_state`, and what was `previous_state` (the state being restored) becomes `new_state`. + - Reverting a `node_add` also removes connected edges to prevent dangling edge references. + - The RevertConfirmDialog uses `z-[70]` to layer above the ActivityHistorySidebar (`z-40`). + - The Revert button uses CSS `group-hover:inline-block` pattern to appear only on entry hover, keeping the UI clean. + - No browser testing tools are available; manual verification is needed. +--- From 34815d70eee3932877c031977648bb95c0f34f03 Mon Sep 17 00:00:00 2001 From: Gustavo Henrique Santos Souza de Miranda Date: Sat, 24 Jan 2026 16:08:32 -0300 Subject: [PATCH 11/20] fix: resolve CRDT collaboration sync by registering broadcast listener before channel subscribe The yjs-update broadcast listener was added after the Supabase channel was already subscribed, which meant it never received messages. Moved the listener registration to the builder chain before .subscribe() (matching how cursor/node-lock listeners work), and removed the broken isRemoteUpdateRef guard that caused ReferenceErrors preventing local changes from reaching the CRDT. Co-Authored-By: Claude Opus 4.5 --- .../editor/[projectId]/FlowchartEditor.tsx | 20 +++++++------ src/lib/collaboration/crdt.ts | 30 +++++++++---------- src/lib/collaboration/realtime.ts | 6 ++++ 3 files changed, 31 insertions(+), 25 deletions(-) diff --git a/src/app/editor/[projectId]/FlowchartEditor.tsx b/src/app/editor/[projectId]/FlowchartEditor.tsx index 31b2c39..0c0f5e6 100644 --- a/src/app/editor/[projectId]/FlowchartEditor.tsx +++ b/src/app/editor/[projectId]/FlowchartEditor.tsx @@ -527,6 +527,12 @@ function FlowchartEditorInner({ projectId, projectName, userId, userDisplayName, const [characters, setCharacters] = useState(migratedData.characters) const [variables, setVariables] = useState(migratedData.variables) + + // Refs to always have the latest characters/variables for the CRDT persist callback + const charactersRef = useRef(characters) + const variablesRef = useRef(variables) + charactersRef.current = characters + variablesRef.current = variables const [showSettings, setShowSettings] = useState(false) const [showShare, setShowShare] = useState(false) const [showHistory, setShowHistory] = useState(false) @@ -540,7 +546,6 @@ function FlowchartEditorInner({ projectId, projectName, userId, userDisplayName, const realtimeRef = useRef(null) const crdtRef = useRef(null) const auditRef = useRef(null) - const isRemoteUpdateRef = useRef(false) const cursorThrottleRef = useRef(0) const [collaborationNotifications, setCollaborationNotifications] = useState([]) const [nodeLocks, setNodeLocks] = useState>(new Map()) @@ -554,14 +559,10 @@ function FlowchartEditorInner({ projectId, projectName, userId, userDisplayName, const crdtManager = new CRDTManager({ onNodesChange: (crdtNodes: FlowchartNode[]) => { - isRemoteUpdateRef.current = true setNodes(toReactFlowNodes(crdtNodes)) - isRemoteUpdateRef.current = false }, onEdgesChange: (crdtEdges: FlowchartEdge[]) => { - isRemoteUpdateRef.current = true setEdges(toReactFlowEdges(crdtEdges)) - isRemoteUpdateRef.current = false }, onPersist: async (persistNodes: FlowchartNode[], persistEdges: FlowchartEdge[]) => { try { @@ -571,8 +572,8 @@ function FlowchartEditorInner({ projectId, projectName, userId, userDisplayName, flowchart_data: { nodes: persistNodes, edges: persistEdges, - characters, - variables, + characters: charactersRef.current, + variables: variablesRef.current, }, }) .eq('id', projectId) @@ -633,6 +634,9 @@ function FlowchartEditorInner({ projectId, projectName, userId, userDisplayName, return next }) }, + onCRDTUpdate: (update: number[]) => { + crdtManager.applyRemoteUpdate(update) + }, onChannelSubscribed: (channel) => { crdtManager.connectChannel(channel) }, @@ -678,7 +682,6 @@ function FlowchartEditorInner({ projectId, projectName, userId, userDisplayName, }, [edges]) useEffect(() => { - if (isRemoteUpdateRef.current) return crdtRef.current?.updateNodes(nodesForCRDT) if (!isRevertingRef.current) { auditRef.current?.recordNodeChanges(nodesForCRDT) @@ -686,7 +689,6 @@ function FlowchartEditorInner({ projectId, projectName, userId, userDisplayName, }, [nodesForCRDT]) useEffect(() => { - if (isRemoteUpdateRef.current) return crdtRef.current?.updateEdges(edgesForCRDT) if (!isRevertingRef.current) { auditRef.current?.recordEdgeChanges(edgesForCRDT) diff --git a/src/lib/collaboration/crdt.ts b/src/lib/collaboration/crdt.ts index e27a3c5..57ac014 100644 --- a/src/lib/collaboration/crdt.ts +++ b/src/lib/collaboration/crdt.ts @@ -57,25 +57,23 @@ export class CRDTManager { }, 'init') } - /** Connect to a Supabase Realtime channel for syncing updates */ + /** Connect to a Supabase Realtime channel for outbound broadcasts */ connectChannel(channel: RealtimeChannel): void { this.channel = channel + } - // Listen for broadcast updates from other clients - channel.on('broadcast', { event: BROADCAST_EVENT }, (payload) => { - if (this.isDestroyed) return - const data = payload.payload as { update?: number[] } | undefined - if (data?.update) { - const update = new Uint8Array(data.update) - this.isApplyingRemote = true - Y.applyUpdate(this.doc, update, 'remote') - this.isApplyingRemote = false - // Notify React state of remote changes - this.notifyNodesChange() - this.notifyEdgesChange() - this.schedulePersist() - } - }) + /** Apply a remote CRDT update received via broadcast */ + applyRemoteUpdate(updateData: number[]): void { + if (this.isDestroyed) return + const update = new Uint8Array(updateData) + this.isApplyingRemote = true + Y.applyUpdate(this.doc, update, 'remote') + this.isApplyingRemote = false + // Notify React state of remote changes + this.notifyNodesChange() + this.notifyEdgesChange() + // Note: we do NOT schedulePersist here. Only the originating client + // persists its own changes to avoid write races and stale data overwrites. } /** Apply local node changes to the Yjs document */ diff --git a/src/lib/collaboration/realtime.ts b/src/lib/collaboration/realtime.ts index d1e892a..90a5762 100644 --- a/src/lib/collaboration/realtime.ts +++ b/src/lib/collaboration/realtime.ts @@ -35,6 +35,7 @@ type RealtimeCallbacks = { onChannelSubscribed?: (channel: RealtimeChannel) => void onCursorUpdate?: (cursor: RemoteCursor) => void onNodeLockUpdate?: (lock: NodeLock | null, userId: string) => void + onCRDTUpdate?: (update: number[]) => void } const HEARTBEAT_INTERVAL_MS = 30_000 @@ -131,6 +132,11 @@ export class RealtimeConnection { this.callbacks.onNodeLockUpdate?.(null, payload.userId) } }) + .on('broadcast', { event: 'yjs-update' }, ({ payload }) => { + if (payload?.update) { + this.callbacks.onCRDTUpdate?.(payload.update) + } + }) .subscribe(async (status) => { if (this.isDestroyed) return From cdaae6b9655caf0b7bddfe6cefca67a513717bfa Mon Sep 17 00:00:00 2001 From: Gustavo Henrique Santos Souza de Miranda Date: Sat, 24 Jan 2026 16:12:58 -0300 Subject: [PATCH 12/20] feat: broadcast full CRDT state to other clients after manual save When a user saves, the full Yjs document state is broadcast so all connected clients converge, even if they missed incremental updates. Co-Authored-By: Claude Opus 4.5 --- src/app/editor/[projectId]/FlowchartEditor.tsx | 3 +++ src/lib/collaboration/crdt.ts | 11 +++++++++++ 2 files changed, 14 insertions(+) diff --git a/src/app/editor/[projectId]/FlowchartEditor.tsx b/src/app/editor/[projectId]/FlowchartEditor.tsx index 0c0f5e6..7179df9 100644 --- a/src/app/editor/[projectId]/FlowchartEditor.tsx +++ b/src/app/editor/[projectId]/FlowchartEditor.tsx @@ -1143,6 +1143,9 @@ function FlowchartEditorInner({ projectId, projectName, userId, userDisplayName, // Update last saved data ref to mark as not dirty lastSavedDataRef.current = flowchartData + // Broadcast full CRDT state so other connected clients sync up + crdtRef.current?.broadcastFullState() + setToast({ message: 'Project saved successfully', type: 'success' }) } catch (error) { console.error('Failed to save project:', error) diff --git a/src/lib/collaboration/crdt.ts b/src/lib/collaboration/crdt.ts index 57ac014..d99478d 100644 --- a/src/lib/collaboration/crdt.ts +++ b/src/lib/collaboration/crdt.ts @@ -173,6 +173,17 @@ export class CRDTManager { this.callbacks.onEdgesChange(this.getEdges()) } + /** Broadcast the full document state to sync all connected clients */ + broadcastFullState(): void { + if (!this.channel || this.isDestroyed) return + const fullState = Y.encodeStateAsUpdate(this.doc) + this.channel.send({ + type: 'broadcast', + event: BROADCAST_EVENT, + payload: { update: Array.from(fullState) }, + }) + } + private broadcastUpdate(update: Uint8Array): void { if (!this.channel || this.isDestroyed) return this.channel.send({ From eb86ccd29197c6a6876151d8de829cdb1e77a2f4 Mon Sep 17 00:00:00 2001 From: Gustavo Henrique Santos Souza de Miranda Date: Sat, 24 Jan 2026 16:36:43 -0300 Subject: [PATCH 13/20] feat: notify collaborators to refresh from DB after save Instead of relying on Yjs broadcast serialization (which has delivery issues), use a lightweight state-refresh broadcast event. When any client persists (manual save or CRDT auto-persist), it broadcasts state-refresh. Other clients fetch the latest flowchart_data from the database and update their local state and CRDT. Added isSuppressed flag to CRDTManager to prevent broadcast/persist loops during initialization and refresh operations. Co-Authored-By: Claude Opus 4.5 --- .../editor/[projectId]/FlowchartEditor.tsx | 26 +++++++++- src/lib/collaboration/crdt.ts | 51 ++++++++++++++----- src/lib/collaboration/realtime.ts | 13 +++++ 3 files changed, 74 insertions(+), 16 deletions(-) diff --git a/src/app/editor/[projectId]/FlowchartEditor.tsx b/src/app/editor/[projectId]/FlowchartEditor.tsx index 7179df9..5c8f969 100644 --- a/src/app/editor/[projectId]/FlowchartEditor.tsx +++ b/src/app/editor/[projectId]/FlowchartEditor.tsx @@ -577,6 +577,8 @@ function FlowchartEditorInner({ projectId, projectName, userId, userDisplayName, }, }) .eq('id', projectId) + // Notify other clients to refresh after successful auto-persist + realtimeRef.current?.broadcastStateRefresh() } catch { // Persistence failure is non-critical; will retry on next change } @@ -637,6 +639,26 @@ function FlowchartEditorInner({ projectId, projectName, userId, userDisplayName, onCRDTUpdate: (update: number[]) => { crdtManager.applyRemoteUpdate(update) }, + onStateRefresh: async () => { + try { + const sb = createClient() + const { data } = await sb + .from('projects') + .select('flowchart_data') + .eq('id', projectId) + .single() + if (data?.flowchart_data) { + const fd = data.flowchart_data as FlowchartData + setNodes(toReactFlowNodes(fd.nodes)) + setEdges(toReactFlowEdges(fd.edges)) + setCharacters(fd.characters) + setVariables(fd.variables) + crdtManager.refreshFromData(fd.nodes, fd.edges) + } + } catch { + // Non-critical: user can still manually refresh + } + }, onChannelSubscribed: (channel) => { crdtManager.connectChannel(channel) }, @@ -1143,8 +1165,8 @@ function FlowchartEditorInner({ projectId, projectName, userId, userDisplayName, // Update last saved data ref to mark as not dirty lastSavedDataRef.current = flowchartData - // Broadcast full CRDT state so other connected clients sync up - crdtRef.current?.broadcastFullState() + // Notify other connected clients to refresh from the database + realtimeRef.current?.broadcastStateRefresh() setToast({ message: 'Project saved successfully', type: 'success' }) } catch (error) { diff --git a/src/lib/collaboration/crdt.ts b/src/lib/collaboration/crdt.ts index d99478d..7c7fcdb 100644 --- a/src/lib/collaboration/crdt.ts +++ b/src/lib/collaboration/crdt.ts @@ -19,6 +19,7 @@ export class CRDTManager { private callbacks: CRDTCallbacks private persistTimer: ReturnType | null = null private isApplyingRemote = false + private isSuppressed = false // suppress broadcast/persist during init or refresh private isDestroyed = false constructor(callbacks: CRDTCallbacks) { @@ -29,24 +30,25 @@ export class CRDTManager { // Schedule persistence on local Yjs document changes this.nodesMap.observe(() => { - if (this.isApplyingRemote) return + if (this.isApplyingRemote || this.isSuppressed) return this.schedulePersist() }) this.edgesMap.observe(() => { - if (this.isApplyingRemote) return + if (this.isApplyingRemote || this.isSuppressed) return this.schedulePersist() }) // Broadcast local updates to other clients this.doc.on('update', (update: Uint8Array, origin: unknown) => { - if (origin === 'remote') return // Don't re-broadcast remote updates + if (origin === 'remote' || this.isSuppressed) return this.broadcastUpdate(update) }) } /** Initialize the Yjs document from database state */ initializeFromData(nodes: FlowchartNode[], edges: FlowchartEdge[]): void { + this.isSuppressed = true this.doc.transact(() => { nodes.forEach((node) => { this.nodesMap.set(node.id, JSON.stringify(node)) @@ -55,6 +57,7 @@ export class CRDTManager { this.edgesMap.set(edge.id, JSON.stringify(edge)) }) }, 'init') + this.isSuppressed = false } /** Connect to a Supabase Realtime channel for outbound broadcasts */ @@ -76,6 +79,37 @@ export class CRDTManager { // persists its own changes to avoid write races and stale data overwrites. } + /** Replace CRDT state from a database refresh without broadcasting */ + refreshFromData(nodes: FlowchartNode[], edges: FlowchartEdge[]): void { + this.isSuppressed = true + this.doc.transact(() => { + // Sync nodes + const nodeIds = new Set(nodes.map((n) => n.id)) + Array.from(this.nodesMap.keys()).forEach((id) => { + if (!nodeIds.has(id)) this.nodesMap.delete(id) + }) + nodes.forEach((node) => { + const serialized = JSON.stringify(node) + if (this.nodesMap.get(node.id) !== serialized) { + this.nodesMap.set(node.id, serialized) + } + }) + + // Sync edges + const edgeIds = new Set(edges.map((e) => e.id)) + Array.from(this.edgesMap.keys()).forEach((id) => { + if (!edgeIds.has(id)) this.edgesMap.delete(id) + }) + edges.forEach((edge) => { + const serialized = JSON.stringify(edge) + if (this.edgesMap.get(edge.id) !== serialized) { + this.edgesMap.set(edge.id, serialized) + } + }) + }, 'remote') + this.isSuppressed = false + } + /** Apply local node changes to the Yjs document */ updateNodes(nodes: FlowchartNode[]): void { if (this.isApplyingRemote) return @@ -173,17 +207,6 @@ export class CRDTManager { this.callbacks.onEdgesChange(this.getEdges()) } - /** Broadcast the full document state to sync all connected clients */ - broadcastFullState(): void { - if (!this.channel || this.isDestroyed) return - const fullState = Y.encodeStateAsUpdate(this.doc) - this.channel.send({ - type: 'broadcast', - event: BROADCAST_EVENT, - payload: { update: Array.from(fullState) }, - }) - } - private broadcastUpdate(update: Uint8Array): void { if (!this.channel || this.isDestroyed) return this.channel.send({ diff --git a/src/lib/collaboration/realtime.ts b/src/lib/collaboration/realtime.ts index 90a5762..5efbdd5 100644 --- a/src/lib/collaboration/realtime.ts +++ b/src/lib/collaboration/realtime.ts @@ -36,6 +36,7 @@ type RealtimeCallbacks = { onCursorUpdate?: (cursor: RemoteCursor) => void onNodeLockUpdate?: (lock: NodeLock | null, userId: string) => void onCRDTUpdate?: (update: number[]) => void + onStateRefresh?: () => void } const HEARTBEAT_INTERVAL_MS = 30_000 @@ -137,6 +138,9 @@ export class RealtimeConnection { this.callbacks.onCRDTUpdate?.(payload.update) } }) + .on('broadcast', { event: 'state-refresh' }, () => { + this.callbacks.onStateRefresh?.() + }) .subscribe(async (status) => { if (this.isDestroyed) return @@ -195,6 +199,15 @@ export class RealtimeConnection { }) } + broadcastStateRefresh(): void { + if (!this.channel) return + this.channel.send({ + type: 'broadcast', + event: 'state-refresh', + payload: {}, + }) + } + broadcastNodeLock(nodeId: string | null): void { if (!this.channel) return this.channel.send({ From cd73b3173934351d2ed1dbc8429cafb45cc15d69 Mon Sep 17 00:00:00 2001 From: Gustavo Henrique Santos Souza de Miranda Date: Sat, 24 Jan 2026 18:20:41 -0300 Subject: [PATCH 14/20] fix: improve realtime connection resilience with auto-reconnect and activity-based lifecycle Add connection timeout (15s) to handle stale initial subscribes, inactivity pause (5min) to save resources when idle, and automatic resume on user activity or tab focus. The heartbeat now detects unhealthy channel states and consecutive failures to trigger reconnects. Unexpected CLOSED status also triggers reconnect instead of staying disconnected silently. Co-Authored-By: Claude Opus 4.5 --- .../editor/[projectId]/FlowchartEditor.tsx | 32 +++++ src/lib/collaboration/realtime.ts | 131 +++++++++++++++++- 2 files changed, 160 insertions(+), 3 deletions(-) diff --git a/src/app/editor/[projectId]/FlowchartEditor.tsx b/src/app/editor/[projectId]/FlowchartEditor.tsx index 5c8f969..325dd07 100644 --- a/src/app/editor/[projectId]/FlowchartEditor.tsx +++ b/src/app/editor/[projectId]/FlowchartEditor.tsx @@ -682,6 +682,38 @@ function FlowchartEditorInner({ projectId, projectName, userId, userDisplayName, // eslint-disable-next-line react-hooks/exhaustive-deps }, [projectId, userId, userDisplayName]) + // Manage connection lifecycle based on visibility and user activity + useEffect(() => { + const handleVisibilityChange = () => { + if (!document.hidden) { + realtimeRef.current?.notifyActivity() + } + } + + // Throttle activity notifications to avoid excessive calls + let activityThrottled = false + const throttledActivity = () => { + if (activityThrottled) return + activityThrottled = true + realtimeRef.current?.notifyActivity() + setTimeout(() => { activityThrottled = false }, 10_000) + } + + document.addEventListener('visibilitychange', handleVisibilityChange) + document.addEventListener('mousedown', throttledActivity) + document.addEventListener('keydown', throttledActivity) + document.addEventListener('scroll', throttledActivity, true) + document.addEventListener('mousemove', throttledActivity) + + return () => { + document.removeEventListener('visibilitychange', handleVisibilityChange) + document.removeEventListener('mousedown', throttledActivity) + document.removeEventListener('keydown', throttledActivity) + document.removeEventListener('scroll', throttledActivity, true) + document.removeEventListener('mousemove', throttledActivity) + } + }, []) + // Sync local React Flow state changes to CRDT (skip remote-originated updates) const nodesForCRDT = useMemo(() => { return nodes.map((node) => ({ diff --git a/src/lib/collaboration/realtime.ts b/src/lib/collaboration/realtime.ts index 5efbdd5..0396e54 100644 --- a/src/lib/collaboration/realtime.ts +++ b/src/lib/collaboration/realtime.ts @@ -42,17 +42,22 @@ type RealtimeCallbacks = { const HEARTBEAT_INTERVAL_MS = 30_000 const RECONNECT_BASE_DELAY_MS = 1000 const RECONNECT_MAX_DELAY_MS = 30_000 +const INACTIVITY_TIMEOUT_MS = 5 * 60_000 // 5 minutes of inactivity before pausing +const CONNECTION_TIMEOUT_MS = 15_000 // 15s timeout for initial connection export class RealtimeConnection { private channel: RealtimeChannel | null = null private heartbeatTimer: ReturnType | null = null private reconnectTimer: ReturnType | null = null + private inactivityTimer: ReturnType | null = null + private connectionTimer: ReturnType | null = null private reconnectAttempts = 0 private projectId: string private userId: string private displayName: string private callbacks: RealtimeCallbacks private isDestroyed = false + private isPaused = false private supabase = createClient() constructor(projectId: string, userId: string, displayName: string, callbacks: RealtimeCallbacks) { @@ -64,7 +69,20 @@ export class RealtimeConnection { async connect(): Promise { if (this.isDestroyed) return + this.isPaused = false this.callbacks.onConnectionStateChange('connecting') + this.resetInactivityTimer() + this.clearConnectionTimer() + + // Set a timeout: if we don't connect within CONNECTION_TIMEOUT_MS, retry + this.connectionTimer = setTimeout(() => { + if (this.isDestroyed || this.isPaused) return + if (this.channel) { + this.supabase.removeChannel(this.channel) + this.channel = null + } + this.scheduleReconnect() + }, CONNECTION_TIMEOUT_MS) this.channel = this.supabase.channel(`project:${this.projectId}`, { config: { presence: { key: this.userId } }, @@ -143,6 +161,7 @@ export class RealtimeConnection { }) .subscribe(async (status) => { if (this.isDestroyed) return + this.clearConnectionTimer() if (status === 'SUBSCRIBED') { this.reconnectAttempts = 0 @@ -162,15 +181,24 @@ export class RealtimeConnection { this.callbacks.onConnectionStateChange('reconnecting') this.scheduleReconnect() } else if (status === 'CLOSED') { - this.callbacks.onConnectionStateChange('disconnected') + if (!this.isPaused) { + // Unexpected close - attempt to reconnect + this.callbacks.onConnectionStateChange('reconnecting') + this.scheduleReconnect() + } else { + this.callbacks.onConnectionStateChange('disconnected') + } } }) } async disconnect(): Promise { this.isDestroyed = true + this.isPaused = false this.stopHeartbeat() this.clearReconnectTimer() + this.clearInactivityTimer() + this.clearConnectionTimer() if (this.channel) { await this.deleteSession() @@ -181,6 +209,54 @@ export class RealtimeConnection { this.callbacks.onConnectionStateChange('disconnected') } + /** + * Pause the connection (e.g. on inactivity or tab hidden). + * Unlike disconnect(), this allows resuming later. + */ + async pause(): Promise { + if (this.isDestroyed || this.isPaused) return + this.isPaused = true + this.stopHeartbeat() + this.clearReconnectTimer() + this.clearInactivityTimer() + this.clearConnectionTimer() + + if (this.channel) { + await this.deleteSession() + this.supabase.removeChannel(this.channel) + this.channel = null + } + + this.callbacks.onConnectionStateChange('disconnected') + } + + /** + * Resume the connection after it was paused. + * Re-establishes the channel and presence. + */ + async resume(): Promise { + if (this.isDestroyed || !this.isPaused) return + this.isPaused = false + this.reconnectAttempts = 0 + await this.connect() + } + + /** + * Notify that user activity has occurred, resetting the inactivity timer. + * If the connection was paused due to inactivity, it will resume. + */ + notifyActivity(): void { + if (this.isDestroyed) return + this.resetInactivityTimer() + if (this.isPaused) { + this.resume() + } + } + + getIsPaused(): boolean { + return this.isPaused + } + getChannel(): RealtimeChannel | null { return this.channel } @@ -254,19 +330,44 @@ export class RealtimeConnection { private startHeartbeat(): void { this.stopHeartbeat() + let consecutiveFailures = 0 this.heartbeatTimer = setInterval(async () => { - if (this.isDestroyed) { + if (this.isDestroyed || this.isPaused) { this.stopHeartbeat() return } + + // Check if the channel is still in a healthy state + if (this.channel) { + const state = (this.channel as unknown as { state?: string }).state + if (state && state !== 'joined' && state !== 'joining') { + // Channel is in an unhealthy state - trigger reconnect + this.callbacks.onConnectionStateChange('reconnecting') + this.supabase.removeChannel(this.channel) + this.channel = null + this.stopHeartbeat() + this.scheduleReconnect() + return + } + } + try { await this.supabase .from('collaboration_sessions') .update({ last_heartbeat: new Date().toISOString() }) .eq('project_id', this.projectId) .eq('user_id', this.userId) + consecutiveFailures = 0 } catch { - // Heartbeat failure is non-critical + consecutiveFailures++ + // If heartbeat fails 3 times in a row, the connection is likely dead + if (consecutiveFailures >= 3 && this.channel) { + this.callbacks.onConnectionStateChange('reconnecting') + this.supabase.removeChannel(this.channel) + this.channel = null + this.stopHeartbeat() + this.scheduleReconnect() + } } }, HEARTBEAT_INTERVAL_MS) } @@ -307,4 +408,28 @@ export class RealtimeConnection { this.reconnectTimer = null } } + + private resetInactivityTimer(): void { + this.clearInactivityTimer() + if (this.isDestroyed) return + this.inactivityTimer = setTimeout(() => { + if (!this.isDestroyed && !this.isPaused) { + this.pause() + } + }, INACTIVITY_TIMEOUT_MS) + } + + private clearInactivityTimer(): void { + if (this.inactivityTimer) { + clearTimeout(this.inactivityTimer) + this.inactivityTimer = null + } + } + + private clearConnectionTimer(): void { + if (this.connectionTimer) { + clearTimeout(this.connectionTimer) + this.connectionTimer = null + } + } } From ec053d81265bce9a077c9536a00ebb37e3fcdb21 Mon Sep 17 00:00:00 2001 From: Gustavo Henrique Santos Souza de Miranda Date: Sat, 24 Jan 2026 18:46:59 -0300 Subject: [PATCH 15/20] fix: stop state ping-pong by removing broadcastStateRefresh from auto-persist The CRDT auto-persist was broadcasting state-refresh after each save, causing other clients to fetch from DB and overwrite their local variables/characters with stale values. Since CRDT already syncs nodes/edges via yjs-update broadcasts, the state-refresh from auto-persist was redundant and destructive. Manual save still broadcasts state-refresh as intended. Co-Authored-By: Claude Opus 4.5 --- src/app/editor/[projectId]/FlowchartEditor.tsx | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/src/app/editor/[projectId]/FlowchartEditor.tsx b/src/app/editor/[projectId]/FlowchartEditor.tsx index 325dd07..2e20982 100644 --- a/src/app/editor/[projectId]/FlowchartEditor.tsx +++ b/src/app/editor/[projectId]/FlowchartEditor.tsx @@ -566,6 +566,11 @@ function FlowchartEditorInner({ projectId, projectName, userId, userDisplayName, }, onPersist: async (persistNodes: FlowchartNode[], persistEdges: FlowchartEdge[]) => { try { + // Auto-persist saves the current state for durability. We do NOT + // broadcast state-refresh here because CRDT already syncs nodes/edges + // via yjs-update broadcasts. Broadcasting here causes ping-pong: + // other clients fetch from DB, overwrite their local variables/characters, + // then their persist writes stale data back, causing a loop. await supabase .from('projects') .update({ @@ -577,8 +582,6 @@ function FlowchartEditorInner({ projectId, projectName, userId, userDisplayName, }, }) .eq('id', projectId) - // Notify other clients to refresh after successful auto-persist - realtimeRef.current?.broadcastStateRefresh() } catch { // Persistence failure is non-critical; will retry on next change } From 2d0c3b6df6f7e1c2687f39a42fb63cac37b1099a Mon Sep 17 00:00:00 2001 From: Gustavo Henrique Santos Souza de Miranda Date: Sat, 24 Jan 2026 18:58:54 -0300 Subject: [PATCH 16/20] fix: reduce auto-save frequency to avoid excessive writes Increase CRDT DB persist debounce from 2s to 30s since it's only needed for crash recovery (CRDT handles real-time sync). Increase LocalStorage draft save debounce from 1s to 5s. Co-Authored-By: Claude Opus 4.5 --- src/app/editor/[projectId]/FlowchartEditor.tsx | 4 ++-- src/lib/collaboration/crdt.ts | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/src/app/editor/[projectId]/FlowchartEditor.tsx b/src/app/editor/[projectId]/FlowchartEditor.tsx index 2e20982..8f7cc67 100644 --- a/src/app/editor/[projectId]/FlowchartEditor.tsx +++ b/src/app/editor/[projectId]/FlowchartEditor.tsx @@ -46,8 +46,8 @@ import type { FlowchartData, FlowchartNode, FlowchartEdge, Character, Variable, // LocalStorage key prefix for draft saves const DRAFT_KEY_PREFIX = 'vnwrite-draft-' -// Debounce delay in ms -const AUTOSAVE_DEBOUNCE_MS = 1000 +// Debounce delay for LocalStorage draft saves +const AUTOSAVE_DEBOUNCE_MS = 5000 type ContextMenuState = { x: number diff --git a/src/lib/collaboration/crdt.ts b/src/lib/collaboration/crdt.ts index 7c7fcdb..eedf8d8 100644 --- a/src/lib/collaboration/crdt.ts +++ b/src/lib/collaboration/crdt.ts @@ -2,7 +2,7 @@ import * as Y from 'yjs' import type { RealtimeChannel } from '@supabase/supabase-js' import type { FlowchartNode, FlowchartEdge } from '@/types/flowchart' -const PERSIST_DEBOUNCE_MS = 2000 +const PERSIST_DEBOUNCE_MS = 30_000 // 30s - DB persist is for crash recovery only; CRDT handles real-time sync const BROADCAST_EVENT = 'yjs-update' export type CRDTCallbacks = { From fa8437d03a1241d2282e5ca869d3b84a218f994a Mon Sep 17 00:00:00 2001 From: Gustavo Henrique Santos Souza de Miranda Date: Sat, 24 Jan 2026 19:06:20 -0300 Subject: [PATCH 17/20] fix: broadcast full CRDT state on connect and when new user joins CRDT broadcasts are fire-and-forget, so any updates missed during a disconnection were permanently lost. Now when a client connects (or reconnects), it broadcasts its full Yjs doc state. When an existing client sees a new user join, it also broadcasts its full state. Yjs merges handle deduplication automatically, so this converges all clients. Co-Authored-By: Claude Opus 4.5 --- src/app/editor/[projectId]/FlowchartEditor.tsx | 2 ++ src/lib/collaboration/crdt.ts | 13 +++++++++++++ 2 files changed, 15 insertions(+) diff --git a/src/app/editor/[projectId]/FlowchartEditor.tsx b/src/app/editor/[projectId]/FlowchartEditor.tsx index 8f7cc67..879df51 100644 --- a/src/app/editor/[projectId]/FlowchartEditor.tsx +++ b/src/app/editor/[projectId]/FlowchartEditor.tsx @@ -605,6 +605,8 @@ function FlowchartEditorInner({ projectId, projectName, userId, userDisplayName, ...prev, { id: nanoid(), displayName: user.displayName, type: 'join', color: getUserColor(user.userId) }, ]) + // Send full CRDT state so the joining client gets caught up + crdtManager.broadcastFullState() }, onPresenceLeave: (user) => { setCollaborationNotifications((prev) => [ diff --git a/src/lib/collaboration/crdt.ts b/src/lib/collaboration/crdt.ts index eedf8d8..6b64c6b 100644 --- a/src/lib/collaboration/crdt.ts +++ b/src/lib/collaboration/crdt.ts @@ -63,6 +63,19 @@ export class CRDTManager { /** Connect to a Supabase Realtime channel for outbound broadcasts */ connectChannel(channel: RealtimeChannel): void { this.channel = channel + // Broadcast full state so other clients merge any updates they missed + this.broadcastFullState() + } + + /** Broadcast the full Yjs document state to sync all connected clients */ + broadcastFullState(): void { + if (!this.channel || this.isDestroyed) return + const state = Y.encodeStateAsUpdate(this.doc) + this.channel.send({ + type: 'broadcast', + event: BROADCAST_EVENT, + payload: { update: Array.from(state) }, + }) } /** Apply a remote CRDT update received via broadcast */ From c28b9ac5658436e1b3cf30a240a69bb53d7be962 Mon Sep 17 00:00:00 2001 From: Gustavo Henrique Santos Souza de Miranda Date: Sat, 24 Jan 2026 19:14:14 -0300 Subject: [PATCH 18/20] fix: force fresh reconnect when returning after idle period The WebSocket can go stale during inactivity without triggering any error status (channel still shows 'joined' but socket is dead). Now when the user returns after 60+ seconds of inactivity, the connection is torn down and re-established to guarantee a fresh WebSocket. This ensures CRDT broadcasts actually reach other clients after returning from idle. Co-Authored-By: Claude Opus 4.5 --- src/lib/collaboration/realtime.ts | 31 +++++++++++++++++++++++++++++++ 1 file changed, 31 insertions(+) diff --git a/src/lib/collaboration/realtime.ts b/src/lib/collaboration/realtime.ts index 0396e54..e5b0cb8 100644 --- a/src/lib/collaboration/realtime.ts +++ b/src/lib/collaboration/realtime.ts @@ -44,6 +44,7 @@ const RECONNECT_BASE_DELAY_MS = 1000 const RECONNECT_MAX_DELAY_MS = 30_000 const INACTIVITY_TIMEOUT_MS = 5 * 60_000 // 5 minutes of inactivity before pausing const CONNECTION_TIMEOUT_MS = 15_000 // 15s timeout for initial connection +const STALE_THRESHOLD_MS = 60_000 // 60s of inactivity = force fresh reconnect on return export class RealtimeConnection { private channel: RealtimeChannel | null = null @@ -58,6 +59,7 @@ export class RealtimeConnection { private callbacks: RealtimeCallbacks private isDestroyed = false private isPaused = false + private lastActivityTime = Date.now() private supabase = createClient() constructor(projectId: string, userId: string, displayName: string, callbacks: RealtimeCallbacks) { @@ -70,6 +72,7 @@ export class RealtimeConnection { async connect(): Promise { if (this.isDestroyed) return this.isPaused = false + this.lastActivityTime = Date.now() this.callbacks.onConnectionStateChange('connecting') this.resetInactivityTimer() this.clearConnectionTimer() @@ -244,15 +247,43 @@ export class RealtimeConnection { /** * Notify that user activity has occurred, resetting the inactivity timer. * If the connection was paused due to inactivity, it will resume. + * If the connection was idle for a significant period, force a fresh reconnect + * to ensure the WebSocket isn't stale. */ notifyActivity(): void { if (this.isDestroyed) return + const now = Date.now() + const idleDuration = now - this.lastActivityTime + this.lastActivityTime = now this.resetInactivityTimer() + if (this.isPaused) { this.resume() + } else if (idleDuration > STALE_THRESHOLD_MS && this.channel) { + // Connection may appear alive but WebSocket could be stale after idle. + // Force a fresh reconnect to ensure broadcasts actually work. + this.forceReconnect() } } + /** + * Force a fresh reconnect by tearing down the current channel and reconnecting. + */ + private async forceReconnect(): Promise { + if (this.isDestroyed || this.isPaused) return + this.stopHeartbeat() + this.clearReconnectTimer() + this.clearConnectionTimer() + + if (this.channel) { + this.supabase.removeChannel(this.channel) + this.channel = null + } + + this.reconnectAttempts = 0 + await this.connect() + } + getIsPaused(): boolean { return this.isPaused } From 3b19f58e264561517409f7174c98adb1ecc0465e Mon Sep 17 00:00:00 2001 From: Gustavo Henrique Santos Souza de Miranda Date: Sat, 24 Jan 2026 19:22:22 -0300 Subject: [PATCH 19/20] fix: wait for auth session before connecting to realtime channel On initial page load, the Supabase browser client's auth session may not be ready yet (still loading from cookies). The Realtime channel subscription silently fails without a valid access token. Now connect() awaits getSession() first, which ensures the token is available and also refreshes expired tokens on reconnect after inactivity. Co-Authored-By: Claude Opus 4.5 --- src/lib/collaboration/realtime.ts | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/src/lib/collaboration/realtime.ts b/src/lib/collaboration/realtime.ts index e5b0cb8..800e273 100644 --- a/src/lib/collaboration/realtime.ts +++ b/src/lib/collaboration/realtime.ts @@ -77,6 +77,21 @@ export class RealtimeConnection { this.resetInactivityTimer() this.clearConnectionTimer() + // Ensure the Supabase client has a valid auth session before connecting. + // On initial page load, the session may still be loading from cookies. + try { + const { data: { session } } = await this.supabase.auth.getSession() + if (!session && !this.isDestroyed) { + // No session yet - wait briefly and retry + this.scheduleReconnect() + return + } + } catch { + // Session check failed - proceed anyway, channel will handle auth errors + } + + if (this.isDestroyed || this.isPaused) return + // Set a timeout: if we don't connect within CONNECTION_TIMEOUT_MS, retry this.connectionTimer = setTimeout(() => { if (this.isDestroyed || this.isPaused) return From cd3ecc45075864b554232a7fc8b8760c3afaab7b Mon Sep 17 00:00:00 2001 From: Gustavo Henrique Santos Souza de Miranda Date: Sat, 24 Jan 2026 19:35:00 -0300 Subject: [PATCH 20/20] fix: only force-reconnect on tab visibility change, not on activity gaps The 60s stale threshold was triggering during normal use (reading, thinking) causing constant reconnects and presence join/leave toast spam. Now force- reconnect only happens when the tab was hidden for 3+ minutes and becomes visible again. Regular activity (mouse/keyboard) only resets the inactivity timer without ever forcing a reconnect. Co-Authored-By: Claude Opus 4.5 --- .../editor/[projectId]/FlowchartEditor.tsx | 2 +- src/lib/collaboration/realtime.ts | 24 +++++++++++++------ 2 files changed, 18 insertions(+), 8 deletions(-) diff --git a/src/app/editor/[projectId]/FlowchartEditor.tsx b/src/app/editor/[projectId]/FlowchartEditor.tsx index 879df51..d134a63 100644 --- a/src/app/editor/[projectId]/FlowchartEditor.tsx +++ b/src/app/editor/[projectId]/FlowchartEditor.tsx @@ -691,7 +691,7 @@ function FlowchartEditorInner({ projectId, projectName, userId, userDisplayName, useEffect(() => { const handleVisibilityChange = () => { if (!document.hidden) { - realtimeRef.current?.notifyActivity() + realtimeRef.current?.notifyVisibilityResumed() } } diff --git a/src/lib/collaboration/realtime.ts b/src/lib/collaboration/realtime.ts index 800e273..e494385 100644 --- a/src/lib/collaboration/realtime.ts +++ b/src/lib/collaboration/realtime.ts @@ -44,7 +44,7 @@ const RECONNECT_BASE_DELAY_MS = 1000 const RECONNECT_MAX_DELAY_MS = 30_000 const INACTIVITY_TIMEOUT_MS = 5 * 60_000 // 5 minutes of inactivity before pausing const CONNECTION_TIMEOUT_MS = 15_000 // 15s timeout for initial connection -const STALE_THRESHOLD_MS = 60_000 // 60s of inactivity = force fresh reconnect on return +const STALE_THRESHOLD_MS = 3 * 60_000 // 3 minutes hidden before forcing fresh reconnect export class RealtimeConnection { private channel: RealtimeChannel | null = null @@ -262,21 +262,31 @@ export class RealtimeConnection { /** * Notify that user activity has occurred, resetting the inactivity timer. * If the connection was paused due to inactivity, it will resume. - * If the connection was idle for a significant period, force a fresh reconnect - * to ensure the WebSocket isn't stale. */ notifyActivity(): void { + if (this.isDestroyed) return + this.lastActivityTime = Date.now() + this.resetInactivityTimer() + + if (this.isPaused) { + this.resume() + } + } + + /** + * Called when the tab becomes visible again. If the tab was hidden for a + * significant period, force a fresh reconnect to handle stale WebSockets. + */ + notifyVisibilityResumed(): void { if (this.isDestroyed) return const now = Date.now() - const idleDuration = now - this.lastActivityTime + const hiddenDuration = now - this.lastActivityTime this.lastActivityTime = now this.resetInactivityTimer() if (this.isPaused) { this.resume() - } else if (idleDuration > STALE_THRESHOLD_MS && this.channel) { - // Connection may appear alive but WebSocket could be stale after idle. - // Force a fresh reconnect to ensure broadcasts actually work. + } else if (hiddenDuration > STALE_THRESHOLD_MS && this.channel) { this.forceReconnect() } }