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
This commit is contained in:
@@ -2,7 +2,7 @@ import { createRouter } from "@tanstack/react-router";
|
|||||||
import { Route as rootRoute } from "./routes/__root";
|
import { Route as rootRoute } from "./routes/__root";
|
||||||
import { Route as loginRoute } from "./routes/login";
|
import { Route as loginRoute } from "./routes/login";
|
||||||
import { Route as appRoute } from "./routes/_app";
|
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 tasksRoute } from "./routes/_app/tasks";
|
||||||
import { Route as habitsRoute } from "./routes/_app/habits";
|
import { Route as habitsRoute } from "./routes/_app/habits";
|
||||||
import { Route as projectsRoute } from "./routes/_app/projects";
|
import { Route as projectsRoute } from "./routes/_app/projects";
|
||||||
|
|||||||
@@ -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<PaginatedResponse<Task>>(["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 (
|
||||||
|
<div className="space-y-2">
|
||||||
|
{today.length === 0 && overdue.length === 0 ? (
|
||||||
|
<p className="text-sm text-muted-foreground">No tasks due today</p>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
{overdue.length > 0 && (
|
||||||
|
<div>
|
||||||
|
<p className="text-xs font-semibold text-destructive mb-1">Overdue ({overdue.length})</p>
|
||||||
|
{overdue.slice(0, 3).map((t) => (
|
||||||
|
<div key={t.id} className="flex items-center gap-2 text-sm py-1">
|
||||||
|
<div className="w-1.5 h-1.5 rounded-full bg-destructive shrink-0" />
|
||||||
|
<span className="truncate flex-1">{t.title}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{today.length > 0 && (
|
||||||
|
<div>
|
||||||
|
<p className="text-xs font-semibold text-amber-500 mb-1">Today ({today.length})</p>
|
||||||
|
{today.slice(0, 5).map((t) => (
|
||||||
|
<div key={t.id} className="flex items-center gap-2 text-sm py-1">
|
||||||
|
<div className="w-1.5 h-1.5 rounded-full bg-amber-500 shrink-0" />
|
||||||
|
<span className="truncate flex-1">{t.title}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function HabitsTodayWidget() {
|
||||||
|
const activeDomainId = useApiDomain();
|
||||||
|
const domainSuffix = activeDomainId ? "&domain=" + activeDomainId : "";
|
||||||
|
const { data } = useApiQuery<PaginatedResponse<Habit>>(["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 (
|
||||||
|
<div className="space-y-1">
|
||||||
|
{habits.length === 0 ? (
|
||||||
|
<p className="text-sm text-muted-foreground">No habits yet</p>
|
||||||
|
) : (
|
||||||
|
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 (
|
||||||
|
<div key={h.id} className="flex items-center gap-2 py-1">
|
||||||
|
<button
|
||||||
|
onClick={() => completeMutation.mutate(h.id)}
|
||||||
|
className={cn("w-4 h-4 rounded border shrink-0 flex items-center justify-center", doneToday ? "bg-green-500 border-green-500" : "border-muted-foreground/30 hover:border-primary")}
|
||||||
|
aria-label={doneToday ? h.name + " (completed)" : "Complete " + h.name}
|
||||||
|
>
|
||||||
|
{doneToday && <span className="text-[10px] text-white">\u2713</span>}
|
||||||
|
</button>
|
||||||
|
<span className="text-sm truncate flex-1">{h.name}</span>
|
||||||
|
{h.streakCount > 0 && (
|
||||||
|
<Badge variant="secondary" className="text-[10px] shrink-0">
|
||||||
|
<Flame className="h-2.5 w-2.5 mr-0.5" />{h.streakCount}
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function RecentNotesWidget() {
|
||||||
|
const activeDomainId = useApiDomain();
|
||||||
|
const domainSuffix = activeDomainId ? "&domain=" + activeDomainId : "";
|
||||||
|
const { data } = useApiQuery<PaginatedResponse<Note>>(["recent-notes", activeDomainId], "/notes?limit=5&sort=-updated" + domainSuffix);
|
||||||
|
const notes = data?.items || [];
|
||||||
|
return (
|
||||||
|
<div className="space-y-2">
|
||||||
|
{notes.length === 0 ? (
|
||||||
|
<p className="text-sm text-muted-foreground">No notes yet</p>
|
||||||
|
) : (
|
||||||
|
notes.map((n) => (
|
||||||
|
<div key={n.id} className="text-sm py-1 border-b last:border-0">
|
||||||
|
<p className="font-medium truncate">{n.title}</p>
|
||||||
|
<p className="text-xs text-muted-foreground">{format(parseISO(n.updatedAt), "MMM d, HH:mm")}</p>
|
||||||
|
</div>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ActiveProjectsWidget() {
|
||||||
|
const activeDomainId = useApiDomain();
|
||||||
|
const domainSuffix = activeDomainId ? "&domain=" + activeDomainId : "";
|
||||||
|
const { data } = useApiQuery<PaginatedResponse<Project>>(["active-projects", activeDomainId], "/projects?limit=10&status=active" + domainSuffix);
|
||||||
|
const projects = data?.items || [];
|
||||||
|
return (
|
||||||
|
<div className="space-y-3">
|
||||||
|
{projects.length === 0 ? (
|
||||||
|
<p className="text-sm text-muted-foreground">No active projects</p>
|
||||||
|
) : (
|
||||||
|
projects.slice(0, 5).map((p) => (
|
||||||
|
<div key={p.id} className="space-y-1">
|
||||||
|
<div className="flex items-center justify-between text-sm">
|
||||||
|
<span className="truncate flex-1">{p.name}</span>
|
||||||
|
<span className="text-xs text-muted-foreground shrink-0 ml-2">{p.progress || 0}%</span>
|
||||||
|
</div>
|
||||||
|
<Progress value={p.progress || 0} className="h-1.5" />
|
||||||
|
</div>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function UpcomingEventsWidget() {
|
||||||
|
const activeDomainId = useApiDomain();
|
||||||
|
const domainSuffix = activeDomainId ? "&domain=" + activeDomainId : "";
|
||||||
|
const { data } = useApiQuery<PaginatedResponse<CalendarEvent>>(["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 (
|
||||||
|
<div className="space-y-2">
|
||||||
|
{upcoming.length === 0 ? (
|
||||||
|
<p className="text-sm text-muted-foreground">No upcoming events</p>
|
||||||
|
) : (
|
||||||
|
upcoming.slice(0, 5).map((e) => (
|
||||||
|
<div key={e.id} className="flex items-center gap-2 text-sm py-1">
|
||||||
|
<div className="w-2 h-2 rounded-full shrink-0" style={{ backgroundColor: e.color || "#3b82f6" }} />
|
||||||
|
<span className="truncate flex-1">{e.title}</span>
|
||||||
|
<span className="text-xs text-muted-foreground shrink-0">{format(parseISO(e.startTime), "MMM d, HH:mm")}</span>
|
||||||
|
</div>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function StreakCounterWidget() {
|
||||||
|
const activeDomainId = useApiDomain();
|
||||||
|
const domainSuffix = activeDomainId ? "&domain=" + activeDomainId : "";
|
||||||
|
const { data } = useApiQuery<PaginatedResponse<Habit>>(["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 (
|
||||||
|
<div className="flex items-center gap-4 h-full">
|
||||||
|
<div className="flex flex-col items-center">
|
||||||
|
<Flame className="h-8 w-8 text-orange-500" />
|
||||||
|
<span className="text-2xl font-bold">{bestStreak}</span>
|
||||||
|
<span className="text-xs text-muted-foreground">Best streak</span>
|
||||||
|
</div>
|
||||||
|
<Separator orientation="vertical" />
|
||||||
|
<div className="flex flex-col items-center">
|
||||||
|
<span className="text-2xl font-bold">{totalActive}</span>
|
||||||
|
<span className="text-xs text-muted-foreground">Active streaks</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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<Task>("/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<Note>("/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 (
|
||||||
|
<form onSubmit={handleSubmit} className="flex gap-2">
|
||||||
|
<div className="flex-1 flex gap-2">
|
||||||
|
<Select value={type} onValueChange={(v) => setType(v as "task" | "note")}>
|
||||||
|
<SelectTrigger className="w-20 h-9"><SelectValue /></SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="task">Task</SelectItem>
|
||||||
|
<SelectItem value="note">Note</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
<Input placeholder="Quick capture..." value={text} onChange={(e) => setText(e.target.value)} className="h-9" />
|
||||||
|
</div>
|
||||||
|
<Button type="button" size="sm" className="h-9" disabled={!text.trim() || createTask.isPending || createNote.isPending} onClick={handleSubmit}>Add</Button>
|
||||||
|
</form>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ProductivityChartWidget() {
|
||||||
|
const activeDomainId = useApiDomain();
|
||||||
|
const domainSuffix = activeDomainId ? "&domain=" + activeDomainId : "";
|
||||||
|
const { data } = useApiQuery<any>(["productivity-chart", activeDomainId], "/analytics/productivity?range=30" + domainSuffix);
|
||||||
|
const stats = data;
|
||||||
|
if (!stats) return <p className="text-sm text-muted-foreground">Loading...</p>;
|
||||||
|
return (
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div className="grid grid-cols-3 gap-2">
|
||||||
|
<div className="text-center p-2 bg-muted/50 rounded">
|
||||||
|
<p className="text-lg font-bold">{stats.totalTasks || 0}</p>
|
||||||
|
<p className="text-[10px] text-muted-foreground">Total</p>
|
||||||
|
</div>
|
||||||
|
<div className="text-center p-2 bg-muted/50 rounded">
|
||||||
|
<p className="text-lg font-bold text-green-500">{stats.completedTasks || 0}</p>
|
||||||
|
<p className="text-[10px] text-muted-foreground">Done</p>
|
||||||
|
</div>
|
||||||
|
<div className="text-center p-2 bg-muted/50 rounded">
|
||||||
|
<p className="text-lg font-bold">{stats.taskCompletionRate || 0}%</p>
|
||||||
|
<p className="text-[10px] text-muted-foreground">Rate</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-muted-foreground text-center">Last {stats.period || 30} days</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function StatsWidget() {
|
||||||
|
const activeDomainId = useApiDomain();
|
||||||
|
const domainSuffix = activeDomainId ? "&domain=" + activeDomainId : "";
|
||||||
|
const { data } = useApiQuery<any>(["stats", activeDomainId], "/analytics/productivity?range=30" + domainSuffix);
|
||||||
|
const stats = data;
|
||||||
|
if (!stats) return <p className="text-sm text-muted-foreground">Loading...</p>;
|
||||||
|
return (
|
||||||
|
<div className="grid grid-cols-3 gap-3 h-full items-center">
|
||||||
|
<div className="text-center">
|
||||||
|
<p className="text-2xl font-bold">{stats.totalTasks || 0}</p>
|
||||||
|
<p className="text-xs text-muted-foreground">Total Tasks</p>
|
||||||
|
</div>
|
||||||
|
<div className="text-center">
|
||||||
|
<p className="text-2xl font-bold text-green-500">{stats.completedTasks || 0}</p>
|
||||||
|
<p className="text-xs text-muted-foreground">Completed</p>
|
||||||
|
</div>
|
||||||
|
<div className="text-center">
|
||||||
|
<p className="text-2xl font-bold">{stats.taskCompletionRate || 0}%</p>
|
||||||
|
<p className="text-xs text-muted-foreground">Rate</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function WidgetRenderer({ type }: { type: string }) {
|
||||||
|
switch (type) {
|
||||||
|
case "tasks_due": return <TasksDueWidget />;
|
||||||
|
case "habits_today": return <HabitsTodayWidget />;
|
||||||
|
case "recent_notes": return <RecentNotesWidget />;
|
||||||
|
case "active_projects": return <ActiveProjectsWidget />;
|
||||||
|
case "upcoming_events": return <UpcomingEventsWidget />;
|
||||||
|
case "streak_counter": return <StreakCounterWidget />;
|
||||||
|
case "quick_capture": return <QuickCaptureWidget />;
|
||||||
|
case "productivity_chart": return <ProductivityChartWidget />;
|
||||||
|
case "stats": return <StatsWidget />;
|
||||||
|
default: return <p className="text-sm text-muted-foreground">Unknown widget: {type}</p>;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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 (
|
||||||
|
<Card
|
||||||
|
className="h-full flex flex-col group lg:[grid-column:span_var(--w)] lg:[grid-row:span_var(--h)]"
|
||||||
|
style={{ "--w": Math.min(widget.layout.w || 2, 4), "--h": widget.layout.h || 2 } as React.CSSProperties}
|
||||||
|
>
|
||||||
|
<CardHeader className="p-3 pb-0 flex flex-row items-center justify-between gap-2">
|
||||||
|
<div className="flex items-center gap-2 min-w-0">
|
||||||
|
<Icon className="h-4 w-4 shrink-0 text-muted-foreground" />
|
||||||
|
<CardTitle className="text-sm font-semibold truncate">{widget.title || typeInfo?.label || widget.type}</CardTitle>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-1 opacity-0 group-hover:opacity-100 transition-opacity shrink-0">
|
||||||
|
<Button variant="ghost" size="icon" className="h-6 w-6" onClick={onConfigure} aria-label="Configure widget"><Settings2 className="h-3.5 w-3.5" /></Button>
|
||||||
|
<Button variant="ghost" size="icon" className="h-6 w-6 text-destructive" onClick={onDelete} aria-label="Remove widget"><Trash2 className="h-3.5 w-3.5" /></Button>
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="p-3 flex-1 overflow-auto">
|
||||||
|
<WidgetRenderer type={widget.type} />
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function AddWidgetDialog({ open, onOpenChange, onAdd }: { open: boolean; onOpenChange: (open: boolean) => void; onAdd: (type: string) => void }) {
|
||||||
|
return (
|
||||||
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||||
|
<DialogContent className="sm:max-w-md">
|
||||||
|
<DialogHeader><DialogTitle>Add Widget</DialogTitle></DialogHeader>
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
{WIDGET_TYPES.map((wt) => {
|
||||||
|
const Icon = wt.icon;
|
||||||
|
return (
|
||||||
|
<button key={wt.id} onClick={() => { onAdd(wt.id); onOpenChange(false); }}
|
||||||
|
className="flex flex-col items-center gap-2 p-4 rounded-lg border hover:bg-accent hover:border-primary transition-colors text-center">
|
||||||
|
<Icon className="h-6 w-6" />
|
||||||
|
<span className="text-xs font-medium">{wt.label}</span>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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 (
|
||||||
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||||
|
<DialogContent>
|
||||||
|
<DialogHeader><DialogTitle>Configure Widget</DialogTitle></DialogHeader>
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div>
|
||||||
|
<Label htmlFor="widget-title">Title</Label>
|
||||||
|
<Input id="widget-title" value={title} onChange={(e) => setTitle(e.target.value)} placeholder="Widget title" />
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
<div>
|
||||||
|
<Label htmlFor="widget-w">Width (columns)</Label>
|
||||||
|
<Select value={String(w)} onValueChange={(v) => setW(parseInt(v))}>
|
||||||
|
<SelectTrigger id="widget-w"><SelectValue /></SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="1">1 column</SelectItem>
|
||||||
|
<SelectItem value="2">2 columns</SelectItem>
|
||||||
|
<SelectItem value="3">3 columns</SelectItem>
|
||||||
|
<SelectItem value="4">4 columns</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Label htmlFor="widget-h">Height (rows)</Label>
|
||||||
|
<Select value={String(h)} onValueChange={(v) => setH(parseInt(v))}>
|
||||||
|
<SelectTrigger id="widget-h"><SelectValue /></SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="1">1 row</SelectItem>
|
||||||
|
<SelectItem value="2">2 rows</SelectItem>
|
||||||
|
<SelectItem value="3">3 rows</SelectItem>
|
||||||
|
<SelectItem value="4">4 rows</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-end gap-2">
|
||||||
|
<Button variant="outline" onClick={() => onOpenChange(false)}>Cancel</Button>
|
||||||
|
<Button onClick={handleSave}>Save</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function DashboardPage() {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
const [addOpen, setAddOpen] = useState(false);
|
||||||
|
const [configOpen, setConfigOpen] = useState(false);
|
||||||
|
const [configWidget, setConfigWidget] = useState<DashboardWidget | null>(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<DashboardWidget>("/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<DashboardWidget>("/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 (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<h1 className="text-2xl font-bold">Dashboard</h1>
|
||||||
|
<Button onClick={() => setAddOpen(true)} aria-label="Add widget"><Plus className="h-4 w-4 mr-2" />Add Widget</Button>
|
||||||
|
</div>
|
||||||
|
{isLoading ? (
|
||||||
|
<div className="flex items-center justify-center py-12 text-muted-foreground">Loading dashboard...</div>
|
||||||
|
) : widgets.length === 0 ? (
|
||||||
|
<div className="text-center py-12">
|
||||||
|
<p className="text-muted-foreground mb-4">Your dashboard is empty. Add some widgets to get started!</p>
|
||||||
|
<Button onClick={() => {
|
||||||
|
const defaults = ["tasks_due", "habits_today", "recent_notes", "active_projects", "upcoming_events", "streak_counter", "quick_capture", "productivity_chart"];
|
||||||
|
defaults.forEach((type, i) => {
|
||||||
|
const typeInfo = WIDGET_TYPES.find((t) => t.id === type);
|
||||||
|
createMutation.mutate({ type, title: typeInfo?.label || type, layout: { x: 0, y: i, w: typeInfo?.defaultW || 2, h: typeInfo?.defaultH || 2 } });
|
||||||
|
});
|
||||||
|
}}><Plus className="h-4 w-4 mr-2" />Add Default Widgets</Button>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="grid grid-cols-1 gap-4 auto-rows-[minmax(120px,auto)] sm:grid-cols-2 lg:grid-cols-4">
|
||||||
|
{widgets.map((w) => (
|
||||||
|
<WidgetCard key={w.id} widget={w} onConfigure={() => handleConfigure(w)} onDelete={() => handleDelete(w.id)} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<AddWidgetDialog open={addOpen} onOpenChange={setAddOpen} onAdd={handleAddWidget} />
|
||||||
|
<ConfigureWidgetDialog widget={configWidget} open={configOpen} onOpenChange={setConfigOpen} onSave={handleSaveConfig} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export const Route = createRoute({
|
||||||
|
getParentRoute: () => appRoute,
|
||||||
|
path: "/",
|
||||||
|
component: DashboardPage,
|
||||||
|
});
|
||||||
@@ -1,511 +1,31 @@
|
|||||||
import { useState, useEffect } from "react";
|
import { Outlet, createRoute } from "@tanstack/react-router";
|
||||||
import { createRoute } from "@tanstack/react-router";
|
|
||||||
import { Route as rootRoute } from "../__root";
|
import { Route as rootRoute } from "../__root";
|
||||||
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
import { Sidebar } from "@/components/shell/sidebar";
|
||||||
import { api, useApiQuery, useApiMutation } from "@/lib/api";
|
import { Topbar } from "@/components/shell/topbar";
|
||||||
import { useRealtime } from "@/hooks/use-realtime";
|
import { CommandPalette } from "@/components/shell/command-palette";
|
||||||
import { useApiDomain } from "@/lib/stores/use-active-domain-store";
|
import { ShortcutsHelp } from "@/components/shell/shortcuts-help";
|
||||||
import { Plus, Settings2, Trash2, ListTodo, Flame, FileText, FolderKanban, Calendar, Zap, TrendingUp, Target } from "lucide-react";
|
import { useKeyboardShortcuts } from "@/hooks/use-keyboard-shortcuts";
|
||||||
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 = [
|
function AppLayout() {
|
||||||
{ id: "tasks_due", label: "Tasks Due Today", icon: ListTodo, defaultW: 2, defaultH: 2 },
|
useKeyboardShortcuts();
|
||||||
{ 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<PaginatedResponse<Task>>(["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 (
|
return (
|
||||||
<div className="space-y-2">
|
<div className="flex h-screen overflow-hidden">
|
||||||
{today.length === 0 && overdue.length === 0 ? (
|
<Sidebar />
|
||||||
<p className="text-sm text-muted-foreground">No tasks due today</p>
|
<div className="flex flex-1 flex-col overflow-hidden">
|
||||||
) : (
|
<Topbar />
|
||||||
<>
|
<main className="flex-1 overflow-auto p-6">
|
||||||
{overdue.length > 0 && (
|
<Outlet />
|
||||||
<div>
|
</main>
|
||||||
<p className="text-xs font-semibold text-destructive mb-1">Overdue ({overdue.length})</p>
|
|
||||||
{overdue.slice(0, 3).map((t) => (
|
|
||||||
<div key={t.id} className="flex items-center gap-2 text-sm py-1">
|
|
||||||
<div className="w-1.5 h-1.5 rounded-full bg-destructive shrink-0" />
|
|
||||||
<span className="truncate flex-1">{t.title}</span>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{today.length > 0 && (
|
|
||||||
<div>
|
|
||||||
<p className="text-xs font-semibold text-amber-500 mb-1">Today ({today.length})</p>
|
|
||||||
{today.slice(0, 5).map((t) => (
|
|
||||||
<div key={t.id} className="flex items-center gap-2 text-sm py-1">
|
|
||||||
<div className="w-1.5 h-1.5 rounded-full bg-amber-500 shrink-0" />
|
|
||||||
<span className="truncate flex-1">{t.title}</span>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function HabitsTodayWidget() {
|
|
||||||
const activeDomainId = useApiDomain();
|
|
||||||
const domainSuffix = activeDomainId ? "&domain=" + activeDomainId : "";
|
|
||||||
const { data } = useApiQuery<PaginatedResponse<Habit>>(["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 (
|
|
||||||
<div className="space-y-1">
|
|
||||||
{habits.length === 0 ? (
|
|
||||||
<p className="text-sm text-muted-foreground">No habits yet</p>
|
|
||||||
) : (
|
|
||||||
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 (
|
|
||||||
<div key={h.id} className="flex items-center gap-2 py-1">
|
|
||||||
<button
|
|
||||||
onClick={() => completeMutation.mutate(h.id)}
|
|
||||||
className={cn("w-4 h-4 rounded border shrink-0 flex items-center justify-center", doneToday ? "bg-green-500 border-green-500" : "border-muted-foreground/30 hover:border-primary")}
|
|
||||||
aria-label={doneToday ? h.name + " (completed)" : "Complete " + h.name}
|
|
||||||
>
|
|
||||||
{doneToday && <span className="text-[10px] text-white">\u2713</span>}
|
|
||||||
</button>
|
|
||||||
<span className="text-sm truncate flex-1">{h.name}</span>
|
|
||||||
{h.streakCount > 0 && (
|
|
||||||
<Badge variant="secondary" className="text-[10px] shrink-0">
|
|
||||||
<Flame className="h-2.5 w-2.5 mr-0.5" />{h.streakCount}
|
|
||||||
</Badge>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
})
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function RecentNotesWidget() {
|
|
||||||
const activeDomainId = useApiDomain();
|
|
||||||
const domainSuffix = activeDomainId ? "&domain=" + activeDomainId : "";
|
|
||||||
const { data } = useApiQuery<PaginatedResponse<Note>>(["recent-notes", activeDomainId], "/notes?limit=5&sort=-updated" + domainSuffix);
|
|
||||||
const notes = data?.items || [];
|
|
||||||
return (
|
|
||||||
<div className="space-y-2">
|
|
||||||
{notes.length === 0 ? (
|
|
||||||
<p className="text-sm text-muted-foreground">No notes yet</p>
|
|
||||||
) : (
|
|
||||||
notes.map((n) => (
|
|
||||||
<div key={n.id} className="text-sm py-1 border-b last:border-0">
|
|
||||||
<p className="font-medium truncate">{n.title}</p>
|
|
||||||
<p className="text-xs text-muted-foreground">{format(parseISO(n.updatedAt), "MMM d, HH:mm")}</p>
|
|
||||||
</div>
|
|
||||||
))
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function ActiveProjectsWidget() {
|
|
||||||
const activeDomainId = useApiDomain();
|
|
||||||
const domainSuffix = activeDomainId ? "&domain=" + activeDomainId : "";
|
|
||||||
const { data } = useApiQuery<PaginatedResponse<Project>>(["active-projects", activeDomainId], "/projects?limit=10&status=active" + domainSuffix);
|
|
||||||
const projects = data?.items || [];
|
|
||||||
return (
|
|
||||||
<div className="space-y-3">
|
|
||||||
{projects.length === 0 ? (
|
|
||||||
<p className="text-sm text-muted-foreground">No active projects</p>
|
|
||||||
) : (
|
|
||||||
projects.slice(0, 5).map((p) => (
|
|
||||||
<div key={p.id} className="space-y-1">
|
|
||||||
<div className="flex items-center justify-between text-sm">
|
|
||||||
<span className="truncate flex-1">{p.name}</span>
|
|
||||||
<span className="text-xs text-muted-foreground shrink-0 ml-2">{p.progress || 0}%</span>
|
|
||||||
</div>
|
|
||||||
<Progress value={p.progress || 0} className="h-1.5" />
|
|
||||||
</div>
|
|
||||||
))
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function UpcomingEventsWidget() {
|
|
||||||
const activeDomainId = useApiDomain();
|
|
||||||
const domainSuffix = activeDomainId ? "&domain=" + activeDomainId : "";
|
|
||||||
const { data } = useApiQuery<PaginatedResponse<CalendarEvent>>(["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 (
|
|
||||||
<div className="space-y-2">
|
|
||||||
{upcoming.length === 0 ? (
|
|
||||||
<p className="text-sm text-muted-foreground">No upcoming events</p>
|
|
||||||
) : (
|
|
||||||
upcoming.slice(0, 5).map((e) => (
|
|
||||||
<div key={e.id} className="flex items-center gap-2 text-sm py-1">
|
|
||||||
<div className="w-2 h-2 rounded-full shrink-0" style={{ backgroundColor: e.color || "#3b82f6" }} />
|
|
||||||
<span className="truncate flex-1">{e.title}</span>
|
|
||||||
<span className="text-xs text-muted-foreground shrink-0">{format(parseISO(e.startTime), "MMM d, HH:mm")}</span>
|
|
||||||
</div>
|
|
||||||
))
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function StreakCounterWidget() {
|
|
||||||
const activeDomainId = useApiDomain();
|
|
||||||
const domainSuffix = activeDomainId ? "&domain=" + activeDomainId : "";
|
|
||||||
const { data } = useApiQuery<PaginatedResponse<Habit>>(["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 (
|
|
||||||
<div className="flex items-center gap-4 h-full">
|
|
||||||
<div className="flex flex-col items-center">
|
|
||||||
<Flame className="h-8 w-8 text-orange-500" />
|
|
||||||
<span className="text-2xl font-bold">{bestStreak}</span>
|
|
||||||
<span className="text-xs text-muted-foreground">Best streak</span>
|
|
||||||
</div>
|
</div>
|
||||||
<Separator orientation="vertical" />
|
<CommandPalette />
|
||||||
<div className="flex flex-col items-center">
|
<ShortcutsHelp />
|
||||||
<span className="text-2xl font-bold">{totalActive}</span>
|
|
||||||
<span className="text-xs text-muted-foreground">Active streaks</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
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<Task>("/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<Note>("/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 (
|
|
||||||
<form onSubmit={handleSubmit} className="flex gap-2">
|
|
||||||
<div className="flex-1 flex gap-2">
|
|
||||||
<Select value={type} onValueChange={(v) => setType(v as "task" | "note")}>
|
|
||||||
<SelectTrigger className="w-20 h-9"><SelectValue /></SelectTrigger>
|
|
||||||
<SelectContent>
|
|
||||||
<SelectItem value="task">Task</SelectItem>
|
|
||||||
<SelectItem value="note">Note</SelectItem>
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
<Input placeholder="Quick capture..." value={text} onChange={(e) => setText(e.target.value)} className="h-9" />
|
|
||||||
</div>
|
|
||||||
<Button type="button" size="sm" className="h-9" disabled={!text.trim() || createTask.isPending || createNote.isPending} onClick={handleSubmit}>Add</Button>
|
|
||||||
</form>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function ProductivityChartWidget() {
|
|
||||||
const activeDomainId = useApiDomain();
|
|
||||||
const domainSuffix = activeDomainId ? "&domain=" + activeDomainId : "";
|
|
||||||
const { data } = useApiQuery<any>(["productivity-chart", activeDomainId], "/analytics/productivity?range=30" + domainSuffix);
|
|
||||||
const stats = data;
|
|
||||||
if (!stats) return <p className="text-sm text-muted-foreground">Loading...</p>;
|
|
||||||
return (
|
|
||||||
<div className="space-y-3">
|
|
||||||
<div className="grid grid-cols-3 gap-2">
|
|
||||||
<div className="text-center p-2 bg-muted/50 rounded">
|
|
||||||
<p className="text-lg font-bold">{stats.totalTasks || 0}</p>
|
|
||||||
<p className="text-[10px] text-muted-foreground">Total</p>
|
|
||||||
</div>
|
|
||||||
<div className="text-center p-2 bg-muted/50 rounded">
|
|
||||||
<p className="text-lg font-bold text-green-500">{stats.completedTasks || 0}</p>
|
|
||||||
<p className="text-[10px] text-muted-foreground">Done</p>
|
|
||||||
</div>
|
|
||||||
<div className="text-center p-2 bg-muted/50 rounded">
|
|
||||||
<p className="text-lg font-bold">{stats.taskCompletionRate || 0}%</p>
|
|
||||||
<p className="text-[10px] text-muted-foreground">Rate</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<p className="text-xs text-muted-foreground text-center">Last {stats.period || 30} days</p>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function StatsWidget() {
|
|
||||||
const activeDomainId = useApiDomain();
|
|
||||||
const domainSuffix = activeDomainId ? "&domain=" + activeDomainId : "";
|
|
||||||
const { data } = useApiQuery<any>(["stats", activeDomainId], "/analytics/productivity?range=30" + domainSuffix);
|
|
||||||
const stats = data;
|
|
||||||
if (!stats) return <p className="text-sm text-muted-foreground">Loading...</p>;
|
|
||||||
return (
|
|
||||||
<div className="grid grid-cols-3 gap-3 h-full items-center">
|
|
||||||
<div className="text-center">
|
|
||||||
<p className="text-2xl font-bold">{stats.totalTasks || 0}</p>
|
|
||||||
<p className="text-xs text-muted-foreground">Total Tasks</p>
|
|
||||||
</div>
|
|
||||||
<div className="text-center">
|
|
||||||
<p className="text-2xl font-bold text-green-500">{stats.completedTasks || 0}</p>
|
|
||||||
<p className="text-xs text-muted-foreground">Completed</p>
|
|
||||||
</div>
|
|
||||||
<div className="text-center">
|
|
||||||
<p className="text-2xl font-bold">{stats.taskCompletionRate || 0}%</p>
|
|
||||||
<p className="text-xs text-muted-foreground">Rate</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function WidgetRenderer({ type }: { type: string }) {
|
|
||||||
switch (type) {
|
|
||||||
case "tasks_due": return <TasksDueWidget />;
|
|
||||||
case "habits_today": return <HabitsTodayWidget />;
|
|
||||||
case "recent_notes": return <RecentNotesWidget />;
|
|
||||||
case "active_projects": return <ActiveProjectsWidget />;
|
|
||||||
case "upcoming_events": return <UpcomingEventsWidget />;
|
|
||||||
case "streak_counter": return <StreakCounterWidget />;
|
|
||||||
case "quick_capture": return <QuickCaptureWidget />;
|
|
||||||
case "productivity_chart": return <ProductivityChartWidget />;
|
|
||||||
case "stats": return <StatsWidget />;
|
|
||||||
default: return <p className="text-sm text-muted-foreground">Unknown widget: {type}</p>;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
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 (
|
|
||||||
<Card
|
|
||||||
className="h-full flex flex-col group lg:[grid-column:span_var(--w)] lg:[grid-row:span_var(--h)]"
|
|
||||||
style={{ "--w": Math.min(widget.layout.w || 2, 4), "--h": widget.layout.h || 2 } as React.CSSProperties}
|
|
||||||
>
|
|
||||||
<CardHeader className="p-3 pb-0 flex flex-row items-center justify-between gap-2">
|
|
||||||
<div className="flex items-center gap-2 min-w-0">
|
|
||||||
<Icon className="h-4 w-4 shrink-0 text-muted-foreground" />
|
|
||||||
<CardTitle className="text-sm font-semibold truncate">{widget.title || typeInfo?.label || widget.type}</CardTitle>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-1 opacity-0 group-hover:opacity-100 transition-opacity shrink-0">
|
|
||||||
<Button variant="ghost" size="icon" className="h-6 w-6" onClick={onConfigure} aria-label="Configure widget"><Settings2 className="h-3.5 w-3.5" /></Button>
|
|
||||||
<Button variant="ghost" size="icon" className="h-6 w-6 text-destructive" onClick={onDelete} aria-label="Remove widget"><Trash2 className="h-3.5 w-3.5" /></Button>
|
|
||||||
</div>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent className="p-3 flex-1 overflow-auto">
|
|
||||||
<WidgetRenderer type={widget.type} />
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function AddWidgetDialog({ open, onOpenChange, onAdd }: { open: boolean; onOpenChange: (open: boolean) => void; onAdd: (type: string) => void }) {
|
|
||||||
return (
|
|
||||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
|
||||||
<DialogContent className="sm:max-w-md">
|
|
||||||
<DialogHeader><DialogTitle>Add Widget</DialogTitle></DialogHeader>
|
|
||||||
<div className="grid grid-cols-2 gap-3">
|
|
||||||
{WIDGET_TYPES.map((wt) => {
|
|
||||||
const Icon = wt.icon;
|
|
||||||
return (
|
|
||||||
<button key={wt.id} onClick={() => { onAdd(wt.id); onOpenChange(false); }}
|
|
||||||
className="flex flex-col items-center gap-2 p-4 rounded-lg border hover:bg-accent hover:border-primary transition-colors text-center">
|
|
||||||
<Icon className="h-6 w-6" />
|
|
||||||
<span className="text-xs font-medium">{wt.label}</span>
|
|
||||||
</button>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
</DialogContent>
|
|
||||||
</Dialog>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
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 (
|
|
||||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
|
||||||
<DialogContent>
|
|
||||||
<DialogHeader><DialogTitle>Configure Widget</DialogTitle></DialogHeader>
|
|
||||||
<div className="space-y-4">
|
|
||||||
<div>
|
|
||||||
<Label htmlFor="widget-title">Title</Label>
|
|
||||||
<Input id="widget-title" value={title} onChange={(e) => setTitle(e.target.value)} placeholder="Widget title" />
|
|
||||||
</div>
|
|
||||||
<div className="grid grid-cols-2 gap-4">
|
|
||||||
<div>
|
|
||||||
<Label htmlFor="widget-w">Width (columns)</Label>
|
|
||||||
<Select value={String(w)} onValueChange={(v) => setW(parseInt(v))}>
|
|
||||||
<SelectTrigger id="widget-w"><SelectValue /></SelectTrigger>
|
|
||||||
<SelectContent>
|
|
||||||
<SelectItem value="1">1 column</SelectItem>
|
|
||||||
<SelectItem value="2">2 columns</SelectItem>
|
|
||||||
<SelectItem value="3">3 columns</SelectItem>
|
|
||||||
<SelectItem value="4">4 columns</SelectItem>
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<Label htmlFor="widget-h">Height (rows)</Label>
|
|
||||||
<Select value={String(h)} onValueChange={(v) => setH(parseInt(v))}>
|
|
||||||
<SelectTrigger id="widget-h"><SelectValue /></SelectTrigger>
|
|
||||||
<SelectContent>
|
|
||||||
<SelectItem value="1">1 row</SelectItem>
|
|
||||||
<SelectItem value="2">2 rows</SelectItem>
|
|
||||||
<SelectItem value="3">3 rows</SelectItem>
|
|
||||||
<SelectItem value="4">4 rows</SelectItem>
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="flex justify-end gap-2">
|
|
||||||
<Button variant="outline" onClick={() => onOpenChange(false)}>Cancel</Button>
|
|
||||||
<Button onClick={handleSave}>Save</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</DialogContent>
|
|
||||||
</Dialog>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function DashboardPage() {
|
|
||||||
const queryClient = useQueryClient();
|
|
||||||
const [addOpen, setAddOpen] = useState(false);
|
|
||||||
const [configOpen, setConfigOpen] = useState(false);
|
|
||||||
const [configWidget, setConfigWidget] = useState<DashboardWidget | null>(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<DashboardWidget>("/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<DashboardWidget>("/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 (
|
|
||||||
<div className="space-y-4">
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<h1 className="text-2xl font-bold">Dashboard</h1>
|
|
||||||
<Button onClick={() => setAddOpen(true)} aria-label="Add widget"><Plus className="h-4 w-4 mr-2" />Add Widget</Button>
|
|
||||||
</div>
|
|
||||||
{isLoading ? (
|
|
||||||
<div className="flex items-center justify-center py-12 text-muted-foreground">Loading dashboard...</div>
|
|
||||||
) : widgets.length === 0 ? (
|
|
||||||
<div className="text-center py-12">
|
|
||||||
<p className="text-muted-foreground mb-4">Your dashboard is empty. Add some widgets to get started!</p>
|
|
||||||
<Button onClick={() => {
|
|
||||||
const defaults = ["tasks_due", "habits_today", "recent_notes", "active_projects", "upcoming_events", "streak_counter", "quick_capture", "productivity_chart"];
|
|
||||||
defaults.forEach((type, i) => {
|
|
||||||
const typeInfo = WIDGET_TYPES.find((t) => t.id === type);
|
|
||||||
createMutation.mutate({ type, title: typeInfo?.label || type, layout: { x: 0, y: i, w: typeInfo?.defaultW || 2, h: typeInfo?.defaultH || 2 } });
|
|
||||||
});
|
|
||||||
}}><Plus className="h-4 w-4 mr-2" />Add Default Widgets</Button>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div className="grid grid-cols-1 gap-4 auto-rows-[minmax(120px,auto)] sm:grid-cols-2 lg:grid-cols-4">
|
|
||||||
{widgets.map((w) => (
|
|
||||||
<WidgetCard key={w.id} widget={w} onConfigure={() => handleConfigure(w)} onDelete={() => handleDelete(w.id)} />
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
<AddWidgetDialog open={addOpen} onOpenChange={setAddOpen} onAdd={handleAddWidget} />
|
|
||||||
<ConfigureWidgetDialog widget={configWidget} open={configOpen} onOpenChange={setConfigOpen} onSave={handleSaveConfig} />
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export const Route = createRoute({
|
export const Route = createRoute({
|
||||||
getParentRoute: () => rootRoute,
|
getParentRoute: () => rootRoute,
|
||||||
path: "/",
|
id: "_app",
|
||||||
component: DashboardPage,
|
component: AppLayout,
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user