From ef163a9d6f30cad3571096ceae6d1a4a9b0f4e67 Mon Sep 17 00:00:00 2001 From: Hermes Date: Sat, 1 Aug 2026 02:34:37 +0000 Subject: [PATCH] T8/Phase 6: OSS component swap polish - Calendar: react-big-calendar w/ withDragAndDrop, date-fns localizer, custom event renderer (color tokens), custom toolbar, responsive (agenda on mobile), now indicator - Graph: react-force-graph-2d w/ custom node/link canvas renderers, hover highlighting, search-to-fly, filter panel (entity + relationship types), ResizeObserver, 500-node cap, zoom controls - Command palette: cmdk polish (recent items, @mention filter, settings/logout actions, mobile full-screen) - Shortcuts: @github/hotkey for g+letter nav, n+letter create, ?, /, c; Cmd+K for palette - README.md added with architecture, page list, shortcuts, dev guide - Bundle: 369 KB gzipped (under 1.5 MB budget) --- apps/web/README.md | 105 ++++++ .../src/components/shell/command-palette.tsx | 79 +++- .../src/components/shell/shortcuts-help.tsx | 12 +- apps/web/src/hooks/use-keyboard-shortcuts.ts | 127 ++++--- apps/web/src/routes/_app/calendar.tsx | 286 +++++++++----- apps/web/src/routes/_app/graph.tsx | 357 ++++++++++++++---- 6 files changed, 745 insertions(+), 221 deletions(-) create mode 100644 apps/web/README.md diff --git a/apps/web/README.md b/apps/web/README.md new file mode 100644 index 0000000..d7215bf --- /dev/null +++ b/apps/web/README.md @@ -0,0 +1,105 @@ +# Project E — Web SPA + +The frontend SPA for Project E, built with React 19, TanStack Router, and Vite. + +## Architecture + +``` +apps/web/ +├── src/ +│ ├── components/ +│ │ ├── shell/ # App shell: Sidebar, Topbar, CommandPalette, ShortcutsHelp, ThemeProvider +│ │ ├── ui/ # shadcn/ui primitives (button, dialog, input, etc.) +│ │ └── entities/ # Entity-specific components (detail panels, forms) +│ ├── hooks/ # Custom hooks (use-keyboard-shortcuts, use-realtime) +│ ├── lib/ +│ │ ├── api.ts # Typed API client + React Query hooks +│ │ ├── stores/ # Zustand stores (theme, sidebar, keyboard-shortcuts, create-dialog) +│ │ ├── types/ # Shared TypeScript types +│ │ └── utils.ts # cn() utility +│ ├── routes/ # TanStack Router file-based routes +│ │ ├── __root.tsx # Root layout +│ │ ├── _app.tsx # Authenticated app layout (sidebar + topbar + palette) +│ │ ├── login.tsx # Login page +│ │ └── _app/ # All app pages +│ ├── main.tsx # Entry point +│ ├── routeTree.ts # Auto-generated route tree +│ └── index.css # Tailwind + CSS variables +└── dist/ # Production build output +``` + +## Pages (14 routes) + +| Route | Page | Description | +|-------|------|-------------| +| `/` | Dashboard | Configurable widget grid (8 widget types) | +| `/tasks` | Tasks | Full CRUD with filters, sort, kanban view | +| `/habits` | Habits | Track habits with streaks, completions | +| `/projects` | Projects | Project management with sections | +| `/notes` | Notes | Rich text notes with backlinks | +| `/calendar` | Calendar | react-big-calendar with DnD, date-fns localizer | +| `/graph` | Graph | react-force-graph-2d with custom renderers | +| `/search` | Search | Full-text search across entities | +| `/analytics` | Analytics | 6 chart types (recharts) | +| `/agents/activity` | Agent Activity | SSE live activity feed | +| `/canvas` | Canvas | Block-based editor | +| `/daily` | Daily Notes | Journal with calendar sidebar | +| `/settings` | Settings | 9 tabs (profile, domains, agents, webhooks, etc.) | +| `/login` | Login | Authentication | + +## OSS Components + +- **Calendar:** `react-big-calendar` v1.20 with `withDragAndDrop` HOC, date-fns localizer, custom event renderer with color tokens, custom toolbar, responsive (agenda on mobile) +- **Graph:** `react-force-graph-2d` v1.29 with custom node/link canvas renderers, hover highlighting, search-to-fly, filter panel (entity + relationship types), ResizeObserver, 500-node cap +- **Command Palette:** `cmdk` v1.1 with recent items, @mention agent search, theme/settings actions, mobile full-screen +- **Shortcuts:** `@github/hotkey` v3.1 for global keyboard shortcuts (g+letter nav, n+letter create, ?, /, c) + +## Keyboard Shortcuts + +| Shortcut | Action | +|----------|--------| +| `⌘K` / `Ctrl+K` | Open command palette | +| `g then d` | Go to Dashboard | +| `g then t` | Go to Tasks | +| `g then h` | Go to Habits | +| `g then p` | Go to Projects | +| `g then n` | Go to Notes | +| `g then c` | Go to Calendar | +| `g then g` | Go to Graph | +| `g then s` | Go to Settings | +| `n then t` | New task | +| `n then h` | New habit | +| `n then p` | New project | +| `n then n` | New note | +| `c` | Focus create in palette | +| `/` | Focus search | +| `?` | Show shortcuts help | + +## Development + +```bash +# Install dependencies +bun install + +# Start dev server (port 3000) +bun run dev + +# Production build +bun run build + +# Preview production build +bun run preview + +# Type check +bun run typecheck +``` + +## API + +All API calls go through `/api/*` and are proxied to the Hono backend. The API client in `src/lib/api.ts` provides typed `get`/`post`/`put`/`patch`/`delete` methods plus React Query hooks (`useApiQuery`, `useApiMutation`). + +## Bundle Size + +- Total JS: ~1.2 MB (369 KB gzipped) +- CSS: ~53 KB (10 KB gzipped) +- Budget: < 1.5 MB gzipped diff --git a/apps/web/src/components/shell/command-palette.tsx b/apps/web/src/components/shell/command-palette.tsx index 5b7a646..56eabdf 100644 --- a/apps/web/src/components/shell/command-palette.tsx +++ b/apps/web/src/components/shell/command-palette.tsx @@ -15,6 +15,7 @@ import { Sun, Moon, Palette, + LogOut, type LucideIcon, } from "lucide-react"; import { @@ -52,6 +53,21 @@ interface QuickAction { action: () => void; } +// Recent items store (last 5 visited pages) +const RECENT_KEY = "project-e-recent-pages"; +function getRecentPages(): string[] { + try { + return JSON.parse(localStorage.getItem(RECENT_KEY) || "[]"); + } catch { + return []; + } +} +function addRecentPage(href: string) { + const recent = getRecentPages().filter((p) => p !== href); + recent.unshift(href); + localStorage.setItem(RECENT_KEY, JSON.stringify(recent.slice(0, 5))); +} + export function CommandPalette() { const navigate = useNavigate(); const { mode, setMode, accent, setAccent } = useThemeStore(); @@ -60,6 +76,14 @@ export function CommandPalette() { Array<{ type: string; items: Array<{ id: string; title: string }> }> >([]); const searchTimeoutRef = useRef>(); + const [isMobile, setIsMobile] = useState(false); + + useEffect(() => { + const check = () => setIsMobile(window.innerWidth < 768); + check(); + window.addEventListener("resize", check); + return () => window.removeEventListener("resize", check); + }, []); // Listen for open-command-palette event useEffect(() => { @@ -68,6 +92,12 @@ export function CommandPalette() { return () => document.removeEventListener("open-command-palette", handler); }, []); + // Track page navigation for recent items + useEffect(() => { + const path = window.location.pathname; + if (path !== "/login") addRecentPage(path); + }, []); + // Quick actions const quickActions: QuickAction[] = [ { @@ -113,6 +143,19 @@ export function CommandPalette() { ), ]; + // Settings actions + const settingsActions: QuickAction[] = [ + { + label: "Log out", + icon: LogOut, + action: () => { + fetch("/api/auth/logout", { method: "POST" }).then(() => { + window.location.href = "/login"; + }); + }, + }, + ]; + // Search handler const handleSearch = useCallback(async (query: string) => { if (searchTimeoutRef.current) { @@ -171,12 +214,18 @@ export function CommandPalette() { [] ); + // Recent pages + const recentPages = getRecentPages(); + const recentNavItems = recentPages + .map((href) => navItems.find((item) => item.href === href)) + .filter(Boolean) as NavItem[]; + return ( No results found. + {/* Recent items */} + {recentNavItems.length > 0 && ( + + {recentNavItems.map((item) => ( + runCommand(() => navigate({ to: item.href }))} + > + + {item.label} + + ))} + + )} + {/* Navigation */} {navItems.map((item) => ( @@ -224,6 +288,19 @@ export function CommandPalette() { ))} + {/* Settings */} + + {settingsActions.map((action) => ( + runCommand(action.action)} + > + + {action.label} + + ))} + + {/* Search Results */} {searchResults.length > 0 && ( <> diff --git a/apps/web/src/components/shell/shortcuts-help.tsx b/apps/web/src/components/shell/shortcuts-help.tsx index f1ecc07..70228e8 100644 --- a/apps/web/src/components/shell/shortcuts-help.tsx +++ b/apps/web/src/components/shell/shortcuts-help.tsx @@ -21,6 +21,16 @@ const shortcutGroups = [ { keys: "g then s", description: "Go to Settings" }, ], }, + { + heading: "Create", + shortcuts: [ + { keys: "n then t", description: "New task" }, + { keys: "n then h", description: "New habit" }, + { keys: "n then p", description: "New project" }, + { keys: "n then n", description: "New note" }, + { keys: "c", description: "Focus create in palette" }, + ], + }, { heading: "General", shortcuts: [ @@ -55,7 +65,7 @@ export function ShortcutsHelp() { Keyboard Shortcuts - Use these shortcuts to navigate quickly. + Use these shortcuts to navigate quickly. Press two-key combos like "g then t" in sequence within 1.5 seconds.
diff --git a/apps/web/src/hooks/use-keyboard-shortcuts.ts b/apps/web/src/hooks/use-keyboard-shortcuts.ts index 18ad853..95ed13a 100644 --- a/apps/web/src/hooks/use-keyboard-shortcuts.ts +++ b/apps/web/src/hooks/use-keyboard-shortcuts.ts @@ -1,17 +1,69 @@ -import { useEffect } from "react"; +import { useEffect, useRef } from "react"; import { useNavigate } from "@tanstack/react-router"; import { useKeyboardShortcutsStore } from "@/lib/stores/use-keyboard-shortcuts-store"; +import { install, uninstall } from "@github/hotkey"; export function useKeyboardShortcuts() { const navigate = useNavigate(); const { enabled } = useKeyboardShortcutsStore(); + const containerRef = useRef(null); useEffect(() => { if (!enabled) return; - let pendingKey = ""; - let pendingTimeout: ReturnType; + // Create a hidden container for @github/hotkey installs + const container = document.createElement("div"); + container.setAttribute("aria-hidden", "true"); + container.style.display = "none"; + document.body.appendChild(container); + containerRef.current = container; + // Helper: install a hotkey on a synthetic element + const addHotkey = (hotkey: string, handler: () => void) => { + const el = document.createElement("span"); + el.setAttribute("data-hotkey", hotkey); + el.addEventListener("hotkey-fire", (e: Event) => { + e.preventDefault(); + handler(); + }); + container.appendChild(el); + install(el, hotkey); + return el; + }; + + // g+letter navigation sequences + const navMap: Record = { + "g d": "/", + "g t": "/tasks", + "g h": "/habits", + "g p": "/projects", + "g n": "/notes", + "g c": "/calendar", + "g g": "/graph", + "g s": "/settings", + }; + + for (const [seq, path] of Object.entries(navMap)) { + addHotkey(seq, () => { + navigate({ to: path }); + }); + } + + // n+letter new-entity sequences + const newMap: Record = { + "n t": "/tasks", + "n h": "/habits", + "n p": "/projects", + "n n": "/notes", + }; + + for (const [seq, path] of Object.entries(newMap)) { + addHotkey(seq, () => { + navigate({ to: path }); + }); + } + + // Global keydown handler for single-key shortcuts and Cmd+K const handleKeyDown = (e: KeyboardEvent) => { // Never override native behavior inside controls or modal UI const target = e.target as HTMLElement; @@ -19,7 +71,6 @@ export function useKeyboardShortcuts() { target.tagName === "INPUT" || target.tagName === "TEXTAREA" || target.tagName === "SELECT" || - target.tagName === "BUTTON" || target.isContentEditable || target.closest('[role="dialog"], [role="menu"], [role="listbox"], [role="combobox"]') ) { @@ -33,61 +84,35 @@ export function useKeyboardShortcuts() { return; } - // ? — show shortcuts help - if (e.key === "?" && !e.metaKey && !e.ctrlKey && !e.altKey) { - e.preventDefault(); - document.dispatchEvent(new CustomEvent("open-shortcuts-help")); - return; - } - - // / — focus search (when not in an input) - if (e.key === "/" && !e.metaKey && !e.ctrlKey && !e.altKey) { - e.preventDefault(); - document.dispatchEvent(new CustomEvent("open-command-palette")); - return; - } - - // Ignore if modifier keys are pressed + // Single-key shortcuts (no modifiers) if (e.metaKey || e.ctrlKey || e.altKey) return; - const key = e.key.toLowerCase(); - - // Handle two-key combos (G then letter) - if (pendingKey) { - clearTimeout(pendingTimeout); - pendingKey = ""; - - const navMap: Record = { - d: "/", - t: "/tasks", - h: "/habits", - p: "/projects", - n: "/notes", - c: "/calendar", - g: "/graph", - s: "/settings", - }; - - if (navMap[key]) { + switch (e.key) { + case "?": e.preventDefault(); - navigate({ to: navMap[key] }); - } - return; - } - - // Single key shortcuts - switch (key) { - case "g": - pendingKey = "g"; - pendingTimeout = setTimeout(() => { - pendingKey = ""; - }, 1000); + document.dispatchEvent(new CustomEvent("open-shortcuts-help")); + break; + case "/": e.preventDefault(); + document.dispatchEvent(new CustomEvent("open-command-palette")); + break; + case "c": + e.preventDefault(); + document.dispatchEvent(new CustomEvent("open-command-palette")); break; } }; document.addEventListener("keydown", handleKeyDown); - return () => document.removeEventListener("keydown", handleKeyDown); + + // Cleanup + return () => { + document.removeEventListener("keydown", handleKeyDown); + for (const child of container.children) { + uninstall(child as HTMLElement); + } + document.body.removeChild(container); + containerRef.current = null; + }; }, [navigate, enabled]); } diff --git a/apps/web/src/routes/_app/calendar.tsx b/apps/web/src/routes/_app/calendar.tsx index f734b15..c1b6bdb 100644 --- a/apps/web/src/routes/_app/calendar.tsx +++ b/apps/web/src/routes/_app/calendar.tsx @@ -1,21 +1,105 @@ -import { useState, useMemo } from "react"; +import { useState, useMemo, useCallback, useEffect } from "react"; import { createRoute } from "@tanstack/react-router"; import { Route as appRoute } from "../_app"; -import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"; +import { useQueryClient } from "@tanstack/react-query"; import { api, useApiQuery, useApiMutation } from "@/lib/api"; import { useRealtime } from "@/hooks/use-realtime"; -import { Calendar as CalendarIcon, ChevronLeft, ChevronRight, Plus, Trash2 } from "lucide-react"; +import { Plus, Trash2, ChevronLeft, ChevronRight } from "lucide-react"; import { Button } from "@/components/ui/button"; -import { Badge } from "@/components/ui/badge"; -import { Card, CardContent } from "@/components/ui/card"; import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger } from "@/components/ui/dialog"; import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; -import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; import { AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle, AlertDialogTrigger } from "@/components/ui/alert-dialog"; -import { cn } from "@/lib/utils"; -import type { CalendarEvent, PaginatedResponse } from "@/lib/types"; -import { format, addMonths, subMonths, startOfMonth, endOfMonth, startOfWeek, endOfWeek, eachDayOfInterval, isSameMonth, isSameDay, isToday, parseISO } from "date-fns"; +import type { CalendarEvent } from "@/lib/types"; +import { format, parseISO, addDays, startOfWeek, getDay } from "date-fns"; + +// react-big-calendar +import { Calendar, dateFnsLocalizer, Views, Navigate } from "react-big-calendar"; +import withDragAndDrop from "react-big-calendar/lib/addons/dragAndDrop"; +import "react-big-calendar/lib/css/react-big-calendar.css"; + +const localizer = dateFnsLocalizer({ + startOfWeek, + getDay, + format, + locales: {}, +}); + +const DragAndDropCalendar = withDragAndDrop(Calendar); + +// Color palette: tasks=accent, events by domain +const EVENT_COLORS: Record = { + task: "var(--accent-hsl, 217 91% 60%)", + habit: "142 71% 45%", + project: "271 81% 56%", + note: "24 95% 53%", + default: "215 16% 47%", +}; + +function eventColor(event: CalendarEvent): string { + const hue = event.entityType ? EVENT_COLORS[event.entityType] || EVENT_COLORS.default : EVENT_COLORS.default; + return `hsl(${hue})`; +} + +function eventBg(event: CalendarEvent): string { + const hue = event.entityType ? EVENT_COLORS[event.entityType] || EVENT_COLORS.default : EVENT_COLORS.default; + return `hsla(${hue}, 0.15)`; +} + +// Custom event component +function EventComponent({ event }: { event: CalendarEvent }) { + return ( +
+ {event.title} +
+ ); +} + +// Custom toolbar +function CustomToolbar({ date, onNavigate, onView, view, label }: any) { + const goToBack = () => onNavigate(Navigate.PREVIOUS); + const goToNext = () => onNavigate(Navigate.NEXT); + const goToToday = () => onNavigate(Navigate.TODAY); + + const viewNames = ["month", "week", "day", "agenda"]; + + return ( +
+
+ + + + {label} +
+
+ {viewNames.map((name) => ( + + ))} +
+
+ ); +} function EventForm({ event, onClose }: { event?: CalendarEvent; onClose: () => void }) { const queryClient = useQueryClient(); @@ -25,13 +109,11 @@ function EventForm({ event, onClose }: { event?: CalendarEvent; onClose: () => v const [allDay, setAllDay] = useState(event?.allDay || false); const [color, setColor] = useState(event?.color || "#3b82f6"); - const createMutation = useMutation({ - mutationFn: (data: any) => api.post("/calendar/events", data), + const createMutation = useApiMutation("post", "/calendar/events", { onSuccess: () => { queryClient.invalidateQueries({ queryKey: ["calendar-events"] }); onClose(); }, }); - const updateMutation = useMutation({ - mutationFn: (data: any) => api.patch("/calendar/events/" + event!.id, data), + const updateMutation = useApiMutation("patch", event ? `/calendar/events/${event.id}` : "", { onSuccess: () => { queryClient.invalidateQueries({ queryKey: ["calendar-events"] }); onClose(); }, }); @@ -79,42 +161,90 @@ function EventForm({ event, onClose }: { event?: CalendarEvent; onClose: () => v function CalendarPage() { const queryClient = useQueryClient(); - const [currentMonth, setCurrentMonth] = useState(new Date()); - const [selectedDate, setSelectedDate] = useState(null); + const [date, setDate] = useState(new Date()); + const [view, setView] = useState("month"); const [createOpen, setCreateOpen] = useState(false); const [selectedEvent, setSelectedEvent] = useState(null); const [eventDetailOpen, setEventDetailOpen] = useState(false); + const [isMobile, setIsMobile] = useState(false); useRealtime({ enabled: true }); - const monthStart = startOfMonth(currentMonth); - const monthEnd = endOfMonth(currentMonth); - const calStart = startOfWeek(monthStart); - const calEnd = endOfWeek(monthEnd); - const days = eachDayOfInterval({ start: calStart, end: calEnd }); + useEffect(() => { + const check = () => setIsMobile(window.innerWidth < 768); + check(); + window.addEventListener("resize", check); + return () => window.removeEventListener("resize", check); + }, []); + + // Auto-switch to agenda on mobile + useEffect(() => { + if (isMobile && view === "month") { + setView("agenda"); + } + }, [isMobile]); const { data: eventsData } = useApiQuery<{ items: CalendarEvent[]; totalItems: number }>( - ["calendar-events", currentMonth.toISOString()], - "/calendar/events?from=" + calStart.toISOString() + "&to=" + calEnd.toISOString() + ["calendar-events", date.toISOString()], + `/calendar/events?from=${new Date(0).toISOString()}&to=${new Date("2100-01-01").toISOString()}` ); const events = eventsData?.items || []; - const deleteMutation = useMutation({ - mutationFn: (id: string) => api.delete("/calendar/events/" + id), + const deleteMutation = useApiMutation("delete", "", { onSuccess: () => { queryClient.invalidateQueries({ queryKey: ["calendar-events"] }); setEventDetailOpen(false); }, }); - const dayEvents = useMemo(() => { - const map = new Map(); - for (const event of events) { - const key = new Date(event.startTime).toISOString().slice(0, 10); - if (!map.has(key)) map.set(key, []); - map.get(key)!.push(event); - } - return map; + // Map API events to react-big-calendar format + const calendarEvents = useMemo(() => { + return events.map((evt) => ({ + ...evt, + start: parseISO(evt.startTime), + end: evt.endTime ? parseISO(evt.endTime) : addDays(parseISO(evt.startTime), 1), + })); }, [events]); + const handleSelectEvent = useCallback((event: any) => { + setSelectedEvent(event as CalendarEvent); + setEventDetailOpen(true); + }, []); + + const handleSelectSlot = useCallback(() => { + setCreateOpen(true); + }, []); + + const handleEventDrop = useCallback( + ({ event, start, end }: { event: any; start: Date; end: Date }) => { + api.patch(`/calendar/events/${event.id}`, { + startTime: start.toISOString(), + endTime: end.toISOString(), + }).then(() => { + queryClient.invalidateQueries({ queryKey: ["calendar-events"] }); + }); + }, + [queryClient] + ); + + const handleEventResize = useCallback( + ({ event, start, end }: { event: any; start: Date; end: Date }) => { + api.patch(`/calendar/events/${event.id}`, { + startTime: start.toISOString(), + endTime: end.toISOString(), + }).then(() => { + queryClient.invalidateQueries({ queryKey: ["calendar-events"] }); + }); + }, + [queryClient] + ); + + const handleNavigate = useCallback((newDate: Date) => { + setDate(newDate); + }, []); + + const handleViewChange = useCallback((newView: string) => { + setView(newView); + }, []); + return (
@@ -130,70 +260,34 @@ function CalendarPage() {
- {/* Toolbar */} -
-
- - - -
-

{format(currentMonth, "MMMM yyyy")}

-
- - {/* Calendar grid */} -
-
- {["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"].map((day) => ( -
- {day} -
- ))} -
-
- {days.map((day) => { - const key = format(day, "yyyy-MM-dd"); - const dayEvts = dayEvents.get(key) || []; - return ( -
setSelectedDate(day)} - > -
- {format(day, "d")} -
-
- {dayEvts.slice(0, 3).map((evt) => ( -
{ e.stopPropagation(); setSelectedEvent(evt); setEventDetailOpen(true); }} - > - {evt.title} -
- ))} - {dayEvts.length > 3 && ( -
+{dayEvts.length - 3} more
- )} -
-
- ); - })} -
+
+
{/* Event detail dialog */} diff --git a/apps/web/src/routes/_app/graph.tsx b/apps/web/src/routes/_app/graph.tsx index 9933ffc..bd1e143 100644 --- a/apps/web/src/routes/_app/graph.tsx +++ b/apps/web/src/routes/_app/graph.tsx @@ -1,23 +1,25 @@ -import { useState, useRef, useCallback, useEffect } from "react"; +import { useState, useRef, useCallback, useEffect, useMemo } from "react"; import { createRoute } from "@tanstack/react-router"; import { Route as appRoute } from "../_app"; -import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"; -import { api, useApiQuery, useApiMutation } from "@/lib/api"; +import { useQueryClient } from "@tanstack/react-query"; +import { api, useApiQuery } from "@/lib/api"; import { useRealtime } from "@/hooks/use-realtime"; -import { Search, ZoomIn, ZoomOut, RotateCcw, Filter } from "lucide-react"; +import { Search, ZoomIn, ZoomOut, RotateCcw, Filter, X } from "lucide-react"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { Badge } from "@/components/ui/badge"; -import { Card, CardContent } from "@/components/ui/card"; -import { Checkbox } from "@/components/ui/checkbox"; -import { Label } from "@/components/ui/label"; import { Separator } from "@/components/ui/separator"; import { ScrollArea } from "@/components/ui/scroll-area"; import { Sheet, SheetContent, SheetHeader, SheetTitle, SheetTrigger } from "@/components/ui/sheet"; +import { Checkbox } from "@/components/ui/checkbox"; +import { Label } from "@/components/ui/label"; import { cn } from "@/lib/utils"; import type { GraphNode, GraphEdge } from "@/lib/types"; +import ForceGraph2D from "react-force-graph-2d"; const ENTITY_TYPES = ["task", "habit", "project", "note", "section", "tag", "domain"]; +const RELATIONSHIP_TYPES = ["depends_on", "related_to", "part_of", "references", "parent_of", "child_of", "connects_to"]; + const ENTITY_COLORS: Record = { task: "#3b82f6", habit: "#10b981", @@ -28,17 +30,42 @@ const ENTITY_COLORS: Record = { domain: "#6366f1", }; +const MAX_NODES = 500; + function GraphPage() { const queryClient = useQueryClient(); const containerRef = useRef(null); + const graphRef = useRef(undefined); + const [dimensions, setDimensions] = useState({ width: 800, height: 600 }); const [search, setSearch] = useState(""); const [filterOpen, setFilterOpen] = useState(false); const [enabledTypes, setEnabledTypes] = useState>(new Set(ENTITY_TYPES)); + const [enabledRelationships, setEnabledRelationships] = useState>(new Set(RELATIONSHIP_TYPES)); const [selectedNode, setSelectedNode] = useState(null); const [detailOpen, setDetailOpen] = useState(false); + const [hoveredNode, setHoveredNode] = useState(null); + const [zoom, setZoom] = useState(1); useRealtime({ enabled: true }); + // ResizeObserver for reactive sizing + useEffect(() => { + const container = containerRef.current; + if (!container) return; + + const observer = new ResizeObserver((entries) => { + for (const entry of entries) { + const { width, height } = entry.contentRect; + if (width > 0 && height > 0) { + setDimensions({ width, height }); + } + } + }); + + observer.observe(container); + return () => observer.disconnect(); + }, []); + const { data: nodesData } = useApiQuery<{ items: GraphNode[]; totalItems: number }>( ["graph", "nodes"], "/graph/nodes?domain=placeholder" @@ -52,9 +79,57 @@ function GraphPage() { const allNodes = nodesData?.items || []; const allEdges = edgesData?.items || []; + // Filter by entity type const filteredNodes = allNodes.filter((n) => enabledTypes.has(n.type)); const filteredNodeIds = new Set(filteredNodes.map((n) => n.id)); - const filteredEdges = allEdges.filter((e) => filteredNodeIds.has(e.source) && filteredNodeIds.has(e.target)); + const filteredEdges = allEdges.filter( + (e) => filteredNodeIds.has(e.source) && filteredNodeIds.has(e.target) && enabledRelationships.has(e.type) + ); + + // Performance cap + const displayNodes = filteredNodes.slice(0, MAX_NODES); + const displayNodeIds = new Set(displayNodes.map((n) => n.id)); + const displayEdges = filteredEdges.filter( + (e) => displayNodeIds.has(e.source) && displayNodeIds.has(e.target) + ); + const exceeded = filteredNodes.length > MAX_NODES; + + // Graph data for react-force-graph-2d + const graphData = useMemo(() => ({ + nodes: displayNodes.map((n) => ({ + id: n.id, + label: n.label, + type: n.type, + color: n.color || ENTITY_COLORS[n.type] || "#6b7280", + })), + links: displayEdges.map((e) => ({ + source: e.source, + target: e.target, + type: e.type, + })), + }), [displayNodes, displayEdges]); + + // Hover highlight: connected nodes/edges + const highlightNodes = useMemo(() => { + if (!hoveredNode) return new Set(); + const connected = new Set([hoveredNode.id]); + displayEdges.forEach((e) => { + if (e.source === hoveredNode.id) connected.add(e.target); + if (e.target === hoveredNode.id) connected.add(e.source); + }); + return connected; + }, [hoveredNode, displayEdges]); + + const highlightLinks = useMemo(() => { + if (!hoveredNode) return new Set(); + const connected = new Set(); + displayEdges.forEach((e) => { + if (e.source === hoveredNode.id || e.target === hoveredNode.id) { + connected.add(`${e.source}-${e.target}`); + } + }); + return connected; + }, [hoveredNode, displayEdges]); const toggleType = (type: string) => { const next = new Set(enabledTypes); @@ -63,6 +138,128 @@ function GraphPage() { setEnabledTypes(next); }; + const toggleRelationship = (type: string) => { + const next = new Set(enabledRelationships); + if (next.has(type)) next.delete(type); + else next.add(type); + setEnabledRelationships(next); + }; + + // Search: fly to node + const handleSearch = useCallback(() => { + if (!search.trim() || !graphRef.current) return; + const found = displayNodes.find( + (n) => n.label.toLowerCase().includes(search.toLowerCase()) + ); + if (found) { + graphRef.current.centerAt(found.x, found.y, 1000); + graphRef.current.zoom(3, 1000); + } + }, [search, displayNodes]); + + const handleSearchKeyDown = useCallback((e: React.KeyboardEvent) => { + if (e.key === "Enter") handleSearch(); + }, [handleSearch]); + + // Node click → side panel + const handleNodeClick = useCallback((node: any) => { + setSelectedNode(node as GraphNode); + setDetailOpen(true); + }, []); + + // Node hover → highlight + const handleNodeHover = useCallback((node: any | null) => { + setHoveredNode(node as GraphNode | null); + }, []); + + // Custom node renderer + const nodeCanvasObject = useCallback( + (node: any, ctx: CanvasRenderingContext2D, globalScale: number) => { + const isHighlighted = highlightNodes.size === 0 || highlightNodes.has(node.id); + const isHovered = hoveredNode?.id === node.id; + const label = node.label || ""; + const fontSize = Math.max(8, 12 / globalScale); + const radius = isHovered ? 8 : 6; + + ctx.beginPath(); + ctx.arc(node.x, node.y, radius, 0, 2 * Math.PI); + ctx.fillStyle = isHighlighted ? node.color : `${node.color}33`; + ctx.fill(); + ctx.strokeStyle = isHovered ? "#fff" : "#fff"; + ctx.lineWidth = isHovered ? 2 / globalScale : 1 / globalScale; + ctx.stroke(); + + // Label below node + ctx.font = `${fontSize}px Sans-Serif`; + ctx.textAlign = "center"; + ctx.textBaseline = "top"; + ctx.fillStyle = isHighlighted + ? (document.documentElement.classList.contains("dark") ? "#e2e8f0" : "#1e293b") + : "#94a3b8"; + const displayLabel = label.length > 15 ? label.slice(0, 15) + "…" : label; + ctx.fillText(displayLabel, node.x, node.y + radius + 2); + }, + [highlightNodes, hoveredNode] + ); + + // Custom link renderer with arrows + const linkCanvasObject = useCallback( + (link: any, ctx: CanvasRenderingContext2D, globalScale: number) => { + const isHighlighted = highlightLinks.size === 0 || highlightLinks.has(`${link.source.id}-${link.target.id}`); + const width = isHighlighted ? 1.5 / globalScale : 0.5 / globalScale; + const opacity = isHighlighted ? 0.6 : 0.1; + + ctx.beginPath(); + ctx.moveTo(link.source.x, link.source.y); + ctx.lineTo(link.target.x, link.target.y); + ctx.strokeStyle = `rgba(148, 163, 184, ${opacity})`; + ctx.lineWidth = width; + ctx.stroke(); + + // Arrow at midpoint + if (isHighlighted && globalScale > 0.5) { + const midX = (link.source.x + link.target.x) / 2; + const midY = (link.source.y + link.target.y) / 2; + const dx = link.target.x - link.source.x; + const dy = link.target.y - link.source.y; + const len = Math.sqrt(dx * dx + dy * dy); + if (len > 0) { + const ux = dx / len; + const uy = dy / len; + const arrowSize = 4 / globalScale; + ctx.beginPath(); + ctx.moveTo(midX + ux * arrowSize, midY + uy * arrowSize); + ctx.lineTo(midX - ux * arrowSize + uy * arrowSize * 0.5, midY - uy * arrowSize - ux * arrowSize * 0.5); + ctx.lineTo(midX - ux * arrowSize - uy * arrowSize * 0.5, midY - uy * arrowSize + ux * arrowSize * 0.5); + ctx.closePath(); + ctx.fillStyle = `rgba(148, 163, 184, ${opacity})`; + ctx.fill(); + } + } + }, + [highlightLinks] + ); + + const handleZoomIn = useCallback(() => { + if (graphRef.current) { + const newZoom = Math.min(graphRef.current.zoom() * 1.3, 10); + graphRef.current.zoom(newZoom, 400); + } + }, []); + + const handleZoomOut = useCallback(() => { + if (graphRef.current) { + const newZoom = Math.max(graphRef.current.zoom() / 1.3, 0.1); + graphRef.current.zoom(newZoom, 400); + } + }, []); + + const handleReset = useCallback(() => { + if (graphRef.current) { + graphRef.current.zoomToFit(400, 50); + } + }, []); + return (
{/* Graph canvas area */} @@ -75,6 +272,7 @@ function GraphPage() { placeholder="Find a node..." value={search} onChange={(e) => setSearch(e.target.value)} + onKeyDown={handleSearchKeyDown} className="pl-8 w-64 bg-background/90 backdrop-blur" />
@@ -83,76 +281,91 @@ function GraphPage() {
- {/* Graph visualization */} -
-
- - {/* Simple force-directed graph visualization */} - {filteredEdges.map((edge, i) => { - const source = filteredNodes.find((n) => n.id === edge.source); - const target = filteredNodes.find((n) => n.id === edge.target); - if (!source || !target) return null; - // Simple circular layout - const srcIdx = filteredNodes.indexOf(source); - const tgtIdx = filteredNodes.indexOf(target); - const total = filteredNodes.length; - const angle1 = (2 * Math.PI * srcIdx) / Math.max(total, 1); - const angle2 = (2 * Math.PI * tgtIdx) / Math.max(total, 1); - const r = 150; - const x1 = 200 + r * Math.cos(angle1); - const y1 = 200 + r * Math.sin(angle1); - const x2 = 200 + r * Math.cos(angle2); - const y2 = 200 + r * Math.sin(angle2); - return ; - })} - {filteredNodes.map((node, i) => { - const total = filteredNodes.length; - const angle = (2 * Math.PI * i) / Math.max(total, 1); - const r = 150; - const x = 200 + r * Math.cos(angle); - const y = 200 + r * Math.sin(angle); - return ( - { setSelectedNode(node); setDetailOpen(true); }} style={{ cursor: "pointer" }}> - - - {node.label.length > 15 ? node.label.slice(0, 15) + "..." : node.label} - - - ); - })} - -

- {filteredNodes.length} nodes, {filteredEdges.length} edges -

-

- Full interactive graph with react-force-graph-2d will be available in T8. -

-
+ {/* Zoom controls */} +
+ + +
+ + {/* Performance warning */} + {exceeded && ( +
+ + Showing {MAX_NODES} of {filteredNodes.length} nodes (cap reached) + +
+ )} + + {/* Force graph */} +
{/* Filter panel */} - + Filters -
-

Entity Types

- {ENTITY_TYPES.map((type) => ( -
- toggleType(type)} - /> -