From 3f20736f4ac79c3820c642446d0a1d718f46e482 Mon Sep 17 00:00:00 2001 From: Matthew Valancy Date: Mon, 7 Sep 2026 17:10:29 -0700 Subject: [PATCH 001/136] Simplify responsive work views and replace simulated progress with recorded data --- docs/USER_STORIES.md | 15 + docs/design/human-ai-workspace.md | 45 + docs/reviews/2026-09-07-state.md | 55 ++ packages/web/src/components/ActivityFeed.tsx | 816 ++---------------- .../src/components/AdminUserManagement.tsx | 53 +- packages/web/src/components/CalendarView.tsx | 153 ++-- packages/web/src/components/Dashboard.tsx | 223 ++--- packages/web/src/components/GanttChart.tsx | 172 ++-- .../InteractiveGraphVisualization.tsx | 99 ++- packages/web/src/components/KanbanView.tsx | 18 +- packages/web/src/components/Layout.tsx | 10 +- packages/web/src/components/NodeInspector.tsx | 8 +- packages/web/src/components/NodeQuickEdit.tsx | 52 +- .../web/src/components/QuickEditSurface.tsx | 132 +++ .../src/components/SafeGraphVisualization.tsx | 6 +- packages/web/src/components/ViewManager.tsx | 18 +- packages/web/src/index.css | 3 +- .../src/lib/__tests__/graphAnalytics.test.ts | 58 ++ .../lib/__tests__/recordedActivity.test.ts | 19 + packages/web/src/lib/graphAnalytics.ts | 52 ++ packages/web/src/lib/recordedActivity.ts | 39 + packages/web/src/pages/Agents.tsx | 439 +--------- packages/web/src/pages/Analytics.tsx | 672 +++++++-------- packages/web/src/pages/Ontology.tsx | 77 +- packages/web/src/pages/Workspace.tsx | 32 +- .../e2e/graph/admin-dialog-responsive.spec.ts | 24 + tests/e2e/graph/local-cloud-progress.spec.ts | 43 + .../e2e/graph/overview-board-actions.spec.ts | 28 + tests/e2e/graph/recorded-ai-work.spec.ts | 44 + .../graph/responsive-real-workflows.spec.ts | 86 ++ tests/e2e/reports/feature-matrix.spec.ts | 1 + tests/lib/graphRequest.ts | 14 + 32 files changed, 1533 insertions(+), 1973 deletions(-) create mode 100644 docs/design/human-ai-workspace.md create mode 100644 docs/reviews/2026-09-07-state.md create mode 100644 packages/web/src/components/QuickEditSurface.tsx create mode 100644 packages/web/src/lib/__tests__/graphAnalytics.test.ts create mode 100644 packages/web/src/lib/__tests__/recordedActivity.test.ts create mode 100644 packages/web/src/lib/graphAnalytics.ts create mode 100644 packages/web/src/lib/recordedActivity.ts create mode 100644 tests/e2e/graph/admin-dialog-responsive.spec.ts create mode 100644 tests/e2e/graph/local-cloud-progress.spec.ts create mode 100644 tests/e2e/graph/overview-board-actions.spec.ts create mode 100644 tests/e2e/graph/recorded-ai-work.spec.ts create mode 100644 tests/e2e/graph/responsive-real-workflows.spec.ts create mode 100644 tests/lib/graphRequest.ts diff --git a/docs/USER_STORIES.md b/docs/USER_STORIES.md index c418f640..5d794242 100644 --- a/docs/USER_STORIES.md +++ b/docs/USER_STORIES.md @@ -121,3 +121,18 @@ These profiles are encoded in `packages/web/src/lib/adaptiveQuality.ts` and exer | AINAT-1 | As an agent, I want MCP tools over the ontology (list sets, get defs, create typed objects/links, coverage), so domain work is fully scriptable. | MCP tools land in the same PR as each ONTO story; validation errors are machine-readable. | `mcp-server/tests/ontology-tools.test.ts` | 💤 | | AINAT-2 | As a Claude user, I want a GraphDone Skill that runs a full requirements workflow (spec doc → proposed graph → maintained trace links → coverage report), so AI does the bookkeeping while humans watch the living graph. | Skill in skills/; end-to-end demo against dev stack; humans see live updates. | skill smoke script | 💤 | | AINAT-3 | As an agent, I want get_graph_context to include the ontology section (active sets, type counts, coverage headline), so one call orients me on any domain graph. | <2.5kB response incl. ontology block. | `mcp-server/tests/graph-context.test.ts` | 💤 | + +## Live-data and responsive completion review (2026-09-07) + +See [state review](reviews/2026-09-07-state.md) for the evidence and remaining gaps. + +| ID | Story | Acceptance / evidence | Status | +| --- | --- | --- | --- | +| DATA-1 | As a contributor, I want analytics based on my selected graph so progress is trustworthy. | Actual node/status/priority/contributor data; graph selection, search/status filters, empty/error/retry states; `graphAnalytics.test.ts` and `responsive-real-workflows.spec.ts`. Historical voting metrics remain unavailable. | 🧪 | +| RESP-1a | As a phone user, I want quick editing to remain within the visible screen in either orientation. | Viewport-bounded editor/sheet, 44px controls, one-tap entry, Escape/click-away and focus handling; responsive persistence journey. Native virtual-keyboard validation still required before closing RESP-1. | 🧪 | + +| OBS-1a | As a human, I want saved API progress to appear in my workspace without invented events or agents. | Recorded notes and timestamps, selected-graph AI contributor view, poll while visible; `recordedActivity.test.ts` and `recorded-ai-work.spec.ts`. Actor attribution and append-only events remain open. | 🧪 | + +The [human-first AI work contract](design/human-ai-workspace.md) defines the remaining release requirements: scoped hosted credentials, atomic claims, idempotent attributed progress, human input/review, evidence and reconnect recovery. + +| UX-1a | As a human, I want the work that matters first and direct access to editing. | Overview attention/current-work sections open items; Board hides empty lanes by default; graph editing takes one tap/click; Admin reference collapses and forms fit short screens. Headed responsive and Admin form journeys. | 🧪 | diff --git a/docs/design/human-ai-workspace.md b/docs/design/human-ai-workspace.md new file mode 100644 index 00000000..e6a7a9eb --- /dev/null +++ b/docs/design/human-ai-workspace.md @@ -0,0 +1,45 @@ +# Human-first workspace, observable AI work + +Product direction clarified 2026-09-07: GraphDone is a friendly place for people to work and understand AI-driven development. Agents participate through clear, authenticated interfaces. Human control and understandable progress drive the interface; infrastructure and exhaustive controls stay secondary. + +## Product boundary and current priority + +Core remains the free, open-source foundation. A managed GraphDone web product can add advanced autonomous execution, LiteLLM gateway integration and external ChatGPT/OpenAI agent interfaces. Do not imply these services ship in Core today. + +The current implementation priority is visual/UI/UX correctness across common resolutions and orientations. Minimize both button presses per task and the number of tasks required to reach an outcome. Primary work should be directly actionable; avoid intermediate read-only dialogs or unnecessary save/confirmation steps. Secondary configuration can expand on demand. Gateway and autonomous-execution implementation follows this UI work. + +## Human questions the default experience must answer + +- What is happening? Show active work, who or which agent is working on it, and the latest meaningful update. +- What needs me? Surface blockers, questions, review requests and decisions with a clear next action. +- What changed? Show progress and evidence since the person's last visit, with links to the work, tests, artifacts and discussions. + +Keep the graph as a powerful exploration view. A phone should offer the same essential actions in a readable list and compact editor. Details, filters, history and configuration expand on demand. Never show fabricated agent activity, simulated progress, or an action that appears to succeed without persistence. + +## Shared human/agent work contract + +Use one authorized domain API behind UI and agent tools. Minimum operations: + +1. Read a bounded work context: graph, requirements, dependencies, eligible work, current revision and caller permissions. +2. Claim eligible work atomically with actor identity, lease expiry and expected revision; reject competing claims. Humans can release/reassign within their permissions. +3. Append a progress event: actor, work item, status, plain-language summary, timestamp, evidence links and idempotency key. Retries must not duplicate history. +4. Report a blocker or ask a question; place it in the human's attention queue with a suggested next action. +5. Submit work for review with evidence and acceptance-check results; completion policy is explicit per graph. Do not equate an agent saying “done” with verified requirements. +6. Resume after reconnect using a durable event cursor/revision, including deletions and failed/abandoned claims. + +Suggested vocabulary: `get_work_context`, `claim_work`, `report_progress`, `request_input`, `submit_for_review`, `release_work`. These names describe the desired contract, not currently shipped tools. Tools must be available over a hosted interface and use scoped credentials; local database access alone does not meet the online requirement. + +## Observability without overload + +A current-work summary points to a durable event history. Preserve who did what and why, distinguish reported from verified progress, and provide evidence without exposing secrets or dumping internal reasoning. Live transport can broadcast changes, but reconnects recover from durable storage. Cursor/presence updates are ephemeral; task status, blockers, review decisions and evidence are persistent. + +## Acceptance scenarios + +- A human creates a requirement and two tasks; an authorized agent finds and claims eligible work, reports an update, and submits evidence for review. The human sees the changes without reloading. +- Two agents try to claim the same task; exactly one succeeds, and the other receives an actionable conflict. +- An agent requests input; the human answers from a phone in portrait, and the agent can resume. +- A network retry repeats a progress report without duplicating history; reconnect restores missed updates. +- A revoked or cross-graph credential cannot read, claim or update work. +- At 320px portrait and short landscape, primary actions and the current result remain visible; controls, charts and metadata never displace the actual work. Keyboard navigation, reduced motion and assistive labels work. + +These are release requirements. Existing CRUD, status notes, MCP and polling are useful foundations but do not by themselves satisfy this contract. diff --git a/docs/reviews/2026-09-07-state.md b/docs/reviews/2026-09-07-state.md new file mode 100644 index 00000000..13ae50b9 --- /dev/null +++ b/docs/reviews/2026-09-07-state.md @@ -0,0 +1,55 @@ +# GraphDone state and delivery gaps — 2026-09-07 + +The product vision is a living, graph-native workspace: easy capture and editing, visible progress, human/agent peers, dependency-driven coordination, requirements traceability, and accessible operation across devices. The source contract is `docs/USER_STORIES.md`, `docs/design/interaction-model.md`, and the clarified [human-first AI workspace](../design/human-ai-workspace.md). A working graph canvas does not establish completion of the entire app. + +## Branch baseline + +After fetching origin, local dev and origin/dev were `8a7de05`. Origin/main was `775362e`; origin/dev contains 126 commits absent from origin/main, and origin/main contains two merge commits absent from dev. Local main (`943d5fe`) is substantially older. Work in this review branches from dev on `improve/real-workflows-responsive`; no branch was merged or deployed. + +## Evidence and gaps + +| Area | Observed state | Remaining acceptance criteria | +| --- | --- | --- | +| Core canvas | Living node/edge rendering, grow, quick edit, undo, adaptive quality and layout persistence exist. Core five-case smoke gate passed locally against Neo4j. | Full click-budget coverage; recovery from failed mutations; keyboard capture/navigation; complete touch gesture coverage. | +| Responsive editing | Node quick editor was a fixed-width, unbounded popover with tiny controls. This change adds a viewport-bounded surface, phone/short-touch-landscape sheet, 44px controls, focus containment, Escape/click-away exit, one-tap editing and visual-viewport resize handling. | Native iOS/Android keyboard and assistive-technology verification; full functional coverage beyond the covered journeys. | +| Analytics | Formerly hard-coded values, invented trends/votes and nonfunctional time filters. Now reads actual selected-graph work, status progress, unfinished priorities and contributor records; filters and refresh work. Navigation enabled. | Real historical events and voting data before adding trends or consensus metrics. | +| Agents | Replaced the mock agent catalogue with selected-graph AI contributor records, current work and saved notes. No simulated executions or action counts. Navigation enabled for this recorded-work view. | Persisted agent identity/actions, hosted API/MCP parity and truthful capability states. | +| Ontology | Requirement/SATISFIES basics and a coverage view exist. Type and relationship usage counts now come from the selected graph. Navigation wraps into readable phone rows; unavailable schema-creation controls removed. Custom sets and full validation/reporting contract are not complete. | Typed definitions as data, validation, provenance, suspect-link reports and real usage counts. | +| Activity | Replaced randomized comments, invented status/assignment/completion events and simulated live updates with creation, latest-save timestamps and persisted status notes. | Append-only attributed events, evidence, idempotency, durable cursors. Editable notes are not an audit log. | +| Collaboration | Polling supplies some freshness. This is not the specified live presence/cursor/event system. | Membership/roles, live room service, durable resynchronization, attribution and two-client tests. | +| Admin | Route-role guards exist. At least one settings save handler remains TODO. | Each visible setting must persist or be clearly unavailable; permission tests at the API boundary. | +| Cloud | The online implementation has a distinct API/storage contract. | Run the same user journeys against the cloud backend; keep cloud-specific findings in the Cloud repository's `docs/COST_AND_COMPLETENESS_REVIEW.md`. | + +## Validation scope + +- Final type checking: all four packages passed. +- Production build: all four packages passed (existing large-chunk warning). +- Baseline unit tests: Core 41, Server 28, Web 228 passed. MCP run failed with heap exhaustion in the performance-chaos suite; it is not an all-green baseline. +- Web unit suite: 235 tests passed, including recorded-activity determinism/malformed-data tests. New analytics unit tests cover empty graphs, real status accounting, priority ranking and malformed values, contributor identity/deduplication. +- `tests/e2e/graph/responsive-real-workflows.spec.ts` exercises live Neo4j-backed data, filtering, persisted edits, completion and editor bounds at seven desktop/portrait/landscape dimensions. Each fixture is isolated and cleaned up; small-screen runs use touch input. +- Seven headed responsive persistence journeys passed at 320×568, 390×844, 844×390, 768×1024, 1024×768, 1280×720, and 1920×1080. The test now requires SVG nodes to fit inside the viewport, not merely have CSS visibility. Initial camera framing follows settling positions until stable and stops when a person interacts. +- Initial headed phone portrait/landscape screen matrix: 29 passed (28 screens plus auth setup). The expanded seven-resolution sweep ran 99 cases: 98 passed and one Admin contrast failure identified gray text on hover; that contrast has been corrected. Screenshots exposed crowding the earlier page-overflow audit missed; calendar/timeline filters now collapse, dashboard graphics scale, and the landscape header is smaller. This audit still does not prove every control works or every internal region is visually correct. +- Web lint has zero errors and 698 warnings, predominantly existing. No assertion is made that every page or every requirement is finished. + +## Delivery priorities + +1. Make the hosted persistence and authorization contract correct and cost-bounded. +2. Integrate these responsive/live-data changes and test them against the hosted API. +3. Finish membership, agent, ontology and admin behavior with end-to-end evidence per story. +4. Replace full polling with revision/delta synchronization and a live collaboration service. +5. Keep a release gate covering portrait, landscape, desktop, touch, keyboard, offline/error recovery and reduced motion. Mark a story shipped only when its complete acceptance criteria pass. + +## Simplicity pass after product clarification + +- Overview now leads with real progress, attention items and current work. Open an item directly from the summary; advanced distributions expand on demand. +- Board defaults to populated status lanes, with an explicit option to show empty lanes. Keyboard users can open cards with Enter or Space. +- A touch tap on a graph item or a desktop click on its edit icon opens quick edit directly. Both native touch events and browser compatibility-click paths are handled. The regression test permits one opening action, with no interaction retries. +- Short-window navigation scrolls instead of stranding account controls below the screen. The project-health toggle no longer covers calendar header actions. +- Admin role reference collapses, low-contrast labels are brighter, and dialogs are viewport-bounded, scrollable, named, focus-contained and dismissible with Escape. +- Latest headed workflow gate: 10 passed (seven direct-edit/persistence sizes, including open-editor rotation, plus three Admin form sizes). +- Local D1-backed frontend progress/persistence journey passed. Core API progress-to-UI journeys passed on phone and desktop. These verify recorded updates, not autonomous execution. +- User direction: visual/UI/UX correctness and fewer actions take priority now. Free open-source Core remains distinct from a future managed product with advanced gateway/autonomous-agent capabilities. See the updated design contract. + +Focused screen rerun after the Board/Overview/Admin changes: **22 passed** across the seven matrix resolutions (including setup). The initial full sweep failure is retained above to make the audit history clear. + +Overview/Board direct-opening and keyboard journeys: **2 passed** at 360px portrait and 844px landscape. Final production build passes all four packages; the existing large-bundle warning remains. diff --git a/packages/web/src/components/ActivityFeed.tsx b/packages/web/src/components/ActivityFeed.tsx index 34ec1d42..0d614b13 100644 --- a/packages/web/src/components/ActivityFeed.tsx +++ b/packages/web/src/components/ActivityFeed.tsx @@ -1,771 +1,59 @@ -import React, { useState, useMemo, useEffect } from 'react'; -import { Clock, User, MessageSquare, AlertCircle, Filter, Search, Trash2, Eye, TrendingUp, UserPlus, Target, Flag, Bell, BellOff, ChevronDown, Calendar } from 'lucide-react'; -import { getStatusConfig, WorkItemStatus, getTypeConfig, WorkItemType, getPriorityConfig, STATUS_OPTIONS, PRIORITY_OPTIONS, TYPE_OPTIONS, WORK_ITEM_PRIORITIES, WORK_ITEM_STATUSES, WORK_ITEM_TYPES } from '../constants/workItemConstants'; - -interface WorkItem { - id: string; - title: string; - description?: string; - type: string; - status: string; - priority: number; - dueDate?: string; - tags?: string[]; - metadata?: string; - createdAt: string; - updatedAt: string; - owner?: { id: string; name: string; username: string; }; - assignedTo?: { id: string; name: string; username: string; }; - graph?: { id: string; name: string; team?: { id: string; name: string; } }; - contributors?: Array<{ id: string; name: string; type: string; }>; - dependencies?: Array<{ id: string; title: string; type: string; }>; - dependents?: Array<{ id: string; title: string; type: string; }>; -} - -interface ActivityFeedProps { - filteredNodes: WorkItem[]; -} - -interface ActivityItem { - id: string; - type: 'created' | 'updated' | 'completed' | 'commented' | 'assigned' | 'priority_changed' | 'status_changed' | 'due_date_changed'; - title: string; - description: string; - timestamp: Date; - user: string; - nodeId: string; - nodeTitle: string; - icon: React.ReactNode; - color: string; - priority: 'minimal' | 'low' | 'moderate' | 'high' | 'critical'; - category: 'task' | 'user' | 'system'; - metadata?: { - oldValue?: string; - newValue?: string; - changes?: string[]; - }; -} - -const formatLabel = (label: string) => { - return label - .split('_') - .map(word => word.charAt(0).toUpperCase() + word.slice(1).toLowerCase()) - .join(' '); -}; - -const getTimeAgo = (date: Date) => { - const now = new Date(); - const diffInSeconds = Math.floor((now.getTime() - date.getTime()) / 1000); - - if (diffInSeconds < 60) return `${diffInSeconds}s ago`; - if (diffInSeconds < 3600) return `${Math.floor(diffInSeconds / 60)}m ago`; - if (diffInSeconds < 86400) return `${Math.floor(diffInSeconds / 3600)}h ago`; - if (diffInSeconds < 604800) return `${Math.floor(diffInSeconds / 86400)}d ago`; - return date.toLocaleDateString(); -}; - -const ActivityFeed: React.FC = ({ filteredNodes }) => { - const [filter, setFilter] = useState('all'); - const [timeRange, setTimeRange] = useState<'hour' | 'today' | 'week' | 'month' | 'all'>('week'); - const [searchQuery, setSearchQuery] = useState(''); - const [categoryFilter, setCategoryFilter] = useState<'all' | 'task' | 'user' | 'system'>('all'); - const [priorityFilter, setPriorityFilter] = useState<'all' | 'minimal' | 'low' | 'moderate' | 'high' | 'critical'>('all'); - const [isRealTimeEnabled, setIsRealTimeEnabled] = useState(true); - const [selectedActivity, setSelectedActivity] = useState(null); - const [currentPage, setCurrentPage] = useState(1); - const [itemsPerPage] = useState(20); - const [priorityDropdownOpen, setPriorityDropdownOpen] = useState(false); - const [statusDropdownOpen, setStatusDropdownOpen] = useState(false); - const [typeDropdownOpen, setTypeDropdownOpen] = useState(false); - const [filterStatus, setFilterStatus] = useState('all'); - const [filterType, setFilterType] = useState('all'); - - // Enhanced activity generation with more realistic data - const activities = useMemo(() => { - const activityList: ActivityItem[] = []; - - filteredNodes.forEach(node => { - const priority = node.priority || 0; - const activityPriority = priority >= 0.8 ? 'critical' : - priority >= 0.6 ? 'high' : - priority >= 0.4 ? 'moderate' : - priority >= 0.2 ? 'low' : 'minimal'; - - // Creation activity - const typeConfig = getTypeConfig(node.type as WorkItemType); - activityList.push({ - id: `${node.id}-created`, - type: 'created', - title: 'Task Created', - description: `Created new ${typeConfig.label.toLowerCase()} "${node.title}"`, - timestamp: new Date(node.createdAt), - user: node.owner?.name || 'System User', - nodeId: node.id, - nodeTitle: node.title, - icon: React.createElement(typeConfig.icon as any, { className: 'h-4 w-4' }), - color: typeConfig.color, - priority: activityPriority, - category: 'task', - metadata: { - changes: [`Type: ${typeConfig.label}`, `Priority: ${Math.round(priority * 100)}%`] - } - }); - - // Status change activity - if (node.updatedAt && new Date(node.updatedAt).getTime() !== new Date(node.createdAt).getTime()) { - const statusConfig = getStatusConfig(node.status as WorkItemStatus); - activityList.push({ - id: `${node.id}-status-changed`, - type: 'status_changed', - title: 'Status Changed', - description: `Changed "${node.title}" status to ${statusConfig.label}`, - timestamp: new Date(node.updatedAt), - user: node.assignedTo?.name || node.owner?.name || 'System User', - nodeId: node.id, - nodeTitle: node.title, - icon: React.createElement(statusConfig.icon as any, { className: 'h-4 w-4' }), - color: statusConfig.color, - priority: activityPriority, - category: 'task', - metadata: { - oldValue: 'PENDING', - newValue: node.status, - changes: [`Status updated to ${statusConfig.label}`] - } - }); - } - - // Assignment activity - if (node.assignedTo) { - activityList.push({ - id: `${node.id}-assigned`, - type: 'assigned', - title: 'Task Assigned', - description: `Assigned "${node.title}" to ${node.assignedTo.name}`, - timestamp: new Date(new Date(node.createdAt).getTime() + Math.random() * 3600000), // Within an hour of creation - user: node.owner?.name || 'Project Manager', - nodeId: node.id, - nodeTitle: node.title, - icon: , - color: 'text-purple-400', - priority: activityPriority, - category: 'user', - metadata: { - newValue: node.assignedTo.name, - changes: [`Assigned to ${node.assignedTo.name}`] - } - }); - } - - // Priority change activity - if (priority > 0.5) { - const priorityConfig = getPriorityConfig(priority); - activityList.push({ - id: `${node.id}-priority-changed`, - type: 'priority_changed', - title: 'Priority Updated', - description: `Updated priority for "${node.title}" to ${priorityConfig.label}`, - timestamp: new Date(node.updatedAt || node.createdAt), - user: node.owner?.name || 'Project Manager', - nodeId: node.id, - nodeTitle: node.title, - icon: React.createElement(priorityConfig.icon as any, { className: 'h-4 w-4' }), - color: priorityConfig.color, - priority: activityPriority, - category: 'task', - metadata: { - oldValue: '30%', - newValue: `${priorityConfig.label} (${Math.round(priority * 100)}%)`, - changes: [`Priority set to ${priorityConfig.label}`] - } - }); - } - - // Due date activity - if (node.dueDate) { - activityList.push({ - id: `${node.id}-due-date-set`, - type: 'due_date_changed', - title: 'Due Date Set', - description: `Set due date for "${node.title}" to ${new Date(node.dueDate).toLocaleDateString()}`, - timestamp: new Date(new Date(node.createdAt).getTime() + Math.random() * 7200000), // Within 2 hours - user: node.assignedTo?.name || node.owner?.name || 'Project Manager', - nodeId: node.id, - nodeTitle: node.title, - icon: , - color: 'text-blue-400', - priority: activityPriority, - category: 'task', - metadata: { - newValue: new Date(node.dueDate).toLocaleDateString(), - changes: [`Due date set to ${new Date(node.dueDate).toLocaleDateString()}`] - } - }); - } - - // Completion activity - if (node.status === 'COMPLETED') { - const completedConfig = getStatusConfig('COMPLETED'); - activityList.push({ - id: `${node.id}-completed`, - type: 'completed', - title: 'Task Completed', - description: `Completed "${node.title}" successfully`, - timestamp: new Date(node.updatedAt || node.createdAt), - user: node.assignedTo?.name || 'Team Member', - nodeId: node.id, - nodeTitle: node.title, - icon: React.createElement(completedConfig.icon as any, { className: 'h-4 w-4' }), - color: completedConfig.color, - priority: activityPriority, - category: 'task', - metadata: { - changes: [`Task marked as completed`] - } - }); - } - - // Mock comment activity (more realistic) - if (Math.random() > 0.6) { - const commentTypes = ['progress update', 'question', 'blocker', 'suggestion']; - const commentType = commentTypes[Math.floor(Math.random() * commentTypes.length)]; - - activityList.push({ - id: `${node.id}-commented-${Date.now()}`, - type: 'commented', - title: 'Comment Added', - description: `Added a ${commentType} comment on "${node.title}"`, - timestamp: new Date(Date.now() - Math.random() * 7 * 24 * 60 * 60 * 1000), - user: node.assignedTo?.name || node.owner?.name || 'Team Member', - nodeId: node.id, - nodeTitle: node.title, - icon: , - color: 'text-purple-400', - priority: commentType === 'blocker' ? 'high' : 'moderate', - category: 'user', - metadata: { - changes: [`Added ${commentType} comment`] - } - }); - } - }); - - return activityList.sort((a, b) => b.timestamp.getTime() - a.timestamp.getTime()); - }, [filteredNodes]); - - // Real-time activity updates simulation - useEffect(() => { - if (!isRealTimeEnabled) return; - - const interval = setInterval(() => { - // Simulate new activities occasionally - if (Math.random() > 0.95 && activities.length > 0) { - // This would normally trigger a re-fetch of activities - console.log('New activity detected (simulation)'); - } - }, 5000); - - return () => clearInterval(interval); - }, [isRealTimeEnabled, activities.length]); - - // Advanced filtering with search and multiple filters - const filteredActivities = useMemo(() => { - let filtered = activities; - - // Search filter - if (searchQuery.trim()) { - const query = searchQuery.toLowerCase(); - filtered = filtered.filter(activity => - activity.title.toLowerCase().includes(query) || - activity.description.toLowerCase().includes(query) || - activity.user.toLowerCase().includes(query) || - activity.nodeTitle.toLowerCase().includes(query) - ); - } - - // Type filter - if (filter !== 'all') { - filtered = filtered.filter(activity => activity.type === filter); - } - - // Category filter - if (categoryFilter !== 'all') { - filtered = filtered.filter(activity => activity.category === categoryFilter); - } - - // Priority filter - if (priorityFilter !== 'all') { - filtered = filtered.filter(activity => activity.priority === priorityFilter); - } - - // Time range filter - const now = new Date(); - const cutoffDate = new Date(); - - switch (timeRange) { - case 'hour': - cutoffDate.setHours(now.getHours() - 1); - break; - case 'today': - cutoffDate.setHours(0, 0, 0, 0); - break; - case 'week': - cutoffDate.setDate(now.getDate() - 7); - break; - case 'month': - cutoffDate.setMonth(now.getMonth() - 1); - break; - case 'all': - cutoffDate.setFullYear(2000); - break; - } - - if (timeRange !== 'all') { - filtered = filtered.filter(activity => activity.timestamp >= cutoffDate); - } - - return filtered; - }, [activities, filter, timeRange, searchQuery, categoryFilter, priorityFilter]); - - // Pagination - const paginatedActivities = useMemo(() => { - const startIndex = (currentPage - 1) * itemsPerPage; - return filteredActivities.slice(startIndex, startIndex + itemsPerPage); - }, [filteredActivities, currentPage, itemsPerPage]); - - const totalPages = Math.ceil(filteredActivities.length / itemsPerPage); - - const getPriorityColor = (priority: string) => { - switch (priority) { - case 'critical': return 'text-red-400 bg-red-400/10 border-red-400/20'; - case 'high': return 'text-orange-400 bg-orange-400/10 border-orange-400/20'; - case 'moderate': return 'text-yellow-400 bg-yellow-400/10 border-yellow-400/20'; - case 'low': return 'text-blue-400 bg-blue-400/10 border-blue-400/20'; - default: return 'text-gray-400 bg-gray-400/10 border-gray-400/20'; - } - }; - - const handleClearFilters = () => { - setFilter('all'); - setCategoryFilter('all'); - setPriorityFilter('all'); - setSearchQuery(''); - setTimeRange('week'); - setCurrentPage(1); - }; - - // Close dropdowns when clicking outside - useEffect(() => { - const handleClickOutside = (event: MouseEvent) => { - const target = event.target as Element; - if (!target.closest('[data-dropdown="priority"]')) { - setPriorityDropdownOpen(false); - } - if (!target.closest('[data-dropdown="status"]')) { - setStatusDropdownOpen(false); - } - if (!target.closest('[data-dropdown="type"]')) { - setTypeDropdownOpen(false); - } - }; - - document.addEventListener('mousedown', handleClickOutside); - return () => document.removeEventListener('mousedown', handleClickOutside); - }, []); +import { useMemo, useState } from 'react'; +import { Clock, MessageSquare, Search } from 'lucide-react'; +import { recordedActivity, type ActivityWorkItem } from '../lib/recordedActivity'; +import { getStatusConfig, type WorkItemStatus } from '../constants/workItemConstants'; + +export default function ActivityFeed({ filteredNodes }: { filteredNodes: ActivityWorkItem[] }) { + const [search, setSearch] = useState(''); + const [kind, setKind] = useState('all'); + const [limit, setLimit] = useState(30); + const activities = useMemo(() => recordedActivity(filteredNodes), [filteredNodes]); + const visible = activities.filter(event => (kind === 'all' || event.kind === kind) && + `${event.itemTitle} ${event.text}`.toLowerCase().includes(search.toLowerCase())); return ( -
- {/* Header */} -
-
-
-
- -
-
-

