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 +