developing #10
2
prd.json
2
prd.json
|
|
@ -394,7 +394,7 @@
|
|||
"Verify in browser using dev-browser skill"
|
||||
],
|
||||
"priority": 22,
|
||||
"passes": false,
|
||||
"passes": true,
|
||||
"notes": "Dependencies: US-051"
|
||||
},
|
||||
{
|
||||
|
|
|
|||
17
progress.txt
17
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.
|
||||
---
|
||||
|
|
|
|||
|
|
@ -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<Variable[]>(migratedData.variables)
|
||||
const [showSettings, setShowSettings] = useState(false)
|
||||
const [showShare, setShowShare] = useState(false)
|
||||
const [showHistory, setShowHistory] = useState(false)
|
||||
const [selectedEdgeId, setSelectedEdgeId] = useState<string | null>(null)
|
||||
const [toastMessage, setToastMessage] = useState<string | null>(migratedData.toastMessage)
|
||||
const [validationIssues, setValidationIssues] = useState<ValidationIssue[] | null>(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,
|
|||
<Controls position="bottom-right" />
|
||||
</ReactFlow>
|
||||
<RemoteCursors cursors={remoteCursors} />
|
||||
{showHistory && (
|
||||
<ActivityHistorySidebar
|
||||
projectId={projectId}
|
||||
onClose={() => setShowHistory(false)}
|
||||
onSelectEntity={handleHistorySelectEntity}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
{contextMenu && (
|
||||
<ContextMenu
|
||||
|
|
|
|||
|
|
@ -0,0 +1,326 @@
|
|||
'use client'
|
||||
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import { createClient } from '@/lib/supabase/client'
|
||||
|
||||
const PAGE_SIZE = 20
|
||||
|
||||
type AuditEntry = {
|
||||
id: string
|
||||
project_id: string
|
||||
user_id: string
|
||||
action_type: string
|
||||
entity_id: string
|
||||
previous_state: Record<string, unknown> | null
|
||||
new_state: Record<string, unknown> | null
|
||||
created_at: string
|
||||
user_display_name?: string
|
||||
}
|
||||
|
||||
type ActivityHistorySidebarProps = {
|
||||
projectId: string
|
||||
onClose: () => void
|
||||
onSelectEntity: (entityId: string, actionType: string) => void
|
||||
}
|
||||
|
||||
const ACTION_LABELS: Record<string, string> = {
|
||||
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<string, string> = {
|
||||
node_add: '+',
|
||||
node_update: '~',
|
||||
node_delete: '-',
|
||||
edge_add: '+',
|
||||
edge_update: '~',
|
||||
edge_delete: '-',
|
||||
}
|
||||
|
||||
const ACTION_COLORS: Record<string, string> = {
|
||||
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<string, unknown>).type as string | undefined
|
||||
const data = (state as Record<string, unknown>).data as Record<string, unknown> | 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<string, AuditEntry[]> = {}
|
||||
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<AuditEntry[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [loadingMore, setLoadingMore] = useState(false)
|
||||
const [hasMore, setHasMore] = useState(true)
|
||||
const [error, setError] = useState<string | null>(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<string, string>()
|
||||
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 (
|
||||
<div className="absolute right-0 top-0 z-40 flex h-full w-80 flex-col border-l border-zinc-200 bg-white shadow-lg dark:border-zinc-700 dark:bg-zinc-800">
|
||||
<div className="flex items-center justify-between border-b border-zinc-200 px-4 py-3 dark:border-zinc-700">
|
||||
<h2 className="text-sm font-semibold text-zinc-900 dark:text-zinc-100">
|
||||
Activity History
|
||||
</h2>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="rounded p-1 text-zinc-400 hover:bg-zinc-100 hover:text-zinc-600 dark:hover:bg-zinc-700 dark:hover:text-zinc-300"
|
||||
title="Close"
|
||||
>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" className="h-4 w-4" viewBox="0 0 20 20" fill="currentColor">
|
||||
<path fillRule="evenodd" d="M4.293 4.293a1 1 0 011.414 0L10 8.586l4.293-4.293a1 1 0 111.414 1.414L11.414 10l4.293 4.293a1 1 0 01-1.414 1.414L10 11.414l-4.293 4.293a1 1 0 01-1.414-1.414L8.586 10 4.293 5.707a1 1 0 010-1.414z" clipRule="evenodd" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
{loading && (
|
||||
<div className="flex items-center justify-center py-8">
|
||||
<svg className="h-5 w-5 animate-spin text-zinc-400" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
|
||||
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
|
||||
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z" />
|
||||
</svg>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<div className="px-4 py-3 text-sm text-red-500 dark:text-red-400">
|
||||
Failed to load history: {error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!loading && !error && entries.length === 0 && (
|
||||
<div className="px-4 py-8 text-center text-sm text-zinc-400 dark:text-zinc-500">
|
||||
No activity recorded yet
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!loading && grouped.map((group) => (
|
||||
<div key={group.period}>
|
||||
<div className="sticky top-0 bg-zinc-50 px-4 py-1.5 text-xs font-medium uppercase tracking-wide text-zinc-500 dark:bg-zinc-850 dark:text-zinc-400" style={{ backgroundColor: 'inherit' }}>
|
||||
<div className="bg-zinc-50 dark:bg-zinc-800">
|
||||
{group.period}
|
||||
</div>
|
||||
</div>
|
||||
<div className="divide-y divide-zinc-100 dark:divide-zinc-700/50">
|
||||
{group.entries.map((entry) => {
|
||||
const userColor = getUserColor(entry.user_id)
|
||||
const isDeleted = entry.action_type.endsWith('_delete')
|
||||
return (
|
||||
<button
|
||||
key={entry.id}
|
||||
onClick={() => !isDeleted && onSelectEntity(entry.entity_id, entry.action_type)}
|
||||
disabled={isDeleted}
|
||||
className={`w-full px-4 py-2.5 text-left transition-colors ${
|
||||
isDeleted
|
||||
? 'cursor-default opacity-60'
|
||||
: 'hover:bg-zinc-50 dark:hover:bg-zinc-700/50'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-start gap-2.5">
|
||||
<div
|
||||
className="mt-1 h-2 w-2 flex-shrink-0 rounded-full"
|
||||
style={{ backgroundColor: userColor }}
|
||||
title={entry.user_display_name}
|
||||
/>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span className={`text-xs font-bold ${ACTION_COLORS[entry.action_type] || 'text-zinc-500'}`}>
|
||||
{ACTION_ICONS[entry.action_type] || '?'}
|
||||
</span>
|
||||
<span className="truncate text-xs font-medium text-zinc-800 dark:text-zinc-200">
|
||||
{ACTION_LABELS[entry.action_type] || entry.action_type}
|
||||
</span>
|
||||
</div>
|
||||
<div className="mt-0.5 truncate text-xs text-zinc-500 dark:text-zinc-400">
|
||||
{getEntityDescription(entry)}
|
||||
</div>
|
||||
<div className="mt-0.5 flex items-center gap-1.5 text-[10px] text-zinc-400 dark:text-zinc-500">
|
||||
<span>{entry.user_display_name}</span>
|
||||
<span>·</span>
|
||||
<span>{formatTime(entry.created_at)}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
|
||||
{!loading && hasMore && entries.length > 0 && (
|
||||
<div className="px-4 py-3">
|
||||
<button
|
||||
onClick={handleLoadMore}
|
||||
disabled={loadingMore}
|
||||
className="w-full rounded border border-zinc-300 bg-white px-3 py-1.5 text-xs font-medium text-zinc-600 hover:bg-zinc-50 disabled:opacity-50 dark:border-zinc-600 dark:bg-zinc-700 dark:text-zinc-300 dark:hover:bg-zinc-600"
|
||||
>
|
||||
{loadingMore ? 'Loading...' : 'Load more'}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -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
|
||||
</button>
|
||||
<button
|
||||
onClick={onHistory}
|
||||
className="rounded border border-zinc-300 bg-white px-3 py-1.5 text-sm font-medium text-zinc-700 hover:bg-zinc-50 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 dark:border-zinc-600 dark:bg-zinc-700 dark:text-zinc-200 dark:hover:bg-zinc-600 dark:focus:ring-offset-zinc-800"
|
||||
>
|
||||
History
|
||||
</button>
|
||||
<button
|
||||
onClick={onSave}
|
||||
disabled={isSaving}
|
||||
|
|
|
|||
Loading…
Reference in New Issue