Activity Feed

-
-
-
- {filteredActivities.length} activities -
-
•
-
- {isRealTimeEnabled ? 'Live updates enabled' : 'Live updates paused'} -
-
-
-
- -
- -
-
- - {/* Search Bar */} -
- - setSearchQuery(e.target.value)} - className="w-full pl-10 pr-4 py-2 bg-gray-700 border border-gray-600 rounded-lg text-white placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-green-500 focus:border-transparent" - /> - {searchQuery && ( - - )} -
+
+
+

What changed

+

Saved work and progress notes from this graph.

+

Shows creation, the latest update, and editable notes. Full change history and actor attribution are not yet recorded.

+
+
+ +
- - {/* Advanced Filters */} -
-
- {/* Type Filter */} -
- -
- - {typeDropdownOpen && ( -
- {TYPE_OPTIONS.map(option => ( - - ))} -
- )} -
-
- - {/* Status Filter */} -
- -
- - {statusDropdownOpen && ( -
- {STATUS_OPTIONS.map(option => ( - - ))} -
- )} -
-
- - {/* Priority Filter */} -
- -
- - {priorityDropdownOpen && ( -
- {PRIORITY_OPTIONS.map(option => ( - - ))} -
- )} -
-
- -
- - -
- - - {(filter !== 'all' || categoryFilter !== 'all' || priorityFilter !== 'all' || searchQuery) && ( - - )} +

