From dc23976dba68da8a4509451798361cd748a64ae6 Mon Sep 17 00:00:00 2001 From: bot-hermes Date: Tue, 8 Sep 2026 10:04:42 +0000 Subject: [PATCH] fix(web): wire up shell navigation layout - Convert _app/index.tsx from dashboard page to app layout shell - Render Sidebar, Topbar, CommandPalette, ShortcutsHelp, and Outlet - Extract DashboardPage into _app/dashboard.tsx as child route - Update routeTree import to point to new dashboard file --- apps/web/src/routeTree.ts | 2 +- apps/web/src/routes/_app/dashboard.tsx | 511 ++++++++++++++++++++++++ apps/web/src/routes/_app/index.tsx | 518 +------------------------ 3 files changed, 531 insertions(+), 500 deletions(-) create mode 100644 apps/web/src/routes/_app/dashboard.tsx diff --git a/apps/web/src/routeTree.ts b/apps/web/src/routeTree.ts index 16e0ab9..89acfd3 100644 --- a/apps/web/src/routeTree.ts +++ b/apps/web/src/routeTree.ts @@ -2,7 +2,7 @@ import { createRouter } from "@tanstack/react-router"; import { Route as rootRoute } from "./routes/__root"; import { Route as loginRoute } from "./routes/login"; import { Route as appRoute } from "./routes/_app"; -import { Route as dashboardRoute } from "./routes/_app/index"; +import { Route as dashboardRoute } from "./routes/_app/dashboard"; import { Route as tasksRoute } from "./routes/_app/tasks"; import { Route as habitsRoute } from "./routes/_app/habits"; import { Route as projectsRoute } from "./routes/_app/projects"; diff --git a/apps/web/src/routes/_app/dashboard.tsx b/apps/web/src/routes/_app/dashboard.tsx new file mode 100644 index 0000000..5bcab98 --- /dev/null +++ b/apps/web/src/routes/_app/dashboard.tsx @@ -0,0 +1,511 @@ +import { useState, useEffect } from "react"; +import { createRoute } from "@tanstack/react-router"; +import { Route as appRoute } from "."; +import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"; +import { api, useApiQuery } from "@/lib/api"; +import { useRealtime } from "@/hooks/use-realtime"; +import { useApiDomain } from "@/lib/stores/use-active-domain-store"; +import { Plus, Settings2, Trash2, ListTodo, Flame, FileText, FolderKanban, Calendar, Zap, TrendingUp, Target } from "lucide-react"; +import { toast } from "sonner"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Badge } from "@/components/ui/badge"; +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; +import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog"; +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; +import { Label } from "@/components/ui/label"; +import { Progress } from "@/components/ui/progress"; +import { Separator } from "@/components/ui/separator"; +import { cn } from "@/lib/utils"; +import type { DashboardWidget, Task, Habit, Note, Project, CalendarEvent, PaginatedResponse } from "@/lib/types"; +import { format, isToday, isPast, addDays, parseISO } from "date-fns"; + +const WIDGET_TYPES = [ + { id: "tasks_due", label: "Tasks Due Today", icon: ListTodo, defaultW: 2, defaultH: 2 }, + { id: "habits_today", label: "Habits Today", icon: Flame, defaultW: 2, defaultH: 2 }, + { id: "recent_notes", label: "Recent Notes", icon: FileText, defaultW: 2, defaultH: 2 }, + { id: "active_projects", label: "Active Projects", icon: FolderKanban, defaultW: 2, defaultH: 2 }, + { id: "upcoming_events", label: "Upcoming Events", icon: Calendar, defaultW: 2, defaultH: 2 }, + { id: "streak_counter", label: "Streak Counter", icon: Flame, defaultW: 1, defaultH: 1 }, + { id: "quick_capture", label: "Quick Capture", icon: Zap, defaultW: 2, defaultH: 1 }, + { id: "productivity_chart", label: "Productivity Chart", icon: TrendingUp, defaultW: 3, defaultH: 2 }, +] as const; + +function TasksDueWidget() { + const activeDomainId = useApiDomain(); + const domainSuffix = activeDomainId ? "&domain=" + activeDomainId : ""; + const { data } = useApiQuery>(["tasks-due", activeDomainId], "/tasks?limit=10&status=todo,in_progress&sort=due_date" + domainSuffix); + const tasks = data?.items || []; + const today = tasks.filter((t) => t.dueDate && isToday(parseISO(t.dueDate))); + const overdue = tasks.filter((t) => t.dueDate && isPast(parseISO(t.dueDate)) && t.status !== "done"); + return ( +
+ {today.length === 0 && overdue.length === 0 ? ( +

No tasks due today

+ ) : ( + <> + {overdue.length > 0 && ( +
+

Overdue ({overdue.length})

+ {overdue.slice(0, 3).map((t) => ( +
+
+ {t.title} +
+ ))} +
+ )} + {today.length > 0 && ( +
+

Today ({today.length})

+ {today.slice(0, 5).map((t) => ( +
+
+ {t.title} +
+ ))} +
+ )} + + )} +
+ ); +} + +function HabitsTodayWidget() { + const activeDomainId = useApiDomain(); + const domainSuffix = activeDomainId ? "&domain=" + activeDomainId : ""; + const { data } = useApiQuery>(["habits-today", activeDomainId], "/habits?limit=20" + domainSuffix); + const habits = data?.items || []; + const queryClient = useQueryClient(); + const completeMutation = useMutation({ + mutationFn: (id: string) => api.post("/habits/" + id + "/complete", {}), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ["habits-today"] }); + queryClient.invalidateQueries({ queryKey: ["streaks"] }); + toast.success("Habit completed"); + }, + onError: (err) => toast.error(err.message || "Failed to complete habit"), + }); + return ( +
+ {habits.length === 0 ? ( +

No habits yet

+ ) : ( + habits.slice(0, 6).map((h) => { + const doneToday = (h.recentCompletions || []).some((c) => { + const d = new Date(c.date); + const today = new Date(); + return d.getUTCFullYear() === today.getUTCFullYear() && d.getUTCMonth() === today.getUTCMonth() && d.getUTCDate() === today.getUTCDate(); + }); + return ( +
+ + {h.name} + {h.streakCount > 0 && ( + + {h.streakCount} + + )} +
+ ); + }) + )} +
+ ); +} + +function RecentNotesWidget() { + const activeDomainId = useApiDomain(); + const domainSuffix = activeDomainId ? "&domain=" + activeDomainId : ""; + const { data } = useApiQuery>(["recent-notes", activeDomainId], "/notes?limit=5&sort=-updated" + domainSuffix); + const notes = data?.items || []; + return ( +
+ {notes.length === 0 ? ( +

No notes yet

+ ) : ( + notes.map((n) => ( +
+

{n.title}

+

{format(parseISO(n.updatedAt), "MMM d, HH:mm")}

+
+ )) + )} +
+ ); +} + +function ActiveProjectsWidget() { + const activeDomainId = useApiDomain(); + const domainSuffix = activeDomainId ? "&domain=" + activeDomainId : ""; + const { data } = useApiQuery>(["active-projects", activeDomainId], "/projects?limit=10&status=active" + domainSuffix); + const projects = data?.items || []; + return ( +
+ {projects.length === 0 ? ( +

No active projects

+ ) : ( + projects.slice(0, 5).map((p) => ( +
+
+ {p.name} + {p.progress || 0}% +
+ +
+ )) + )} +
+ ); +} + +function UpcomingEventsWidget() { + const activeDomainId = useApiDomain(); + const domainSuffix = activeDomainId ? "&domain=" + activeDomainId : ""; + const { data } = useApiQuery>(["upcoming-events", activeDomainId], "/calendar/events?limit=20" + domainSuffix); + const events = data?.items || []; + const now = new Date(); + const weekFromNow = addDays(now, 7); + const upcoming = events.filter((e) => { + const start = parseISO(e.startTime); + return start >= now && start <= weekFromNow; + }).sort((a, b) => parseISO(a.startTime).getTime() - parseISO(b.startTime).getTime()); + return ( +
+ {upcoming.length === 0 ? ( +

No upcoming events

+ ) : ( + upcoming.slice(0, 5).map((e) => ( +
+
+ {e.title} + {format(parseISO(e.startTime), "MMM d, HH:mm")} +
+ )) + )} +
+ ); +} + +function StreakCounterWidget() { + const activeDomainId = useApiDomain(); + const domainSuffix = activeDomainId ? "&domain=" + activeDomainId : ""; + const { data } = useApiQuery>(["streaks", activeDomainId], "/habits?limit=50" + domainSuffix); + const habits = data?.items || []; + const bestStreak = Math.max(...habits.map((h) => h.streakCount || 0), 0); + const totalActive = habits.filter((h) => h.streakCount > 0).length; + return ( +
+
+ + {bestStreak} + Best streak +
+ +
+ {totalActive} + Active streaks +
+
+ ); +} + +function QuickCaptureWidget() { + const queryClient = useQueryClient(); + const activeDomainId = useApiDomain(); + const [text, setText] = useState(""); + const [type, setType] = useState<"task" | "note">("task"); + const createTask = useMutation({ + mutationFn: (title: string) => api.post("/tasks", { title, status: "todo", priority: "medium", ...(activeDomainId ? { domain: activeDomainId } : {}) }), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ["tasks-due"] }); + setText(""); + toast.success("Task added"); + }, + onError: (err) => toast.error(err.message || "Failed to create task"), + }); + const createNote = useMutation({ + mutationFn: (title: string) => api.post("/notes", { title, content: "", ...(activeDomainId ? { domain: activeDomainId } : {}) }), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ["recent-notes"] }); + setText(""); + toast.success("Note added"); + }, + onError: (err) => toast.error(err.message || "Failed to create note"), + }); + const handleSubmit = (e: React.FormEvent) => { + e.preventDefault(); + e.stopPropagation(); + if (!text.trim()) return; + if (type === "task") createTask.mutate(text.trim()); + else createNote.mutate(text.trim()); + }; + return ( +
+
+ + setText(e.target.value)} className="h-9" /> +
+ +
+ ); +} + +function ProductivityChartWidget() { + const activeDomainId = useApiDomain(); + const domainSuffix = activeDomainId ? "&domain=" + activeDomainId : ""; + const { data } = useApiQuery(["productivity-chart", activeDomainId], "/analytics/productivity?range=30" + domainSuffix); + const stats = data; + if (!stats) return

Loading...

; + return ( +
+
+
+

{stats.totalTasks || 0}

+

Total

+
+
+

{stats.completedTasks || 0}

+

Done

+
+
+

{stats.taskCompletionRate || 0}%

+

Rate

+
+
+

Last {stats.period || 30} days

+
+ ); +} + +function StatsWidget() { + const activeDomainId = useApiDomain(); + const domainSuffix = activeDomainId ? "&domain=" + activeDomainId : ""; + const { data } = useApiQuery(["stats", activeDomainId], "/analytics/productivity?range=30" + domainSuffix); + const stats = data; + if (!stats) return

Loading...

; + return ( +
+
+

{stats.totalTasks || 0}

+

Total Tasks

+
+
+

{stats.completedTasks || 0}

+

Completed

+
+
+

{stats.taskCompletionRate || 0}%

+

Rate

+
+
+ ); +} + +function WidgetRenderer({ type }: { type: string }) { + switch (type) { + case "tasks_due": return ; + case "habits_today": return ; + case "recent_notes": return ; + case "active_projects": return ; + case "upcoming_events": return ; + case "streak_counter": return ; + case "quick_capture": return ; + case "productivity_chart": return ; + case "stats": return ; + default: return

Unknown widget: {type}

; + } +} + +function WidgetCard({ widget, onConfigure, onDelete }: { widget: DashboardWidget; onConfigure: () => void; onDelete: () => void }) { + const typeInfo = WIDGET_TYPES.find((t) => t.id === widget.type); + const Icon = typeInfo?.icon || Target; + return ( + + +
+ + {widget.title || typeInfo?.label || widget.type} +
+
+ + +
+
+ + + +
+ ); +} + +function AddWidgetDialog({ open, onOpenChange, onAdd }: { open: boolean; onOpenChange: (open: boolean) => void; onAdd: (type: string) => void }) { + return ( + + + Add Widget +
+ {WIDGET_TYPES.map((wt) => { + const Icon = wt.icon; + return ( + + ); + })} +
+
+
+ ); +} + +function ConfigureWidgetDialog({ widget, open, onOpenChange, onSave }: { widget: DashboardWidget | null; open: boolean; onOpenChange: (open: boolean) => void; onSave: (title: string, w: number, h: number) => void }) { + const [title, setTitle] = useState(widget?.title || ""); + const [w, setW] = useState(widget?.layout.w || 2); + const [h, setH] = useState(widget?.layout.h || 2); + useEffect(() => { + if (widget) { setTitle(widget.title || ""); setW(widget.layout.w || 2); setH(widget.layout.h || 2); } + }, [widget]); + const handleSave = () => { onSave(title, w, h); onOpenChange(false); }; + return ( + + + Configure Widget +
+
+ + setTitle(e.target.value)} placeholder="Widget title" /> +
+
+
+ + +
+
+ + +
+
+
+ + +
+
+
+
+ ); +} + +function DashboardPage() { + const queryClient = useQueryClient(); + const [addOpen, setAddOpen] = useState(false); + const [configOpen, setConfigOpen] = useState(false); + const [configWidget, setConfigWidget] = useState(null); + useRealtime({ enabled: true }); + const { data: widgetsData, isLoading } = useApiQuery<{ items: DashboardWidget[]; totalItems: number }>(["dashboard-widgets"], "/dashboard/widgets"); + const widgets = widgetsData?.items || []; + const createMutation = useMutation({ + mutationFn: (data: any) => api.post("/dashboard/widgets", data), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ["dashboard-widgets"] }); + toast.success("Widget added"); + }, + onError: (err) => toast.error(err.message || "Failed to add widget"), + }); + const updateMutation = useMutation({ + mutationFn: ({ id, data }: { id: string; data: any }) => api.patch("/dashboard/widgets/" + id, data), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ["dashboard-widgets"] }); + toast.success("Widget updated"); + }, + onError: (err) => toast.error(err.message || "Failed to update widget"), + }); + const deleteMutation = useMutation({ + mutationFn: (id: string) => api.delete("/dashboard/widgets/" + id), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ["dashboard-widgets"] }); + toast.success("Widget removed"); + }, + onError: (err) => toast.error(err.message || "Failed to remove widget"), + }); + const handleAddWidget = (type: string) => { + const typeInfo = WIDGET_TYPES.find((t) => t.id === type); + createMutation.mutate({ type, title: typeInfo?.label || type, layout: { x: 0, y: widgets.length, w: typeInfo?.defaultW || 2, h: typeInfo?.defaultH || 2 } }); + }; + const handleConfigure = (widget: DashboardWidget) => { setConfigWidget(widget); setConfigOpen(true); }; + const handleSaveConfig = (title: string, w: number, h: number) => { + if (!configWidget) return; + updateMutation.mutate({ id: configWidget.id, data: { title: title || null, layout: { ...configWidget.layout, w, h } } }); + }; + const handleDelete = (id: string) => { deleteMutation.mutate(id); }; + return ( +
+
+

Dashboard

+ +
+ {isLoading ? ( +
Loading dashboard...
+ ) : widgets.length === 0 ? ( +
+

Your dashboard is empty. Add some widgets to get started!

+ +
+ ) : ( +
+ {widgets.map((w) => ( + handleConfigure(w)} onDelete={() => handleDelete(w.id)} /> + ))} +
+ )} + + +
+ ); +} + +export const Route = createRoute({ + getParentRoute: () => appRoute, + path: "/", + component: DashboardPage, +}); diff --git a/apps/web/src/routes/_app/index.tsx b/apps/web/src/routes/_app/index.tsx index a493056..dbe3bec 100644 --- a/apps/web/src/routes/_app/index.tsx +++ b/apps/web/src/routes/_app/index.tsx @@ -1,511 +1,31 @@ -import { useState, useEffect } from "react"; -import { createRoute } from "@tanstack/react-router"; +import { Outlet, createRoute } from "@tanstack/react-router"; import { Route as rootRoute } from "../__root"; -import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"; -import { api, useApiQuery, useApiMutation } from "@/lib/api"; -import { useRealtime } from "@/hooks/use-realtime"; -import { useApiDomain } from "@/lib/stores/use-active-domain-store"; -import { Plus, Settings2, Trash2, ListTodo, Flame, FileText, FolderKanban, Calendar, Zap, TrendingUp, Target } from "lucide-react"; -import { toast } from "sonner"; -import { Button } from "@/components/ui/button"; -import { Input } from "@/components/ui/input"; -import { Badge } from "@/components/ui/badge"; -import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; -import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog"; -import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; -import { Label } from "@/components/ui/label"; -import { Progress } from "@/components/ui/progress"; -import { Separator } from "@/components/ui/separator"; -import { cn } from "@/lib/utils"; -import type { DashboardWidget, Task, Habit, Note, Project, CalendarEvent, PaginatedResponse } from "@/lib/types"; -import { format, isToday, isPast, addDays, parseISO } from "date-fns"; +import { Sidebar } from "@/components/shell/sidebar"; +import { Topbar } from "@/components/shell/topbar"; +import { CommandPalette } from "@/components/shell/command-palette"; +import { ShortcutsHelp } from "@/components/shell/shortcuts-help"; +import { useKeyboardShortcuts } from "@/hooks/use-keyboard-shortcuts"; -const WIDGET_TYPES = [ - { id: "tasks_due", label: "Tasks Due Today", icon: ListTodo, defaultW: 2, defaultH: 2 }, - { id: "habits_today", label: "Habits Today", icon: Flame, defaultW: 2, defaultH: 2 }, - { id: "recent_notes", label: "Recent Notes", icon: FileText, defaultW: 2, defaultH: 2 }, - { id: "active_projects", label: "Active Projects", icon: FolderKanban, defaultW: 2, defaultH: 2 }, - { id: "upcoming_events", label: "Upcoming Events", icon: Calendar, defaultW: 2, defaultH: 2 }, - { id: "streak_counter", label: "Streak Counter", icon: Flame, defaultW: 1, defaultH: 1 }, - { id: "quick_capture", label: "Quick Capture", icon: Zap, defaultW: 2, defaultH: 1 }, - { id: "productivity_chart", label: "Productivity Chart", icon: TrendingUp, defaultW: 3, defaultH: 2 }, -] as const; +function AppLayout() { + useKeyboardShortcuts(); -function TasksDueWidget() { - const activeDomainId = useApiDomain(); - const domainSuffix = activeDomainId ? "&domain=" + activeDomainId : ""; - const { data } = useApiQuery>(["tasks-due", activeDomainId], "/tasks?limit=10&status=todo,in_progress&sort=due_date" + domainSuffix); - const tasks = data?.items || []; - const today = tasks.filter((t) => t.dueDate && isToday(parseISO(t.dueDate))); - const overdue = tasks.filter((t) => t.dueDate && isPast(parseISO(t.dueDate)) && t.status !== "done"); return ( -
- {today.length === 0 && overdue.length === 0 ? ( -

No tasks due today

- ) : ( - <> - {overdue.length > 0 && ( -
-

Overdue ({overdue.length})

- {overdue.slice(0, 3).map((t) => ( -
-
- {t.title} -
- ))} -
- )} - {today.length > 0 && ( -
-

Today ({today.length})

- {today.slice(0, 5).map((t) => ( -
-
- {t.title} -
- ))} -
- )} - - )} -
- ); -} - -function HabitsTodayWidget() { - const activeDomainId = useApiDomain(); - const domainSuffix = activeDomainId ? "&domain=" + activeDomainId : ""; - const { data } = useApiQuery>(["habits-today", activeDomainId], "/habits?limit=20" + domainSuffix); - const habits = data?.items || []; - const queryClient = useQueryClient(); - const completeMutation = useMutation({ - mutationFn: (id: string) => api.post("/habits/" + id + "/complete", {}), - onSuccess: () => { - queryClient.invalidateQueries({ queryKey: ["habits-today"] }); - queryClient.invalidateQueries({ queryKey: ["streaks"] }); - toast.success("Habit completed"); - }, - onError: (err) => toast.error(err.message || "Failed to complete habit"), - }); - return ( -
- {habits.length === 0 ? ( -

No habits yet

- ) : ( - habits.slice(0, 6).map((h) => { - const doneToday = (h.recentCompletions || []).some((c) => { - const d = new Date(c.date); - const today = new Date(); - return d.getUTCFullYear() === today.getUTCFullYear() && d.getUTCMonth() === today.getUTCMonth() && d.getUTCDate() === today.getUTCDate(); - }); - return ( -
- - {h.name} - {h.streakCount > 0 && ( - - {h.streakCount} - - )} -
- ); - }) - )} -
- ); -} - -function RecentNotesWidget() { - const activeDomainId = useApiDomain(); - const domainSuffix = activeDomainId ? "&domain=" + activeDomainId : ""; - const { data } = useApiQuery>(["recent-notes", activeDomainId], "/notes?limit=5&sort=-updated" + domainSuffix); - const notes = data?.items || []; - return ( -
- {notes.length === 0 ? ( -

No notes yet

- ) : ( - notes.map((n) => ( -
-

{n.title}

-

{format(parseISO(n.updatedAt), "MMM d, HH:mm")}

-
- )) - )} -
- ); -} - -function ActiveProjectsWidget() { - const activeDomainId = useApiDomain(); - const domainSuffix = activeDomainId ? "&domain=" + activeDomainId : ""; - const { data } = useApiQuery>(["active-projects", activeDomainId], "/projects?limit=10&status=active" + domainSuffix); - const projects = data?.items || []; - return ( -
- {projects.length === 0 ? ( -

No active projects

- ) : ( - projects.slice(0, 5).map((p) => ( -
-
- {p.name} - {p.progress || 0}% -
- -
- )) - )} -
- ); -} - -function UpcomingEventsWidget() { - const activeDomainId = useApiDomain(); - const domainSuffix = activeDomainId ? "&domain=" + activeDomainId : ""; - const { data } = useApiQuery>(["upcoming-events", activeDomainId], "/calendar/events?limit=20" + domainSuffix); - const events = data?.items || []; - const now = new Date(); - const weekFromNow = addDays(now, 7); - const upcoming = events.filter((e) => { - const start = parseISO(e.startTime); - return start >= now && start <= weekFromNow; - }).sort((a, b) => parseISO(a.startTime).getTime() - parseISO(b.startTime).getTime()); - return ( -
- {upcoming.length === 0 ? ( -

No upcoming events

- ) : ( - upcoming.slice(0, 5).map((e) => ( -
-
- {e.title} - {format(parseISO(e.startTime), "MMM d, HH:mm")} -
- )) - )} -
- ); -} - -function StreakCounterWidget() { - const activeDomainId = useApiDomain(); - const domainSuffix = activeDomainId ? "&domain=" + activeDomainId : ""; - const { data } = useApiQuery>(["streaks", activeDomainId], "/habits?limit=50" + domainSuffix); - const habits = data?.items || []; - const bestStreak = Math.max(...habits.map((h) => h.streakCount || 0), 0); - const totalActive = habits.filter((h) => h.streakCount > 0).length; - return ( -
-
- - {bestStreak} - Best streak +
+ +
+ +
+ +
- -
- {totalActive} - Active streaks -
-
- ); -} - -function QuickCaptureWidget() { - const queryClient = useQueryClient(); - const activeDomainId = useApiDomain(); - const [text, setText] = useState(""); - const [type, setType] = useState<"task" | "note">("task"); - const createTask = useMutation({ - mutationFn: (title: string) => api.post("/tasks", { title, status: "todo", priority: "medium", ...(activeDomainId ? { domain: activeDomainId } : {}) }), - onSuccess: () => { - queryClient.invalidateQueries({ queryKey: ["tasks-due"] }); - setText(""); - toast.success("Task added"); - }, - onError: (err) => toast.error(err.message || "Failed to create task"), - }); - const createNote = useMutation({ - mutationFn: (title: string) => api.post("/notes", { title, content: "", ...(activeDomainId ? { domain: activeDomainId } : {}) }), - onSuccess: () => { - queryClient.invalidateQueries({ queryKey: ["recent-notes"] }); - setText(""); - toast.success("Note added"); - }, - onError: (err) => toast.error(err.message || "Failed to create note"), - }); - const handleSubmit = (e: React.FormEvent) => { - e.preventDefault(); - e.stopPropagation(); - if (!text.trim()) return; - if (type === "task") createTask.mutate(text.trim()); - else createNote.mutate(text.trim()); - }; - return ( -
-
- - setText(e.target.value)} className="h-9" /> -
- -
- ); -} - -function ProductivityChartWidget() { - const activeDomainId = useApiDomain(); - const domainSuffix = activeDomainId ? "&domain=" + activeDomainId : ""; - const { data } = useApiQuery(["productivity-chart", activeDomainId], "/analytics/productivity?range=30" + domainSuffix); - const stats = data; - if (!stats) return

Loading...

; - return ( -
-
-
-

{stats.totalTasks || 0}

-

Total

-
-
-

{stats.completedTasks || 0}

-

Done

-
-
-

{stats.taskCompletionRate || 0}%

-

Rate

-
-
-

Last {stats.period || 30} days

-
- ); -} - -function StatsWidget() { - const activeDomainId = useApiDomain(); - const domainSuffix = activeDomainId ? "&domain=" + activeDomainId : ""; - const { data } = useApiQuery(["stats", activeDomainId], "/analytics/productivity?range=30" + domainSuffix); - const stats = data; - if (!stats) return

Loading...

; - return ( -
-
-

{stats.totalTasks || 0}

-

Total Tasks

-
-
-

{stats.completedTasks || 0}

-

Completed

-
-
-

{stats.taskCompletionRate || 0}%

-

Rate

-
-
- ); -} - -function WidgetRenderer({ type }: { type: string }) { - switch (type) { - case "tasks_due": return ; - case "habits_today": return ; - case "recent_notes": return ; - case "active_projects": return ; - case "upcoming_events": return ; - case "streak_counter": return ; - case "quick_capture": return ; - case "productivity_chart": return ; - case "stats": return ; - default: return

Unknown widget: {type}

; - } -} - -function WidgetCard({ widget, onConfigure, onDelete }: { widget: DashboardWidget; onConfigure: () => void; onDelete: () => void }) { - const typeInfo = WIDGET_TYPES.find((t) => t.id === widget.type); - const Icon = typeInfo?.icon || Target; - return ( - - -
- - {widget.title || typeInfo?.label || widget.type} -
-
- - -
-
- - - -
- ); -} - -function AddWidgetDialog({ open, onOpenChange, onAdd }: { open: boolean; onOpenChange: (open: boolean) => void; onAdd: (type: string) => void }) { - return ( - - - Add Widget -
- {WIDGET_TYPES.map((wt) => { - const Icon = wt.icon; - return ( - - ); - })} -
-
-
- ); -} - -function ConfigureWidgetDialog({ widget, open, onOpenChange, onSave }: { widget: DashboardWidget | null; open: boolean; onOpenChange: (open: boolean) => void; onSave: (title: string, w: number, h: number) => void }) { - const [title, setTitle] = useState(widget?.title || ""); - const [w, setW] = useState(widget?.layout.w || 2); - const [h, setH] = useState(widget?.layout.h || 2); - useEffect(() => { - if (widget) { setTitle(widget.title || ""); setW(widget.layout.w || 2); setH(widget.layout.h || 2); } - }, [widget]); - const handleSave = () => { onSave(title, w, h); onOpenChange(false); }; - return ( - - - Configure Widget -
-
- - setTitle(e.target.value)} placeholder="Widget title" /> -
-
-
- - -
-
- - -
-
-
- - -
-
-
-
- ); -} - -function DashboardPage() { - const queryClient = useQueryClient(); - const [addOpen, setAddOpen] = useState(false); - const [configOpen, setConfigOpen] = useState(false); - const [configWidget, setConfigWidget] = useState(null); - useRealtime({ enabled: true }); - const { data: widgetsData, isLoading } = useApiQuery<{ items: DashboardWidget[]; totalItems: number }>(["dashboard-widgets"], "/dashboard/widgets"); - const widgets = widgetsData?.items || []; - const createMutation = useMutation({ - mutationFn: (data: any) => api.post("/dashboard/widgets", data), - onSuccess: () => { - queryClient.invalidateQueries({ queryKey: ["dashboard-widgets"] }); - toast.success("Widget added"); - }, - onError: (err) => toast.error(err.message || "Failed to add widget"), - }); - const updateMutation = useMutation({ - mutationFn: ({ id, data }: { id: string; data: any }) => api.patch("/dashboard/widgets/" + id, data), - onSuccess: () => { - queryClient.invalidateQueries({ queryKey: ["dashboard-widgets"] }); - toast.success("Widget updated"); - }, - onError: (err) => toast.error(err.message || "Failed to update widget"), - }); - const deleteMutation = useMutation({ - mutationFn: (id: string) => api.delete("/dashboard/widgets/" + id), - onSuccess: () => { - queryClient.invalidateQueries({ queryKey: ["dashboard-widgets"] }); - toast.success("Widget removed"); - }, - onError: (err) => toast.error(err.message || "Failed to remove widget"), - }); - const handleAddWidget = (type: string) => { - const typeInfo = WIDGET_TYPES.find((t) => t.id === type); - createMutation.mutate({ type, title: typeInfo?.label || type, layout: { x: 0, y: widgets.length, w: typeInfo?.defaultW || 2, h: typeInfo?.defaultH || 2 } }); - }; - const handleConfigure = (widget: DashboardWidget) => { setConfigWidget(widget); setConfigOpen(true); }; - const handleSaveConfig = (title: string, w: number, h: number) => { - if (!configWidget) return; - updateMutation.mutate({ id: configWidget.id, data: { title: title || null, layout: { ...configWidget.layout, w, h } } }); - }; - const handleDelete = (id: string) => { deleteMutation.mutate(id); }; - return ( -
-
-

Dashboard

- -
- {isLoading ? ( -
Loading dashboard...
- ) : widgets.length === 0 ? ( -
-

Your dashboard is empty. Add some widgets to get started!

- -
- ) : ( -
- {widgets.map((w) => ( - handleConfigure(w)} onDelete={() => handleDelete(w.id)} /> - ))} -
- )} - - + +
); } export const Route = createRoute({ getParentRoute: () => rootRoute, - path: "/", - component: DashboardPage, + id: "_app", + component: AppLayout, });