{visible.length} recorded entries

+ {visible.length === 0 ? ( +
+ +

{search || kind !== 'all' ? 'No matching updates' : 'A place to follow the work'}

+

Open a work item’s editor and add a progress note to share what happened or what needs attention.

-
- - {/* Activity List */} -
- {paginatedActivities.length === 0 ? ( -
- -

No activities found for the selected filters.

- -
- ) : ( -
- {paginatedActivities.map((activity) => { - const isSelected = selectedActivity === activity.id; - - return ( -
setSelectedActivity(isSelected ? null : activity.id)} - > -
-
-
-
- {activity.icon} -
-
- -
-
-
-

{activity.title}

- {(() => { - const priorityConfig = PRIORITY_OPTIONS.find(opt => opt.value === activity.priority); - return priorityConfig && priorityConfig.value !== 'all' ? ( - - {priorityConfig.icon && React.createElement(priorityConfig.icon as any, { className: 'h-3 w-3 mr-1' })} - {priorityConfig.label} - - ) : null; - })()} - - {activity.category} - -
-
- {getTimeAgo(activity.timestamp)} - {isSelected && ( -
- -
- )} -
-
- -

{activity.description}

- -
-
-
- - {activity.user} -
-
- - {activity.nodeTitle} -
-
-
- {activity.timestamp.toLocaleString()} -
-
- - {/* Expanded Details */} - {isSelected && activity.metadata && ( -
-
- {activity.metadata.oldValue && activity.metadata.newValue && ( -
- Changed from: - - {activity.metadata.oldValue} - - to: - - {activity.metadata.newValue} - -
- )} - {activity.metadata.changes && ( -
-
Changes:
-
    - {activity.metadata.changes.map((change, index) => ( -
  • {change}
  • - ))} -
-
- )} -
-
- )} -
-
-
-
- ); - })} -
- )} -
- - {/* Pagination */} - {totalPages > 1 && ( -
-
-
- Showing {((currentPage - 1) * itemsPerPage) + 1} to {Math.min(currentPage * itemsPerPage, filteredActivities.length)} of {filteredActivities.length} activities -
- -
- - -
- {Array.from({ length: Math.min(totalPages, 5) }, (_, i) => { - const pageNum = Math.max(1, Math.min(currentPage - 2 + i, totalPages - 4 + i)); - return ( - - ); - })} + ) : ( +
    + {visible.slice(0, limit).map(event => ( +
  1. +
    + {event.kind === 'note' ? : }{event.kind === 'note' ? 'Progress note' : event.text} +
    - - -
-
-
+

{event.itemTitle}

+ {event.kind === 'note' &&

{event.text}

} +

Current status: {getStatusConfig(event.status as WorkItemStatus).label}

+ + ))} + )} -
+ {visible.length > limit && } +
); -}; - -export default ActivityFeed; \ No newline at end of file +} diff --git a/packages/web/src/components/AdminUserManagement.tsx b/packages/web/src/components/AdminUserManagement.tsx index 31752e08..15a59fca 100644 --- a/packages/web/src/components/AdminUserManagement.tsx +++ b/packages/web/src/components/AdminUserManagement.tsx @@ -1,4 +1,4 @@ -import React, { useState, useEffect } from 'react'; +import React, { useState, useEffect, useRef } from 'react'; import { useQuery, useMutation } from '@apollo/client'; import { Users, @@ -21,6 +21,7 @@ import { import { CheckCircle } from '../constants/workItemConstants'; import { GET_ALL_USERS, UPDATE_USER_ROLE, RESET_USER_PASSWORD, DELETE_USER, CREATE_USER, UPDATE_USER_STATUS } from '../lib/queries'; import { CustomDropdown } from './CustomDropdown'; +import { useModalA11y } from '../hooks/useModalA11y'; interface User { id: string; @@ -52,6 +53,22 @@ export function AdminUserManagement() { role: 'VIEWER' }); + const createDialogRef = useRef(null); + const deleteDialogRef = useRef(null); + const notificationRef = useRef(null); + useModalA11y(createDialogRef, { isOpen: showCreateUser, label: 'Create new user' }); + useModalA11y(deleteDialogRef, { isOpen: !!deleteConfirmation, label: 'Delete user' }); + useModalA11y(notificationRef, { isOpen: !!notification, label: 'User management result' }); + useEffect(() => { + if (!showCreateUser && !deleteConfirmation && !notification) return; + const close = (event: KeyboardEvent) => { + if (event.key !== 'Escape') return; + setShowCreateUser(false); setDeleteConfirmation(null); setNotification(null); + }; + document.addEventListener('keydown', close); + return () => document.removeEventListener('keydown', close); + }, [showCreateUser, deleteConfirmation, notification]); + const { data, loading, error, refetch } = useQuery(GET_ALL_USERS); const [updateUserRole] = useMutation(UPDATE_USER_ROLE); const [resetPassword] = useMutation(RESET_USER_PASSWORD); @@ -273,7 +290,7 @@ export function AdminUserManagement() { } return ( -
+
{/* Header */}

User Management

@@ -281,8 +298,8 @@ export function AdminUserManagement() {
{/* Role Legend */} -
-

Role Hierarchy

+
+ About roles and permissions
{ROLE_HIERARCHY.map((role) => { const IconComponent = role.icon; @@ -297,7 +314,7 @@ export function AdminUserManagement() { ); })}
-
+ {/* Controls */}
@@ -385,7 +402,7 @@ export function AdminUserManagement() {
{user.name}
{user.email}
-
@{user.username}
+
@{user.username}
@@ -465,12 +482,12 @@ export function AdminUserManagement() {
- Created: + Created: {new Date(user.createdAt).toLocaleDateString()}
{user.deactivationDate && (
- Deactivated: + Deactivated: {new Date(user.deactivationDate).toLocaleDateString()}
)} @@ -483,7 +500,7 @@ export function AdminUserManagement() { disabled={!canDeactivateUser(user)} className={`inline-flex items-center px-2 py-1 border rounded text-xs focus:outline-none focus:ring-1 ${ !canDeactivateUser(user) - ? 'border-gray-600 text-gray-500 cursor-not-allowed' + ? 'border-gray-600 text-gray-400 cursor-not-allowed' : user.isActive ? 'border-orange-600 text-orange-300 hover:text-orange-100 hover:bg-orange-900/20 focus:ring-orange-500' : 'border-green-600 text-green-300 hover:text-green-100 hover:bg-green-900/20 focus:ring-green-500' @@ -522,7 +539,7 @@ export function AdminUserManagement() { className={`inline-flex items-center px-2 py-1 border rounded text-xs focus:outline-none focus:ring-1 ${ canDeleteUser(user) ? 'border-red-600 text-red-300 hover:text-red-100 hover:bg-red-900/20 focus:ring-red-500' - : 'border-gray-600 text-gray-500 cursor-not-allowed' + : 'border-gray-600 text-gray-400 cursor-not-allowed' }`} title={canDeleteUser(user) ? "Delete user" : "Cannot delete the last admin user"} > @@ -538,7 +555,7 @@ export function AdminUserManagement() { {filteredUsers.length === 0 && (
- +

No users found matching your criteria.

)} @@ -548,7 +565,7 @@ export function AdminUserManagement() { {/* Delete Confirmation Modal */} {deleteConfirmation && (
-
+

Delete User

@@ -619,7 +636,7 @@ export function AdminUserManagement() { {/* Notification Modal */} {notification && (
-
+
{notification.type === 'success' ? ( @@ -661,7 +678,7 @@ export function AdminUserManagement() { {/* Create User Modal */} {showCreateUser && (
-
+

Create New User

@@ -675,7 +692,7 @@ export function AdminUserManagement() { value={newUser.email} onChange={(e) => setNewUser(prev => ({ ...prev, email: e.target.value }))} className="w-full px-3 py-2 bg-gray-700 border border-gray-600 rounded-lg text-gray-100 placeholder-gray-400 focus:ring-2 focus:ring-green-500 focus:border-transparent" - placeholder="user@example.com" + aria-label="Email" placeholder="user@example.com" />
@@ -686,7 +703,7 @@ export function AdminUserManagement() { value={newUser.username} onChange={(e) => setNewUser(prev => ({ ...prev, username: e.target.value }))} className="w-full px-3 py-2 bg-gray-700 border border-gray-600 rounded-lg text-gray-100 placeholder-gray-400 focus:ring-2 focus:ring-green-500 focus:border-transparent" - placeholder="username" + aria-label="Username" placeholder="username" />
@@ -697,7 +714,7 @@ export function AdminUserManagement() { value={newUser.name} onChange={(e) => setNewUser(prev => ({ ...prev, name: e.target.value }))} className="w-full px-3 py-2 bg-gray-700 border border-gray-600 rounded-lg text-gray-100 placeholder-gray-400 focus:ring-2 focus:ring-green-500 focus:border-transparent" - placeholder="Full Name" + aria-label="Full name" placeholder="Full Name" />
@@ -708,7 +725,7 @@ export function AdminUserManagement() { value={newUser.password} onChange={(e) => setNewUser(prev => ({ ...prev, password: e.target.value }))} className="w-full px-3 py-2 bg-gray-700 border border-gray-600 rounded-lg text-gray-100 placeholder-gray-400 focus:ring-2 focus:ring-green-500 focus:border-transparent" - placeholder="Initial password" + aria-label="Initial password" placeholder="Initial password" />
diff --git a/packages/web/src/components/CalendarView.tsx b/packages/web/src/components/CalendarView.tsx index 45f220d1..449b2b3b 100644 --- a/packages/web/src/components/CalendarView.tsx +++ b/packages/web/src/components/CalendarView.tsx @@ -53,7 +53,7 @@ const CalendarViewComponent: React.FC = ({ filteredNodes }) = const [typeDropdownOpen, setTypeDropdownOpen] = useState(false); const [statusDropdownOpen, setStatusDropdownOpen] = useState(false); const [searchQuery, setSearchQuery] = useState(''); - + // Filter and group nodes by date const nodesByDate = useMemo(() => { const filtered = filteredNodes.filter(node => { @@ -67,7 +67,7 @@ const CalendarViewComponent: React.FC = ({ filteredNodes }) = return false; } } - + if (!showCompleted && node.status === 'COMPLETED') return false; if (filterPriority !== 'all') { const priority = node.priority || 0; @@ -80,7 +80,7 @@ const CalendarViewComponent: React.FC = ({ filteredNodes }) = }); const grouped: Record = {}; - + filtered.forEach(node => { if (node.dueDate) { const dateKey = new Date(node.dueDate).toDateString(); @@ -89,7 +89,7 @@ const CalendarViewComponent: React.FC = ({ filteredNodes }) = } grouped[dateKey].push(node); } - + // Also add created date for context (only if it has a due date) if (node.dueDate) { const createdKey = new Date(node.createdAt).toDateString(); @@ -99,7 +99,7 @@ const CalendarViewComponent: React.FC = ({ filteredNodes }) = } } }); - + // Sort tasks within each day by priority Object.keys(grouped).forEach(dateKey => { grouped[dateKey].sort((a, b) => { @@ -108,7 +108,7 @@ const CalendarViewComponent: React.FC = ({ filteredNodes }) = return bPriority - aPriority; }); }); - + return grouped; }, [filteredNodes, showCompleted, filterPriority, filterType, filterStatus, searchQuery]); @@ -124,21 +124,21 @@ const CalendarViewComponent: React.FC = ({ filteredNodes }) = const calendarDays = useMemo(() => { const year = currentDate.getFullYear(); const month = currentDate.getMonth(); - + const firstDayOfMonth = new Date(year, month, 1); const lastDayOfMonth = new Date(year, month + 1, 0); const firstCalendarDay = new Date(firstDayOfMonth); firstCalendarDay.setDate(firstCalendarDay.getDate() - firstDayOfMonth.getDay()); - + const days = []; const currentDay = new Date(firstCalendarDay); - + // Generate 42 days (6 weeks) for (let i = 0; i < 42; i++) { days.push(new Date(currentDay)); currentDay.setDate(currentDay.getDate() + 1); } - + return days; }, [currentDate]); @@ -214,13 +214,13 @@ const CalendarViewComponent: React.FC = ({ filteredNodes }) = }; return ( -
+
{/* Header */} -
-
+
+
-

Calendar View

+

Calendar View

- + + {/* Controls */} +
+ {/* View Mode */} +
+ +
+ {(['month', 'week', 'agenda'] as const).map(mode => ( + + ))} +
+
+ +
+ Search & filters {/* Search Bar */}
@@ -251,29 +275,9 @@ const CalendarViewComponent: React.FC = ({ filteredNodes }) = )}
- - {/* Controls */} -
- {/* View Mode */} -
- -
- {(['month', 'week', 'agenda'] as const).map(mode => ( - - ))} -
-
+ +
{/* Type Filter */}
@@ -286,8 +290,8 @@ const CalendarViewComponent: React.FC = ({ filteredNodes }) = const selectedOption = TYPE_OPTIONS.find(opt => opt.value === filterType); return ( <> - {selectedOption?.icon && React.createElement(selectedOption.icon as any, { - className: `h-3 w-3 ${selectedOption.color || 'text-gray-400'}` + {selectedOption?.icon && React.createElement(selectedOption.icon as any, { + className: `h-3 w-3 ${selectedOption.color || 'text-gray-400'}` })} {selectedOption?.label || 'All Types'} @@ -306,8 +310,8 @@ const CalendarViewComponent: React.FC = ({ filteredNodes }) = }} className="w-full px-3 py-2 text-left text-sm text-white hover:bg-gray-600 flex items-center space-x-2 first:rounded-t-lg last:rounded-b-lg" > - {option.icon && React.createElement(option.icon as any, { - className: `h-3 w-3 ${option.color || 'text-gray-400'}` + {option.icon && React.createElement(option.icon as any, { + className: `h-3 w-3 ${option.color || 'text-gray-400'}` })} {option.label} @@ -329,8 +333,8 @@ const CalendarViewComponent: React.FC = ({ filteredNodes }) = const selectedOption = STATUS_OPTIONS.find(opt => opt.value === filterStatus); return ( <> - {selectedOption?.icon && React.createElement(selectedOption.icon as any, { - className: `h-3 w-3 ${selectedOption.color || 'text-gray-400'}` + {selectedOption?.icon && React.createElement(selectedOption.icon as any, { + className: `h-3 w-3 ${selectedOption.color || 'text-gray-400'}` })} {selectedOption?.label || 'All Status'} @@ -349,8 +353,8 @@ const CalendarViewComponent: React.FC = ({ filteredNodes }) = }} className="w-full px-3 py-2 text-left text-sm text-white hover:bg-gray-600 flex items-center space-x-2 first:rounded-t-lg last:rounded-b-lg" > - {option.icon && React.createElement(option.icon as any, { - className: `h-3 w-3 ${option.color || 'text-gray-400'}` + {option.icon && React.createElement(option.icon as any, { + className: `h-3 w-3 ${option.color || 'text-gray-400'}` })} {option.label} @@ -372,8 +376,8 @@ const CalendarViewComponent: React.FC = ({ filteredNodes }) = const selectedOption = PRIORITY_OPTIONS.find(opt => opt.value === filterPriority); return ( <> - {selectedOption?.icon && React.createElement(selectedOption.icon as any, { - className: `h-3 w-3 ${selectedOption.color || 'text-gray-400'}` + {selectedOption?.icon && React.createElement(selectedOption.icon as any, { + className: `h-3 w-3 ${selectedOption.color || 'text-gray-400'}` })} {selectedOption?.label || 'All Priority'} @@ -392,8 +396,8 @@ const CalendarViewComponent: React.FC = ({ filteredNodes }) = }} className="w-full px-3 py-2 text-left text-sm text-white hover:bg-gray-600 flex items-center space-x-2 first:rounded-t-lg last:rounded-b-lg" > - {option.icon && React.createElement(option.icon as any, { - className: `h-3 w-3 ${option.color || 'text-gray-400'}` + {option.icon && React.createElement(option.icon as any, { + className: `h-3 w-3 ${option.color || 'text-gray-400'}` })} {option.label} @@ -403,11 +407,13 @@ const CalendarViewComponent: React.FC = ({ filteredNodes }) =
+
+
{/* Main Content */} -
+
{/* Calendar Header */}
@@ -417,11 +423,11 @@ const CalendarViewComponent: React.FC = ({ filteredNodes }) = > - +

{formatDateRange()}

- +
)}
- + {/* Tasks */}
{dayNodes.slice(0, 4).map((node, nodeIndex) => { const statusConfig = getStatusConfig(node.status as WorkItemStatus); const typeConfig = getTypeConfig(node.type as WorkItemType); const priority = node.priority || 0; - + return (
= ({ filteredNodes }) = setSelectedTask(selectedTask === node.id ? null : node.id); }} className={`text-xs px-2 py-1 rounded-md truncate transition-all cursor-pointer border ${ - selectedTask === node.id - ? `${WORK_ITEM_STATUSES.COMPLETED.borderColor} ${WORK_ITEM_STATUSES.COMPLETED.bgColor}` + selectedTask === node.id + ? `${WORK_ITEM_STATUSES.COMPLETED.borderColor} ${WORK_ITEM_STATUSES.COMPLETED.bgColor}` : `${statusConfig.bgColor} ${statusConfig.color} border-transparent hover:border-gray-400` }`} title={`${node.title}\nType: ${node.type}\nStatus: ${node.status.replace('_', ' ')}\nPriority: ${Math.round(priority * 100)}%\nDue: ${node.dueDate ? new Date(node.dueDate).toLocaleDateString() : 'No due date'}`} @@ -583,7 +589,7 @@ const CalendarViewComponent: React.FC = ({ filteredNodes }) = ); })} {dayNodes.length > 4 && ( -
{ e.stopPropagation(); @@ -607,11 +613,11 @@ const CalendarViewComponent: React.FC = ({ filteredNodes }) =

- {selectedDate.toLocaleDateString('en-US', { - weekday: 'long', - year: 'numeric', - month: 'long', - day: 'numeric' + {selectedDate.toLocaleDateString('en-US', { + weekday: 'long', + year: 'numeric', + month: 'long', + day: 'numeric' })}

- + {nodesByDate[selectedDate.toDateString()]?.length ? (
{nodesByDate[selectedDate.toDateString()].map((node, index) => { @@ -629,7 +635,7 @@ const CalendarViewComponent: React.FC = ({ filteredNodes }) = const typeConfig = getTypeConfig(node.type as WorkItemType); const priority = node.priority || 0; const priorityConfig = getPriorityConfig(priority); - + return (
@@ -669,27 +675,28 @@ const CalendarViewComponent: React.FC = ({ filteredNodes }) =
{/* Footer Statistics */} -
+
+ Summary
{Object.values(nodesByDate).flat().filter(node => { if (!node.dueDate) return false; const dueDate = new Date(node.dueDate); - return dueDate.getMonth() === currentDate.getMonth() && + return dueDate.getMonth() === currentDate.getMonth() && dueDate.getFullYear() === currentDate.getFullYear(); }).length}
This Month
- +
{(nodesByDate[new Date().toDateString()] || []).length}
Due Today
- +
{filteredNodes.filter(node => { @@ -699,21 +706,21 @@ const CalendarViewComponent: React.FC = ({ filteredNodes }) =
Overdue
- +
{filteredNodes.filter(node => node.status === 'COMPLETED').length}
Completed
- +
{filteredNodes.filter(node => node.status === 'IN_PROGRESS').length}
In Progress
- +
{filteredNodes.filter(node => !node.dueDate).length} @@ -721,9 +728,9 @@ const CalendarViewComponent: React.FC = ({ filteredNodes }) =
No Due Date
-
+
); }; -export default CalendarViewComponent; \ No newline at end of file +export default CalendarViewComponent; diff --git a/packages/web/src/components/Dashboard.tsx b/packages/web/src/components/Dashboard.tsx index 0ad8d6d1..cde02216 100644 --- a/packages/web/src/components/Dashboard.tsx +++ b/packages/web/src/components/Dashboard.tsx @@ -1,20 +1,20 @@ import React, { useState } from 'react'; -import { - BarChart3, +import { + BarChart3, Sigma, RotateCcw, ZoomIn, ZoomOut } from 'lucide-react'; -import { - getStatusConfig, - getTypeConfig, +import { + getStatusConfig, + getTypeConfig, getPriorityConfig, getStatusIconElement, getTypeIconElement, getPriorityIconElement, getStatusGradientBackground, - WORK_ITEM_STATUSES, + WORK_ITEM_STATUSES, WORK_ITEM_PRIORITIES, WORK_ITEM_TYPES, type WorkItemStatus, @@ -47,6 +47,7 @@ interface WorkItem { } interface DashboardProps { + onOpenWork: (id: string) => void; filteredNodes: WorkItem[]; stats: { total: number; @@ -82,11 +83,11 @@ const PieChart = ({ data, title }: { data: Array<{label: string, value: number, const filteredData = data.filter(item => item.value > 0); const total = filteredData.reduce((sum, item) => sum + item.value, 0); let cumulativePercentage = 0; - + const handleZoomIn = () => { setZoomLevel(prev => Math.min(prev + 0.1, 2)); }; - + const handleZoomOut = () => { setZoomLevel(prev => Math.max(prev - 0.1, 0.5)); }; @@ -99,7 +100,7 @@ const PieChart = ({ data, title }: { data: Array<{label: string, value: number, const startAngle = startPercentage * 3.6 - 90; const endAngle = (startPercentage + percentage) * 3.6 - 90; const largeArcFlag = percentage > 50 ? 1 : 0; - + const startX = 50 + 40 * Math.cos(startAngle * Math.PI / 180); const startY = 50 + 40 * Math.sin(startAngle * Math.PI / 180); const endX = 50 + 40 * Math.cos(endAngle * Math.PI / 180); @@ -151,26 +152,27 @@ const PieChart = ({ data, title }: { data: Array<{label: string, value: number,
- -
-
+
- {filteredData.map((item, index) => { const percentage = (item.value / total) * 100; const path = createPath(percentage, cumulativePercentage); - + cumulativePercentage += percentage; - + return ( {filteredData.map((item, index) => { @@ -205,13 +207,13 @@ const PieChart = ({ data, title }: { data: Array<{label: string, value: number, if (statusEntry) { return getStatusIconElement(statusEntry[0] as WorkItemStatus, "h-5 w-5"); } - - // Find type by label + + // Find type by label const typeEntry = Object.entries(WORK_ITEM_TYPES).find(([, config]) => config.label === label); if (typeEntry) { return getTypeIconElement(typeEntry[0] as WorkItemType, "h-5 w-5"); } - + // Find priority by label const priorityEntry = Object.entries(WORK_ITEM_PRIORITIES).find(([, config]) => config.label === label); if (priorityEntry) { @@ -220,10 +222,10 @@ const PieChart = ({ data, title }: { data: Array<{label: string, value: number, const priorityValue = priorityEntry[1].threshold.min; return getPriorityIconElement(priorityValue, "h-5 w-5"); } - + return
; }; - + return (
= ({ filteredNodes, stats }) => { +const Dashboard: React.FC = ({ filteredNodes, stats, onOpenWork }) => { return ( -
- {/* Summary strip — compact + content-sized (was a tall, near-empty banner) */} -
- - Total Tasks - {stats.total} -
- - {/* Status breakdown — one compact, dense grid (was 3 sparse rows of 3 with - single-digit numbers in oversized boxes). 2 cols on phones up to 5 on - desktop so the 9 statuses read as a tidy panel, not a wall of whitespace. */} -
-
-
-
- {React.createElement(getStatusConfig('NOT_STARTED').icon!, { className: `h-8 w-8 ${getStatusConfig('NOT_STARTED').color}` })} -
-
-
Not Started
-
{stats.notStarted}
-
-
-
- -
-
-
- {React.createElement(getStatusConfig('PROPOSED').icon!, { className: `h-8 w-8 ${getStatusConfig('PROPOSED').color}` })} -
-
-
Proposed
-
{stats.proposed}
-
-
-
- -
-
-
- {React.createElement(getStatusConfig('PLANNED').icon!, { className: `h-8 w-8 ${getStatusConfig('PLANNED').color}` })} -
-
-
Planned
-
{stats.planned}
-
-
-
-
-
-
- {React.createElement(getStatusConfig('IN_PROGRESS').icon!, { className: `h-8 w-8 ${getStatusConfig('IN_PROGRESS').color}` })} -
-
-
In Progress
-
{stats.inProgress}
-
+
+
+

Your work, at a glance

+

{stats.completed} of {stats.total} work items completed

+ +
+
+ {[ + ['Working', stats.inProgress, 'text-blue-300'], + ['Blocked', stats.blocked, 'text-red-300'], + ['Review', stats.inReview, 'text-amber-300'], + ['Done', stats.completed, 'text-emerald-300'], + ].map(([label, count, color]) => ( +
+

{label}

+

{count}

-
- -
-
-
- {React.createElement(getStatusConfig('IN_REVIEW').icon!, { className: `h-8 w-8 ${getStatusConfig('IN_REVIEW').color}` })} -
-
-
In Review
-
{stats.inReview}
-
-
-
- -
-
-
- {React.createElement(getStatusConfig('BLOCKED').icon!, { className: `h-8 w-8 ${getStatusConfig('BLOCKED').color}` })} -
-
-
Blocked
-
{stats.blocked}
-
-
-
-
-
-
- {React.createElement(getStatusConfig('ON_HOLD').icon!, { className: `h-8 w-8 ${getStatusConfig('ON_HOLD').color}` })} -
-
-
On Hold
-
{stats.onHold}
-
-
-
- -
-
-
- {React.createElement(getStatusConfig('COMPLETED').icon!, { className: `h-8 w-8 ${getStatusConfig('COMPLETED').color}` })} -
-
-
Completed
-
{stats.completed}
-
-
-
- -
-
-
- {React.createElement(getStatusConfig('CANCELLED').icon!, { className: `h-8 w-8 ${getStatusConfig('CANCELLED').color}` })} -
-
-
Cancelled
-
{stats.cancelled}
-
-
-
+ ))}
- + {[ + { title: 'Needs attention', statuses: ['BLOCKED', 'IN_REVIEW'], empty: 'No blockers or review requests right now.' }, + { title: 'In motion', statuses: ['IN_PROGRESS', 'PLANNED'], empty: 'Choose a work item to get started.' }, + ].map(section => { + const work = filteredNodes.filter(item => section.statuses.includes(item.status)) + .sort((a, b) => b.priority - a.priority || a.title.localeCompare(b.title)); + return ( +
+

{section.title}

+ {work.length === 0 ?

{section.empty}

: ( +
    + {work.slice(0, 5).map(item =>
  • + +
  • )} +
+ )} + {work.length > 5 &&

Showing 5 of {work.length}. All work is available in List.

} +
+ ); + })} +
+ Explore distributions {/* Analytics Dashboard */}
{/* Dashboard Header */} @@ -407,13 +334,13 @@ const Dashboard: React.FC = ({ filteredNodes, stats }) => { Pie Charts
- +
{/* Status Distribution */}

Status Distribution

- = ({ filteredNodes, stats }) => {

Priority Distribution

- = ({ filteredNodes, stats }) => {

Node Distribution

- count > 0) @@ -518,9 +445,9 @@ const Dashboard: React.FC = ({ filteredNodes, stats }) => {
- +
); }; -export default Dashboard; \ No newline at end of file +export default Dashboard; diff --git a/packages/web/src/components/GanttChart.tsx b/packages/web/src/components/GanttChart.tsx index 15409a96..dda8c0e0 100644 --- a/packages/web/src/components/GanttChart.tsx +++ b/packages/web/src/components/GanttChart.tsx @@ -66,20 +66,20 @@ const GanttChart: React.FC = ({ filteredNodes }) => { return false; } } - + // Status filter if (filterStatus !== 'all' && node.status !== filterStatus) return false; - + // Priority filter if (filterPriority !== 'all') { const priority = node.priority || 0; const priorityLevel = getPriorityConfig(priority).value; if (priorityLevel !== filterPriority) return false; } - + // Type filter if (filterType !== 'all' && node.type !== filterType) return false; - + return true; }) .map((node, index) => { @@ -88,7 +88,7 @@ const GanttChart: React.FC = ({ filteredNodes }) => { const duration = Math.max(1, Math.ceil((endDate.getTime() - startDate.getTime()) / (1000 * 60 * 60 * 24))); const progress = getStatusCompletionPercentage(node.status as WorkItemStatus); const priority = node.priority || 0; - + return { ...node, startDate, @@ -130,15 +130,15 @@ const GanttChart: React.FC = ({ filteredNodes }) => { const allDates = timelineData.flatMap(item => [item.startDate, item.endDate]); const rawMinDate = new Date(Math.min(...allDates.map(d => d.getTime()))); const rawMaxDate = new Date(Math.max(...allDates.map(d => d.getTime()))); - + // Add padding const minDate = new Date(rawMinDate); minDate.setDate(minDate.getDate() - 7); const maxDate = new Date(rawMaxDate); maxDate.setDate(maxDate.getDate() + 14); - + const totalDays = Math.ceil((maxDate.getTime() - minDate.getTime()) / (1000 * 60 * 60 * 24)) + 1; - + return { minDate, maxDate, totalDays }; }, [timelineData]); @@ -148,7 +148,7 @@ const GanttChart: React.FC = ({ filteredNodes }) => { const timelineHeaders = useMemo(() => { const headers = []; const current = new Date(minDate); - + if (viewMode === 'days') { while (current <= maxDate) { headers.push({ @@ -181,7 +181,7 @@ const GanttChart: React.FC = ({ filteredNodes }) => { current.setMonth(current.getMonth() + 1); } } - + return headers; }, [minDate, maxDate, viewMode]); @@ -206,7 +206,7 @@ const GanttChart: React.FC = ({ filteredNodes }) => { document.addEventListener('mousedown', handleClickOutside); return () => document.removeEventListener('mousedown', handleClickOutside); }, []); - + const getProgressColor = (status: WorkItemStatus) => { return getStatusConfig(status).color.replace('text-', 'bg-'); }; @@ -216,51 +216,24 @@ const GanttChart: React.FC = ({ filteredNodes }) => { const toY = toTask.row * 60 + 30; const fromX = ((fromTask.endDate.getTime() - minDate.getTime()) / (1000 * 60 * 60 * 24) / totalDays) * 100; const toX = ((toTask.startDate.getTime() - minDate.getTime()) / (1000 * 60 * 60 * 24) / totalDays) * 100; - + return `M ${fromX}% ${fromY}px L ${toX}% ${toY}px`; }; return ( -
+
{/* Header */} -
-
+
+
-

Gantt Chart

+

Gantt Chart

-
- - -
-
- - {/* Search Bar */} -
- - setSearchQuery(e.target.value)} - className="w-full pl-10 pr-4 py-2 bg-gray-700 border border-gray-600 rounded-lg text-white placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-green-500 focus:border-transparent" - /> - {searchQuery && ( - - )} +
- + {/* Primary Controls */} -
+
{/* View Mode */}
@@ -269,9 +242,9 @@ const GanttChart: React.FC = ({ filteredNodes }) => {
+
+ Search, filters & sorting + {/* Search Bar */} +
+ + setSearchQuery(e.target.value)} + className="w-full pl-10 pr-4 py-2 bg-gray-700 border border-gray-600 rounded-lg text-white placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-green-500 focus:border-transparent" + /> + {searchQuery && ( + + )} +
+ + {/* Filter Controls Row */}
{/* Type Filter */} @@ -316,8 +312,8 @@ const GanttChart: React.FC = ({ filteredNodes }) => { const selectedOption = TYPE_OPTIONS.find(opt => opt.value === filterType); return ( <> - {selectedOption?.icon && React.createElement(selectedOption.icon as any, { - className: `h-3 w-3 ${selectedOption.color || 'text-gray-400'}` + {selectedOption?.icon && React.createElement(selectedOption.icon as any, { + className: `h-3 w-3 ${selectedOption.color || 'text-gray-400'}` })} {selectedOption?.label || 'All Type'} @@ -336,8 +332,8 @@ const GanttChart: React.FC = ({ filteredNodes }) => { }} className="w-full px-3 py-2 text-left text-sm text-white hover:bg-gray-600 flex items-center space-x-2 first:rounded-t-lg last:rounded-b-lg" > - {option.icon && React.createElement(option.icon as any, { - className: `h-3 w-3 ${option.color || 'text-gray-400'}` + {option.icon && React.createElement(option.icon as any, { + className: `h-3 w-3 ${option.color || 'text-gray-400'}` })} {option.label} @@ -359,8 +355,8 @@ const GanttChart: React.FC = ({ filteredNodes }) => { const selectedOption = STATUS_OPTIONS.find(opt => opt.value === filterStatus); return ( <> - {selectedOption?.icon && React.createElement(selectedOption.icon as any, { - className: `h-3 w-3 ${selectedOption.color || 'text-gray-400'}` + {selectedOption?.icon && React.createElement(selectedOption.icon as any, { + className: `h-3 w-3 ${selectedOption.color || 'text-gray-400'}` })} {selectedOption?.label || 'All Status'} @@ -379,8 +375,8 @@ const GanttChart: React.FC = ({ filteredNodes }) => { }} className="w-full px-3 py-2 text-left text-sm text-white hover:bg-gray-600 flex items-center space-x-2 first:rounded-t-lg last:rounded-b-lg" > - {option.icon && React.createElement(option.icon as any, { - className: `h-3 w-3 ${option.color || 'text-gray-400'}` + {option.icon && React.createElement(option.icon as any, { + className: `h-3 w-3 ${option.color || 'text-gray-400'}` })} {option.label} @@ -402,8 +398,8 @@ const GanttChart: React.FC = ({ filteredNodes }) => { const selectedOption = PRIORITY_OPTIONS.find(opt => opt.value === filterPriority); return ( <> - {selectedOption?.icon && React.createElement(selectedOption.icon as any, { - className: `h-3 w-3 ${selectedOption.color || 'text-gray-400'}` + {selectedOption?.icon && React.createElement(selectedOption.icon as any, { + className: `h-3 w-3 ${selectedOption.color || 'text-gray-400'}` })} {selectedOption?.label || 'All Priority'} @@ -422,8 +418,8 @@ const GanttChart: React.FC = ({ filteredNodes }) => { }} className="w-full px-3 py-2 text-left text-sm text-white hover:bg-gray-600 flex items-center space-x-2 first:rounded-t-lg last:rounded-b-lg" > - {option.icon && React.createElement(option.icon as any, { - className: `h-3 w-3 ${option.color || 'text-gray-400'}` + {option.icon && React.createElement(option.icon as any, { + className: `h-3 w-3 ${option.color || 'text-gray-400'}` })} {option.label} @@ -448,12 +444,13 @@ const GanttChart: React.FC = ({ filteredNodes }) => {
+
{/* Main Content */} -
+
{/* Mobile Schedule List */} -
+
{timelineData.length === 0 ? (
No tasks to display
) : ( @@ -506,8 +503,8 @@ const GanttChart: React.FC = ({ filteredNodes }) => {
{timelineHeaders.map((header, index) => ( -
= ({ filteredNodes }) => { /> - {timelineData.map(task => + {timelineData.map(task => task.dependencies?.map((dep: any) => { const depTask = timelineData.find(t => t.id === dep.id); if (!depTask) return null; - + const fromY = depTask.row * 60 + 30; const toY = task.row * 60 + 30; const fromX = ((depTask.endDate.getTime() - minDate.getTime()) / (1000 * 60 * 60 * 24) / totalDays) * 100; const toX = ((task.startDate.getTime() - minDate.getTime()) / (1000 * 60 * 60 * 24) / totalDays) * 100; - + return ( = ({ filteredNodes }) => { ).flat().filter(Boolean)} )} - + {timelineData.map((item, index) => { const startOffset = Math.floor((item.startDate.getTime() - minDate.getTime()) / (1000 * 60 * 60 * 24)); const barWidth = (item.duration / totalDays) * 100; @@ -584,8 +581,8 @@ const GanttChart: React.FC = ({ filteredNodes }) => { const typeConfig = getTypeConfig(item.type as WorkItemType); return ( -
= ({ filteredNodes }) => {
{/* Priority Indicator */} -
- + {/* Status & Type */}
{React.createElement(typeConfig.icon as any, { className: 'h-3 w-3' })}
- + {/* Task Details */}
{item.title}
@@ -630,7 +627,7 @@ const GanttChart: React.FC = ({ filteredNodes }) => {
{/* Main Task Bar */} -
= ({ filteredNodes }) => { title={`${item.title}\nStart: ${item.startDate.toLocaleDateString()}\nEnd: ${item.endDate.toLocaleDateString()}\nDuration: ${item.duration} days\nProgress: ${item.progress}%`} > {/* Progress Fill */} -
- + {/* Task Label */}
{item.title.length > 20 ? item.title.substring(0, 20) + '...' : item.title}
- + {/* Progress Percentage */}
{item.progress}%
- + {/* Milestone Indicator */} {item.type === 'MILESTONE' && ( -
= ({ filteredNodes }) => { title="Milestone" /> )} - + {/* Overdue Indicator */} {new Date() > item.endDate && item.status !== 'COMPLETED' && (
@@ -690,10 +687,11 @@ const GanttChart: React.FC = ({ filteredNodes }) => {
{/* Footer with Statistics and Legend */} -
+
+ Summary
{/* Statistics */} -
+
{timelineData.length} tasks
@@ -713,9 +711,9 @@ const GanttChart: React.FC = ({ filteredNodes }) => { avg progress
- + {/* Legend */} -
+
Not Started @@ -738,9 +736,9 @@ const GanttChart: React.FC = ({ filteredNodes }) => {
-
+
); }; -export default GanttChart; \ No newline at end of file +export default GanttChart; diff --git a/packages/web/src/components/InteractiveGraphVisualization.tsx b/packages/web/src/components/InteractiveGraphVisualization.tsx index b56dbc22..9dc1a668 100644 --- a/packages/web/src/components/InteractiveGraphVisualization.tsx +++ b/packages/web/src/components/InteractiveGraphVisualization.tsx @@ -1,3 +1,4 @@ +import { QuickEditSurface } from './QuickEditSurface'; import React, { useEffect, useRef, useState, useCallback } from 'react'; import ReactDOM from 'react-dom/client'; import { createPortal } from 'react-dom'; @@ -191,9 +192,11 @@ interface InteractiveGraphVisualizationProps { /** When true (phone "locked" mode), node + edge-label dragging is disabled so * a touch gesture pans the canvas instead of accidentally moving things. */ interactionLocked?: boolean; + quickEditRequest?: WorkItem | null; + onQuickEditRequestHandled?: () => void; } -export function InteractiveGraphVisualization({ onResetLayout, onNodeSelected, interactionLocked }: InteractiveGraphVisualizationProps = {}) { +export function InteractiveGraphVisualization({ onResetLayout, onNodeSelected, interactionLocked, quickEditRequest, onQuickEditRequestHandled }: InteractiveGraphVisualizationProps = {}) { const svgRef = useRef(null); const containerRef = useRef(null); // Live-readable lock flag for d3 drag filters (avoids re-binding handlers on toggle). @@ -445,6 +448,13 @@ export function InteractiveGraphVisualization({ onResetLayout, onNodeSelected, i // field without the heavy details modal. Holds the work item being edited. const [quickEditNode, setQuickEditNode] = useState(null); const quickEditNodeId = quickEditNode?.id ?? null; + useEffect(() => { + if (!quickEditRequest) return; + setInlineEdit(null); + setExpandedNode(null); + setQuickEditNode(quickEditRequest); + onQuickEditRequestHandled?.(); + }, [quickEditRequest, onQuickEditRequestHandled]); // D3 handlers are bound once at init and the init effect intentionally // avoids re-initialising on mode changes — so handlers would capture STALE @@ -2586,7 +2596,7 @@ export function InteractiveGraphVisualization({ onResetLayout, onNodeSelected, i .style('cursor', 'pointer') .style('opacity', (currentTransform?.k || 1) >= LOD_THRESHOLDS.FAR ? 0.85 : 0) .style('pointer-events', 'all'); - editIcons.append('title').text('Open details / edit'); // native hover tooltip + editIcons.append('title').text('Quick edit'); // native hover tooltip // Edit icon background - scales with icon const editBg = editIcons.append('rect') @@ -2655,11 +2665,9 @@ export function InteractiveGraphVisualization({ onResetLayout, onNodeSelected, i event.stopPropagation(); event.preventDefault(); - // Set selected node and show details modal for editing - setSelectedNode(d); - lastSelectedNodeRef.current = d; // Track last selected - applyNodeGlowImmediately(d); - setShowNodeDetailsModal(true); + setInlineEdit(null); + setExpandedNode(null); + setQuickEditNode(d); }); // Relationship creation icon in title bar (centered vertically, right side) - mirrors edit icon @@ -3481,7 +3489,17 @@ export function InteractiveGraphVisualization({ onResetLayout, onNodeSelected, i return; } - // Regular node clicks (right-click menu) + // Some touch browsers deliver a compatibility click after D3 consumes + // touchstart. Keep that path identical to a direct touchend tap. + const pointer = event as MouseEvent & { pointerType?: string; sourceCapabilities?: { firesTouchEvents?: boolean } }; + if ((pointer.pointerType === 'touch' || pointer.sourceCapabilities?.firesTouchEvents) && !isConnectingRef.current && !mousedownNodeRef.current) { + event.stopPropagation(); + setInlineEdit(null); + setExpandedNode(null); + setQuickEditNode(d); + return; + } + // Regular mouse selection. setTimeout(() => { if (!mousedownNodeRef.current) { handleNodeClick(event, d); @@ -3633,7 +3651,18 @@ export function InteractiveGraphVisualization({ onResetLayout, onNodeSelected, i const duration = Date.now() - touchData.startTime; if (isTap(duration, touchData.moved ? 999 : 0)) { event.preventDefault(); - handleNodeClick(event as unknown as MouseEvent, d); + const control = (event.target as Element).closest('.node-edit-icon, .node-relationship-icon, .node-expand-icon, .node-descend-icon'); + if (control) { + // Dispatch the icon's own action once; the suppressed synthetic + // mouse event must not turn a grow/expand tap into body editing. + control.dispatchEvent(new MouseEvent('click', { bubbles: true, cancelable: true, clientX: event.changedTouches[0]?.clientX, clientY: event.changedTouches[0]?.clientY })); + } else if (isConnectingRef.current) { + handleNodeClick(event as unknown as MouseEvent, d); + } else { + setInlineEdit(null); + setExpandedNode(null); + setQuickEditNode(d); + } } delete (element.node() as any).__touchData; } @@ -4117,7 +4146,7 @@ export function InteractiveGraphVisualization({ onResetLayout, onNodeSelected, i // false if it couldn't yet (no svg/container, or the simulation has no // positioned nodes) — the once-per-graph framing polls on this so a slow data // load doesn't leave the graph pinned off-screen (the live-guest bug). - const fitViewToNodes = useCallback((): boolean => { + const fitViewToNodes = useCallback((animate = true): boolean => { if (!svgRef.current || !containerRef.current) return false; // Bounds must come from the LIVE simulation objects (the React-state // nodes are different objects since the identity merge and may hold @@ -4159,7 +4188,8 @@ export function InteractiveGraphVisualization({ onResetLayout, onNodeSelected, i // Use the component's own zoom behavior so handlers fire and state stays // coherent; animate so the user keeps spatial context. if (zoomBehaviorRef.current) { - svg.transition().duration(450).call(zoomBehaviorRef.current.transform as any, transform); + if (animate) svg.transition().duration(450).call(zoomBehaviorRef.current.transform as any, transform); + else svg.interrupt().call(zoomBehaviorRef.current.transform as any, transform); } else { svg.call(d3.zoom().transform as any, transform); } @@ -4552,6 +4582,13 @@ export function InteractiveGraphVisualization({ onResetLayout, onNodeSelected, i let timer: ReturnType | null = null; let tries = 0; + let lastPositions = new Map(); + let stableSamples = 0; + // Once a person touches the canvas, preserve their camera choice. + const canvas = containerRef.current; + const stopFraming = () => fittedGraphsRef.current.add(gid); + canvas?.addEventListener('pointerdown', stopFraming); + canvas?.addEventListener('wheel', stopFraming, { passive: true }); const MAX_TRIES = 60; // ~15s at 250ms — covers a cold Worker/D1 first paint const attempt = () => { @@ -4574,7 +4611,17 @@ export function InteractiveGraphVisualization({ onResetLayout, onNodeSelected, i ); ok = true; } else { - ok = fitViewRef.current(); // false until the sim has positioned nodes + const liveNodes = (simulationRef.current?.nodes() || []) as { id: string; x: number; y: number }[]; + const stable = liveNodes.length > 0 && liveNodes.every(node => { + const previous = lastPositions.get(node.id); + return previous && Math.hypot(node.x - previous.x, node.y - previous.y) < 1; + }); + stableSamples = stable ? stableSamples + 1 : 0; + lastPositions = new Map(liveNodes.map(node => [node.id, { x: node.x, y: node.y }])); + // Frame the moving layout too: fitting once mid-settle can strand all + // nodes below a short landscape viewport after the forces move them. + const framed = fitViewRef.current(false); + ok = framed && stableSamples >= 4; } } @@ -4591,7 +4638,11 @@ export function InteractiveGraphVisualization({ onResetLayout, onNodeSelected, i // Small initial delay so the first simulation build/tick has run. timer = setTimeout(attempt, 300); - return () => { if (timer) clearTimeout(timer); }; + return () => { + if (timer) clearTimeout(timer); + canvas?.removeEventListener('pointerdown', stopFraming); + canvas?.removeEventListener('wheel', stopFraming); + }; }, [hasNodes, currentGraphId]); // PR-3: keep the expand-in-place panel glued to its node through drags, ticks @@ -5084,26 +5135,29 @@ export function InteractiveGraphVisualization({ onResetLayout, onNodeSelected, i aria-describedby="graph-a11y-desc" />

- Force-directed graph of work items and their dependencies. Use the view switcher to see the same data as a list, table, or board, which are keyboard-navigable. + Force-directed graph of work items and their dependencies. Double-click with a mouse or tap an item to edit it. Use the view switcher to see the same data as a list, table, or board, which are keyboard-navigable.

{/* Empty State Overlay */} {showEmptyStateOverlay && ( -
-
+
+
🌱

- Transform Your Vision + Start with one idea

- Break free from rigid hierarchies. Create your first work item and experience how GraphDone intelligently connects ideas, surfaces priorities, and accelerates meaningful outcomes. + Add a work item, give it a name, then use + to grow a connected idea.
} +
@@ -120,7 +122,7 @@ function ModeBtn({ active, onClick, icon, label, disabled, title }: { active: bo onClick={onClick} disabled={disabled} title={title} - className={`flex-1 flex items-center justify-center gap-1.5 px-2 py-1.5 rounded-lg text-xs font-medium transition-colors ${ + className={`flex-1 flex items-center justify-center gap-1.5 min-h-[44px] px-2 py-1.5 rounded-lg text-sm font-medium transition-colors ${ disabled ? 'text-gray-600 cursor-not-allowed' : active diff --git a/packages/web/src/components/NodeQuickEdit.tsx b/packages/web/src/components/NodeQuickEdit.tsx index 1ca32bb6..10779413 100644 --- a/packages/web/src/components/NodeQuickEdit.tsx +++ b/packages/web/src/components/NodeQuickEdit.tsx @@ -86,48 +86,48 @@ export function NodeQuickEdit({ node, onCommit, onClose, rootTestId = 'node-quic return (
e.stopPropagation()} onDoubleClick={(e) => e.stopPropagation()} > -
- {typeCfg.label} - Quick edit -
-
+
{/* Title */} {/* Description */}