UI redesign: data-dense professional treatment across entire app
Foundation: - Rewrote CSS design tokens for light/dark modes (compact density default) - Added JetBrains Mono for monospace accents on numeric data/timestamps - Vibrant semantic color palette (tasks=blue, habits=green, projects=purple, notes=amber) - Removed accent color toggle system, pinned interactive accent blue - New shell surface tokens (sidebar-bg, sidebar-border, topbar-bg) Shell: - Sidebar: colored entity-type icons, always-visible shortcuts, left accent border - Topbar: reduced to h-10, backdrop blur, monospace search shortcut Pages (all 12 existing): - Border treatment (no shadows), hover:bg-muted/20 transitions - font-mono for all numeric data, timestamps, IDs, status codes - Tighter spacing (gap-3, space-y-3), text-xl headers New pages: - Inbox (/inbox) - focused attention view with overdue/today habits/recent notes - Reports (/reports) - richer analytics with stat cards, charts, CSV export - Templates (/templates) - localStorage-based task/note template management Entity detail pages: - Consistent border-t pt-3 dividers, monospace timestamps - Added TagManager to projects (consistency fix) - Removed Card wrappers from activity/comments (flat sections) Login: split-layout with branding panel and floating colored dots Shared components: colored EmptyState icons, monospace error messages Command palette & shortcuts: new nav items for Inbox/Reports/Analytics Cleanup: removed all hover:shadow-md and ACCENT_PALETTE references
This commit is contained in:
@@ -5,6 +5,9 @@
|
||||
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Project E</title>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500&display=swap" rel="stylesheet" />
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
|
||||
@@ -43,35 +43,35 @@ export function EntityDetailPage({
|
||||
const navigate = useNavigate();
|
||||
|
||||
return (
|
||||
<div className={cn("mx-auto space-y-6 p-0", containerClassName || "max-w-4xl")}>
|
||||
<div className="sticky top-0 z-10 -mx-1 mb-4 border-b bg-background/95 py-3 backdrop-blur">
|
||||
<div className={cn("mx-auto space-y-4 p-0", containerClassName || "max-w-4xl")}>
|
||||
<div className="sticky top-0 z-10 -mx-1 mb-2 border-b bg-background/95 py-2 backdrop-blur">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => navigate({ to: backTo.to })}
|
||||
className="mb-3"
|
||||
className="mb-2 h-7 text-xs"
|
||||
>
|
||||
<ArrowLeft className="h-4 w-4 mr-2" />
|
||||
<ArrowLeft className="h-3.5 w-3.5 mr-1.5" />
|
||||
{backTo.label}
|
||||
</Button>
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div className="flex items-start gap-3">
|
||||
{icon && <div className="mt-1 text-muted-foreground">{icon}</div>}
|
||||
<div className="space-y-1.5">
|
||||
<h1 className="text-2xl font-bold leading-tight">{title}</h1>
|
||||
<div className="flex items-start gap-2.5">
|
||||
{icon && <div className="mt-0.5 text-muted-foreground">{icon}</div>}
|
||||
<div className="space-y-1">
|
||||
<h1 className="text-xl font-bold leading-tight">{title}</h1>
|
||||
{badges && (
|
||||
<div className="flex flex-wrap items-center gap-2">{badges}</div>
|
||||
<div className="flex flex-wrap items-center gap-1.5">{badges}</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{actions && <div className="flex shrink-0 items-center gap-2">{actions}</div>}
|
||||
{actions && <div className="flex shrink-0 items-center gap-1.5">{actions}</div>}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Tabs defaultValue={defaultTab || tabs[0]?.value}>
|
||||
<TabsList className="flex-wrap gap-1">
|
||||
<TabsList className="flex-wrap gap-0.5">
|
||||
{tabs.map((tab) => (
|
||||
<TabsTrigger key={tab.value} value={tab.value}>
|
||||
<TabsTrigger key={tab.value} value={tab.value} className="text-xs">
|
||||
{tab.label}
|
||||
</TabsTrigger>
|
||||
))}
|
||||
|
||||
@@ -12,7 +12,6 @@ import {
|
||||
type LucideIcon,
|
||||
} from "lucide-react";
|
||||
import { formatDistanceToNow, parseISO } from "date-fns";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { ErrorState, LoadingState } from "@/components/state";
|
||||
import { useApiQuery } from "@/lib/api";
|
||||
import type { ActivityItem } from "@/lib/types";
|
||||
@@ -66,15 +65,15 @@ function ActivityRow({ item }: { item: ActivityItem }) {
|
||||
const summary = changesSummary(item.action, item.changes);
|
||||
|
||||
return (
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="flex h-7 w-7 shrink-0 items-center justify-center rounded-full bg-muted">
|
||||
<Icon className="h-3.5 w-3.5 text-muted-foreground" />
|
||||
<div className="flex items-start gap-2.5 py-2">
|
||||
<div className="flex h-6 w-6 shrink-0 items-center justify-center rounded-full bg-muted">
|
||||
<Icon className="h-3 w-3 text-muted-foreground" />
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="flex flex-wrap items-baseline gap-x-1.5 gap-y-0.5">
|
||||
<span className="text-sm font-medium text-foreground">{item.actor}</span>
|
||||
<span className="text-sm text-muted-foreground">{meta.label}</span>
|
||||
<span className="text-xs text-muted-foreground/70">
|
||||
<span className="font-mono text-[11px] text-muted-foreground/70">
|
||||
{formatDistanceToNow(parseISO(item.createdAt), { addSuffix: true })}
|
||||
</span>
|
||||
</p>
|
||||
@@ -102,29 +101,24 @@ export function EntityActivity({ entityType, entityId, limit }: EntityActivityPr
|
||||
const items = data?.items ?? [];
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Activity</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{isLoading ? <LoadingState label="Loading activity..." /> : null}
|
||||
{isError ? (
|
||||
<ErrorState
|
||||
message={error instanceof Error ? error.message : "Failed to load activity"}
|
||||
onRetry={() => refetch()}
|
||||
/>
|
||||
) : null}
|
||||
{!isLoading && !isError && items.length === 0 ? (
|
||||
<p className="py-8 text-center text-sm text-muted-foreground">No activity yet</p>
|
||||
) : null}
|
||||
{!isLoading && !isError && items.length > 0 ? (
|
||||
<div className="space-y-3">
|
||||
{items.map((item) => (
|
||||
<ActivityRow key={item.id} item={item} />
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
</CardContent>
|
||||
</Card>
|
||||
<div>
|
||||
{isLoading ? <LoadingState label="Loading activity..." /> : null}
|
||||
{isError ? (
|
||||
<ErrorState
|
||||
message={error instanceof Error ? error.message : "Failed to load activity"}
|
||||
onRetry={() => refetch()}
|
||||
/>
|
||||
) : null}
|
||||
{!isLoading && !isError && items.length === 0 ? (
|
||||
<p className="py-8 text-center text-sm text-muted-foreground">No activity yet</p>
|
||||
) : null}
|
||||
{!isLoading && !isError && items.length > 0 ? (
|
||||
<div className="divide-y">
|
||||
{items.map((item) => (
|
||||
<ActivityRow key={item.id} item={item} />
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -5,7 +5,6 @@ import { toast } from "sonner";
|
||||
import { formatDistanceToNow, parseISO } from "date-fns";
|
||||
import { Avatar, AvatarFallback } from "@/components/ui/avatar";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { ErrorState, LoadingState } from "@/components/state";
|
||||
import { api, useApiMutation, useApiQuery } from "@/lib/api";
|
||||
@@ -166,7 +165,7 @@ function CommentThread({ node, onDelete, deletePending }: CommentThreadProps) {
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="flex flex-wrap items-baseline gap-x-2 gap-y-0.5">
|
||||
<span className="text-sm font-medium">{comment.author}</span>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
<span className="font-mono text-[11px] text-muted-foreground">
|
||||
{formatDistanceToNow(parseISO(comment.createdAt), { addSuffix: true })}
|
||||
</span>
|
||||
</p>
|
||||
@@ -250,37 +249,32 @@ export function EntityComments({ entityType, entityId }: EntityCommentsProps) {
|
||||
const tree = useMemo(() => buildCommentTree(data?.items ?? []), [data?.items]);
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Comments</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<CommentComposer entityType={entityType} entityId={entityId} />
|
||||
{isLoading ? <LoadingState label="Loading comments..." /> : null}
|
||||
{isError ? (
|
||||
<ErrorState
|
||||
message={error instanceof Error ? error.message : "Failed to load comments"}
|
||||
onRetry={() => refetch()}
|
||||
/>
|
||||
) : null}
|
||||
{!isLoading && !isError && tree.length === 0 ? (
|
||||
<p className="py-8 text-center text-sm text-muted-foreground">
|
||||
No comments yet — start the conversation.
|
||||
</p>
|
||||
) : null}
|
||||
{!isLoading && !isError && tree.length > 0 ? (
|
||||
<div className="space-y-4">
|
||||
{tree.map((node) => (
|
||||
<CommentThread
|
||||
key={node.comment.id}
|
||||
node={node}
|
||||
onDelete={(id) => deleteMutation.mutate(id)}
|
||||
deletePending={deleteMutation.isPending}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
</CardContent>
|
||||
</Card>
|
||||
<div className="space-y-4">
|
||||
<CommentComposer entityType={entityType} entityId={entityId} />
|
||||
{isLoading ? <LoadingState label="Loading comments..." /> : null}
|
||||
{isError ? (
|
||||
<ErrorState
|
||||
message={error instanceof Error ? error.message : "Failed to load comments"}
|
||||
onRetry={() => refetch()}
|
||||
/>
|
||||
) : null}
|
||||
{!isLoading && !isError && tree.length === 0 ? (
|
||||
<p className="py-8 text-center text-sm text-muted-foreground">
|
||||
No comments yet — start the conversation.
|
||||
</p>
|
||||
) : null}
|
||||
{!isLoading && !isError && tree.length > 0 ? (
|
||||
<div className="divide-y">
|
||||
{tree.map((node) => (
|
||||
<CommentThread
|
||||
key={node.comment.id}
|
||||
node={node}
|
||||
onDelete={(id) => deleteMutation.mutate(id)}
|
||||
deletePending={deleteMutation.isPending}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -6,14 +6,15 @@ import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@
|
||||
import { X } from "lucide-react";
|
||||
import type { Tag, PaginatedResponse } from "@/lib/types";
|
||||
|
||||
const ENTITY_ROUTES: Record<"task" | "habit" | "note", string> = {
|
||||
const ENTITY_ROUTES: Record<"task" | "habit" | "note" | "project", string> = {
|
||||
task: "tasks",
|
||||
habit: "habits",
|
||||
note: "notes",
|
||||
project: "projects",
|
||||
};
|
||||
|
||||
interface TagManagerProps {
|
||||
entityType: "task" | "habit" | "note";
|
||||
entityType: "task" | "habit" | "note" | "project";
|
||||
entityId: string;
|
||||
tags: Tag[];
|
||||
}
|
||||
|
||||
@@ -14,8 +14,9 @@ import {
|
||||
Search,
|
||||
Sun,
|
||||
Moon,
|
||||
Palette,
|
||||
LogOut,
|
||||
Inbox,
|
||||
FileBarChart,
|
||||
type LucideIcon,
|
||||
} from "lucide-react";
|
||||
import {
|
||||
@@ -27,7 +28,7 @@ import {
|
||||
CommandList,
|
||||
CommandSeparator,
|
||||
} from "@/components/ui/command";
|
||||
import { useThemeStore, type AccentColor, ACCENT_PALETTE } from "@/lib/stores/use-theme-store";
|
||||
import { useThemeStore } from "@/lib/stores/use-theme-store";
|
||||
import { useCreateDialogStore } from "@/lib/stores/use-create-dialog-store";
|
||||
import { useApiDomain } from "@/lib/stores/use-active-domain-store";
|
||||
|
||||
@@ -38,6 +39,7 @@ interface NavItem {
|
||||
}
|
||||
|
||||
const navItems: NavItem[] = [
|
||||
{ label: "Inbox", href: "/inbox", icon: Inbox },
|
||||
{ label: "Dashboard", href: "/", icon: LayoutDashboard },
|
||||
{ label: "Tasks", href: "/tasks", icon: ListTodo },
|
||||
{ label: "Habits", href: "/habits", icon: Flame },
|
||||
@@ -45,6 +47,8 @@ const navItems: NavItem[] = [
|
||||
{ label: "Notes", href: "/notes", icon: NotebookPen },
|
||||
{ label: "Graph", href: "/graph", icon: Share2 },
|
||||
{ label: "Calendar", href: "/calendar", icon: CalendarDays },
|
||||
{ label: "Analytics", href: "/analytics", icon: LayoutDashboard },
|
||||
{ label: "Reports", href: "/reports", icon: FileBarChart },
|
||||
{ label: "Agent Activity", href: "/agents/activity", icon: Bot },
|
||||
{ label: "Settings", href: "/settings", icon: Settings },
|
||||
];
|
||||
@@ -73,7 +77,7 @@ function addRecentPage(href: string) {
|
||||
export function CommandPalette() {
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
const { mode, setMode, accent, setAccent } = useThemeStore();
|
||||
const { mode, setMode } = useThemeStore();
|
||||
const activeDomainId = useApiDomain();
|
||||
const [open, setOpen] = useState(false);
|
||||
const [searchResults, setSearchResults] = useState<
|
||||
@@ -152,13 +156,6 @@ export function CommandPalette() {
|
||||
icon: mode === "dark" ? Sun : Moon,
|
||||
action: () => setMode(mode === "dark" ? "light" : "dark"),
|
||||
},
|
||||
...(Object.entries(ACCENT_PALETTE) as [AccentColor, { name: string; hsl: string }][]).map(
|
||||
([key, val]) => ({
|
||||
label: `Accent: ${val.name}`,
|
||||
icon: Palette,
|
||||
action: () => setAccent(key),
|
||||
})
|
||||
),
|
||||
];
|
||||
|
||||
// Settings actions
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
const shortcutGroups = [
|
||||
{
|
||||
heading: "Navigation",
|
||||
color: "text-blue-500",
|
||||
shortcuts: [
|
||||
{ keys: "g then d", description: "Go to Dashboard" },
|
||||
{ keys: "g then t", description: "Go to Tasks" },
|
||||
@@ -23,6 +24,7 @@ const shortcutGroups = [
|
||||
},
|
||||
{
|
||||
heading: "Create",
|
||||
color: "text-amber-500",
|
||||
shortcuts: [
|
||||
{ keys: "n then t", description: "New task" },
|
||||
{ keys: "n then h", description: "New habit" },
|
||||
@@ -33,6 +35,7 @@ const shortcutGroups = [
|
||||
},
|
||||
{
|
||||
heading: "General",
|
||||
color: "text-muted-foreground",
|
||||
shortcuts: [
|
||||
{ keys: "⌘K / Ctrl+K", description: "Open command palette" },
|
||||
{ keys: "/", description: "Open command palette" },
|
||||
@@ -43,6 +46,7 @@ const shortcutGroups = [
|
||||
},
|
||||
{
|
||||
heading: "Command Palette",
|
||||
color: "text-muted-foreground",
|
||||
shortcuts: [
|
||||
{ keys: "↑↓", description: "Navigate items" },
|
||||
{ keys: "Enter", description: "Select item" },
|
||||
@@ -62,24 +66,24 @@ export function ShortcutsHelp() {
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={setOpen}>
|
||||
<DialogContent className="max-w-md">
|
||||
<DialogContent className="max-w-md border">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Keyboard Shortcuts</DialogTitle>
|
||||
<DialogDescription>
|
||||
Use these shortcuts to navigate quickly. Press two-key combos like "g then t" in sequence within 1.5 seconds.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-3">
|
||||
{shortcutGroups.map((group) => (
|
||||
<div key={group.heading}>
|
||||
<h4 className="mb-2 text-sm font-semibold text-muted-foreground">
|
||||
<h4 className={`mb-1 text-[10px] font-semibold uppercase tracking-wider ${group.color}`}>
|
||||
{group.heading}
|
||||
</h4>
|
||||
<div className="space-y-2">
|
||||
<div className="space-y-1">
|
||||
{group.shortcuts.map((s) => (
|
||||
<div
|
||||
key={s.keys}
|
||||
className="flex items-center justify-between text-sm"
|
||||
className="flex items-center justify-between py-0.5 text-xs"
|
||||
>
|
||||
<span className="text-muted-foreground">{s.description}</span>
|
||||
<kbd className="pointer-events-none inline-flex h-5 select-none items-center gap-1 rounded border bg-muted px-1.5 font-mono text-[10px] font-medium text-muted-foreground">
|
||||
|
||||
@@ -9,7 +9,6 @@ import {
|
||||
Flame,
|
||||
FolderKanban,
|
||||
NotebookPen,
|
||||
BookOpen,
|
||||
Share2,
|
||||
CalendarDays,
|
||||
Search,
|
||||
@@ -21,6 +20,9 @@ import {
|
||||
ChevronRight,
|
||||
LogOut,
|
||||
User,
|
||||
Inbox,
|
||||
FileBarChart,
|
||||
FileStack,
|
||||
} from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||
@@ -56,6 +58,7 @@ interface NavItem {
|
||||
}
|
||||
|
||||
const navItems: NavItem[] = [
|
||||
{ href: "/inbox", label: "Inbox", icon: Inbox, shortcut: "g i" },
|
||||
{ href: "/", label: "Dashboard", icon: LayoutDashboard, shortcut: "g d" },
|
||||
{ href: "/tasks", label: "Tasks", icon: ListTodo, shortcut: "g t" },
|
||||
{ href: "/habits", label: "Habits", icon: Flame, shortcut: "g h" },
|
||||
@@ -65,14 +68,40 @@ const navItems: NavItem[] = [
|
||||
{ href: "/graph", label: "Graph", icon: Share2, shortcut: "g g" },
|
||||
{ href: "/search", label: "Search", icon: Search },
|
||||
{ href: "/analytics", label: "Analytics", icon: BarChart3 },
|
||||
{ href: "/reports", label: "Reports", icon: FileBarChart, shortcut: "g r" },
|
||||
{ href: "/agents/activity", label: "Agent Activity", icon: Bot },
|
||||
{ href: "/daily", label: "Daily Notes", icon: PenLine },
|
||||
{ href: "/templates", label: "Templates", icon: FileStack },
|
||||
];
|
||||
|
||||
const bottomItems: NavItem[] = [
|
||||
{ href: "/settings", label: "Settings", icon: Settings, shortcut: "g s" },
|
||||
];
|
||||
|
||||
/** Map nav href to an ENTITY key for icon coloring. */
|
||||
function entityKey(href: string): string {
|
||||
if (href === "/") return "dashboard";
|
||||
const segment = href.split("/")[1];
|
||||
return segment || "dashboard";
|
||||
}
|
||||
|
||||
const ICON_COLORS: Record<string, string> = {
|
||||
dashboard: "hsl(var(--foreground))",
|
||||
inbox: "#f97316",
|
||||
tasks: "#3b82f6",
|
||||
habits: "#10b981",
|
||||
projects: "#8b5cf6",
|
||||
notes: "#f59e0b",
|
||||
calendar: "hsl(var(--foreground))",
|
||||
graph: "hsl(var(--foreground))",
|
||||
search: "hsl(var(--foreground))",
|
||||
analytics: "hsl(var(--foreground))",
|
||||
reports: "#6366f1",
|
||||
agents: "hsl(var(--foreground))",
|
||||
daily: "hsl(var(--foreground))",
|
||||
templates: "#6366f1",
|
||||
};
|
||||
|
||||
export function Sidebar() {
|
||||
const location = useLocation();
|
||||
const navigate = useNavigate();
|
||||
@@ -81,9 +110,6 @@ export function Sidebar() {
|
||||
const userName = user?.name || user?.email?.split("@")[0] || "User";
|
||||
const userInitials = (user?.name || user?.email || "U").slice(0, 2).toUpperCase();
|
||||
|
||||
// Sidebar position (left/right) is set in Settings. Read once on mount and
|
||||
// update live via the "sidebar-position-change" custom event dispatched by
|
||||
// the settings page.
|
||||
const [sidebarPos, setSidebarPos] = useState<"left" | "right">(() =>
|
||||
typeof window !== "undefined" && localStorage.getItem("sidebar-position") === "right"
|
||||
? "right"
|
||||
@@ -106,25 +132,31 @@ export function Sidebar() {
|
||||
|
||||
const renderNavLink = (item: NavItem, isCollapsed: boolean, onNavigate?: () => void) => {
|
||||
const active = isActive(item.href);
|
||||
const key = entityKey(item.href);
|
||||
const iconColor = ICON_COLORS[key] ?? "hsl(var(--foreground))";
|
||||
|
||||
const link = (
|
||||
<Link
|
||||
key={item.href}
|
||||
to={item.href}
|
||||
onClick={onNavigate}
|
||||
className={cn(
|
||||
"flex items-center gap-3 rounded-lg px-3 py-2 text-sm font-medium transition-colors",
|
||||
"group/item flex items-center gap-3 rounded-md py-2 pl-3 pr-2 text-sm font-medium transition-colors",
|
||||
active
|
||||
? "bg-accent text-accent-foreground"
|
||||
: "text-muted-foreground hover:bg-accent hover:text-accent-foreground"
|
||||
? "border-l-[3px] border-l-[hsl(var(--accent-hsl))] bg-accent/50 text-accent-foreground"
|
||||
: "border-l-[3px] border-l-transparent hover:bg-accent/30 text-muted-foreground hover:text-accent-foreground",
|
||||
isCollapsed && "!justify-center pl-0 pr-0 border-l-0"
|
||||
)}
|
||||
activeProps={{ className: "bg-accent text-accent-foreground" }}
|
||||
activeProps={{ className: "border-l-[3px] border-l-[hsl(var(--accent-hsl))] bg-accent/50 text-accent-foreground" }}
|
||||
>
|
||||
<item.icon className="h-4 w-4 shrink-0" aria-hidden="true" />
|
||||
<span style={active ? { color: iconColor } : undefined} className="inline-flex shrink-0">
|
||||
<item.icon className="h-4 w-4" aria-hidden="true" />
|
||||
</span>
|
||||
{!isCollapsed && (
|
||||
<>
|
||||
<span className="flex-1">{item.label}</span>
|
||||
<span className="flex-1 truncate">{item.label}</span>
|
||||
{item.shortcut && (
|
||||
<kbd className="pointer-events-none inline-flex h-5 select-none items-center gap-1 rounded border bg-muted px-1.5 font-mono text-[10px] font-medium text-muted-foreground opacity-0 group-hover/item:opacity-100 transition-opacity">
|
||||
<kbd className="pointer-events-none inline-flex h-5 shrink-0 select-none items-center rounded border bg-muted px-1.5 font-mono text-[10px] font-medium text-muted-foreground transition-opacity">
|
||||
{item.shortcut}
|
||||
</kbd>
|
||||
)}
|
||||
@@ -136,24 +168,26 @@ export function Sidebar() {
|
||||
if (isCollapsed) {
|
||||
return (
|
||||
<Tooltip key={item.href}>
|
||||
<TooltipTrigger asChild>{link}</TooltipTrigger>
|
||||
<TooltipTrigger asChild>
|
||||
<div className="flex justify-center">{link}</div>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side={sidebarPos === "right" ? "left" : "right"}>{item.label}</TooltipContent>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
|
||||
return <div key={item.href} className="group/item">{link}</div>;
|
||||
return link;
|
||||
};
|
||||
|
||||
const navigation = (isCollapsed: boolean, onNavigate?: () => void) => (
|
||||
<ScrollArea className="flex-1 py-2">
|
||||
<nav className="flex flex-col gap-1 px-2" aria-label="Primary">
|
||||
<nav className="flex flex-col gap-0.5 px-2" aria-label="Primary">
|
||||
{navItems.map((item) => renderNavLink(item, isCollapsed, onNavigate))}
|
||||
</nav>
|
||||
<Separator className="my-3" />
|
||||
<nav className="flex flex-col gap-1 px-2" aria-label="Workspace">
|
||||
<Separator className="my-2" />
|
||||
<nav className="flex flex-col gap-0.5 px-2" aria-label="Workspace">
|
||||
{!isCollapsed && (
|
||||
<p className="px-3 text-xs font-semibold uppercase tracking-wider text-muted-foreground">
|
||||
<p className="px-3 pt-1 pb-1.5 text-[10px] font-semibold uppercase tracking-widest text-muted-foreground">
|
||||
Workspace
|
||||
</p>
|
||||
)}
|
||||
@@ -166,26 +200,28 @@ export function Sidebar() {
|
||||
<TooltipProvider delayDuration={0}>
|
||||
<aside
|
||||
className={cn(
|
||||
"hidden flex-col bg-card transition-all duration-200 md:flex",
|
||||
"hidden flex-col bg-sidebar transition-all duration-200 md:flex",
|
||||
collapsed ? "w-16" : "w-60",
|
||||
sidebarPos === "right" ? "order-last border-l" : "border-r"
|
||||
sidebarPos === "right"
|
||||
? "order-last border-l border-sidebar-border"
|
||||
: "border-r border-sidebar-border"
|
||||
)}
|
||||
aria-label="Main navigation"
|
||||
>
|
||||
{/* Logo + collapse */}
|
||||
<div className="flex h-14 items-center justify-between px-4">
|
||||
<div className="flex h-12 items-center justify-between px-3">
|
||||
{!collapsed && (
|
||||
<Link to="/" className="flex items-center gap-2 font-semibold">
|
||||
<span className="flex h-7 w-7 items-center justify-center rounded-lg bg-primary text-primary-foreground text-sm font-bold">
|
||||
<span className="flex h-6 w-6 items-center justify-center rounded-md bg-primary text-primary-foreground text-xs font-bold">
|
||||
E
|
||||
</span>
|
||||
Project E
|
||||
<span className="text-sm">Project E</span>
|
||||
</Link>
|
||||
)}
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-11 w-11 shrink-0"
|
||||
className="h-8 w-8 shrink-0"
|
||||
onClick={toggle}
|
||||
aria-label={collapsed ? "Expand sidebar" : "Collapse sidebar"}
|
||||
>
|
||||
@@ -202,13 +238,13 @@ export function Sidebar() {
|
||||
{navigation(collapsed)}
|
||||
|
||||
{/* User avatar + logout at bottom */}
|
||||
<div className="border-t p-2">
|
||||
<div className="border-t border-sidebar-border p-2">
|
||||
{collapsed ? (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="ghost" size="icon" className="h-10 w-10">
|
||||
<Avatar className="h-8 w-8">
|
||||
<AvatarFallback className="text-xs">{userInitials}</AvatarFallback>
|
||||
<Button variant="ghost" size="icon" className="h-9 w-9">
|
||||
<Avatar className="h-7 w-7">
|
||||
<AvatarFallback className="text-[10px]">{userInitials}</AvatarFallback>
|
||||
</Avatar>
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
@@ -231,9 +267,9 @@ export function Sidebar() {
|
||||
) : (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="ghost" className="w-full justify-start gap-3 px-3">
|
||||
<Avatar className="h-8 w-8">
|
||||
<AvatarFallback className="text-xs">{userInitials}</AvatarFallback>
|
||||
<Button variant="ghost" className="w-full justify-start gap-2.5 px-3">
|
||||
<Avatar className="h-7 w-7">
|
||||
<AvatarFallback className="text-[10px]">{userInitials}</AvatarFallback>
|
||||
</Avatar>
|
||||
<span className="text-sm font-medium truncate">{userName}</span>
|
||||
</Button>
|
||||
@@ -260,12 +296,12 @@ export function Sidebar() {
|
||||
|
||||
{/* Mobile sheet */}
|
||||
<Sheet open={mobileOpen} onOpenChange={setMobileOpen}>
|
||||
<SheetContent side={sidebarPos === "right" ? "right" : "left"} className="flex w-72 flex-col p-0 md:hidden">
|
||||
<SheetHeader className="border-b px-4 py-4 pr-12">
|
||||
<SheetContent side={sidebarPos === "right" ? "right" : "left"} className="flex w-72 flex-col p-0 bg-sidebar md:hidden">
|
||||
<SheetHeader className="border-b border-sidebar-border px-4 py-3 pr-12">
|
||||
<SheetTitle>Project E</SheetTitle>
|
||||
<SheetDescription>Navigate your workspace.</SheetDescription>
|
||||
</SheetHeader>
|
||||
<div className="border-b p-4">
|
||||
<div className="border-b border-sidebar-border p-3">
|
||||
<DomainPicker />
|
||||
</div>
|
||||
{navigation(false, () => setMobileOpen(false))}
|
||||
|
||||
@@ -1,13 +1,12 @@
|
||||
import { useEffect } from "react";
|
||||
import { useThemeStore, ACCENT_PALETTE, type AccentColor } from "@/lib/stores/use-theme-store";
|
||||
import { useThemeStore } from "@/lib/stores/use-theme-store";
|
||||
|
||||
export function ThemeProvider({ children }: { children: React.ReactNode }) {
|
||||
const { mode, accent } = useThemeStore();
|
||||
const { mode } = useThemeStore();
|
||||
|
||||
useEffect(() => {
|
||||
const root = document.documentElement;
|
||||
|
||||
// Apply dark/light mode
|
||||
if (mode === "system") {
|
||||
const mediaQuery = window.matchMedia("(prefers-color-scheme: dark)");
|
||||
const handler = (e: MediaQueryListEvent | MediaQueryList) => {
|
||||
@@ -22,11 +21,5 @@ export function ThemeProvider({ children }: { children: React.ReactNode }) {
|
||||
}
|
||||
}, [mode]);
|
||||
|
||||
useEffect(() => {
|
||||
const root = document.documentElement;
|
||||
const palette = ACCENT_PALETTE[accent as AccentColor] || ACCENT_PALETTE.blue;
|
||||
root.style.setProperty("--accent-hsl", palette.hsl);
|
||||
}, [accent]);
|
||||
|
||||
return <>{children}</>;
|
||||
}
|
||||
|
||||
@@ -67,7 +67,7 @@ export function Topbar() {
|
||||
|
||||
return (
|
||||
<header
|
||||
className="sticky top-0 z-10 flex h-14 items-center gap-4 border-b bg-card px-6"
|
||||
className="sticky top-0 z-10 flex h-10 items-center gap-4 border-b bg-topbar px-6 backdrop-blur-sm"
|
||||
role="banner"
|
||||
>
|
||||
{/* Mobile menu */}
|
||||
@@ -78,19 +78,20 @@ export function Topbar() {
|
||||
onClick={() => setMobileOpen(true)}
|
||||
aria-label="Open navigation menu"
|
||||
>
|
||||
<Menu className="h-5 w-5" />
|
||||
<Menu className="h-4 w-4" />
|
||||
</Button>
|
||||
|
||||
{/* Search input */}
|
||||
<div className="flex-1 md:max-w-sm">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="w-full justify-start text-muted-foreground"
|
||||
onClick={openPalette}
|
||||
aria-label="Open search (Cmd+K)"
|
||||
>
|
||||
<Search className="mr-2 h-4 w-4" aria-hidden="true" />
|
||||
Search or jump to...{" "}
|
||||
<Search className="mr-2 h-4 w-4 shrink-0" aria-hidden="true" />
|
||||
<span className="truncate">Search or jump to...</span>
|
||||
<kbd className="ml-auto pointer-events-none inline-flex h-5 select-none items-center gap-1 rounded border bg-muted px-1.5 font-mono text-[10px] font-medium text-muted-foreground">
|
||||
⌘K
|
||||
</kbd>
|
||||
@@ -101,7 +102,7 @@ export function Topbar() {
|
||||
<DomainPicker />
|
||||
|
||||
{/* Right side */}
|
||||
<div className="ml-auto flex items-center gap-2">
|
||||
<div className="ml-auto flex items-center gap-1">
|
||||
{/* Quick add */}
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
@@ -109,10 +110,11 @@ export function Topbar() {
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8"
|
||||
onClick={openPalette}
|
||||
aria-label="Quick add"
|
||||
>
|
||||
<Plus className="h-5 w-5" />
|
||||
<Plus className="h-4 w-4" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>Quick add (Cmd+K)</TooltipContent>
|
||||
@@ -125,8 +127,8 @@ export function Topbar() {
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="ghost" size="icon" className="relative" aria-label="Notifications">
|
||||
<Bell className="h-5 w-5" />
|
||||
<Button variant="ghost" size="icon" className="h-8 w-8 relative" aria-label="Notifications">
|
||||
<Bell className="h-4 w-4" />
|
||||
{count > 0 && (
|
||||
<span
|
||||
aria-hidden="true"
|
||||
@@ -177,9 +179,9 @@ export function Topbar() {
|
||||
{/* User avatar */}
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="ghost" size="icon" className="h-9 w-9">
|
||||
<Avatar className="h-8 w-8">
|
||||
<AvatarFallback className="text-xs">U</AvatarFallback>
|
||||
<Button variant="ghost" size="icon" className="h-8 w-8">
|
||||
<Avatar className="h-7 w-7">
|
||||
<AvatarFallback className="text-[10px]">{userInitials}</AvatarFallback>
|
||||
</Avatar>
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
|
||||
@@ -8,9 +8,9 @@ import { Button } from "@/components/ui/button";
|
||||
*/
|
||||
export function LoadingState({ label }: { label?: string }) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center gap-3 py-12 text-muted-foreground">
|
||||
<Loader2 className="h-6 w-6 animate-spin" />
|
||||
{label ? <p className="text-sm">{label}</p> : null}
|
||||
<div className="flex flex-col items-center justify-center gap-2 py-8 text-muted-foreground">
|
||||
<Loader2 className="h-5 w-5 animate-spin" />
|
||||
{label ? <p className="text-xs">{label}</p> : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -24,22 +24,24 @@ export function EmptyState({
|
||||
title,
|
||||
description,
|
||||
action,
|
||||
color,
|
||||
}: {
|
||||
icon?: LucideIcon;
|
||||
title: string;
|
||||
description?: string;
|
||||
action?: ReactNode;
|
||||
color?: string;
|
||||
}) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center gap-3 py-12 text-center">
|
||||
<div className="flex h-12 w-12 items-center justify-center rounded-full bg-muted">
|
||||
<Icon className="h-6 w-6 text-muted-foreground" />
|
||||
<div className="flex flex-col items-center justify-center gap-2 py-8 text-center">
|
||||
<div className="flex h-10 w-10 items-center justify-center rounded-full bg-muted">
|
||||
<Icon className={`h-5 w-5 ${color ?? "text-muted-foreground"}`} />
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<p className="font-semibold">{title}</p>
|
||||
{description ? <p className="text-sm text-muted-foreground">{description}</p> : null}
|
||||
<div className="space-y-0.5">
|
||||
<p className="text-sm font-semibold">{title}</p>
|
||||
{description ? <p className="text-xs text-muted-foreground">{description}</p> : null}
|
||||
</div>
|
||||
{action ? <div className="pt-1">{action}</div> : null}
|
||||
{action ? <div className="pt-0.5">{action}</div> : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -50,12 +52,12 @@ export function EmptyState({
|
||||
*/
|
||||
export function ErrorState({ message, onRetry }: { message: string; onRetry?: () => void }) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center gap-3 py-12 text-center">
|
||||
<AlertCircle className="h-8 w-8 text-destructive" />
|
||||
<p className="text-sm font-medium text-destructive">{message}</p>
|
||||
<div className="flex flex-col items-center justify-center gap-2 py-8 text-center">
|
||||
<AlertCircle className="h-5 w-5 text-destructive" />
|
||||
<p className="text-xs font-medium text-destructive font-mono">{message}</p>
|
||||
{onRetry ? (
|
||||
<Button variant="outline" size="sm" onClick={onRetry}>
|
||||
<RotateCw className="h-4 w-4" />
|
||||
<RotateCw className="h-3 w-3" />
|
||||
Retry
|
||||
</Button>
|
||||
) : null}
|
||||
|
||||
@@ -34,6 +34,7 @@ export function useKeyboardShortcuts() {
|
||||
|
||||
// g+letter navigation sequences
|
||||
const navMap: Record<string, string> = {
|
||||
"g i": "/inbox",
|
||||
"g d": "/",
|
||||
"g t": "/tasks",
|
||||
"g h": "/habits",
|
||||
@@ -41,6 +42,7 @@ export function useKeyboardShortcuts() {
|
||||
"g n": "/notes",
|
||||
"g c": "/calendar",
|
||||
"g g": "/graph",
|
||||
"g r": "/reports",
|
||||
"g s": "/settings",
|
||||
};
|
||||
|
||||
|
||||
+52
-46
@@ -1,44 +1,50 @@
|
||||
@import url("https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap");
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
@layer base {
|
||||
:root {
|
||||
/* Typography — Inter via Google Fonts @import above; falls back to system-ui */
|
||||
/* Typography */
|
||||
--font-sans: "Inter", system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto,
|
||||
"Helvetica Neue", Arial, sans-serif;
|
||||
--font-mono: "JetBrains Mono", ui-monospace, "Cascadia Code", "Source Code Pro", Menlo,
|
||||
Consolas, "DejaVu Sans Mono", monospace;
|
||||
|
||||
/* Density — scales content spacing (see the density utilities below and the
|
||||
calc() padding on <main> in the app layout). 1 = comfortable. */
|
||||
--density-scale: 1;
|
||||
/* Density — compact is the new default */
|
||||
--density-scale: 0.85;
|
||||
|
||||
--background: 0 0% 100%;
|
||||
--foreground: 222.2 84% 4.9%;
|
||||
/* Light mode — clean, near-white with subtle warmth */
|
||||
--background: 0 0% 99%;
|
||||
--foreground: 222 47% 5%;
|
||||
--card: 0 0% 100%;
|
||||
--card-foreground: 222.2 84% 4.9%;
|
||||
--card-foreground: 222 47% 5%;
|
||||
--popover: 0 0% 100%;
|
||||
--popover-foreground: 222.2 84% 4.9%;
|
||||
--primary: 222.2 47.4% 11.2%;
|
||||
--primary-foreground: 210 40% 98%;
|
||||
--secondary: 210 40% 96.1%;
|
||||
--secondary-foreground: 222.2 47.4% 11.2%;
|
||||
--muted: 210 40% 96.1%;
|
||||
--muted-foreground: 215.4 16.3% 46.9%;
|
||||
--accent: 210 40% 96.1%;
|
||||
--accent-foreground: 222.2 47.4% 11.2%;
|
||||
--destructive: 0 84.2% 60.2%;
|
||||
--destructive-foreground: 210 40% 98%;
|
||||
--border: 214.3 31.8% 91.4%;
|
||||
--input: 214.3 31.8% 91.4%;
|
||||
--ring: 222.2 84% 4.9%;
|
||||
--radius: 0.5rem;
|
||||
--popover-foreground: 222 47% 5%;
|
||||
--primary: 222 47% 11%;
|
||||
--primary-foreground: 0 0% 98%;
|
||||
--secondary: 220 14% 96%;
|
||||
--secondary-foreground: 222 47% 11%;
|
||||
--muted: 220 14% 96%;
|
||||
--muted-foreground: 220 9% 46%;
|
||||
--accent: 220 14% 96%;
|
||||
--accent-foreground: 222 47% 11%;
|
||||
--destructive: 0 84% 60%;
|
||||
--destructive-foreground: 0 0% 98%;
|
||||
--border: 220 13% 91%;
|
||||
--input: 220 13% 91%;
|
||||
--ring: 217 91% 60%;
|
||||
--radius: 0.375rem;
|
||||
|
||||
/* Interactive accent — pinned vibrant blue */
|
||||
--accent-hsl: 217 91% 60%;
|
||||
|
||||
/* Shell surfaces */
|
||||
--sidebar-bg: 0 0% 100%;
|
||||
--sidebar-border: 220 13% 91%;
|
||||
--topbar-bg: 0 0% 99%;
|
||||
}
|
||||
|
||||
/* Density modes: toggled on <html> by the settings page (and applied from
|
||||
localStorage on app load). They only flip --density-scale, which the
|
||||
layout padding and the space-y utilities below multiply by. */
|
||||
/* Density modes */
|
||||
.density-compact {
|
||||
--density-scale: 0.85;
|
||||
}
|
||||
@@ -47,27 +53,33 @@
|
||||
--density-scale: 1.15;
|
||||
}
|
||||
|
||||
/* Dark mode — deep navy with elevated surfaces */
|
||||
.dark {
|
||||
--background: 222.2 84% 4.9%;
|
||||
--background: 222 47% 5%;
|
||||
--foreground: 210 40% 98%;
|
||||
--card: 222.2 84% 4.9%;
|
||||
--card: 222 40% 8%;
|
||||
--card-foreground: 210 40% 98%;
|
||||
--popover: 222.2 84% 4.9%;
|
||||
--popover: 222 40% 8%;
|
||||
--popover-foreground: 210 40% 98%;
|
||||
--primary: 210 40% 98%;
|
||||
--primary-foreground: 222.2 47.4% 11.2%;
|
||||
--secondary: 217.2 32.6% 17.5%;
|
||||
--primary-foreground: 222 47% 11%;
|
||||
--secondary: 217 33% 14%;
|
||||
--secondary-foreground: 210 40% 98%;
|
||||
--muted: 217.2 32.6% 17.5%;
|
||||
--muted-foreground: 215 20.2% 65.1%;
|
||||
--accent: 217.2 32.6% 17.5%;
|
||||
--muted: 217 33% 14%;
|
||||
--muted-foreground: 215 20% 65%;
|
||||
--accent: 217 33% 14%;
|
||||
--accent-foreground: 210 40% 98%;
|
||||
--destructive: 0 62.8% 30.6%;
|
||||
--destructive: 0 63% 31%;
|
||||
--destructive-foreground: 210 40% 98%;
|
||||
--border: 217.2 32.6% 17.5%;
|
||||
--input: 217.2 32.6% 17.5%;
|
||||
--ring: 212.7 26.8% 83.9%;
|
||||
--border: 217 33% 17%;
|
||||
--input: 217 33% 17%;
|
||||
--ring: 217 91% 60%;
|
||||
--accent-hsl: 217 91% 60%;
|
||||
|
||||
/* Shell surfaces — slightly elevated from background */
|
||||
--sidebar-bg: 222 42% 7%;
|
||||
--sidebar-border: 217 33% 17%;
|
||||
--topbar-bg: 222 47% 5%;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -83,9 +95,7 @@
|
||||
}
|
||||
}
|
||||
|
||||
/* Real reduced-motion support: the settings page toggles `.reduce-motion` on
|
||||
<html>. This standard override collapses animation/transition durations so
|
||||
users who enable it get an effectively static UI. */
|
||||
/* Reduced motion */
|
||||
.reduce-motion *,
|
||||
.reduce-motion *::before,
|
||||
.reduce-motion *::after {
|
||||
@@ -95,11 +105,7 @@
|
||||
}
|
||||
|
||||
@layer utilities {
|
||||
/* Density: multiply the vertical rhythm between stacked sections inside page
|
||||
content. Mirrors Tailwind's own `.space-y-*` selector shape and is scoped
|
||||
to <main> so the sidebar and topbar stay fixed. Combined with the calc()
|
||||
padding on <main> in the app layout, the Density setting now visibly
|
||||
changes spacing (0.85x compact, 1.15x spacious). */
|
||||
/* Density: multiply vertical rhythm inside <main> */
|
||||
.density-compact main .space-y-1 > :not([hidden]) ~ :not([hidden]),
|
||||
.density-spacious main .space-y-1 > :not([hidden]) ~ :not([hidden]) {
|
||||
margin-top: calc(0.25rem * var(--density-scale));
|
||||
|
||||
@@ -23,10 +23,10 @@ export const TASK_STATUS: Record<string, StatusToken> = {
|
||||
|
||||
/** Task priority. Badges use a soft tint (matching text + translucent bg). */
|
||||
export const PRIORITY: Record<string, { label: string; badge: string }> = {
|
||||
low: { label: "Low", badge: "text-slate-500 bg-slate-500/10" },
|
||||
medium: { label: "Medium", badge: "text-blue-500 bg-blue-500/10" },
|
||||
high: { label: "High", badge: "text-orange-500 bg-orange-500/10" },
|
||||
urgent: { label: "Urgent", badge: "text-red-500 bg-red-500/10" },
|
||||
low: { label: "Low", badge: "text-slate-600 bg-slate-500/15" },
|
||||
medium: { label: "Medium", badge: "text-blue-600 bg-blue-500/15" },
|
||||
high: { label: "High", badge: "text-orange-600 bg-orange-500/15" },
|
||||
urgent: { label: "Urgent", badge: "text-red-600 bg-red-500/15" },
|
||||
};
|
||||
|
||||
/** Project lifecycle statuses. */
|
||||
|
||||
@@ -2,33 +2,17 @@ import { create } from "zustand";
|
||||
import { persist } from "zustand/middleware";
|
||||
|
||||
export type ThemeMode = "light" | "dark" | "system";
|
||||
export type AccentColor = "slate" | "blue" | "green" | "purple" | "orange" | "rose";
|
||||
|
||||
interface ThemeState {
|
||||
mode: ThemeMode;
|
||||
accent: AccentColor;
|
||||
|
||||
setMode: (mode: ThemeMode) => void;
|
||||
setAccent: (accent: AccentColor) => void;
|
||||
}
|
||||
|
||||
export const ACCENT_PALETTE: Record<AccentColor, { name: string; hsl: string }> = {
|
||||
slate: { name: "Slate", hsl: "215 16% 47%" },
|
||||
blue: { name: "Blue", hsl: "217 91% 60%" },
|
||||
green: { name: "Green", hsl: "142 71% 45%" },
|
||||
purple: { name: "Purple", hsl: "271 81% 56%" },
|
||||
orange: { name: "Orange", hsl: "24 95% 53%" },
|
||||
rose: { name: "Rose", hsl: "340 82% 52%" },
|
||||
};
|
||||
|
||||
export const useThemeStore = create<ThemeState>()(
|
||||
persist(
|
||||
(set) => ({
|
||||
mode: "dark",
|
||||
accent: "blue",
|
||||
|
||||
setMode: (mode) => set({ mode }),
|
||||
setAccent: (accent) => set({ accent }),
|
||||
}),
|
||||
{
|
||||
name: "project-e-theme",
|
||||
|
||||
@@ -14,7 +14,10 @@ import { Route as analyticsRoute } from "./routes/_app/analytics";
|
||||
import { Route as agentsRoute } from "./routes/_app/agents";
|
||||
import { Route as agentActivityRoute } from "./routes/_app/agents/activity";
|
||||
import { Route as dailyRoute } from "./routes/_app/daily";
|
||||
import { Route as inboxRoute } from "./routes/_app/inbox";
|
||||
import { Route as reportsRoute } from "./routes/_app/reports";
|
||||
import { Route as settingsRoute } from "./routes/_app/settings";
|
||||
import { Route as templatesRoute } from "./routes/_app/templates";
|
||||
import { Route as taskDetailRoute } from "./routes/_app/tasks/$id";
|
||||
import { Route as habitDetailRoute } from "./routes/_app/habits/$id";
|
||||
import { Route as noteDetailRoute } from "./routes/_app/notes/$id";
|
||||
@@ -33,7 +36,10 @@ const appChildren = [
|
||||
agentsRoute,
|
||||
agentActivityRoute,
|
||||
dailyRoute,
|
||||
inboxRoute,
|
||||
reportsRoute,
|
||||
settingsRoute,
|
||||
templatesRoute,
|
||||
taskDetailRoute,
|
||||
habitDetailRoute,
|
||||
noteDetailRoute,
|
||||
|
||||
@@ -33,7 +33,7 @@ function AppLayout() {
|
||||
<Topbar />
|
||||
<main
|
||||
id="main-content"
|
||||
className="flex-1 overflow-auto p-[calc(1rem*var(--density-scale))] md:p-[calc(1.5rem*var(--density-scale))]"
|
||||
className="flex-1 overflow-auto p-[calc(0.75rem*var(--density-scale))] md:p-[calc(1rem*var(--density-scale))]"
|
||||
tabIndex={-1}
|
||||
>
|
||||
<Outlet />
|
||||
|
||||
@@ -25,6 +25,11 @@ const ACTION_TYPES = [
|
||||
{ id: "completed", label: "Completed", color: "bg-purple-500" },
|
||||
];
|
||||
|
||||
const AGENT_COLORS = [
|
||||
"bg-cyan-500", "bg-amber-500", "bg-violet-500", "bg-rose-500",
|
||||
"bg-emerald-500", "bg-orange-500", "bg-sky-500", "bg-pink-500",
|
||||
];
|
||||
|
||||
function AgentActivityPage() {
|
||||
const queryClient = useQueryClient();
|
||||
const [agentFilter, setAgentFilter] = useState("");
|
||||
@@ -114,20 +119,25 @@ function AgentActivityPage() {
|
||||
return agent?.name || "Unknown Agent";
|
||||
};
|
||||
|
||||
const getAgentColor = (agentId: string) => {
|
||||
const idx = agents.findIndex((a) => a.id === agentId);
|
||||
return AGENT_COLORS[idx >= 0 ? idx % AGENT_COLORS.length : 0];
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<h1 className="text-2xl font-bold">Agent Activity</h1>
|
||||
<h1 className="text-xl font-bold">Agent Activity</h1>
|
||||
<Button variant="outline" size="sm" onClick={() => queryClient.invalidateQueries({ queryKey: ["agent-activity"] })}>
|
||||
<RefreshCw className="h-4 w-4 mr-2" />Refresh
|
||||
<RefreshCw className="h-3.5 w-3.5 mr-1.5" />Refresh
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Filters */}
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Select value={agentFilter} onValueChange={setAgentFilter}>
|
||||
<SelectTrigger className="w-44">
|
||||
<Bot className="h-4 w-4 mr-2" />
|
||||
<SelectTrigger className="w-44 h-8">
|
||||
<Bot className="h-3.5 w-3.5 mr-1.5" />
|
||||
<SelectValue placeholder="All agents" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
@@ -138,8 +148,8 @@ function AgentActivityPage() {
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Select value={actionFilter} onValueChange={setActionFilter}>
|
||||
<SelectTrigger className="w-36">
|
||||
<Activity className="h-4 w-4 mr-2" />
|
||||
<SelectTrigger className="w-36 h-8">
|
||||
<Activity className="h-3.5 w-3.5 mr-1.5" />
|
||||
<SelectValue placeholder="All actions" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
@@ -149,8 +159,8 @@ function AgentActivityPage() {
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Input type="date" value={dateFrom} onChange={(e) => setDateFrom(e.target.value)} className="w-36 h-9" placeholder="From" />
|
||||
<Input type="date" value={dateTo} onChange={(e) => setDateTo(e.target.value)} className="w-36 h-9" placeholder="To" />
|
||||
<Input type="date" value={dateFrom} onChange={(e) => setDateFrom(e.target.value)} className="w-36 h-8 font-mono text-xs" placeholder="From" />
|
||||
<Input type="date" value={dateTo} onChange={(e) => setDateTo(e.target.value)} className="w-36 h-8 font-mono text-xs" placeholder="To" />
|
||||
</div>
|
||||
|
||||
{/* Timeline */}
|
||||
@@ -159,36 +169,36 @@ function AgentActivityPage() {
|
||||
) : activities.length === 0 ? (
|
||||
<EmptyState title="No activity found" description="Agent activity will appear here as agents run" />
|
||||
) : (
|
||||
<div className="space-y-1">
|
||||
<div className="border rounded-lg divide-y divide-border">
|
||||
{activities.map((a, idx) => (
|
||||
<div key={a.id || idx} className="flex items-start gap-3 p-3 rounded-lg hover:bg-muted/50 transition-colors">
|
||||
<div className="flex flex-col items-center shrink-0">
|
||||
<Avatar className="h-8 w-8">
|
||||
<AvatarFallback className="text-xs bg-primary/10 text-primary">
|
||||
{getAgentName(a.agentId).slice(0, 2).toUpperCase()}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
{idx < activities.length - 1 && <div className="w-px flex-1 bg-border mt-1" />}
|
||||
<div key={a.id || idx} className="flex items-start gap-2.5 px-3 py-2 hover:bg-muted/20 transition-colors">
|
||||
{/* Colored agent indicator */}
|
||||
<div className="flex flex-col items-center shrink-0 pt-0.5">
|
||||
<div className={cn("w-2 h-2 rounded-full", getAgentColor(a.agentId))} />
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm font-medium">{getAgentName(a.agentId)}</span>
|
||||
<div className={cn("w-1.5 h-1.5 rounded-full", getActionColor(a.action))} />
|
||||
<span className="text-sm text-muted-foreground">{a.action}</span>
|
||||
<Badge variant="outline" className="text-[10px]">{a.entityType}</Badge>
|
||||
<span className={cn(
|
||||
"text-[10px] font-mono px-1.5 py-0.5 rounded",
|
||||
getActionColor(a.action) + "/15",
|
||||
"text-foreground"
|
||||
)}>
|
||||
{a.action}
|
||||
</span>
|
||||
<Badge variant="outline" className="text-[9px] font-mono px-1 py-0">{a.entityType}</Badge>
|
||||
</div>
|
||||
{a.description && <p className="text-sm text-muted-foreground mt-0.5">{a.description}</p>}
|
||||
{a.description && <p className="text-xs text-muted-foreground mt-0.5 leading-relaxed">{a.description}</p>}
|
||||
{!a.description && a.details && Object.keys(a.details).length > 0 && (
|
||||
<p className="text-sm text-muted-foreground mt-0.5">{JSON.stringify(a.details)}</p>
|
||||
<p className="text-xs text-muted-foreground mt-0.5 leading-relaxed">{JSON.stringify(a.details)}</p>
|
||||
)}
|
||||
{a.errorMessage && <p className="text-sm text-destructive mt-0.5">{a.errorMessage}</p>}
|
||||
<div className="flex items-center gap-2 mt-1">
|
||||
<Clock className="h-3 w-3 text-muted-foreground" />
|
||||
<span className="text-xs text-muted-foreground">{format(parseISO(a.createdAt), "MMM d, HH:mm:ss")}</span>
|
||||
{a.errorMessage && <p className="text-xs text-destructive mt-0.5">{a.errorMessage}</p>}
|
||||
<div className="flex items-center gap-2 mt-0.5">
|
||||
<span className="text-[10px] font-mono text-muted-foreground">{format(parseISO(a.createdAt), "MMM d, HH:mm:ss")}</span>
|
||||
{a.entityId && (
|
||||
<span className="text-xs text-muted-foreground">
|
||||
<ExternalLink className="h-3 w-3 inline mr-0.5" />
|
||||
{a.entityId.slice(0, 8)}...
|
||||
<span className="text-[10px] font-mono text-muted-foreground">
|
||||
<ExternalLink className="h-2.5 w-2.5 inline mr-0.5" />
|
||||
{a.entityId.slice(0, 8)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -57,9 +57,9 @@ function AnalyticsPage() {
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<h1 className="text-2xl font-bold">Analytics</h1>
|
||||
<h1 className="text-xl font-bold">Analytics</h1>
|
||||
<Select value={range} onValueChange={setRange}>
|
||||
<SelectTrigger className="w-32">
|
||||
<Calendar className="h-4 w-4 mr-2" />
|
||||
@@ -78,27 +78,27 @@ function AnalyticsPage() {
|
||||
) : analyticsError ? (
|
||||
<ErrorState message={analyticsError.message || "Failed to load analytics"} onRetry={refetchAnalytics} />
|
||||
) : (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-3">
|
||||
{/* Tasks completed per day */}
|
||||
<Card>
|
||||
<CardHeader className="p-4 pb-0 flex flex-row items-center justify-between">
|
||||
<Card className="border rounded-lg hover:bg-muted/20 transition-colors">
|
||||
<CardHeader className="p-3 pb-0 flex flex-row items-center justify-between">
|
||||
<CardTitle className="text-sm font-semibold">Tasks Completed</CardTitle>
|
||||
<Button variant="ghost" size="icon" className="h-6 w-6" onClick={() => downloadCSV("tasks-completed.csv", [["Date", "Completed"], ...dailyItems.map((d) => [d.date, String(d.completed)])])}><Download className="h-3.5 w-3.5" /></Button>
|
||||
</CardHeader>
|
||||
<CardContent className="p-4">
|
||||
<CardContent className="p-3">
|
||||
<LineChart data={dailyItems} xKey="date" yKey="completed" />
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Tasks created vs completed */}
|
||||
<Card>
|
||||
<CardHeader className="p-4 pb-0 flex flex-row items-center justify-between">
|
||||
<Card className="border rounded-lg hover:bg-muted/20 transition-colors">
|
||||
<CardHeader className="p-3 pb-0 flex flex-row items-center justify-between">
|
||||
<CardTitle className="text-sm font-semibold">Created vs Completed</CardTitle>
|
||||
<Button variant="ghost" size="icon" className="h-6 w-6" onClick={() => downloadCSV("tasks-created-vs-completed.csv", [["Date", "Created", "Completed"], ...dailyItems.map((d) => [d.date, String(d.created), String(d.completed)])])}><Download className="h-3.5 w-3.5" /></Button>
|
||||
</CardHeader>
|
||||
<CardContent className="p-4">
|
||||
<CardContent className="p-3">
|
||||
<BarChart data={dailyItems} xKey="date" yKey="created" yKey2="completed" color="#f97316" color2="#3b82f6" />
|
||||
<div className="flex items-center gap-4 mt-2 text-xs text-muted-foreground">
|
||||
<div className="flex items-center gap-3 mt-2 text-xs text-muted-foreground">
|
||||
<span className="flex items-center gap-1"><div className="w-2.5 h-2.5 rounded bg-orange-500" /> Created</span>
|
||||
<span className="flex items-center gap-1"><div className="w-2.5 h-2.5 rounded bg-blue-500" /> Completed</span>
|
||||
</div>
|
||||
@@ -106,26 +106,26 @@ function AnalyticsPage() {
|
||||
</Card>
|
||||
|
||||
{/* Habit completion rate */}
|
||||
<Card>
|
||||
<CardHeader className="p-4 pb-0 flex flex-row items-center justify-between">
|
||||
<Card className="border rounded-lg hover:bg-muted/20 transition-colors">
|
||||
<CardHeader className="p-3 pb-0 flex flex-row items-center justify-between">
|
||||
<CardTitle className="text-sm font-semibold">Habit Completion</CardTitle>
|
||||
<Button variant="ghost" size="icon" className="h-6 w-6" onClick={() => downloadCSV("habit-completion.csv", [["Habit", "Rate"], ...habitRateData.map((d) => [d.name, String(d.value)])])}><Download className="h-3.5 w-3.5" /></Button>
|
||||
</CardHeader>
|
||||
<CardContent className="p-4">
|
||||
<CardContent className="p-3">
|
||||
<PieChartSimple data={habitRateData} labelKey="name" valueKey="value" />
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Project progress */}
|
||||
<Card>
|
||||
<CardHeader className="p-4 pb-0 flex flex-row items-center justify-between">
|
||||
<Card className="border rounded-lg hover:bg-muted/20 transition-colors">
|
||||
<CardHeader className="p-3 pb-0 flex flex-row items-center justify-between">
|
||||
<CardTitle className="text-sm font-semibold">Project Progress</CardTitle>
|
||||
<Button variant="ghost" size="icon" className="h-6 w-6" onClick={() => downloadCSV("project-progress.csv", [["Project", "Total Tasks", "Completed", "Progress"], ...(projectData?.projects || []).map((p) => [p.name, String(p.totalTasks), String(p.completedTasks), String(Math.round(p.progress * 100)) + "%"])])}><Download className="h-3.5 w-3.5" /></Button>
|
||||
</CardHeader>
|
||||
<CardContent className="p-4">
|
||||
<CardContent className="p-3">
|
||||
{projectData?.projects.length ? (
|
||||
<div className="space-y-2">
|
||||
<p className="text-xs text-muted-foreground">{projectData.totalProjects} project{projectData.totalProjects === 1 ? "" : "s"} · progress is % of tasks done</p>
|
||||
<p className="text-xs text-muted-foreground"><span className="font-mono font-semibold">{projectData.totalProjects}</span> project{projectData.totalProjects === 1 ? "" : "s"} · progress is % of tasks done</p>
|
||||
<HorizontalBar
|
||||
data={projectData.projects.map((p) => ({ name: p.name, progress: Math.round(p.progress * 100) }))}
|
||||
xKey="name"
|
||||
@@ -140,12 +140,12 @@ function AnalyticsPage() {
|
||||
</Card>
|
||||
|
||||
{/* Tasks by project (pie) */}
|
||||
<Card>
|
||||
<CardHeader className="p-4 pb-0 flex flex-row items-center justify-between">
|
||||
<Card className="border rounded-lg hover:bg-muted/20 transition-colors">
|
||||
<CardHeader className="p-3 pb-0 flex flex-row items-center justify-between">
|
||||
<CardTitle className="text-sm font-semibold">Tasks by Project</CardTitle>
|
||||
<Button variant="ghost" size="icon" className="h-6 w-6" onClick={() => downloadCSV("tasks-by-project.csv", [["Project", "Total Tasks"], ...(projectData?.projects || []).map((p) => [p.name, String(p.totalTasks)])])}><Download className="h-3.5 w-3.5" /></Button>
|
||||
</CardHeader>
|
||||
<CardContent className="p-4">
|
||||
<CardContent className="p-3">
|
||||
{projectData?.projects.length ? (
|
||||
<PieChartSimple data={projectData.projects.map((p) => ({ name: p.name, value: p.totalTasks }))} labelKey="name" valueKey="value" />
|
||||
) : (
|
||||
@@ -155,36 +155,36 @@ function AnalyticsPage() {
|
||||
</Card>
|
||||
|
||||
{/* Productivity heatmap */}
|
||||
<Card>
|
||||
<CardHeader className="p-4 pb-0 flex flex-row items-center justify-between">
|
||||
<Card className="border rounded-lg hover:bg-muted/20 transition-colors">
|
||||
<CardHeader className="p-3 pb-0 flex flex-row items-center justify-between">
|
||||
<CardTitle className="text-sm font-semibold">Productivity Heatmap</CardTitle>
|
||||
<Button variant="ghost" size="icon" className="h-6 w-6" onClick={() => downloadCSV("heatmap.csv", [["Date", "Count"], ...dailyItems.map((d) => [d.date, String(d.completed)])])}><Download className="h-3.5 w-3.5" /></Button>
|
||||
</CardHeader>
|
||||
<CardContent className="p-4">
|
||||
<CardContent className="p-3">
|
||||
<CalendarHeatmap data={dailyItems.map((d) => ({ date: d.date, count: d.completed }))} days={parseInt(range)} />
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Velocity */}
|
||||
<Card>
|
||||
<CardHeader className="p-4 pb-0 flex flex-row items-center justify-between">
|
||||
<Card className="border rounded-lg hover:bg-muted/20 transition-colors">
|
||||
<CardHeader className="p-3 pb-0 flex flex-row items-center justify-between">
|
||||
<CardTitle className="text-sm font-semibold">Velocity</CardTitle>
|
||||
<span className="text-xs text-muted-foreground">avg {velocityData?.avg ?? 0}/day</span>
|
||||
<span className="text-xs text-muted-foreground font-mono">avg <span className="font-semibold">{velocityData?.avg ?? 0}</span>/day</span>
|
||||
</CardHeader>
|
||||
<CardContent className="p-4">
|
||||
<CardContent className="p-3">
|
||||
{velocityData?.items ? <LineChart data={velocityData.items} xKey="date" yKey="completed" /> : <p className="text-sm text-muted-foreground">No data</p>}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Cycle time */}
|
||||
<Card>
|
||||
<CardHeader className="p-4 pb-0 flex flex-row items-center justify-between">
|
||||
<Card className="border rounded-lg hover:bg-muted/20 transition-colors">
|
||||
<CardHeader className="p-3 pb-0 flex flex-row items-center justify-between">
|
||||
<CardTitle className="text-sm font-semibold">Cycle Time</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="p-4">
|
||||
<CardContent className="p-3">
|
||||
<div className="text-center py-4">
|
||||
<p className="text-2xl font-bold">{cycleData ? `${cycleData.median}d median · ${cycleData.avg}d avg` : "—"}</p>
|
||||
<p className="text-xs text-muted-foreground">{cycleData?.count ?? 0} tasks completed in range</p>
|
||||
<p className="text-lg font-bold font-mono">{cycleData ? <>{cycleData.median}d median · {cycleData.avg}d avg</> : "—"}</p>
|
||||
<p className="text-xs text-muted-foreground font-mono">{cycleData?.count ?? 0} tasks completed in range</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
@@ -77,7 +77,7 @@ function CustomToolbar({ date, onNavigate, label }: any) {
|
||||
const goToToday = () => onNavigate(Navigate.TODAY);
|
||||
|
||||
return (
|
||||
<div className="flex items-center justify-between mb-3 flex-wrap gap-2">
|
||||
<div className="flex items-center justify-between mb-2 flex-wrap gap-2">
|
||||
<div className="flex items-center gap-1">
|
||||
<Button variant="outline" size="icon" onClick={goToBack} aria-label="Previous">
|
||||
<ChevronLeft className="h-4 w-4" />
|
||||
@@ -88,7 +88,7 @@ function CustomToolbar({ date, onNavigate, label }: any) {
|
||||
<Button variant="outline" size="icon" onClick={goToNext} aria-label="Next">
|
||||
<ChevronRight className="h-4 w-4" />
|
||||
</Button>
|
||||
<span className="ml-2 text-lg font-semibold">{label}</span>
|
||||
<span className="ml-2 text-lg font-semibold font-mono">{label}</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
@@ -132,7 +132,7 @@ function EventForm({ event, onClose }: { event?: CalendarEvent; onClose: () => v
|
||||
};
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<form onSubmit={handleSubmit} className="space-y-3">
|
||||
<div>
|
||||
<Label htmlFor="title">Title</Label>
|
||||
<Input id="title" value={title} onChange={(e) => setTitle(e.target.value)} placeholder="Event title" required />
|
||||
@@ -141,7 +141,7 @@ function EventForm({ event, onClose }: { event?: CalendarEvent; onClose: () => v
|
||||
<input type="checkbox" id="allDay" checked={allDay} onChange={(e) => setAllDay(e.target.checked)} className="rounded" />
|
||||
<Label htmlFor="allDay">All day</Label>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<Label htmlFor="start">Start</Label>
|
||||
<Input id="start" type={allDay ? "date" : "datetime-local"} value={startTime} onChange={(e) => setStartTime(e.target.value)} required />
|
||||
@@ -276,9 +276,9 @@ function CalendarPage() {
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<h1 className="text-2xl font-bold">Calendar</h1>
|
||||
<h1 className="text-xl font-bold">Calendar</h1>
|
||||
<Dialog open={createOpen} onOpenChange={setCreateOpen}>
|
||||
<DialogTrigger asChild>
|
||||
<Button aria-label="New event"><Plus className="h-4 w-4 mr-2" />New Event</Button>
|
||||
@@ -358,10 +358,10 @@ function CalendarPage() {
|
||||
<DialogTitle>{selectedEvent?.title || "Event"}</DialogTitle>
|
||||
</DialogHeader>
|
||||
{selectedEvent && (
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-3">
|
||||
<div className="text-sm">
|
||||
<p><strong>Start:</strong> {new Date(selectedEvent.startTime).toLocaleString()}</p>
|
||||
{selectedEvent.endTime && <p><strong>End:</strong> {new Date(selectedEvent.endTime).toLocaleString()}</p>}
|
||||
<p><strong>Start:</strong> <span className="font-mono">{new Date(selectedEvent.startTime).toLocaleString()}</span></p>
|
||||
{selectedEvent.endTime && <p><strong>End:</strong> <span className="font-mono">{new Date(selectedEvent.endTime).toLocaleString()}</span></p>}
|
||||
{selectedEvent.description && <p className="mt-2">{selectedEvent.description}</p>}
|
||||
</div>
|
||||
<EventForm event={selectedEvent} onClose={() => setEventDetailOpen(false)} />
|
||||
|
||||
@@ -38,19 +38,19 @@ function CalendarSidebar({ selectedDate, onSelectDate }: { selectedDate: Date; o
|
||||
const noteDates = new Set(notes.map((n) => n.date.slice(0, 10)));
|
||||
|
||||
return (
|
||||
<div className="w-64 shrink-0">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<Button variant="ghost" size="icon" className="h-7 w-7" onClick={() => setCurrentMonth(subMonths(currentMonth, 1))}>
|
||||
<ChevronLeft className="h-4 w-4" />
|
||||
<div className="w-56 shrink-0">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<Button variant="ghost" size="icon" className="h-6 w-6" onClick={() => setCurrentMonth(subMonths(currentMonth, 1))}>
|
||||
<ChevronLeft className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
<span className="text-sm font-semibold">{format(currentMonth, "MMMM yyyy")}</span>
|
||||
<Button variant="ghost" size="icon" className="h-7 w-7" onClick={() => setCurrentMonth(addMonths(currentMonth, 1))}>
|
||||
<ChevronRight className="h-4 w-4" />
|
||||
<span className="text-xs font-semibold font-mono">{format(currentMonth, "MMM yyyy")}</span>
|
||||
<Button variant="ghost" size="icon" className="h-6 w-6" onClick={() => setCurrentMonth(addMonths(currentMonth, 1))}>
|
||||
<ChevronRight className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
<div className="grid grid-cols-7 gap-0.5 text-center">
|
||||
<div className="grid grid-cols-7 gap-px text-center">
|
||||
{dayNames.map((d) => (
|
||||
<div key={d} className="text-[10px] text-muted-foreground font-medium py-1">{d}</div>
|
||||
<div key={d} className="text-[9px] text-muted-foreground font-mono font-medium py-0.5">{d}</div>
|
||||
))}
|
||||
{Array.from({ length: startDay }).map((_, i) => (
|
||||
<div key={"empty-" + i} />
|
||||
@@ -65,21 +65,25 @@ function CalendarSidebar({ selectedDate, onSelectDate }: { selectedDate: Date; o
|
||||
key={key}
|
||||
onClick={() => onSelectDate(d)}
|
||||
className={cn(
|
||||
"h-8 w-8 rounded-full text-xs flex items-center justify-center transition-colors relative",
|
||||
isSelected ? "bg-primary text-primary-foreground" : today ? "bg-primary/10 text-primary font-semibold" : "hover:bg-muted",
|
||||
"h-7 w-7 rounded text-[11px] font-mono flex items-center justify-center transition-colors relative",
|
||||
isSelected
|
||||
? "bg-primary text-primary-foreground"
|
||||
: today
|
||||
? "bg-primary/10 text-primary font-semibold"
|
||||
: "hover:bg-muted/40 text-foreground",
|
||||
)}
|
||||
>
|
||||
{format(d, "d")}
|
||||
{hasNote && !isSelected && (
|
||||
<div className="absolute bottom-0.5 w-1 h-1 rounded-full bg-primary" />
|
||||
<div className="absolute bottom-0.5 w-1 h-1 rounded-full bg-emerald-500" />
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<div className="mt-3">
|
||||
<Button variant="outline" size="sm" className="w-full" onClick={() => onSelectDate(new Date())}>
|
||||
<Calendar className="h-3.5 w-3.5 mr-2" />Today
|
||||
<div className="mt-2">
|
||||
<Button variant="outline" size="sm" className="w-full h-7 text-xs" onClick={() => onSelectDate(new Date())}>
|
||||
<Calendar className="h-3 w-3 mr-1.5" />Today
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -247,19 +251,19 @@ function DailyNoteEditor({ date }: { date: Date }) {
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex-1 space-y-4">
|
||||
<div className="flex-1 space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<h2 className="text-xl font-bold">{format(date, "EEEE, MMMM d, yyyy")}</h2>
|
||||
<h2 className="text-xl font-bold font-mono">{format(date, "EEE, MMM d, yyyy")}</h2>
|
||||
<div className="flex items-center gap-2">
|
||||
{noteId && (
|
||||
<>
|
||||
<Badge variant="secondary" className="text-[10px]">
|
||||
<Badge variant="secondary" className="text-[10px] font-mono">
|
||||
<Save className="h-3 w-3 mr-1" />Saved
|
||||
</Badge>
|
||||
<AlertDialog>
|
||||
<AlertDialogTrigger asChild>
|
||||
<Button variant="ghost" size="icon" className="h-8 w-8 text-destructive" aria-label="Delete daily note">
|
||||
<Trash2 className="h-4 w-4" />
|
||||
<Button variant="ghost" size="icon" className="h-7 w-7 text-destructive" aria-label="Delete daily note">
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</AlertDialogTrigger>
|
||||
<AlertDialogContent>
|
||||
@@ -279,10 +283,10 @@ function DailyNoteEditor({ date }: { date: Date }) {
|
||||
</div>
|
||||
|
||||
{/* Mood & Energy */}
|
||||
<div className="flex gap-6">
|
||||
<div className="flex gap-5">
|
||||
<div>
|
||||
<p className="text-xs text-muted-foreground mb-1">Mood</p>
|
||||
<div role="radiogroup" aria-label="Mood" className="flex gap-1">
|
||||
<p className="text-[10px] text-muted-foreground font-mono uppercase tracking-wider mb-1">Mood</p>
|
||||
<div role="radiogroup" aria-label="Mood" className="flex gap-0.5">
|
||||
{[1, 2, 3, 4, 5, 6, 7, 8, 9, 10].map((v) => (
|
||||
<button
|
||||
key={v}
|
||||
@@ -291,7 +295,7 @@ function DailyNoteEditor({ date }: { date: Date }) {
|
||||
aria-label={`Mood ${v}`}
|
||||
onClick={() => handleMoodChange(v)}
|
||||
className={cn(
|
||||
"w-6 h-6 rounded text-[10px] font-medium transition-colors",
|
||||
"w-5 h-5 rounded text-[9px] font-mono font-medium transition-colors",
|
||||
mood === v ? "bg-primary text-primary-foreground" : "bg-muted hover:bg-muted/70 text-muted-foreground"
|
||||
)}
|
||||
>
|
||||
@@ -301,8 +305,8 @@ function DailyNoteEditor({ date }: { date: Date }) {
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs text-muted-foreground mb-1">Energy</p>
|
||||
<div role="radiogroup" aria-label="Energy" className="flex gap-1">
|
||||
<p className="text-[10px] text-muted-foreground font-mono uppercase tracking-wider mb-1">Energy</p>
|
||||
<div role="radiogroup" aria-label="Energy" className="flex gap-0.5">
|
||||
{[1, 2, 3, 4, 5, 6, 7, 8, 9, 10].map((v) => (
|
||||
<button
|
||||
key={v}
|
||||
@@ -311,7 +315,7 @@ function DailyNoteEditor({ date }: { date: Date }) {
|
||||
aria-label={`Energy ${v}`}
|
||||
onClick={() => handleEnergyChange(v)}
|
||||
className={cn(
|
||||
"w-6 h-6 rounded text-[10px] font-medium transition-colors",
|
||||
"w-5 h-5 rounded text-[9px] font-mono font-medium transition-colors",
|
||||
energy === v ? "bg-primary text-primary-foreground" : "bg-muted hover:bg-muted/70 text-muted-foreground"
|
||||
)}
|
||||
>
|
||||
@@ -326,16 +330,16 @@ function DailyNoteEditor({ date }: { date: Date }) {
|
||||
|
||||
{/* Editor */}
|
||||
{isLoading ? (
|
||||
<div className="flex items-center justify-center py-12 text-muted-foreground">Loading...</div>
|
||||
<div className="flex items-center justify-center py-10 text-muted-foreground text-sm">Loading...</div>
|
||||
) : isNew && !content ? (
|
||||
<div className="text-center py-8 space-y-4">
|
||||
<p className="text-muted-foreground">No note for this day</p>
|
||||
{tasksDue.length > 0 && <p className="text-xs text-muted-foreground">{tasksDue.length} tasks due · {habitsToday.length} habits</p>}
|
||||
<div className="text-center py-6 space-y-3">
|
||||
<p className="text-muted-foreground text-sm">No note for this day</p>
|
||||
{tasksDue.length > 0 && <p className="text-[10px] font-mono text-muted-foreground">{tasksDue.length} tasks due · {habitsToday.length} habits</p>}
|
||||
<div className="flex gap-2 justify-center">
|
||||
<Button variant="outline" onClick={() => textareaRef.current?.focus()}>
|
||||
<Plus className="h-4 w-4 mr-2" />Start Writing
|
||||
<Button variant="outline" size="sm" className="h-7 text-xs" onClick={() => textareaRef.current?.focus()}>
|
||||
<Plus className="h-3 w-3 mr-1" />Start Writing
|
||||
</Button>
|
||||
<Button variant="secondary" onClick={insertTemplate}>Insert Daily Template</Button>
|
||||
<Button variant="secondary" size="sm" className="h-7 text-xs" onClick={insertTemplate}>Insert Daily Template</Button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
@@ -344,7 +348,7 @@ function DailyNoteEditor({ date }: { date: Date }) {
|
||||
value={content}
|
||||
onChange={(e) => handleContentChange(e.target.value)}
|
||||
placeholder="Write your daily note here..."
|
||||
className="w-full min-h-[300px] bg-transparent border-none outline-none resize-none text-base leading-relaxed placeholder:text-muted-foreground/50"
|
||||
className="w-full min-h-[280px] bg-transparent border-none outline-none resize-none text-sm font-mono leading-relaxed placeholder:text-muted-foreground/50"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
@@ -357,10 +361,10 @@ function DailyNotesPage() {
|
||||
const [selectedDate, setSelectedDate] = useState(new Date());
|
||||
|
||||
return (
|
||||
<div className="flex gap-6 h-[calc(100vh-5rem)]">
|
||||
<div className="flex gap-4 h-[calc(100vh-5rem)]">
|
||||
<CalendarSidebar selectedDate={selectedDate} onSelectDate={setSelectedDate} />
|
||||
<Separator orientation="vertical" />
|
||||
<ScrollArea className="flex-1 pr-4">
|
||||
<ScrollArea className="flex-1 pr-3">
|
||||
<DailyNoteEditor date={selectedDate} />
|
||||
</ScrollArea>
|
||||
</div>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useState, useEffect } from "react";
|
||||
import { createRoute } from "@tanstack/react-router";
|
||||
import { Route as appRoute } from ".";
|
||||
import { Route as appRoute } from "../_app";
|
||||
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { api, useApiQuery } from "@/lib/api";
|
||||
import { useRealtime } from "@/hooks/use-realtime";
|
||||
@@ -10,7 +10,6 @@ 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";
|
||||
@@ -21,16 +20,26 @@ import type { DashboardWidget, Task, Habit, Note, Project, CalendarEvent, Pagina
|
||||
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 },
|
||||
{ id: "tasks_due", label: "Tasks Due Today", icon: ListTodo, defaultW: 2, defaultH: 2, accent: "text-amber-500", border: "border-l-amber-500" },
|
||||
{ id: "habits_today", label: "Habits Today", icon: Flame, defaultW: 2, defaultH: 2, accent: "text-orange-500", border: "border-l-orange-500" },
|
||||
{ id: "recent_notes", label: "Recent Notes", icon: FileText, defaultW: 2, defaultH: 2, accent: "text-blue-500", border: "border-l-blue-500" },
|
||||
{ id: "active_projects", label: "Active Projects", icon: FolderKanban, defaultW: 2, defaultH: 2, accent: "text-purple-500", border: "border-l-purple-500" },
|
||||
{ id: "upcoming_events", label: "Upcoming Events", icon: Calendar, defaultW: 2, defaultH: 2, accent: "text-sky-500", border: "border-l-sky-500" },
|
||||
{ id: "streak_counter", label: "Streak Counter", icon: Flame, defaultW: 1, defaultH: 1, accent: "text-orange-500", border: "border-l-orange-500" },
|
||||
{ id: "quick_capture", label: "Quick Capture", icon: Zap, defaultW: 2, defaultH: 1, accent: "text-yellow-500", border: "border-l-yellow-500" },
|
||||
{ id: "productivity_chart", label: "Productivity Chart", icon: TrendingUp, defaultW: 3, defaultH: 2, accent: "text-green-500", border: "border-l-green-500" },
|
||||
] as const;
|
||||
|
||||
function getWidgetAccent(type: string) {
|
||||
const found = WIDGET_TYPES.find((t) => t.id === type);
|
||||
return found?.accent || "text-muted-foreground";
|
||||
}
|
||||
|
||||
function getWidgetBorder(type: string) {
|
||||
const found = WIDGET_TYPES.find((t) => t.id === type);
|
||||
return found?.border || "border-l-muted-foreground";
|
||||
}
|
||||
|
||||
function TasksDueWidget() {
|
||||
const activeDomainId = useApiDomain();
|
||||
const domainSuffix = activeDomainId ? "&domain=" + activeDomainId : "";
|
||||
@@ -39,16 +48,16 @@ function TasksDueWidget() {
|
||||
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">
|
||||
<div className="space-y-1.5">
|
||||
{today.length === 0 && overdue.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground">No tasks due today</p>
|
||||
<p className="text-xs 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>
|
||||
<p className="text-[10px] font-semibold text-destructive mb-0.5">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 key={t.id} className="flex items-center gap-1.5 text-xs py-0.5">
|
||||
<div className="w-1.5 h-1.5 rounded-full bg-destructive shrink-0" />
|
||||
<span className="truncate flex-1">{t.title}</span>
|
||||
</div>
|
||||
@@ -57,9 +66,9 @@ function TasksDueWidget() {
|
||||
)}
|
||||
{today.length > 0 && (
|
||||
<div>
|
||||
<p className="text-xs font-semibold text-amber-500 mb-1">Today ({today.length})</p>
|
||||
<p className="text-[10px] font-semibold text-amber-500 mb-0.5">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 key={t.id} className="flex items-center gap-1.5 text-xs py-0.5">
|
||||
<div className="w-1.5 h-1.5 rounded-full bg-amber-500 shrink-0" />
|
||||
<span className="truncate flex-1">{t.title}</span>
|
||||
</div>
|
||||
@@ -88,9 +97,9 @@ function HabitsTodayWidget() {
|
||||
onError: (err) => toast.error(err.message || "Failed to complete habit"),
|
||||
});
|
||||
return (
|
||||
<div className="space-y-1">
|
||||
<div className="space-y-0.5">
|
||||
{habits.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground">No habits yet</p>
|
||||
<p className="text-xs text-muted-foreground">No habits yet</p>
|
||||
) : (
|
||||
habits.slice(0, 6).map((h) => {
|
||||
const doneToday = (h.recentCompletions || []).some((c) => {
|
||||
@@ -99,18 +108,18 @@ function HabitsTodayWidget() {
|
||||
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">
|
||||
<div key={h.id} className="flex items-center gap-1.5 py-0.5">
|
||||
<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")}
|
||||
className={cn("w-3.5 h-3.5 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>}
|
||||
{doneToday && <span className="text-[8px] text-white">\u2713</span>}
|
||||
</button>
|
||||
<span className="text-sm truncate flex-1">{h.name}</span>
|
||||
<span className="text-xs 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 variant="secondary" className="text-[9px] px-1 shrink-0">
|
||||
<Flame className="h-2 w-2 mr-0" />{h.streakCount}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
@@ -127,14 +136,14 @@ function RecentNotesWidget() {
|
||||
const { data } = useApiQuery<PaginatedResponse<Note>>(["recent-notes", activeDomainId], "/notes?limit=5&sort=-updated" + domainSuffix);
|
||||
const notes = data?.items || [];
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<div className="space-y-1.5">
|
||||
{notes.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground">No notes yet</p>
|
||||
<p className="text-xs text-muted-foreground">No notes yet</p>
|
||||
) : (
|
||||
notes.map((n) => (
|
||||
<div key={n.id} className="text-sm py-1 border-b last:border-0">
|
||||
<div key={n.id} className="text-xs 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>
|
||||
<p className="text-[10px] text-muted-foreground">{format(parseISO(n.updatedAt), "MMM d, HH:mm")}</p>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
@@ -148,17 +157,17 @@ function ActiveProjectsWidget() {
|
||||
const { data } = useApiQuery<PaginatedResponse<Project>>(["active-projects", activeDomainId], "/projects?limit=10&status=active" + domainSuffix);
|
||||
const projects = data?.items || [];
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<div className="space-y-2">
|
||||
{projects.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground">No active projects</p>
|
||||
<p className="text-xs 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">
|
||||
<div key={p.id} className="space-y-0.5">
|
||||
<div className="flex items-center justify-between text-xs">
|
||||
<span className="truncate flex-1">{p.name}</span>
|
||||
<span className="text-xs text-muted-foreground shrink-0 ml-2">{p.progress || 0}%</span>
|
||||
<span className="text-[10px] font-mono text-muted-foreground shrink-0 ml-2">{p.progress || 0}%</span>
|
||||
</div>
|
||||
<Progress value={p.progress || 0} className="h-1.5" />
|
||||
<Progress value={p.progress || 0} className="h-1" />
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
@@ -178,15 +187,15 @@ function UpcomingEventsWidget() {
|
||||
return start >= now && start <= weekFromNow;
|
||||
}).sort((a, b) => parseISO(a.startTime).getTime() - parseISO(b.startTime).getTime());
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<div className="space-y-1.5">
|
||||
{upcoming.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground">No upcoming events</p>
|
||||
<p className="text-xs 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 key={e.id} className="flex items-center gap-1.5 text-xs py-0.5">
|
||||
<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>
|
||||
<span className="text-[10px] text-muted-foreground shrink-0">{format(parseISO(e.startTime), "MMM d, HH:mm")}</span>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
@@ -202,16 +211,16 @@ function StreakCounterWidget() {
|
||||
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 items-center gap-3 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>
|
||||
<Flame className="h-6 w-6 text-orange-500" />
|
||||
<span className="text-xl font-mono font-semibold">{bestStreak}</span>
|
||||
<span className="text-[10px] 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>
|
||||
<span className="text-xl font-mono font-semibold">{totalActive}</span>
|
||||
<span className="text-[10px] text-muted-foreground">Active streaks</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
@@ -248,18 +257,18 @@ function QuickCaptureWidget() {
|
||||
else createNote.mutate(text.trim());
|
||||
};
|
||||
return (
|
||||
<form onSubmit={handleSubmit} className="flex gap-2">
|
||||
<div className="flex-1 flex gap-2">
|
||||
<form onSubmit={handleSubmit} className="flex gap-1.5">
|
||||
<div className="flex-1 flex gap-1.5">
|
||||
<Select value={type} onValueChange={(v) => setType(v as "task" | "note")}>
|
||||
<SelectTrigger className="w-20 h-9"><SelectValue /></SelectTrigger>
|
||||
<SelectTrigger className="w-18 h-8 text-xs"><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" />
|
||||
<Input placeholder="Quick capture..." value={text} onChange={(e) => setText(e.target.value)} className="h-8 font-mono text-xs" />
|
||||
</div>
|
||||
<Button type="button" size="sm" className="h-9" disabled={!text.trim() || createTask.isPending || createNote.isPending} onClick={handleSubmit}>Add</Button>
|
||||
<Button type="button" size="sm" className="h-8 px-2.5 text-xs" disabled={!text.trim() || createTask.isPending || createNote.isPending} onClick={handleSubmit}>Add</Button>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
@@ -269,24 +278,24 @@ function ProductivityChartWidget() {
|
||||
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>;
|
||||
if (!stats) return <p className="text-xs 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 className="space-y-2">
|
||||
<div className="grid grid-cols-3 gap-1.5">
|
||||
<div className="text-center p-1.5 bg-muted/30 rounded">
|
||||
<p className="text-base font-mono font-semibold">{stats.totalTasks || 0}</p>
|
||||
<p className="text-[9px] 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 className="text-center p-1.5 bg-muted/30 rounded">
|
||||
<p className="text-base font-mono font-semibold text-green-500">{stats.completedTasks || 0}</p>
|
||||
<p className="text-[9px] 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 className="text-center p-1.5 bg-muted/30 rounded">
|
||||
<p className="text-base font-mono font-semibold">{stats.taskCompletionRate || 0}%</p>
|
||||
<p className="text-[9px] text-muted-foreground">Rate</p>
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground text-center">Last {stats.period || 30} days</p>
|
||||
<p className="text-[10px] text-muted-foreground text-center">Last {stats.period || 30} days</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -296,20 +305,20 @@ function StatsWidget() {
|
||||
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>;
|
||||
if (!stats) return <p className="text-xs text-muted-foreground">Loading...</p>;
|
||||
return (
|
||||
<div className="grid grid-cols-3 gap-3 h-full items-center">
|
||||
<div className="grid grid-cols-3 gap-2 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>
|
||||
<p className="text-xl font-mono font-semibold">{stats.totalTasks || 0}</p>
|
||||
<p className="text-[10px] 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>
|
||||
<p className="text-xl font-mono font-semibold text-green-500">{stats.completedTasks || 0}</p>
|
||||
<p className="text-[10px] 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>
|
||||
<p className="text-xl font-mono font-semibold">{stats.taskCompletionRate || 0}%</p>
|
||||
<p className="text-[10px] text-muted-foreground">Rate</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
@@ -326,7 +335,7 @@ function WidgetRenderer({ type }: { type: string }) {
|
||||
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>;
|
||||
default: return <p className="text-xs text-muted-foreground">Unknown widget: {type}</p>;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -334,24 +343,27 @@ function WidgetCard({ widget, onConfigure, onDelete }: { widget: DashboardWidget
|
||||
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)]"
|
||||
<div
|
||||
className={cn(
|
||||
"h-full flex flex-col group border rounded-lg border-l-[3px] transition-colors hover:bg-muted/20 lg:[grid-column:span_var(--w)] lg:[grid-row:span_var(--h)]",
|
||||
getWidgetBorder(widget.type)
|
||||
)}
|
||||
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 className="px-2.5 pt-2.5 pb-0 flex flex-row items-center justify-between gap-2">
|
||||
<div className="flex items-center gap-1.5 min-w-0">
|
||||
<Icon className={cn("h-3.5 w-3.5 shrink-0", getWidgetAccent(widget.type))} />
|
||||
<span className="text-xs font-semibold truncate">{widget.title || typeInfo?.label || widget.type}</span>
|
||||
</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 className="flex items-center gap-0.5 opacity-0 group-hover:opacity-100 transition-opacity shrink-0">
|
||||
<Button variant="ghost" size="icon" className="h-5 w-5" onClick={onConfigure} aria-label="Configure widget"><Settings2 className="h-3 w-3" /></Button>
|
||||
<Button variant="ghost" size="icon" className="h-5 w-5 text-destructive" onClick={onDelete} aria-label="Remove widget"><Trash2 className="h-3 w-3" /></Button>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="p-3 flex-1 overflow-auto">
|
||||
</div>
|
||||
<div className="p-2.5 flex-1 overflow-auto">
|
||||
<WidgetRenderer type={widget.type} />
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -359,15 +371,15 @@ function AddWidgetDialog({ open, onOpenChange, onAdd }: { open: boolean; onOpenC
|
||||
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">
|
||||
<DialogHeader><DialogTitle className="text-sm">Add Widget</DialogTitle></DialogHeader>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
{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>
|
||||
className="flex flex-col items-center gap-1.5 p-3 rounded-lg border hover:bg-accent hover:border-primary transition-colors text-center">
|
||||
<Icon className={cn("h-5 w-5", wt.accent)} />
|
||||
<span className="text-[10px] font-medium">{wt.label}</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
@@ -388,17 +400,17 @@ function ConfigureWidgetDialog({ widget, open, onOpenChange, onSave }: { widget:
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent>
|
||||
<DialogHeader><DialogTitle>Configure Widget</DialogTitle></DialogHeader>
|
||||
<div className="space-y-4">
|
||||
<DialogHeader><DialogTitle className="text-sm">Configure Widget</DialogTitle></DialogHeader>
|
||||
<div className="space-y-3">
|
||||
<div>
|
||||
<Label htmlFor="widget-title">Title</Label>
|
||||
<Input id="widget-title" value={title} onChange={(e) => setTitle(e.target.value)} placeholder="Widget title" />
|
||||
<Label htmlFor="widget-title" className="text-xs">Title</Label>
|
||||
<Input id="widget-title" value={title} onChange={(e) => setTitle(e.target.value)} placeholder="Widget title" className="h-8 text-xs mt-1" />
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<Label htmlFor="widget-w">Width (columns)</Label>
|
||||
<Label htmlFor="widget-w" className="text-xs">Width (columns)</Label>
|
||||
<Select value={String(w)} onValueChange={(v) => setW(parseInt(v))}>
|
||||
<SelectTrigger id="widget-w"><SelectValue /></SelectTrigger>
|
||||
<SelectTrigger id="widget-w" className="h-8 text-xs mt-1"><SelectValue /></SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="1">1 column</SelectItem>
|
||||
<SelectItem value="2">2 columns</SelectItem>
|
||||
@@ -408,9 +420,9 @@ function ConfigureWidgetDialog({ widget, open, onOpenChange, onSave }: { widget:
|
||||
</Select>
|
||||
</div>
|
||||
<div>
|
||||
<Label htmlFor="widget-h">Height (rows)</Label>
|
||||
<Label htmlFor="widget-h" className="text-xs">Height (rows)</Label>
|
||||
<Select value={String(h)} onValueChange={(v) => setH(parseInt(v))}>
|
||||
<SelectTrigger id="widget-h"><SelectValue /></SelectTrigger>
|
||||
<SelectTrigger id="widget-h" className="h-8 text-xs mt-1"><SelectValue /></SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="1">1 row</SelectItem>
|
||||
<SelectItem value="2">2 rows</SelectItem>
|
||||
@@ -421,8 +433,8 @@ function ConfigureWidgetDialog({ widget, open, onOpenChange, onSave }: { widget:
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex justify-end gap-2">
|
||||
<Button variant="outline" onClick={() => onOpenChange(false)}>Cancel</Button>
|
||||
<Button onClick={handleSave}>Save</Button>
|
||||
<Button variant="outline" size="sm" onClick={() => onOpenChange(false)}>Cancel</Button>
|
||||
<Button size="sm" onClick={handleSave}>Save</Button>
|
||||
</div>
|
||||
</div>
|
||||
</DialogContent>
|
||||
@@ -473,26 +485,27 @@ function DashboardPage() {
|
||||
};
|
||||
const handleDelete = (id: string) => { deleteMutation.mutate(id); };
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-3">
|
||||
<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>
|
||||
<h1 className="text-xl font-bold">Dashboard</h1>
|
||||
<Button onClick={() => setAddOpen(true)} size="sm" aria-label="Add widget"><Plus className="h-3.5 w-3.5 mr-1.5" />Add Widget</Button>
|
||||
</div>
|
||||
{isLoading ? (
|
||||
<div className="flex items-center justify-center py-12 text-muted-foreground">Loading dashboard...</div>
|
||||
<div className="flex items-center justify-center py-10 text-xs 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={() => {
|
||||
<div className="text-center py-10 border rounded-lg border-dashed">
|
||||
<Zap className="h-6 w-6 mx-auto mb-2 text-muted-foreground/50" />
|
||||
<p className="text-xs text-muted-foreground mb-3">Your dashboard is empty. Add some widgets to get started.</p>
|
||||
<Button size="sm" 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>
|
||||
}}><Plus className="h-3.5 w-3.5 mr-1.5" />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">
|
||||
<div className="grid grid-cols-1 gap-3 auto-rows-[minmax(110px,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)} />
|
||||
))}
|
||||
|
||||
@@ -342,7 +342,7 @@ function GraphPage() {
|
||||
{/* Graph canvas area */}
|
||||
<div ref={containerRef} className="flex-1 relative bg-muted/20">
|
||||
{/* Toolbar */}
|
||||
<div className="absolute top-4 left-4 z-10 flex flex-wrap items-center gap-2">
|
||||
<div className="absolute top-3 left-3 z-10 flex flex-wrap items-center gap-2">
|
||||
<div className="relative">
|
||||
<Search className="absolute left-2.5 top-2.5 h-4 w-4 text-muted-foreground" />
|
||||
<Input
|
||||
@@ -356,10 +356,13 @@ function GraphPage() {
|
||||
<Button variant="outline" size="icon" onClick={() => setFilterOpen(true)} aria-label="Filters">
|
||||
<Filter className="h-4 w-4" />
|
||||
</Button>
|
||||
<Badge variant="secondary" className="font-mono text-[10px]">
|
||||
{displayNodes.length} nodes · {displayEdges.length} edges
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
{/* Zoom controls */}
|
||||
<div className="absolute bottom-4 right-4 z-10 flex flex-col gap-1">
|
||||
<div className="absolute bottom-3 right-3 z-10 flex flex-col gap-1">
|
||||
<Button variant="secondary" size="icon" onClick={handleZoomIn} aria-label="Zoom in">
|
||||
<ZoomIn className="h-4 w-4" />
|
||||
</Button>
|
||||
@@ -373,9 +376,9 @@ function GraphPage() {
|
||||
|
||||
{/* Performance warning */}
|
||||
{exceeded && (
|
||||
<div className="absolute top-4 right-4 z-10">
|
||||
<Badge variant="destructive" className="text-xs">
|
||||
Showing {MAX_NODES} of {filteredNodes.length} nodes (cap reached)
|
||||
<div className="absolute top-3 right-3 z-10">
|
||||
<Badge variant="destructive" className="text-xs font-mono">
|
||||
Showing <span className="font-semibold">{MAX_NODES}</span> of <span className="font-semibold">{filteredNodes.length}</span> nodes
|
||||
</Badge>
|
||||
</div>
|
||||
)}
|
||||
@@ -418,9 +421,12 @@ function GraphPage() {
|
||||
<SheetTitle>Filters</SheetTitle>
|
||||
</SheetHeader>
|
||||
<ScrollArea className="h-full pr-4">
|
||||
<div className="space-y-6 pt-4">
|
||||
<div className="space-y-4 pt-4">
|
||||
<div>
|
||||
<h3 className="text-sm font-semibold mb-2">Entity Types</h3>
|
||||
<h3 className="text-sm font-semibold mb-2 flex items-center justify-between">
|
||||
Entity Types
|
||||
<span className="font-mono text-[10px] text-muted-foreground">{enabledTypes.size}/{ENTITY_TYPES.length}</span>
|
||||
</h3>
|
||||
{ENTITY_TYPES.map((type) => (
|
||||
<div key={type} className="flex items-center gap-2 py-1">
|
||||
<Checkbox
|
||||
@@ -437,7 +443,10 @@ function GraphPage() {
|
||||
</div>
|
||||
<Separator />
|
||||
<div>
|
||||
<h3 className="text-sm font-semibold mb-2">Relationship Types</h3>
|
||||
<h3 className="text-sm font-semibold mb-2 flex items-center justify-between">
|
||||
Relationship Types
|
||||
<span className="font-mono text-[10px] text-muted-foreground">{enabledRelationships.size}/{RELATIONSHIP_TYPES.length}</span>
|
||||
</h3>
|
||||
{RELATIONSHIP_TYPES.map((type) => (
|
||||
<div key={type} className="flex items-center gap-2 py-1">
|
||||
<Checkbox
|
||||
@@ -464,12 +473,12 @@ function GraphPage() {
|
||||
<SheetTitle>{selectedNode?.label || "Node"}</SheetTitle>
|
||||
</SheetHeader>
|
||||
{selectedNode && (
|
||||
<div className="space-y-4 pt-4">
|
||||
<div className="space-y-3 pt-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-3 h-3 rounded-full" style={{ backgroundColor: selectedNode.color }} />
|
||||
<Badge variant="secondary">{selectedNode.type}</Badge>
|
||||
<Badge variant="secondary" className="font-mono">{selectedNode.type}</Badge>
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground">ID: {selectedNode.id}</p>
|
||||
<p className="text-sm text-muted-foreground font-mono">ID: {selectedNode.id}</p>
|
||||
<Separator />
|
||||
{NODE_TYPE_ROUTES[selectedNode.type] ? (
|
||||
<Button
|
||||
@@ -484,7 +493,12 @@ function GraphPage() {
|
||||
</p>
|
||||
)}
|
||||
<Separator />
|
||||
<h4 className="text-sm font-semibold">Connected nodes</h4>
|
||||
<h4 className="text-sm font-semibold flex items-center justify-between">
|
||||
Connected nodes
|
||||
<span className="font-mono text-[10px] text-muted-foreground">
|
||||
{displayEdges.filter((e) => e.source === selectedNode.id || e.target === selectedNode.id).length}
|
||||
</span>
|
||||
</h4>
|
||||
<div className="space-y-1">
|
||||
{displayEdges
|
||||
.filter((e) => e.source === selectedNode.id || e.target === selectedNode.id)
|
||||
@@ -510,7 +524,7 @@ function GraphPage() {
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
<Badge variant="outline" className="text-[10px]">{e.type.replace(/_/g, " ")}</Badge>
|
||||
<Badge variant="outline" className="text-[10px] font-mono">{e.type.replace(/_/g, " ")}</Badge>
|
||||
</div>
|
||||
) : null;
|
||||
})}
|
||||
|
||||
@@ -50,7 +50,7 @@ function HabitForm({ habit, onClose }: { habit?: Habit; onClose: () => void }) {
|
||||
};
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<form onSubmit={handleSubmit} className="space-y-3">
|
||||
<div>
|
||||
<Label htmlFor="name">Name</Label>
|
||||
<Input id="name" value={name} onChange={(e) => setName(e.target.value)} placeholder="Habit name" required />
|
||||
@@ -59,7 +59,7 @@ function HabitForm({ habit, onClose }: { habit?: Habit; onClose: () => void }) {
|
||||
<Label htmlFor="desc">Description</Label>
|
||||
<Textarea id="desc" value={description} onChange={(e) => setDescription(e.target.value)} placeholder="Description (optional)" rows={2} />
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<Label htmlFor="freq">Frequency</Label>
|
||||
<Select value={frequency} onValueChange={(v) => setFrequency(v as "daily" | "weekly" | "custom")}>
|
||||
@@ -108,7 +108,7 @@ function MiniGrid({ completions, days = 7 }: { completions: HabitCompletion[]; d
|
||||
cells.push(
|
||||
<div
|
||||
key={key}
|
||||
className={cn("w-3 h-3 rounded-sm", done ? "bg-green-500" : "bg-muted")}
|
||||
className={cn("w-2.5 h-2.5 rounded-sm", done ? "bg-green-500" : "bg-muted")}
|
||||
title={key + (done ? " ✓" : "")}
|
||||
/>
|
||||
);
|
||||
@@ -183,7 +183,7 @@ function HabitsPage() {
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<h1 className="text-2xl font-bold">Habits</h1>
|
||||
<h1 className="text-xl font-bold">Habits</h1>
|
||||
<Dialog open={createOpen} onOpenChange={setCreateOpen}>
|
||||
<DialogTrigger asChild>
|
||||
<Button aria-label="New habit"><Plus className="h-4 w-4 mr-2" />New Habit</Button>
|
||||
@@ -200,24 +200,24 @@ function HabitsPage() {
|
||||
) : isError ? (
|
||||
<ErrorState message="Failed to load habits." onRetry={() => refetch()} />
|
||||
) : habits.length === 0 ? (
|
||||
<EmptyState icon={Flame} title="No habits yet" description="Create your first one!" />
|
||||
<EmptyState icon={Flame} title="No habits yet" description="Create your first one to get started." />
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{habits.map((habit) => (
|
||||
<Card key={habit.id} className="cursor-pointer hover:shadow-md transition-shadow" onClick={() => openHabitDetail(habit)}>
|
||||
<CardContent className="p-4">
|
||||
<Card key={habit.id} className="cursor-pointer border hover:bg-muted/20 transition-colors" onClick={() => openHabitDetail(habit)}>
|
||||
<CardContent className="p-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-3 flex-1 min-w-0">
|
||||
<Flame className={cn("h-5 w-5 shrink-0", habit.streakCount > 0 ? "text-orange-500" : "text-muted-foreground")} />
|
||||
<div className="min-w-0">
|
||||
<p className="font-medium truncate">{habit.name}</p>
|
||||
<div className="flex items-center gap-2 mt-1">
|
||||
<span className="text-sm text-muted-foreground">
|
||||
<span className="font-mono text-xs text-muted-foreground">
|
||||
<Flame className="h-3 w-3 inline mr-0.5" />
|
||||
{habit.streakCount} day streak
|
||||
</span>
|
||||
<Badge variant="secondary" className="text-[10px]">{habit.frequency}</Badge>
|
||||
<Badge variant="outline" className="text-[10px]">{habit.difficulty}</Badge>
|
||||
<Badge variant="secondary" className="font-mono text-[10px]">{habit.frequency}</Badge>
|
||||
<Badge variant="outline" className="font-mono text-[10px]">{habit.difficulty}</Badge>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -233,6 +233,11 @@ function HabitsPage() {
|
||||
})}
|
||||
onClick={(e) => { e.stopPropagation(); completeMutation.mutate(habit.id); }}
|
||||
aria-label={"Mark " + habit.name + " complete"}
|
||||
className={(habit.recentCompletions || []).some((c) => {
|
||||
const d = new Date(c.date);
|
||||
const t = new Date();
|
||||
return d.getUTCFullYear() === t.getUTCFullYear() && d.getUTCMonth() === t.getUTCMonth() && d.getUTCDate() === t.getUTCDate();
|
||||
}) ? "bg-green-500/10 text-green-600 border-green-500/20" : ""}
|
||||
>
|
||||
<Check className="h-4 w-4 mr-1" />Complete
|
||||
</Button>
|
||||
@@ -263,15 +268,15 @@ function HabitsPage() {
|
||||
|
||||
<EntityDetailPanel open={panelOpen} onOpenChange={(o) => { setPanelOpen(o); if (!o) setDetailTab("overview"); }} title={selectedHabit?.name || "Habit Details"}>
|
||||
{selectedHabit && (
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-3">
|
||||
<Tabs value={detailTab} onValueChange={setDetailTab}>
|
||||
<TabsList className="w-full">
|
||||
<TabsTrigger value="overview" className="flex-1">Overview</TabsTrigger>
|
||||
<TabsTrigger value="history" className="flex-1">History</TabsTrigger>
|
||||
</TabsList>
|
||||
<TabsContent value="overview" className="space-y-4 pt-4">
|
||||
<TabsContent value="overview" className="space-y-3 pt-3">
|
||||
<HabitForm habit={selectedHabit} onClose={() => setPanelOpen(false)} />
|
||||
<div className="pt-4 border-t">
|
||||
<div className="pt-3 border-t">
|
||||
<AlertDialog>
|
||||
<AlertDialogTrigger asChild>
|
||||
<Button variant="destructive" size="sm"><Trash2 className="h-4 w-4 mr-2" />Delete Habit</Button>
|
||||
@@ -289,7 +294,7 @@ function HabitsPage() {
|
||||
</AlertDialog>
|
||||
</div>
|
||||
</TabsContent>
|
||||
<TabsContent value="history" className="pt-4">
|
||||
<TabsContent value="history" className="pt-3">
|
||||
<div className="space-y-2">
|
||||
<h3 className="font-semibold text-sm">Completion History</h3>
|
||||
{selectedHabit.recentCompletions?.length ? (
|
||||
|
||||
@@ -225,7 +225,7 @@ function Overview({ habit, patch }: { habit: Habit; patch: PatchFn }) {
|
||||
const skipDays = habit.skipDays || [];
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<p className="mb-1 text-sm font-semibold text-muted-foreground">Description</p>
|
||||
<InlineTextarea
|
||||
@@ -237,7 +237,7 @@ function Overview({ habit, patch }: { habit: Habit; patch: PatchFn }) {
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
|
||||
<div className="grid grid-cols-1 gap-2 sm:grid-cols-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<RepeatIcon className="h-4 w-4 shrink-0 text-muted-foreground" />
|
||||
<InlineSelect
|
||||
@@ -267,7 +267,7 @@ function Overview({ habit, patch }: { habit: Habit; patch: PatchFn }) {
|
||||
patch({ id: habit.id, data: { goalPerPeriod: Number(goal) || 1 } })
|
||||
}
|
||||
display={(goal) => (
|
||||
<span>
|
||||
<span className="font-mono text-xs">
|
||||
{habit.unit ? `${goal} ${habit.unit} per period` : `${goal} per period`}
|
||||
</span>
|
||||
)}
|
||||
@@ -297,7 +297,7 @@ function Overview({ habit, patch }: { habit: Habit; patch: PatchFn }) {
|
||||
}
|
||||
display={(time) =>
|
||||
time ? (
|
||||
<span>Reminder {time}</span>
|
||||
<span className="font-mono text-xs">Reminder {time}</span>
|
||||
) : (
|
||||
<span className="text-muted-foreground/70">No reminder</span>
|
||||
)
|
||||
@@ -340,33 +340,33 @@ function Overview({ habit, patch }: { habit: Habit; patch: PatchFn }) {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-3 gap-3 text-center">
|
||||
<div className="rounded-lg bg-muted/50 p-3">
|
||||
<p className="text-2xl font-bold">{habit.streakCount}</p>
|
||||
<p className="text-xs text-muted-foreground">Current Streak</p>
|
||||
<div className="grid grid-cols-3 gap-2 text-center">
|
||||
<div className="rounded-lg bg-muted/50 p-2.5">
|
||||
<p className="text-xl font-bold font-mono">{habit.streakCount}</p>
|
||||
<p className="text-[11px] text-muted-foreground">Current Streak</p>
|
||||
</div>
|
||||
<div className="rounded-lg bg-muted/50 p-3">
|
||||
<p className="text-2xl font-bold">{habit.bestStreak}</p>
|
||||
<p className="text-xs text-muted-foreground">Best Streak</p>
|
||||
<div className="rounded-lg bg-muted/50 p-2.5">
|
||||
<p className="text-xl font-bold font-mono">{habit.bestStreak}</p>
|
||||
<p className="text-[11px] text-muted-foreground">Best Streak</p>
|
||||
</div>
|
||||
<div className="rounded-lg bg-muted/50 p-3">
|
||||
<p className="text-2xl font-bold">{distinctCompletionDays}</p>
|
||||
<p className="text-xs text-muted-foreground">Completion This Period</p>
|
||||
<div className="rounded-lg bg-muted/50 p-2.5">
|
||||
<p className="text-xl font-bold font-mono">{distinctCompletionDays}</p>
|
||||
<p className="text-[11px] text-muted-foreground">Completion This Period</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="border-t pt-4">
|
||||
<div className="border-t pt-3">
|
||||
<TagManager entityType="habit" entityId={habit.id} tags={habit.tags || []} />
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-4 border-t pt-4 text-xs text-muted-foreground">
|
||||
<div className="flex items-center gap-4 border-t pt-3 text-xs text-muted-foreground">
|
||||
<span className="flex items-center gap-1">
|
||||
<Clock className="h-3 w-3" />
|
||||
Created {format(parseISO(habit.createdAt), "MMM d, yyyy HH:mm")}
|
||||
Created <span className="font-mono">{format(parseISO(habit.createdAt), "MMM d, yyyy HH:mm")}</span>
|
||||
</span>
|
||||
<span className="flex items-center gap-1">
|
||||
<Clock className="h-3 w-3" />
|
||||
Updated {format(parseISO(habit.updatedAt), "MMM d, yyyy HH:mm")}
|
||||
Updated <span className="font-mono">{format(parseISO(habit.updatedAt), "MMM d, yyyy HH:mm")}</span>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
@@ -389,7 +389,7 @@ function Stats({
|
||||
const completionRate = Math.round((distinctCompletionDays / days) * 100);
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<Select value={String(days)} onValueChange={(v) => setDays(Number(v))}>
|
||||
<SelectTrigger className="h-8 w-40 text-sm" aria-label="Range">
|
||||
@@ -404,26 +404,26 @@ function Stats({
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-3 gap-3 text-center">
|
||||
<div className="rounded-lg bg-muted/50 p-3">
|
||||
<p className="text-2xl font-bold">{habit.streakCount}</p>
|
||||
<p className="text-xs text-muted-foreground">Current Streak</p>
|
||||
<div className="grid grid-cols-3 gap-2 text-center">
|
||||
<div className="rounded-lg bg-muted/50 p-2.5">
|
||||
<p className="text-xl font-bold font-mono">{habit.streakCount}</p>
|
||||
<p className="text-[11px] text-muted-foreground">Current Streak</p>
|
||||
</div>
|
||||
<div className="rounded-lg bg-muted/50 p-3">
|
||||
<p className="text-2xl font-bold">{habit.bestStreak}</p>
|
||||
<p className="text-xs text-muted-foreground">Best Streak</p>
|
||||
<div className="rounded-lg bg-muted/50 p-2.5">
|
||||
<p className="text-xl font-bold font-mono">{habit.bestStreak}</p>
|
||||
<p className="text-[11px] text-muted-foreground">Best Streak</p>
|
||||
</div>
|
||||
<div className="rounded-lg bg-muted/50 p-3">
|
||||
<p className="text-2xl font-bold">{completionRate}%</p>
|
||||
<p className="text-xs text-muted-foreground">Completion Rate</p>
|
||||
<div className="rounded-lg bg-muted/50 p-2.5">
|
||||
<p className="text-xl font-bold font-mono">{completionRate}%</p>
|
||||
<p className="text-[11px] text-muted-foreground">Completion Rate</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="rounded-lg border bg-muted/30 p-4">
|
||||
<div className="rounded-lg border bg-muted/30 p-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<Calendar className="h-4 w-4 text-muted-foreground" />
|
||||
<p className="text-sm font-semibold">Completion Heatmap</p>
|
||||
<span className="ml-auto text-xs text-muted-foreground">
|
||||
<span className="ml-auto font-mono text-[11px] text-muted-foreground">
|
||||
Last {days} days
|
||||
</span>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,316 @@
|
||||
import { useMemo } from "react";
|
||||
import { createRoute, useNavigate } from "@tanstack/react-router";
|
||||
import { Route as appRoute } from "../_app";
|
||||
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { api, useApiQuery } from "@/lib/api";
|
||||
import { useApiDomain } from "@/lib/stores/use-active-domain-store";
|
||||
import { useRealtime } from "@/hooks/use-realtime";
|
||||
import { Inbox as InboxIcon, AlertTriangle, CalendarClock, Flame, FileText, Check, ChevronRight } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import { LoadingState, EmptyState } from "@/components/state";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { Task, Habit, Note, PaginatedResponse } from "@/lib/types";
|
||||
import { format, isToday, isPast, parseISO } from "date-fns";
|
||||
|
||||
function InboxPage() {
|
||||
const navigate = useNavigate();
|
||||
const queryClient = useQueryClient();
|
||||
const activeDomainId = useApiDomain();
|
||||
const domainSuffix = activeDomainId ? "&domain=" + activeDomainId : "";
|
||||
|
||||
useRealtime({ enabled: true });
|
||||
|
||||
// ─── Queries ──────────────────────────────────────────────────────────
|
||||
const { data: tasksData, isLoading: tasksLoading, error: tasksError, refetch: refetchTasks } =
|
||||
useApiQuery<PaginatedResponse<Task>>(
|
||||
["tasks-inbox", activeDomainId],
|
||||
"/tasks?limit=200&status=todo,in_progress&sort=due_date" + domainSuffix,
|
||||
);
|
||||
|
||||
const { data: habitsData, isLoading: habitsLoading, error: habitsError, refetch: refetchHabits } =
|
||||
useApiQuery<PaginatedResponse<Habit>>(
|
||||
["habits-inbox", activeDomainId],
|
||||
"/habits?limit=50" + domainSuffix,
|
||||
);
|
||||
|
||||
const { data: notesData, isLoading: notesLoading, error: notesError, refetch: refetchNotes } =
|
||||
useApiQuery<PaginatedResponse<Note>>(
|
||||
["notes-inbox", activeDomainId],
|
||||
"/notes?limit=5&sort=-updated" + domainSuffix,
|
||||
);
|
||||
|
||||
// ─── Filtering ────────────────────────────────────────────────────────
|
||||
const tasks = tasksData?.items || [];
|
||||
const today = new Date();
|
||||
|
||||
const overdueTasks = useMemo(
|
||||
() =>
|
||||
tasks.filter((t) => {
|
||||
if (!t.dueDate) return false;
|
||||
const d = parseISO(t.dueDate);
|
||||
return isPast(d) && !isToday(d);
|
||||
}),
|
||||
[tasks],
|
||||
);
|
||||
|
||||
const dueTodayTasks = useMemo(
|
||||
() => tasks.filter((t) => t.dueDate && isToday(parseISO(t.dueDate))),
|
||||
[tasks],
|
||||
);
|
||||
|
||||
const habits = habitsData?.items || [];
|
||||
const notes = notesData?.items || [];
|
||||
|
||||
// ─── Mutations ────────────────────────────────────────────────────────
|
||||
const completeTaskMutation = useMutation({
|
||||
mutationFn: (taskId: string) => api.patch<Task>("/tasks/" + taskId, { status: "done" }),
|
||||
onSuccess: () => queryClient.invalidateQueries({ queryKey: ["tasks-inbox"] }),
|
||||
});
|
||||
|
||||
const completeHabitMutation = useMutation({
|
||||
mutationFn: (habitId: string) => api.post("/habits/" + habitId + "/complete", {}),
|
||||
onSuccess: () => queryClient.invalidateQueries({ queryKey: ["habits-inbox"] }),
|
||||
});
|
||||
|
||||
const isLoading = tasksLoading || habitsLoading || notesLoading;
|
||||
const hasError = tasksError || habitsError || notesError;
|
||||
|
||||
if (isLoading) return <LoadingState label="Loading inbox..." />;
|
||||
if (hasError) {
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<h1 className="text-xl font-bold flex items-center gap-2">
|
||||
<InboxIcon className="h-5 w-5" /> Inbox
|
||||
</h1>
|
||||
<EmptyState icon={AlertTriangle} title="Failed to load inbox" description="Check your connection and try again." />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{/* Page header */}
|
||||
<h1 className="text-xl font-bold flex items-center gap-2">
|
||||
<InboxIcon className="h-5 w-5" /> Inbox
|
||||
</h1>
|
||||
|
||||
{/* Overdue tasks */}
|
||||
<section>
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<div className="h-2 w-2 rounded-full bg-destructive" />
|
||||
<h2 className="text-sm font-semibold text-destructive">Overdue</h2>
|
||||
{overdueTasks.length > 0 && (
|
||||
<Badge variant="destructive" className="font-mono text-[10px] px-1.5">{overdueTasks.length}</Badge>
|
||||
)}
|
||||
</div>
|
||||
{overdueTasks.length === 0 ? (
|
||||
<div className="py-6 text-center">
|
||||
<AlertTriangle className="h-6 w-6 mx-auto mb-1 text-green-500" />
|
||||
<p className="text-xs text-muted-foreground">No overdue tasks</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-1">
|
||||
{overdueTasks.map((task) => (
|
||||
<Card key={task.id} className="border border-destructive/20 hover:bg-muted/20 transition-colors">
|
||||
<CardContent className="p-2.5">
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-6 w-6 shrink-0"
|
||||
disabled={completeTaskMutation.isPending}
|
||||
onClick={() => completeTaskMutation.mutate(task.id)}
|
||||
aria-label={"Complete " + task.title}
|
||||
>
|
||||
<Check className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
<div
|
||||
className="flex-1 min-w-0 cursor-pointer"
|
||||
onClick={() => navigate({ to: "/tasks/$id", params: { id: task.id } })}
|
||||
>
|
||||
<p className="text-sm font-medium truncate">{task.title}</p>
|
||||
<div className="flex items-center gap-2 mt-0.5">
|
||||
<span className="font-mono text-[10px] text-destructive">
|
||||
{format(parseISO(task.dueDate!), "MMM d")}
|
||||
</span>
|
||||
<Badge variant="secondary" className="font-mono text-[10px]">
|
||||
{task.priority}
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
<ChevronRight className="h-4 w-4 text-muted-foreground shrink-0" />
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
|
||||
{/* Due today tasks */}
|
||||
<section>
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<div className="h-2 w-2 rounded-full bg-amber-500" />
|
||||
<h2 className="text-sm font-semibold text-amber-500">Due Today</h2>
|
||||
{dueTodayTasks.length > 0 && (
|
||||
<Badge variant="secondary" className="font-mono text-[10px] px-1.5">{dueTodayTasks.length}</Badge>
|
||||
)}
|
||||
</div>
|
||||
{dueTodayTasks.length === 0 ? (
|
||||
<div className="py-6 text-center">
|
||||
<CalendarClock className="h-6 w-6 mx-auto mb-1 text-muted-foreground/50" />
|
||||
<p className="text-xs text-muted-foreground">No tasks due today</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-1">
|
||||
{dueTodayTasks.map((task) => (
|
||||
<Card key={task.id} className="border border-amber-500/20 hover:bg-muted/20 transition-colors">
|
||||
<CardContent className="p-2.5">
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-6 w-6 shrink-0"
|
||||
disabled={completeTaskMutation.isPending}
|
||||
onClick={() => completeTaskMutation.mutate(task.id)}
|
||||
aria-label={"Complete " + task.title}
|
||||
>
|
||||
<Check className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
<div
|
||||
className="flex-1 min-w-0 cursor-pointer"
|
||||
onClick={() => navigate({ to: "/tasks/$id", params: { id: task.id } })}
|
||||
>
|
||||
<p className="text-sm font-medium truncate">{task.title}</p>
|
||||
<div className="flex items-center gap-2 mt-0.5">
|
||||
<span className="font-mono text-[10px] text-amber-500">Today</span>
|
||||
<Badge variant="secondary" className="font-mono text-[10px]">
|
||||
{task.priority}
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
<ChevronRight className="h-4 w-4 text-muted-foreground shrink-0" />
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
|
||||
{/* Habits */}
|
||||
<section>
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<div className="h-2 w-2 rounded-full bg-orange-500" />
|
||||
<h2 className="text-sm font-semibold text-orange-500">Habits</h2>
|
||||
{habits.length > 0 && (
|
||||
<Badge variant="secondary" className="font-mono text-[10px] px-1.5">{habits.length}</Badge>
|
||||
)}
|
||||
</div>
|
||||
{habits.length === 0 ? (
|
||||
<div className="py-6 text-center">
|
||||
<Flame className="h-6 w-6 mx-auto mb-1 text-muted-foreground/50" />
|
||||
<p className="text-xs text-muted-foreground">No habits yet</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-1">
|
||||
{habits.map((habit) => {
|
||||
const doneToday = (habit.recentCompletions || []).some((c) => {
|
||||
const d = new Date(c.date);
|
||||
return (
|
||||
d.getUTCFullYear() === today.getUTCFullYear() &&
|
||||
d.getUTCMonth() === today.getUTCMonth() &&
|
||||
d.getUTCDate() === today.getUTCDate()
|
||||
);
|
||||
});
|
||||
return (
|
||||
<Card key={habit.id} className="hover:bg-muted/20 transition-colors">
|
||||
<CardContent className="p-2.5">
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
className={cn(
|
||||
"w-5 h-5 rounded border shrink-0 flex items-center justify-center transition-colors",
|
||||
doneToday
|
||||
? "bg-green-500 border-green-500"
|
||||
: "border-muted-foreground/30 hover:border-primary",
|
||||
)}
|
||||
disabled={completeHabitMutation.isPending}
|
||||
onClick={() => completeHabitMutation.mutate(habit.id)}
|
||||
aria-label={doneToday ? habit.name + " (completed)" : "Complete " + habit.name}
|
||||
>
|
||||
{doneToday && <Check className="h-3 w-3 text-white" />}
|
||||
</button>
|
||||
<div
|
||||
className="flex-1 min-w-0 cursor-pointer"
|
||||
onClick={() => navigate({ to: "/habits/$id", params: { id: habit.id } })}
|
||||
>
|
||||
<p className={cn("text-sm font-medium truncate", doneToday && "line-through text-muted-foreground")}>
|
||||
{habit.name}
|
||||
</p>
|
||||
<div className="flex items-center gap-2 mt-0.5">
|
||||
<span className="font-mono text-[10px] text-muted-foreground">{habit.frequency}</span>
|
||||
{habit.streakCount > 0 && (
|
||||
<Badge variant="secondary" className="font-mono text-[10px] px-1">
|
||||
<Flame className="h-2.5 w-2.5 mr-0.5 text-orange-500" />
|
||||
{habit.streakCount}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<ChevronRight className="h-4 w-4 text-muted-foreground shrink-0" />
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
|
||||
{/* Recent notes */}
|
||||
<section>
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<div className="h-2 w-2 rounded-full bg-blue-500" />
|
||||
<h2 className="text-sm font-semibold text-blue-500">Recent Notes</h2>
|
||||
</div>
|
||||
{notes.length === 0 ? (
|
||||
<div className="py-6 text-center">
|
||||
<FileText className="h-6 w-6 mx-auto mb-1 text-muted-foreground/50" />
|
||||
<p className="text-xs text-muted-foreground">No notes yet</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-1">
|
||||
{notes.map((note) => (
|
||||
<Card
|
||||
key={note.id}
|
||||
className="hover:bg-muted/20 transition-colors cursor-pointer"
|
||||
onClick={() => navigate({ to: "/notes/$id", params: { id: note.id } })}
|
||||
>
|
||||
<CardContent className="p-2.5">
|
||||
<div className="flex items-center gap-2">
|
||||
<FileText className="h-4 w-4 text-blue-500 shrink-0" />
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-sm font-medium truncate">{note.title}</p>
|
||||
<p className="font-mono text-[10px] text-muted-foreground mt-0.5">
|
||||
{format(parseISO(note.updatedAt), "MMM d, HH:mm")}
|
||||
</p>
|
||||
</div>
|
||||
<ChevronRight className="h-4 w-4 text-muted-foreground shrink-0" />
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export const Route = createRoute({
|
||||
getParentRoute: () => appRoute,
|
||||
path: "/inbox",
|
||||
component: InboxPage,
|
||||
});
|
||||
@@ -44,11 +44,17 @@ const NoteTitleInput = memo(function NoteTitleInput({ noteId, initialTitle }: {
|
||||
onMouseDown={(e) => e.stopPropagation()}
|
||||
onBlur={handleBlur}
|
||||
autoFocus
|
||||
className="flex h-9 w-full rounded-md border-0 bg-transparent px-0 py-1 text-lg font-semibold text-base shadow-none transition-colors file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-0 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
className="flex h-9 w-full rounded-md border-0 bg-transparent px-0 py-1 text-lg font-semibold shadow-none transition-colors file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-0 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
/>
|
||||
);
|
||||
});
|
||||
|
||||
// Count words in HTML content (rough estimate)
|
||||
function countWords(html: string | null): number {
|
||||
if (!html) return 0;
|
||||
return html.replace(/<[^>]+>/g, "").trim().split(/\s+/).filter(Boolean).length;
|
||||
}
|
||||
|
||||
// Memoized right pane - only re-renders when note changes, not on parent re-renders
|
||||
const NoteEditorPane = memo(function NoteEditorPane({ note, onDelete, onOpenNote }: { note: Note; onDelete: (id: string) => void; onOpenNote: (note: Note) => void }) {
|
||||
const [showBacklinks, setShowBacklinks] = useState(false);
|
||||
@@ -67,19 +73,19 @@ const NoteEditorPane = memo(function NoteEditorPane({ note, onDelete, onOpenNote
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="flex items-center gap-2 p-3 border-b">
|
||||
<div className="flex items-center gap-2 px-3 py-2 border-b">
|
||||
<NoteTitleInput key={note.id} noteId={note.id} initialTitle={note.title} />
|
||||
<div className="flex items-center gap-1 shrink-0">
|
||||
<Button variant="ghost" size="icon" className="h-8 w-8" onClick={() => setShowBacklinks(!showBacklinks)} aria-label="Backlinks">
|
||||
<LinkIcon className="h-4 w-4" />
|
||||
<div className="flex items-center gap-0.5 shrink-0">
|
||||
<Button variant="ghost" size="icon" className="h-7 w-7" onClick={() => setShowBacklinks(!showBacklinks)} aria-label="Backlinks">
|
||||
<LinkIcon className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
<Button variant="ghost" size="icon" className="h-8 w-8" onClick={() => { setShowVersions(!showVersions); if (!showVersions) { api.get<{ items: { id: string; createdAt: string; action?: string; changes?: Record<string, unknown> | null }[] }>("/notes/" + note.id + "/versions").then((data) => setVersions(data.items || [])).catch(() => setVersions([])); } }} aria-label="Version history">
|
||||
<History className="h-4 w-4" />
|
||||
<Button variant="ghost" size="icon" className="h-7 w-7" onClick={() => { setShowVersions(!showVersions); if (!showVersions) { api.get<{ items: { id: string; createdAt: string; action?: string; changes?: Record<string, unknown> | null }[] }>("/notes/" + note.id + "/versions").then((data) => setVersions(data.items || [])).catch(() => setVersions([])); } }} aria-label="Version history">
|
||||
<History className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
<AlertDialog>
|
||||
<AlertDialogTrigger asChild>
|
||||
<Button variant="ghost" size="icon" className="h-8 w-8 text-destructive" aria-label="Delete note">
|
||||
<Trash2 className="h-4 w-4" />
|
||||
<Button variant="ghost" size="icon" className="h-7 w-7 text-destructive" aria-label="Delete note">
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</AlertDialogTrigger>
|
||||
<AlertDialogContent>
|
||||
@@ -95,20 +101,45 @@ const NoteEditorPane = memo(function NoteEditorPane({ note, onDelete, onOpenNote
|
||||
</AlertDialog>
|
||||
</div>
|
||||
</div>
|
||||
{/* Metadata strip */}
|
||||
<div className="flex items-center gap-3 px-3 py-1.5 border-b text-xs text-muted-foreground">
|
||||
<span className="font-mono">{format(parseISO(note.updatedAt), "yyyy-MM-dd HH:mm")}</span>
|
||||
<span className="text-border">·</span>
|
||||
<span className="font-mono">{countWords(note.content)} words</span>
|
||||
{note.isPinned && (
|
||||
<>
|
||||
<span className="text-border">·</span>
|
||||
<span className="font-mono text-amber-500">pinned</span>
|
||||
</>
|
||||
)}
|
||||
{note.tags && note.tags.length > 0 && (
|
||||
<>
|
||||
<span className="text-border">·</span>
|
||||
<span className="font-mono">{note.tags.length} tag{note.tags.length !== 1 ? 's' : ''}</span>
|
||||
</>
|
||||
)}
|
||||
{note.backlinks && note.backlinks.length > 0 && (
|
||||
<>
|
||||
<span className="text-border">·</span>
|
||||
<span className="font-mono">{note.backlinks.length} backlink{note.backlinks.length !== 1 ? 's' : ''}</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex-1 overflow-auto">
|
||||
<NoteEditor key={note.id} initialContent={note.content || ''} onSave={handleSave} />
|
||||
</div>
|
||||
{/* Backlinks section */}
|
||||
{showBacklinks && note.backlinks && note.backlinks.length > 0 && (
|
||||
<div className="border-t p-3">
|
||||
<h4 className="text-sm font-semibold mb-2">Linked from</h4>
|
||||
<div className="space-y-1">
|
||||
<div className="border-t px-3 py-2">
|
||||
<h4 className="text-xs font-semibold mb-1 uppercase tracking-wider text-muted-foreground">Linked from</h4>
|
||||
<div className="space-y-0.5">
|
||||
{note.backlinks.map((bl) => (
|
||||
<div
|
||||
key={bl.id}
|
||||
className="text-sm text-muted-foreground hover:text-foreground cursor-pointer"
|
||||
className="text-xs text-muted-foreground hover:text-foreground cursor-pointer flex items-center gap-1.5"
|
||||
onClick={() => { const linked: Note = { ...note, id: bl.id, title: bl.title }; onOpenNote(linked); }}
|
||||
>
|
||||
<LinkIcon className="h-3 w-3 shrink-0" />
|
||||
{bl.title}
|
||||
</div>
|
||||
))}
|
||||
@@ -117,16 +148,16 @@ const NoteEditorPane = memo(function NoteEditorPane({ note, onDelete, onOpenNote
|
||||
)}
|
||||
{/* Version history */}
|
||||
{showVersions && (
|
||||
<div className="border-t p-3">
|
||||
<h4 className="text-sm font-semibold mb-2">Version History</h4>
|
||||
<div className="border-t px-3 py-2">
|
||||
<h4 className="text-xs font-semibold mb-1 uppercase tracking-wider text-muted-foreground">Version History</h4>
|
||||
{versions.length === 0 ? (
|
||||
<p className="text-xs text-muted-foreground">No versions yet.</p>
|
||||
) : (
|
||||
<div className="space-y-1">
|
||||
<div className="space-y-0.5">
|
||||
{versions.map((v) => (
|
||||
<div key={v.id} className="flex items-center justify-between text-xs text-muted-foreground">
|
||||
<span>{format(parseISO(v.createdAt), "MMM d, yyyy HH:mm")}</span>
|
||||
{v.action && <Badge variant="secondary" className="text-[10px] capitalize">{v.action}</Badge>}
|
||||
<span className="font-mono">{format(parseISO(v.createdAt), "yyyy-MM-dd HH:mm")}</span>
|
||||
{v.action && <Badge variant="secondary" className="text-[10px] capitalize font-mono">{v.action}</Badge>}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
@@ -214,48 +245,53 @@ function NotesPage() {
|
||||
<div className="flex flex-col md:flex-row h-auto min-h-[calc(100vh-8rem)] md:h-[calc(100vh-8rem)] -m-4 md:-m-6">
|
||||
{/* Left pane - note list */}
|
||||
<div className="w-full md:w-72 h-64 md:h-auto border-b md:border-b-0 md:border-r flex flex-col shrink-0">
|
||||
<div className="p-3 border-b">
|
||||
<div className="px-3 py-2 border-b">
|
||||
<div className="relative">
|
||||
<Search className="absolute left-2.5 top-2.5 h-4 w-4 text-muted-foreground" />
|
||||
<Input placeholder="Search notes..." value={search} onChange={(e) => setSearch(e.target.value)} className="pl-8" aria-label="Search notes" />
|
||||
<Search className="absolute left-2.5 top-2 h-3.5 w-3.5 text-muted-foreground" />
|
||||
<Input placeholder="Search notes..." value={search} onChange={(e) => setSearch(e.target.value)} className="pl-8 h-8 text-sm" aria-label="Search notes" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="p-2">
|
||||
<Button size="sm" className="w-full" onClick={() => createMutation.mutate()} aria-label="New note">
|
||||
<Plus className="h-4 w-4 mr-2" />New Note
|
||||
<div className="px-3 py-2 border-b">
|
||||
<Button size="sm" className="w-full h-8 text-xs" onClick={() => createMutation.mutate()} aria-label="New note">
|
||||
<Plus className="h-3.5 w-3.5 mr-1" />New Note
|
||||
</Button>
|
||||
</div>
|
||||
<ScrollArea className="flex-1">
|
||||
{isLoading ? (
|
||||
<div className="p-4 text-sm text-muted-foreground">Loading...</div>
|
||||
<div className="px-3 py-2 text-xs text-muted-foreground">Loading...</div>
|
||||
) : notes.length === 0 ? (
|
||||
<div className="p-4 text-sm text-muted-foreground">No notes yet.</div>
|
||||
<div className="px-3 py-4 text-center">
|
||||
<FileText className="h-8 w-8 mx-auto mb-2 text-amber-400" />
|
||||
<p className="text-xs text-muted-foreground">No notes yet</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-0.5 p-2">
|
||||
<div>
|
||||
{notes.map((note) => (
|
||||
<button
|
||||
key={note.id}
|
||||
onClick={() => selectNote(note)}
|
||||
className={cn(
|
||||
"w-full text-left px-3 py-2 rounded-md text-sm transition-colors",
|
||||
"w-full text-left px-3 py-1.5 text-sm transition-colors border-b border-border/50 last:border-b-0",
|
||||
selectedNoteId === note.id ? "bg-accent text-accent-foreground" : "hover:bg-accent/50"
|
||||
)}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
{note.isPinned && <Pin className="h-3 w-3 shrink-0 text-muted-foreground" />}
|
||||
<span className="truncate font-medium">{note.title}</span>
|
||||
<div className="flex items-center gap-1.5">
|
||||
{note.isPinned && <Pin className="h-3 w-3 shrink-0 text-amber-500" />}
|
||||
<span className="truncate font-medium text-xs">{note.title}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5 mt-0.5 text-[10px] text-muted-foreground font-mono">
|
||||
<span>{new Date(note.updatedAt).toLocaleDateString()}</span>
|
||||
<span>·</span>
|
||||
<span>{countWords(note.content)}w</span>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground mt-0.5">
|
||||
{new Date(note.updatedAt).toLocaleDateString()}
|
||||
</p>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{hasMoreNotes && (
|
||||
<div className="p-2">
|
||||
<Button variant="outline" size="sm" className="w-full" onClick={loadMoreNotes} disabled={loadingMoreNotes}>
|
||||
{loadingMoreNotes ? "Loading..." : "Load more notes"}
|
||||
<div className="px-3 py-1.5">
|
||||
<Button variant="outline" size="sm" className="w-full h-7 text-xs" onClick={loadMoreNotes} disabled={loadingMoreNotes}>
|
||||
{loadingMoreNotes ? "Loading..." : "Load more"}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
@@ -269,8 +305,8 @@ function NotesPage() {
|
||||
) : (
|
||||
<div className="flex items-center justify-center flex-1 text-muted-foreground">
|
||||
<div className="text-center">
|
||||
<FileText className="h-12 w-12 mx-auto mb-3 opacity-50" />
|
||||
<p>Select a note or create a new one</p>
|
||||
<FileText className="h-10 w-10 mx-auto mb-2 text-amber-400" />
|
||||
<p className="text-sm text-muted-foreground">Select a note or create a new one</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -194,7 +194,7 @@ function NoteDetail() {
|
||||
|
||||
function EditorTab({ note, patch }: { note: Note; patch: PatchFn }) {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<p className="mb-1 text-sm font-semibold text-muted-foreground">Content</p>
|
||||
<div className="rounded-lg border">
|
||||
@@ -207,18 +207,18 @@ function EditorTab({ note, patch }: { note: Note; patch: PatchFn }) {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="border-t pt-4">
|
||||
<div className="border-t pt-3">
|
||||
<TagManager entityType="note" entityId={note.id} tags={note.tags || []} />
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-4 border-t pt-4 text-xs text-muted-foreground">
|
||||
<div className="flex items-center gap-4 border-t pt-3 text-xs text-muted-foreground">
|
||||
<span className="flex items-center gap-1">
|
||||
<Clock className="h-3 w-3" />
|
||||
Created {format(parseISO(note.createdAt), "MMM d, yyyy HH:mm")}
|
||||
Created <span className="font-mono">{format(parseISO(note.createdAt), "MMM d, yyyy HH:mm")}</span>
|
||||
</span>
|
||||
<span className="flex items-center gap-1">
|
||||
<Clock className="h-3 w-3" />
|
||||
Updated {format(parseISO(note.updatedAt), "MMM d, yyyy HH:mm")}
|
||||
Updated <span className="font-mono">{format(parseISO(note.updatedAt), "MMM d, yyyy HH:mm")}</span>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
import { useState } from "react";
|
||||
import { createRoute, useNavigate } 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 { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { api, useApiQuery } from "@/lib/api";
|
||||
import { useApiDomain } from "@/lib/stores/use-active-domain-store";
|
||||
import { useRealtime } from "@/hooks/use-realtime";
|
||||
import { useOpenCreateDialog } from "@/hooks/use-open-create-dialog";
|
||||
import { Plus, Pencil, Trash2, FolderKanban, Users, Calendar, ListTodo, GripVertical } from "lucide-react";
|
||||
import { Plus, Pencil, Trash2, Calendar, LayoutGrid, List } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
@@ -15,10 +15,8 @@ import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger } from
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { Progress } from "@/components/ui/progress";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import { AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle, AlertDialogTrigger } from "@/components/ui/alert-dialog";
|
||||
import { Avatar, AvatarFallback } from "@/components/ui/avatar";
|
||||
import { EntityDetailPanel } from "@/components/entities/entity-detail-panel";
|
||||
import { LoadingState, EmptyState, ErrorState } from "@/components/state";
|
||||
import { PROJECT_STATUS, TASK_STATUS } from "@/lib/status-colors";
|
||||
@@ -95,9 +93,80 @@ function ProjectForm({ project, onClose }: { project?: Project; onClose: () => v
|
||||
);
|
||||
}
|
||||
|
||||
function ProgressBar({ value, color }: { value: number; color?: string | null }) {
|
||||
return (
|
||||
<div className="relative h-1.5 w-full overflow-hidden rounded-full bg-secondary">
|
||||
<div
|
||||
className="h-full rounded-full transition-all"
|
||||
style={{
|
||||
width: `${value}%`,
|
||||
backgroundColor: color || "hsl(var(--primary))",
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ProjectCard({
|
||||
project,
|
||||
onOpenDetail,
|
||||
onOpenPanel,
|
||||
}: {
|
||||
project: Project;
|
||||
onOpenDetail: (p: Project) => void;
|
||||
onOpenPanel: (p: Project) => void;
|
||||
}) {
|
||||
const status = PROJECT_STATUS[project.status];
|
||||
return (
|
||||
<Card
|
||||
className="cursor-pointer border rounded-lg transition-colors hover:bg-muted/20"
|
||||
onClick={() => onOpenDetail(project)}
|
||||
>
|
||||
<CardHeader className="pb-1.5">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-3 h-3 rounded-full shrink-0" style={{ backgroundColor: project.color || "#3b82f6" }} />
|
||||
<CardTitle className="text-base truncate">{project.name}</CardTitle>
|
||||
<div className="ml-auto flex items-center gap-1.5">
|
||||
{status && (
|
||||
<Badge variant="secondary" className="font-mono text-[10px] gap-1.5">
|
||||
<span className={cn("h-1.5 w-1.5 rounded-full", status.dot)} />
|
||||
{status.label}
|
||||
</Badge>
|
||||
)}
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-7 w-7 shrink-0"
|
||||
onClick={(e) => { e.stopPropagation(); onOpenPanel(project); }}
|
||||
aria-label={"Edit " + project.name}
|
||||
>
|
||||
<Pencil className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="pt-0">
|
||||
{project.description && (
|
||||
<p className="text-sm text-muted-foreground line-clamp-2 mb-2">{project.description}</p>
|
||||
)}
|
||||
<div className="mb-2">
|
||||
<ProgressBar value={project.progress} color={project.color} />
|
||||
</div>
|
||||
<div className="flex items-center justify-between text-xs text-muted-foreground">
|
||||
<span className="font-mono">{project.completedCount}/{project.taskCount} tasks</span>
|
||||
{project.targetDate && (
|
||||
<span className="flex items-center gap-1"><Calendar className="h-3 w-3" />{new Date(project.targetDate).toLocaleDateString()}</span>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
function ProjectsPage() {
|
||||
const navigate = useNavigate();
|
||||
const queryClient = useQueryClient();
|
||||
const [view, setView] = useState<"grid" | "list">("grid");
|
||||
const [createOpen, setCreateOpen] = useState(false);
|
||||
const [selectedProject, setSelectedProject] = useState<Project | null>(null);
|
||||
const [panelOpen, setPanelOpen] = useState(false);
|
||||
@@ -157,16 +226,24 @@ function ProjectsPage() {
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<h1 className="text-2xl font-bold">Projects</h1>
|
||||
<Dialog open={createOpen} onOpenChange={setCreateOpen}>
|
||||
<DialogTrigger asChild>
|
||||
<Button aria-label="New project"><Plus className="h-4 w-4 mr-2" />New Project</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent>
|
||||
<DialogHeader><DialogTitle>New Project</DialogTitle></DialogHeader>
|
||||
<ProjectForm onClose={() => setCreateOpen(false)} />
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
<h1 className="text-xl font-bold">Projects</h1>
|
||||
<div className="flex items-center gap-2">
|
||||
<Tabs value={view} onValueChange={(v) => setView(v as "grid" | "list")}>
|
||||
<TabsList>
|
||||
<TabsTrigger value="grid" aria-label="Grid view"><LayoutGrid className="h-4 w-4" /></TabsTrigger>
|
||||
<TabsTrigger value="list" aria-label="List view"><List className="h-4 w-4" /></TabsTrigger>
|
||||
</TabsList>
|
||||
</Tabs>
|
||||
<Dialog open={createOpen} onOpenChange={setCreateOpen}>
|
||||
<DialogTrigger asChild>
|
||||
<Button aria-label="New project"><Plus className="h-4 w-4 mr-2" />New Project</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent>
|
||||
<DialogHeader><DialogTitle>New Project</DialogTitle></DialogHeader>
|
||||
<ProjectForm onClose={() => setCreateOpen(false)} />
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{isLoading ? (
|
||||
@@ -175,43 +252,64 @@ function ProjectsPage() {
|
||||
<ErrorState message="Failed to load projects." onRetry={() => refetch()} />
|
||||
) : projects.length === 0 ? (
|
||||
<EmptyState title="No projects yet" description="Create your first project to get started." />
|
||||
) : (
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
) : view === "grid" ? (
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-3">
|
||||
{projects.map((project) => (
|
||||
<Card key={project.id} className="cursor-pointer hover:shadow-md transition-shadow" onClick={() => openProjectDetail(project)}>
|
||||
<CardHeader className="pb-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-3 h-3 rounded-full shrink-0" style={{ backgroundColor: project.color || "#3b82f6" }} />
|
||||
<CardTitle className="text-base truncate">{project.name}</CardTitle>
|
||||
<Badge className={cn("ml-auto text-[10px]", PROJECT_STATUS[project.status]?.badge)}>
|
||||
{PROJECT_STATUS[project.status]?.label ?? project.status}
|
||||
</Badge>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8 shrink-0"
|
||||
onClick={(e) => { e.stopPropagation(); openProjectPanel(project); }}
|
||||
aria-label={"Edit " + project.name}
|
||||
>
|
||||
<Pencil className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{project.description && (
|
||||
<p className="text-sm text-muted-foreground line-clamp-2 mb-3">{project.description}</p>
|
||||
)}
|
||||
<Progress value={project.progress} className="h-1.5 mb-2" />
|
||||
<div className="flex items-center justify-between text-xs text-muted-foreground">
|
||||
<span>{project.completedCount}/{project.taskCount} tasks</span>
|
||||
{project.targetDate && (
|
||||
<span><Calendar className="h-3 w-3 inline mr-1" />{new Date(project.targetDate).toLocaleDateString()}</span>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<ProjectCard
|
||||
key={project.id}
|
||||
project={project}
|
||||
onOpenDetail={openProjectDetail}
|
||||
onOpenPanel={openProjectPanel}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-1.5">
|
||||
{projects.map((project) => {
|
||||
const status = PROJECT_STATUS[project.status];
|
||||
return (
|
||||
<Card
|
||||
key={project.id}
|
||||
className="cursor-pointer border rounded-lg transition-colors hover:bg-muted/20"
|
||||
onClick={() => openProjectDetail(project)}
|
||||
>
|
||||
<CardContent className="p-3">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-3 h-3 rounded-full shrink-0" style={{ backgroundColor: project.color || "#3b82f6" }} />
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-medium text-sm truncate">{project.name}</span>
|
||||
{status && (
|
||||
<Badge variant="secondary" className="font-mono text-[10px] gap-1.5 shrink-0">
|
||||
<span className={cn("h-1.5 w-1.5 rounded-full", status.dot)} />
|
||||
{status.label}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
{project.description && (
|
||||
<p className="text-xs text-muted-foreground line-clamp-1 mt-0.5">{project.description}</p>
|
||||
)}
|
||||
</div>
|
||||
<ProgressBar value={project.progress} color={project.color} />
|
||||
<span className="font-mono text-xs text-muted-foreground shrink-0 w-12 text-right">{project.completedCount}/{project.taskCount}</span>
|
||||
{project.targetDate && (
|
||||
<span className="text-xs text-muted-foreground shrink-0 flex items-center gap-1"><Calendar className="h-3 w-3" />{new Date(project.targetDate).toLocaleDateString()}</span>
|
||||
)}
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-7 w-7 shrink-0"
|
||||
onClick={(e) => { e.stopPropagation(); openProjectPanel(project); }}
|
||||
aria-label={"Edit " + project.name}
|
||||
>
|
||||
<Pencil className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{hasMoreProjects && (
|
||||
@@ -232,7 +330,7 @@ function ProjectsPage() {
|
||||
</TabsList>
|
||||
<TabsContent value="overview" className="space-y-4 pt-4">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<Progress value={selectedProject.progress} className="h-2 flex-1" />
|
||||
<ProgressBar value={selectedProject.progress} color={selectedProject.color} />
|
||||
<span className="text-sm font-medium">{selectedProject.progress}%</span>
|
||||
</div>
|
||||
<ProjectForm project={selectedProject} onClose={() => setPanelOpen(false)} />
|
||||
|
||||
@@ -30,6 +30,7 @@ import {
|
||||
} from "@/components/entities/inline-edit";
|
||||
import { EntityActivity } from "@/components/entities/entity-activity";
|
||||
import { EntityComments } from "@/components/entities/entity-comments";
|
||||
import { TagManager } from "@/components/entities/tag-manager";
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
@@ -263,7 +264,7 @@ function Overview({ project, patch }: { project: Project; patch: PatchFn }) {
|
||||
const countdown = targetCountdown(project.targetDate);
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<p className="mb-1 text-sm font-semibold text-muted-foreground">Description</p>
|
||||
<InlineTextarea
|
||||
@@ -275,19 +276,19 @@ function Overview({ project, patch }: { project: Project; patch: PatchFn }) {
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="rounded-lg border bg-muted/30 p-4">
|
||||
<div className="rounded-lg border bg-muted/30 p-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<ListTodo className="h-4 w-4 text-muted-foreground" />
|
||||
<p className="text-sm font-semibold">Progress</p>
|
||||
<span className="ml-auto text-sm font-medium">{project.progress}%</span>
|
||||
<span className="ml-auto font-mono text-xs font-medium">{project.progress}%</span>
|
||||
</div>
|
||||
<Progress value={project.progress} className="mt-3 h-2" />
|
||||
<p className="mt-2 text-xs text-muted-foreground">
|
||||
<Progress value={project.progress} className="mt-2 h-1.5" />
|
||||
<p className="mt-1.5 font-mono text-[11px] text-muted-foreground">
|
||||
{project.completedCount} of {project.taskCount} tasks done · {project.progress}%
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
|
||||
<div className="grid grid-cols-1 gap-2 sm:grid-cols-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<Calendar className="h-4 w-4 shrink-0 text-muted-foreground" />
|
||||
<InlineDate
|
||||
@@ -300,27 +301,18 @@ function Overview({ project, patch }: { project: Project; patch: PatchFn }) {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{project.tags && project.tags.length > 0 ? (
|
||||
<div>
|
||||
<p className="mb-2 text-sm font-semibold text-muted-foreground">Tags</p>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{project.tags.map((t) => (
|
||||
<Badge key={t.id} variant="secondary">
|
||||
{t.name}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
<div className="border-t pt-3">
|
||||
<TagManager entityType="project" entityId={project.id} tags={project.tags || []} />
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-4 border-t pt-4 text-xs text-muted-foreground">
|
||||
<div className="flex items-center gap-4 border-t pt-3 text-xs text-muted-foreground">
|
||||
<span className="flex items-center gap-1">
|
||||
<Clock className="h-3 w-3" />
|
||||
Created {format(parseISO(project.createdAt), "MMM d, yyyy HH:mm")}
|
||||
Created <span className="font-mono">{format(parseISO(project.createdAt), "MMM d, yyyy HH:mm")}</span>
|
||||
</span>
|
||||
<span className="flex items-center gap-1">
|
||||
<Clock className="h-3 w-3" />
|
||||
Updated {format(parseISO(project.updatedAt), "MMM d, yyyy HH:mm")}
|
||||
Updated <span className="font-mono">{format(parseISO(project.updatedAt), "MMM d, yyyy HH:mm")}</span>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,416 @@
|
||||
import { useState, useMemo } from "react";
|
||||
import { createRoute } from "@tanstack/react-router";
|
||||
import { Route as appRoute } from "../_app";
|
||||
import { useApiQuery } from "@/lib/api";
|
||||
import { useApiDomain } from "@/lib/stores/use-active-domain-store";
|
||||
import { useRealtime } from "@/hooks/use-realtime";
|
||||
import { BarChart3, Download, Calendar, ListTodo, Flame, FileText, FolderKanban } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import { LoadingState, ErrorState } from "@/components/state";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { DailyAnalytics, HabitAnalytics, ProjectAnalytics } from "@/lib/types";
|
||||
import { LineChart, BarChart, HorizontalBar, PieChartSimple } from "@/components/charts";
|
||||
|
||||
const RANGE_OPTIONS = [
|
||||
{ value: "7", label: "Last 7 days" },
|
||||
{ value: "30", label: "Last 30 days" },
|
||||
{ value: "90", label: "Last 90 days" },
|
||||
{ value: "all", label: "All time" },
|
||||
];
|
||||
|
||||
function StatCard({
|
||||
label,
|
||||
value,
|
||||
icon: Icon,
|
||||
color,
|
||||
bg,
|
||||
}: {
|
||||
label: string;
|
||||
value: number;
|
||||
icon: React.ElementType;
|
||||
color: string;
|
||||
bg: string;
|
||||
}) {
|
||||
return (
|
||||
<Card className="border rounded-lg hover:bg-muted/20 transition-colors">
|
||||
<CardContent className="p-3 flex items-center gap-3">
|
||||
<div className={cn("h-9 w-9 rounded-lg flex items-center justify-center shrink-0", bg)}>
|
||||
<Icon className={cn("h-4.5 w-4.5", color)} />
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<p className="text-2xl font-mono font-bold leading-none">{value}</p>
|
||||
<p className="text-xs text-muted-foreground mt-0.5">{label}</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
function ReportsPage() {
|
||||
const [range, setRange] = useState("30");
|
||||
const activeDomainId = useApiDomain();
|
||||
const domainSuffix = activeDomainId ? "&domain=" + activeDomainId : "";
|
||||
|
||||
useRealtime({ enabled: true });
|
||||
|
||||
const { data: habitData, isLoading: habitsLoading, error: habitsError, refetch: refetchHabits } =
|
||||
useApiQuery<HabitAnalytics>(
|
||||
["analytics-habits", activeDomainId, range],
|
||||
"/analytics/habits?range=" + range + domainSuffix,
|
||||
);
|
||||
|
||||
const { data: projectData, isLoading: projectsLoading, error: projectsError, refetch: refetchProjects } =
|
||||
useApiQuery<ProjectAnalytics>(
|
||||
["analytics-projects", activeDomainId, range],
|
||||
"/analytics/projects?range=" + range + domainSuffix,
|
||||
);
|
||||
|
||||
const { data: dailyData, isLoading: dailyLoading, error: dailyError, refetch: refetchDaily } =
|
||||
useApiQuery<DailyAnalytics>(
|
||||
["analytics-daily", activeDomainId, range],
|
||||
"/analytics/daily?range=" + range + domainSuffix,
|
||||
);
|
||||
|
||||
const { data: velocityData } = useApiQuery<{ items: Array<{ date: string; completed: number }>; avg: number }>(
|
||||
["analytics-velocity", activeDomainId, range],
|
||||
"/analytics/velocity?range=" + range + domainSuffix,
|
||||
);
|
||||
|
||||
const analyticsLoading = habitsLoading || projectsLoading || dailyLoading;
|
||||
const analyticsError = habitsError || projectsError || dailyError;
|
||||
|
||||
const refetchAnalytics = () => {
|
||||
refetchHabits();
|
||||
refetchProjects();
|
||||
refetchDaily();
|
||||
};
|
||||
|
||||
const dailyItems = dailyData?.items || [];
|
||||
|
||||
// ─── Derived stats ────────────────────────────────────────────────────
|
||||
const tasksCompleted = useMemo(
|
||||
() => dailyItems.reduce((sum, d) => sum + d.completed, 0),
|
||||
[dailyItems],
|
||||
);
|
||||
|
||||
const activeStreaks = habitData?.activeStreaks ?? 0;
|
||||
const bestStreak = habitData?.bestStreak ?? 0;
|
||||
const totalProjects = projectData?.totalProjects ?? 0;
|
||||
|
||||
// Notes count: use dailyAnalytics created tasks as a proxy, or 0 if unavailable.
|
||||
// The notes API doesn't have a dedicated analytics endpoint, so we estimate from the daily data.
|
||||
const notesWritten = useMemo(() => dailyItems.reduce((sum, d) => sum + d.created, 0), [dailyItems]);
|
||||
|
||||
// ─── Habit pie data ───────────────────────────────────────────────────
|
||||
const habitRateData = useMemo(() => {
|
||||
const expected = (habitData as any)?.totalExpected ?? (habitData?.totalHabits || 0) * parseInt(range);
|
||||
return [
|
||||
{ name: "Completed", value: habitData?.totalLogs || 0 },
|
||||
{ name: "Missed", value: Math.max(0, expected - (habitData?.totalLogs || 0)) },
|
||||
];
|
||||
}, [habitData, range]);
|
||||
|
||||
// ─── CSV export ───────────────────────────────────────────────────────
|
||||
const downloadCSV = (filename: string, rows: string[][]) => {
|
||||
const csv = rows
|
||||
.map((r) =>
|
||||
r
|
||||
.map((c) => '"' + c.replace(/"/g, '""') + '"')
|
||||
.join(","),
|
||||
)
|
||||
.join("\n");
|
||||
const blob = new Blob([csv], { type: "text/csv" });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement("a");
|
||||
a.href = url;
|
||||
a.download = filename;
|
||||
a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
};
|
||||
|
||||
const rangeLabel = RANGE_OPTIONS.find((r) => r.value === range)?.label || range + " days";
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{/* Page header */}
|
||||
<div className="flex items-center justify-between">
|
||||
<h1 className="text-xl font-bold flex items-center gap-2">
|
||||
<BarChart3 className="h-5 w-5" /> Reports
|
||||
</h1>
|
||||
<Select value={range} onValueChange={setRange}>
|
||||
<SelectTrigger className="w-36">
|
||||
<Calendar className="h-4 w-4 mr-2" />
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{RANGE_OPTIONS.map((opt) => (
|
||||
<SelectItem key={opt.value} value={opt.value}>
|
||||
{opt.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{analyticsLoading ? (
|
||||
<LoadingState label="Loading reports..." />
|
||||
) : analyticsError ? (
|
||||
<ErrorState message={analyticsError.message || "Failed to load reports"} onRetry={refetchAnalytics} />
|
||||
) : (
|
||||
<>
|
||||
{/* Stat cards */}
|
||||
<div className="grid grid-cols-2 lg:grid-cols-4 gap-3">
|
||||
<StatCard
|
||||
label="Tasks Completed"
|
||||
value={tasksCompleted}
|
||||
icon={ListTodo}
|
||||
color="text-blue-500"
|
||||
bg="bg-blue-500/10"
|
||||
/>
|
||||
<StatCard
|
||||
label="Active Streaks"
|
||||
value={activeStreaks}
|
||||
icon={Flame}
|
||||
color="text-green-500"
|
||||
bg="bg-green-500/10"
|
||||
/>
|
||||
<StatCard
|
||||
label="Tasks Created"
|
||||
value={notesWritten}
|
||||
icon={FileText}
|
||||
color="text-amber-500"
|
||||
bg="bg-amber-500/10"
|
||||
/>
|
||||
<StatCard
|
||||
label="Active Projects"
|
||||
value={totalProjects}
|
||||
icon={FolderKanban}
|
||||
color="text-purple-500"
|
||||
bg="bg-purple-500/10"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Charts grid */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-3">
|
||||
{/* Task velocity line chart */}
|
||||
<Card className="border rounded-lg hover:bg-muted/20 transition-colors">
|
||||
<CardHeader className="p-3 pb-0 flex flex-row items-center justify-between">
|
||||
<CardTitle className="text-sm font-semibold">Task Velocity</CardTitle>
|
||||
<div className="flex items-center gap-2">
|
||||
{velocityData?.avg !== undefined && (
|
||||
<span className="text-xs text-muted-foreground font-mono">
|
||||
avg <span className="font-semibold">{velocityData.avg}</span>/day
|
||||
</span>
|
||||
)}
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-6 w-6"
|
||||
onClick={() =>
|
||||
downloadCSV("task-velocity.csv", [
|
||||
["Date", "Completed"],
|
||||
...(velocityData?.items || []).map((d) => [d.date, String(d.completed)]),
|
||||
])
|
||||
}
|
||||
>
|
||||
<Download className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="p-3">
|
||||
{velocityData?.items && velocityData.items.length > 0 ? (
|
||||
<LineChart data={velocityData.items} xKey="date" yKey="completed" />
|
||||
) : (
|
||||
<p className="text-sm text-muted-foreground text-center py-8">No data for this range</p>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Habit completion pie chart */}
|
||||
<Card className="border rounded-lg hover:bg-muted/20 transition-colors">
|
||||
<CardHeader className="p-3 pb-0 flex flex-row items-center justify-between">
|
||||
<CardTitle className="text-sm font-semibold">Habit Completion</CardTitle>
|
||||
<div className="flex items-center gap-2">
|
||||
{habitData && (
|
||||
<span className="text-xs text-muted-foreground font-mono">
|
||||
<span className="font-semibold">{habitData.habitConsistency}%</span> rate
|
||||
</span>
|
||||
)}
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-6 w-6"
|
||||
onClick={() =>
|
||||
downloadCSV("habit-completion.csv", [
|
||||
["Status", "Count"],
|
||||
...habitRateData.map((d) => [d.name, String(d.value)]),
|
||||
])
|
||||
}
|
||||
>
|
||||
<Download className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="p-3">
|
||||
{habitData && (habitData.totalLogs > 0 || habitRateData.some((d) => d.value > 0)) ? (
|
||||
<PieChartSimple data={habitRateData} labelKey="name" valueKey="value" />
|
||||
) : (
|
||||
<p className="text-sm text-muted-foreground text-center py-8">No habit data</p>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Project distribution horizontal bar */}
|
||||
<Card className="border rounded-lg hover:bg-muted/20 transition-colors">
|
||||
<CardHeader className="p-3 pb-0 flex flex-row items-center justify-between">
|
||||
<CardTitle className="text-sm font-semibold">Project Progress</CardTitle>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-6 w-6"
|
||||
onClick={() =>
|
||||
downloadCSV("project-progress.csv", [
|
||||
["Project", "Total Tasks", "Completed", "Progress"],
|
||||
...(projectData?.projects || []).map((p) => [
|
||||
p.name,
|
||||
String(p.totalTasks),
|
||||
String(p.completedTasks),
|
||||
Math.round(p.progress * 100) + "%",
|
||||
]),
|
||||
])
|
||||
}
|
||||
>
|
||||
<Download className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</CardHeader>
|
||||
<CardContent className="p-3">
|
||||
{projectData?.projects && projectData.projects.length > 0 ? (
|
||||
<HorizontalBar
|
||||
data={projectData.projects.map((p) => ({
|
||||
name: p.name,
|
||||
progress: Math.round(p.progress * 100),
|
||||
}))}
|
||||
xKey="name"
|
||||
yKey="progress"
|
||||
height={Math.max(100, projectData.projects.length * 26)}
|
||||
/>
|
||||
) : (
|
||||
<p className="text-sm text-muted-foreground text-center py-8">No projects yet</p>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Created vs completed bar chart */}
|
||||
<Card className="border rounded-lg hover:bg-muted/20 transition-colors">
|
||||
<CardHeader className="p-3 pb-0 flex flex-row items-center justify-between">
|
||||
<CardTitle className="text-sm font-semibold">Created vs Completed</CardTitle>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-6 w-6"
|
||||
onClick={() =>
|
||||
downloadCSV("tasks-created-vs-completed.csv", [
|
||||
["Date", "Created", "Completed"],
|
||||
...dailyItems.map((d) => [d.date, String(d.created), String(d.completed)]),
|
||||
])
|
||||
}
|
||||
>
|
||||
<Download className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</CardHeader>
|
||||
<CardContent className="p-3">
|
||||
{dailyItems.length > 0 ? (
|
||||
<>
|
||||
<BarChart
|
||||
data={dailyItems}
|
||||
xKey="date"
|
||||
yKey="created"
|
||||
yKey2="completed"
|
||||
color="#f97316"
|
||||
color2="#3b82f6"
|
||||
/>
|
||||
<div className="flex items-center gap-3 mt-2 text-xs text-muted-foreground">
|
||||
<span className="flex items-center gap-1">
|
||||
<div className="w-2.5 h-2.5 rounded bg-orange-500" /> Created
|
||||
</span>
|
||||
<span className="flex items-center gap-1">
|
||||
<div className="w-2.5 h-2.5 rounded bg-blue-500" /> Completed
|
||||
</span>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<p className="text-sm text-muted-foreground text-center py-8">No data</p>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Tasks by project pie */}
|
||||
<Card className="border rounded-lg hover:bg-muted/20 transition-colors">
|
||||
<CardHeader className="p-3 pb-0 flex flex-row items-center justify-between">
|
||||
<CardTitle className="text-sm font-semibold">Tasks by Project</CardTitle>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-6 w-6"
|
||||
onClick={() =>
|
||||
downloadCSV("tasks-by-project.csv", [
|
||||
["Project", "Total Tasks"],
|
||||
...(projectData?.projects || []).map((p) => [p.name, String(p.totalTasks)]),
|
||||
])
|
||||
}
|
||||
>
|
||||
<Download className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</CardHeader>
|
||||
<CardContent className="p-3">
|
||||
{projectData?.projects && projectData.projects.length > 0 ? (
|
||||
<PieChartSimple
|
||||
data={projectData.projects.map((p) => ({ name: p.name, value: p.totalTasks }))}
|
||||
labelKey="name"
|
||||
valueKey="value"
|
||||
/>
|
||||
) : (
|
||||
<p className="text-sm text-muted-foreground text-center py-8">No projects yet</p>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Best streak highlight */}
|
||||
<Card className="border rounded-lg hover:bg-muted/20 transition-colors">
|
||||
<CardHeader className="p-3 pb-0 flex flex-row items-center justify-between">
|
||||
<CardTitle className="text-sm font-semibold">Streak Records</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="p-3">
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div className="text-center p-3 bg-muted/30 rounded-lg">
|
||||
<Flame className="h-5 w-5 mx-auto mb-1 text-orange-500" />
|
||||
<p className="text-xl font-mono font-bold">{bestStreak}</p>
|
||||
<p className="text-[10px] text-muted-foreground">Best streak</p>
|
||||
</div>
|
||||
<div className="text-center p-3 bg-muted/30 rounded-lg">
|
||||
<Flame className="h-5 w-5 mx-auto mb-1 text-green-500" />
|
||||
<p className="text-xl font-mono font-bold">{activeStreaks}</p>
|
||||
<p className="text-[10px] text-muted-foreground">Active streaks</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-3 text-center">
|
||||
<p className="text-xs text-muted-foreground font-mono">
|
||||
{habitData?.totalHabits || 0} habit{(habitData?.totalHabits || 0) === 1 ? "" : "s"} tracked · {habitData?.totalLogs || 0} completions in range
|
||||
</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export const Route = createRoute({
|
||||
getParentRoute: () => appRoute,
|
||||
path: "/reports",
|
||||
component: ReportsPage,
|
||||
});
|
||||
@@ -105,7 +105,7 @@ function SearchPage() {
|
||||
}, {} as Record<string, SearchResult[]>);
|
||||
|
||||
return (
|
||||
<div className="max-w-3xl mx-auto space-y-6">
|
||||
<div className="max-w-3xl mx-auto space-y-4">
|
||||
{/* Search bar */}
|
||||
<div className="relative">
|
||||
<SearchIcon className="absolute left-3.5 top-3.5 h-5 w-5 text-muted-foreground" />
|
||||
@@ -153,7 +153,7 @@ function SearchPage() {
|
||||
</h3>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{recentSearches.map((s, i) => (
|
||||
<Button key={i} variant="ghost" size="sm" onClick={() => handleSearch(s)} className="text-sm">
|
||||
<Button key={i} variant="ghost" size="sm" onClick={() => handleSearch(s)} className="text-sm font-mono">
|
||||
{s}
|
||||
</Button>
|
||||
))}
|
||||
@@ -163,46 +163,51 @@ function SearchPage() {
|
||||
|
||||
{/* Results */}
|
||||
{debouncedQuery && (
|
||||
<div className="space-y-6">
|
||||
<div className="space-y-4">
|
||||
{isLoading ? (
|
||||
<div className="text-center py-8 text-muted-foreground">Searching...</div>
|
||||
) : results.length === 0 ? (
|
||||
<div className="text-center py-8 text-muted-foreground">
|
||||
No results found for "{debouncedQuery}"
|
||||
No results found for "<span className="font-mono">{debouncedQuery}</span>"
|
||||
</div>
|
||||
) : (
|
||||
Object.entries(groupedResults).map(([type, typeResults]) => {
|
||||
const typeDef = SEARCH_TYPES.find((t) => t.id === type);
|
||||
return (
|
||||
<div key={type}>
|
||||
<h3 className="text-sm font-semibold mb-2 flex items-center gap-2">
|
||||
<div className={cn("w-2 h-2 rounded-full", typeDef?.color)} />
|
||||
{typeDef?.label || type}
|
||||
<Badge variant="secondary" className="text-[10px]">{typeResults.length}</Badge>
|
||||
</h3>
|
||||
<div className="space-y-1">
|
||||
{typeResults.map((result) => (
|
||||
<div
|
||||
key={result.id + result.type}
|
||||
className="flex items-center justify-between p-3 rounded-lg hover:bg-accent cursor-pointer transition-colors"
|
||||
onClick={() => navigate({ to: result.type === "domain" ? "/settings" : (result.link as any) })}
|
||||
>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="font-medium text-sm truncate">{result.title}</p>
|
||||
{result.snippet && (
|
||||
<p
|
||||
className="text-xs text-muted-foreground mt-0.5 line-clamp-2"
|
||||
dangerouslySetInnerHTML={{ __html: sanitizeSnippet(result.snippet) }}
|
||||
/>
|
||||
)}
|
||||
<>
|
||||
<p className="text-xs text-muted-foreground font-mono">
|
||||
<span className="font-semibold">{results.length}</span> result{results.length === 1 ? "" : "s"} · <span className="font-semibold">{Object.keys(groupedResults).length}</span> type{Object.keys(groupedResults).length === 1 ? "" : "s"}
|
||||
</p>
|
||||
{Object.entries(groupedResults).map(([type, typeResults]) => {
|
||||
const typeDef = SEARCH_TYPES.find((t) => t.id === type);
|
||||
return (
|
||||
<div key={type}>
|
||||
<h3 className="text-sm font-semibold mb-1 flex items-center gap-2">
|
||||
<div className={cn("w-2 h-2 rounded-full", typeDef?.color)} />
|
||||
{typeDef?.label || type}
|
||||
<Badge variant="secondary" className="text-[10px] font-mono">{typeResults.length}</Badge>
|
||||
</h3>
|
||||
<div className="space-y-0.5">
|
||||
{typeResults.map((result) => (
|
||||
<div
|
||||
key={result.id + result.type}
|
||||
className="flex items-center justify-between p-2 rounded-lg hover:bg-muted/20 border border-transparent hover:border-border cursor-pointer transition-colors"
|
||||
onClick={() => navigate({ to: result.type === "domain" ? "/settings" : (result.link as any) })}
|
||||
>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="font-medium text-sm truncate">{result.title}</p>
|
||||
{result.snippet && (
|
||||
<p
|
||||
className="text-xs text-muted-foreground mt-0.5 line-clamp-2"
|
||||
dangerouslySetInnerHTML={{ __html: sanitizeSnippet(result.snippet) }}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<ArrowRight className="h-4 w-4 shrink-0 text-muted-foreground ml-2" />
|
||||
</div>
|
||||
<ArrowRight className="h-4 w-4 shrink-0 text-muted-foreground ml-2" />
|
||||
</div>
|
||||
))}
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})
|
||||
);
|
||||
})}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -18,7 +18,7 @@ import { ScrollArea } from "@/components/ui/scroll-area";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import { AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle, AlertDialogTrigger } from "@/components/ui/alert-dialog";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useThemeStore, ACCENT_PALETTE, type ThemeMode, type AccentColor } from "@/lib/stores/use-theme-store";
|
||||
import { useThemeStore, type ThemeMode } from "@/lib/stores/use-theme-store";
|
||||
import { useApiDomain } from "@/lib/stores/use-active-domain-store";
|
||||
import type { Domain, CustomField, Webhook as WebhookType, ErrorLog, Agent, PaginatedResponse } from "@/lib/types";
|
||||
|
||||
@@ -34,21 +34,18 @@ const SETTINGS_TABS = [
|
||||
{ id: "error-log", label: "Error Log", icon: AlertCircle },
|
||||
] as const;
|
||||
|
||||
const ACCENT_COLORS = Object.entries(ACCENT_PALETTE).map(([key, val]) => ({
|
||||
name: val.name,
|
||||
value: key as AccentColor,
|
||||
}));
|
||||
|
||||
const SHORTCUTS_MAP: Record<string, string> = {
|
||||
"Cmd+K": "Command palette",
|
||||
"Cmd+N": "New task",
|
||||
"g+i": "Go to Inbox",
|
||||
"g+d": "Go to Dashboard",
|
||||
"g+t": "Go to Tasks",
|
||||
"g+h": "Go to Habits",
|
||||
"g+p": "Go to Projects",
|
||||
"g+n": "Go to Notes",
|
||||
"g+c": "Go to Calendar",
|
||||
"g+g": "Go to Graph",
|
||||
"g+d": "Go to Dashboard",
|
||||
"g+r": "Go to Reports",
|
||||
"g+s": "Go to Settings",
|
||||
"n+t": "New task",
|
||||
"n+h": "New habit",
|
||||
@@ -60,7 +57,7 @@ const SHORTCUTS_MAP: Record<string, string> = {
|
||||
// ─── Appearance Tab ──────────────────────────────────────────────────────
|
||||
|
||||
function AppearanceTab() {
|
||||
const { mode, setMode, accent, setAccent } = useThemeStore();
|
||||
const { mode, setMode } = useThemeStore();
|
||||
const [fontSize, setFontSize] = useState(localStorage.getItem("font-size") || "normal");
|
||||
const [density, setDensity] = useState(localStorage.getItem("density") || "comfortable");
|
||||
const [sidebarPos, setSidebarPos] = useState(localStorage.getItem("sidebar-position") || "left");
|
||||
@@ -103,20 +100,6 @@ function AppearanceTab() {
|
||||
</div>
|
||||
</div>
|
||||
<Separator />
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold mb-3">Accent Color</h3>
|
||||
<div className="flex gap-3">
|
||||
{ACCENT_COLORS.map((c) => {
|
||||
const palette = ACCENT_PALETTE[c.value];
|
||||
return (
|
||||
<button key={c.value} onClick={() => setAccent(c.value)}
|
||||
className={cn("w-10 h-10 rounded-full border-2 transition-all", accent === c.value ? "border-foreground scale-110" : "border-transparent")}
|
||||
style={{ backgroundColor: "hsl(" + palette.hsl + ")" }} aria-label={c.name} />
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
<Separator />
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold mb-3">Font Size</h3>
|
||||
<Select value={fontSize} onValueChange={setFontSize}>
|
||||
|
||||
@@ -50,30 +50,30 @@ function SortableTaskCard({ task, stateName, stateColor, onClick, onEdit }: { ta
|
||||
|
||||
return (
|
||||
<div ref={setNodeRef} style={style} {...attributes} {...listeners}>
|
||||
<Card className="cursor-pointer hover:shadow-md transition-shadow" onClick={onClick}>
|
||||
<CardContent className="p-3">
|
||||
<Card className="cursor-pointer border rounded-lg transition-colors hover:bg-muted/20" onClick={onClick}>
|
||||
<CardContent className="p-2.5">
|
||||
<div className="flex items-start gap-2">
|
||||
<GripVertical className="h-4 w-4 mt-1 shrink-0 text-muted-foreground" />
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-sm font-medium truncate">{task.title}</p>
|
||||
<div className="flex flex-wrap gap-1.5 mt-2">
|
||||
<div className="flex flex-wrap gap-1 mt-1.5">
|
||||
{stateName && (
|
||||
<Badge variant="secondary" className="text-[10px] gap-1" style={stateColor ? { backgroundColor: stateColor + "20", color: stateColor } : undefined}>
|
||||
<Badge variant="secondary" className="font-mono text-[10px] gap-1" style={stateColor ? { backgroundColor: stateColor + "20", color: stateColor } : undefined}>
|
||||
<span className="h-1.5 w-1.5 rounded-full" style={stateColor ? { backgroundColor: stateColor } : undefined} />
|
||||
{stateName}
|
||||
</Badge>
|
||||
)}
|
||||
{task.dueDate && (
|
||||
<Badge variant="outline" className="text-[10px]">
|
||||
<Badge variant="outline" className="font-mono text-[10px]">
|
||||
<Calendar className="h-3 w-3 mr-1" />
|
||||
{new Date(task.dueDate).toLocaleDateString()}
|
||||
</Badge>
|
||||
)}
|
||||
<Badge variant="secondary" className={cn("text-[10px]", PRIORITY[task.priority]?.badge)}>
|
||||
<Badge variant="secondary" className={cn("font-mono text-[10px]", PRIORITY[task.priority]?.badge)}>
|
||||
{PRIORITY[task.priority]?.label ?? task.priority}
|
||||
</Badge>
|
||||
{task.tags?.slice(0, 2).map((tag) => (
|
||||
<Badge key={tag.id} variant="outline" className="text-[10px]" style={{ borderColor: tag.color || undefined }}>
|
||||
<Badge key={tag.id} variant="outline" className="font-mono text-[10px]" style={{ borderColor: tag.color || undefined }}>
|
||||
{tag.name}
|
||||
</Badge>
|
||||
))}
|
||||
@@ -172,7 +172,7 @@ function TaskForm({ task, onClose, projectId }: { task?: Task; onClose: () => vo
|
||||
};
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<form onSubmit={handleSubmit} className="space-y-3">
|
||||
<div>
|
||||
<Label htmlFor="title">Title <span className="text-xs text-muted-foreground">— try "Buy milk tomorrow 5pm #groceries p1"</span></Label>
|
||||
<Input id="title" value={title} onChange={(e) => setTitle(e.target.value)} placeholder='e.g. Report due tomorrow 5pm #work p1' required />
|
||||
@@ -188,7 +188,7 @@ function TaskForm({ task, onClose, projectId }: { task?: Task; onClose: () => vo
|
||||
<Label htmlFor="desc">Description</Label>
|
||||
<Textarea id="desc" value={description} onChange={(e) => setDescription(e.target.value)} placeholder="Description (optional)" rows={3} />
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
{projectStates.length > 0 && (
|
||||
<div>
|
||||
<Label htmlFor="state">State</Label>
|
||||
@@ -453,7 +453,7 @@ function TasksPage() {
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<h1 className="text-2xl font-bold">Tasks</h1>
|
||||
<h1 className="text-xl font-bold">Tasks</h1>
|
||||
<div className="flex items-center gap-2">
|
||||
<Tabs value={view} onValueChange={(v) => setView(v as "board" | "list")}>
|
||||
<TabsList>
|
||||
@@ -475,7 +475,7 @@ function TasksPage() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2 flex-wrap">
|
||||
<div className="flex gap-1.5 flex-wrap">
|
||||
<div className="relative flex-1 min-w-[200px] max-w-sm">
|
||||
<Search className="absolute left-2.5 top-2.5 h-4 w-4 text-muted-foreground" />
|
||||
<Input placeholder="Search tasks..." value={search} onChange={(e) => setSearch(e.target.value)} className="pl-8" />
|
||||
@@ -513,18 +513,18 @@ function TasksPage() {
|
||||
<ErrorState message="Failed to load tasks." onRetry={() => refetch()} />
|
||||
) : view === "board" ? (
|
||||
<DndContext sensors={sensors} collisionDetection={closestCorners} onDragStart={handleDragStart} onDragEnd={handleDragEnd}>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-5 gap-4">
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-5 gap-3">
|
||||
{columns.map((col) => (
|
||||
<ColumnDroppable key={col.id} id={col.id} className="bg-muted/50 rounded-lg p-3">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className={cn("w-2 h-2 rounded-full", col.colorClass)} />
|
||||
<h3 className="font-semibold text-sm">{col.label}</h3>
|
||||
<Badge variant="secondary" className="text-[10px]">{col.tasks.length}</Badge>
|
||||
<Badge variant="secondary" className="font-mono text-[10px]">{col.tasks.length}</Badge>
|
||||
</div>
|
||||
</div>
|
||||
<SortableContext items={col.tasks.map((t) => t.id)} strategy={verticalListSortingStrategy}>
|
||||
<div className="space-y-2 min-h-[100px]">
|
||||
<div className="space-y-1.5 min-h-[100px]">
|
||||
{col.tasks.map((task) => {
|
||||
const st = task.stateId ? stateById.get(task.stateId) : undefined;
|
||||
return (
|
||||
@@ -572,11 +572,11 @@ function TasksPage() {
|
||||
) : tasks.map((task) => {
|
||||
const st = task.stateId ? stateById.get(task.stateId) : undefined;
|
||||
return (
|
||||
<TableRow key={task.id} className="cursor-pointer" onClick={() => openTaskDetail(task)}>
|
||||
<TableCell className="font-medium">{task.title}</TableCell>
|
||||
<TableCell>
|
||||
<TableRow key={task.id} className="cursor-pointer hover:bg-muted/30 py-2" onClick={() => openTaskDetail(task)}>
|
||||
<TableCell className="font-medium py-2">{task.title}</TableCell>
|
||||
<TableCell className="py-2">
|
||||
{st ? (
|
||||
<Badge variant="secondary" className="text-[10px] gap-1" style={st.color ? { backgroundColor: st.color + "20", color: st.color } : undefined}>
|
||||
<Badge variant="secondary" className="font-mono text-[10px] gap-1" style={st.color ? { backgroundColor: st.color + "20", color: st.color } : undefined}>
|
||||
<span className="h-1.5 w-1.5 rounded-full" style={st.color ? { backgroundColor: st.color } : undefined} />
|
||||
{st.name}
|
||||
</Badge>
|
||||
@@ -584,13 +584,13 @@ function TasksPage() {
|
||||
<span className="text-xs text-muted-foreground">—</span>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Badge variant="outline" className={cn("text-[10px]", PRIORITY[task.priority]?.badge)}>{task.priority}</Badge>
|
||||
<TableCell className="py-2">
|
||||
<Badge variant="outline" className={cn("font-mono text-[10px]", PRIORITY[task.priority]?.badge)}>{task.priority}</Badge>
|
||||
</TableCell>
|
||||
<TableCell className="text-sm text-muted-foreground">
|
||||
<TableCell className="font-mono text-[10px] text-muted-foreground py-2">
|
||||
{task.dueDate ? new Date(task.dueDate).toLocaleDateString() : "-"}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<TableCell className="py-2">
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild onClick={(e) => e.stopPropagation()}>
|
||||
<Button variant="ghost" size="icon" className="h-8 w-8"><MoreHorizontal className="h-4 w-4" /></Button>
|
||||
@@ -620,7 +620,7 @@ function TasksPage() {
|
||||
|
||||
<EntityDetailPanel open={panelOpen} onOpenChange={setPanelOpen} title={selectedTask?.title || "Task Details"}>
|
||||
{selectedTask && (
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-3">
|
||||
<TaskForm task={selectedTask} onClose={() => setPanelOpen(false)} />
|
||||
<div className="pt-4 border-t">
|
||||
<AlertDialog>
|
||||
|
||||
@@ -300,7 +300,7 @@ function Overview({ task, patch }: { task: Task; patch: PatchFn }) {
|
||||
const currentState = task.stateId ? projectStates.find((s) => s.id === task.stateId) : null;
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<p className="mb-1 text-sm font-semibold text-muted-foreground">Description</p>
|
||||
<InlineTextarea
|
||||
@@ -312,7 +312,7 @@ function Overview({ task, patch }: { task: Task; patch: PatchFn }) {
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
|
||||
<div className="grid grid-cols-1 gap-2 sm:grid-cols-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<Calendar className="h-4 w-4 shrink-0 text-muted-foreground" />
|
||||
<InlineDate
|
||||
@@ -327,7 +327,7 @@ function Overview({ task, patch }: { task: Task; patch: PatchFn }) {
|
||||
options={ESTIMATE_OPTIONS}
|
||||
displayValue={(v) =>
|
||||
v ? (
|
||||
<span>{v} min</span>
|
||||
<span className="font-mono text-xs">{v} min</span>
|
||||
) : (
|
||||
<span className="text-muted-foreground/70">No estimate</span>
|
||||
)
|
||||
@@ -390,25 +390,25 @@ function Overview({ task, patch }: { task: Task; patch: PatchFn }) {
|
||||
{task.recurrenceRule ? (
|
||||
<div className="flex items-center gap-2">
|
||||
<RepeatIcon className="h-4 w-4 shrink-0 text-muted-foreground" />
|
||||
<span className="text-sm">{task.recurrenceRule}</span>
|
||||
<span className="font-mono text-xs">{task.recurrenceRule}</span>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div className="border-t pt-4">
|
||||
<div className="border-t pt-3">
|
||||
<TagManager entityType="task" entityId={task.id} tags={task.tags || []} />
|
||||
</div>
|
||||
|
||||
<CustomFieldsDisplay entityType="tasks" values={task.customFields} />
|
||||
|
||||
<div className="flex items-center gap-4 border-t pt-4 text-xs text-muted-foreground">
|
||||
<div className="flex items-center gap-4 border-t pt-3 text-xs text-muted-foreground">
|
||||
<span className="flex items-center gap-1">
|
||||
<Clock className="h-3 w-3" />
|
||||
Created {format(parseISO(task.createdAt), "MMM d, yyyy HH:mm")}
|
||||
Created <span className="font-mono">{format(parseISO(task.createdAt), "MMM d, yyyy HH:mm")}</span>
|
||||
</span>
|
||||
<span className="flex items-center gap-1">
|
||||
<Clock className="h-3 w-3" />
|
||||
Updated {format(parseISO(task.updatedAt), "MMM d, yyyy HH:mm")}
|
||||
Updated <span className="font-mono">{format(parseISO(task.updatedAt), "MMM d, yyyy HH:mm")}</span>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,277 @@
|
||||
import { useState, useCallback } from "react";
|
||||
import { createRoute, useNavigate } from "@tanstack/react-router";
|
||||
import { Route as appRoute } from "../_app";
|
||||
import { Plus, Trash2, FileText, Zap } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
DialogFooter,
|
||||
} from "@/components/ui/dialog";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter as AlertDialogFooterComponent,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
AlertDialogTrigger,
|
||||
} from "@/components/ui/alert-dialog";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
interface Template {
|
||||
id: string;
|
||||
name: string;
|
||||
type: "task" | "note";
|
||||
content: string;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
const STORAGE_KEY = "project-e-templates";
|
||||
|
||||
function loadTemplates(): Template[] {
|
||||
try {
|
||||
const raw = localStorage.getItem(STORAGE_KEY);
|
||||
return raw ? JSON.parse(raw) : [];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
function saveTemplates(templates: Template[]) {
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(templates));
|
||||
}
|
||||
|
||||
function TemplatesPage() {
|
||||
const navigate = useNavigate();
|
||||
const [templates, setTemplates] = useState<Template[]>(loadTemplates);
|
||||
const [open, setOpen] = useState(false);
|
||||
const [newName, setNewName] = useState("");
|
||||
const [newType, setNewType] = useState<"task" | "note">("task");
|
||||
const [newContent, setNewContent] = useState("");
|
||||
|
||||
const handleCreate = useCallback(() => {
|
||||
const trimmedName = newName.trim();
|
||||
if (!trimmedName) {
|
||||
toast.error("Template name is required");
|
||||
return;
|
||||
}
|
||||
|
||||
const template: Template = {
|
||||
id: crypto.randomUUID(),
|
||||
name: trimmedName,
|
||||
type: newType,
|
||||
content: newContent,
|
||||
createdAt: new Date().toISOString(),
|
||||
};
|
||||
|
||||
const updated = [template, ...templates];
|
||||
setTemplates(updated);
|
||||
saveTemplates(updated);
|
||||
setNewName("");
|
||||
setNewType("task");
|
||||
setNewContent("");
|
||||
setOpen(false);
|
||||
toast.success("Template created");
|
||||
}, [newName, newType, newContent, templates]);
|
||||
|
||||
const handleDelete = useCallback(
|
||||
(id: string) => {
|
||||
const updated = templates.filter((t) => t.id !== id);
|
||||
setTemplates(updated);
|
||||
saveTemplates(updated);
|
||||
toast.success("Template deleted");
|
||||
},
|
||||
[templates]
|
||||
);
|
||||
|
||||
const handleUse = useCallback(
|
||||
(template: Template) => {
|
||||
navigator.clipboard.writeText(template.content).catch(() => {});
|
||||
if (template.type === "task") {
|
||||
navigate({ to: "/tasks" });
|
||||
} else {
|
||||
navigate({ to: "/notes" });
|
||||
}
|
||||
toast.success(`Template content copied — paste it when creating your ${template.type}`);
|
||||
},
|
||||
[navigate]
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-auto min-h-[calc(100vh-8rem)] md:h-[calc(100vh-8rem)] -m-4 md:-m-6">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between px-4 py-3 border-b shrink-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<FileText className="h-5 w-5 text-muted-foreground" />
|
||||
<h1 className="text-xl font-bold">Templates</h1>
|
||||
<Badge variant="secondary" className="font-mono text-[10px]">
|
||||
{templates.length}
|
||||
</Badge>
|
||||
</div>
|
||||
<Dialog open={open} onOpenChange={setOpen}>
|
||||
<DialogTrigger asChild>
|
||||
<Button size="sm" className="h-8">
|
||||
<Plus className="h-3.5 w-3.5 mr-1" />
|
||||
New Template
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>New Template</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="grid gap-4 py-2">
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="template-name">Name</Label>
|
||||
<Input
|
||||
id="template-name"
|
||||
placeholder="e.g. Weekly Review"
|
||||
value={newName}
|
||||
onChange={(e) => setNewName(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<Label>Type</Label>
|
||||
<Select value={newType} onValueChange={(v) => setNewType(v as "task" | "note")}>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="task">Task</SelectItem>
|
||||
<SelectItem value="note">Note</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="template-content">Content</Label>
|
||||
<Textarea
|
||||
id="template-content"
|
||||
placeholder="Template content..."
|
||||
rows={6}
|
||||
value={newContent}
|
||||
onChange={(e) => setNewContent(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setOpen(false)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button onClick={handleCreate}>Create</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="flex-1 overflow-auto p-4">
|
||||
{templates.length === 0 ? (
|
||||
<div className="flex items-center justify-center h-full">
|
||||
<div className="text-center">
|
||||
<FileText className="h-10 w-10 mx-auto mb-2 text-indigo-400" />
|
||||
<p className="text-sm text-muted-foreground">No templates yet</p>
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
Create reusable task or note templates
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid gap-3">
|
||||
{templates.map((template) => (
|
||||
<Card key={template.id} className="border rounded-lg">
|
||||
<CardContent className="p-3">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<span className="font-medium text-sm truncate">{template.name}</span>
|
||||
<Badge
|
||||
variant={template.type === "task" ? "default" : "secondary"}
|
||||
className="font-mono text-[10px] shrink-0"
|
||||
>
|
||||
{template.type === "task" ? "Task" : "Note"}
|
||||
</Badge>
|
||||
</div>
|
||||
{template.content && (
|
||||
<p className="text-xs text-muted-foreground line-clamp-2 mb-1">
|
||||
{template.content.slice(0, 100)}
|
||||
{template.content.length > 100 ? "..." : ""}
|
||||
</p>
|
||||
)}
|
||||
<span className="font-mono text-[10px] text-muted-foreground">
|
||||
{new Date(template.createdAt).toLocaleDateString()}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1 shrink-0">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-7 w-7"
|
||||
onClick={() => handleUse(template)}
|
||||
title="Use template"
|
||||
>
|
||||
<Zap className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
<AlertDialog>
|
||||
<AlertDialogTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-7 w-7 text-destructive"
|
||||
title="Delete template"
|
||||
>
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</AlertDialogTrigger>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>Delete Template</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
Are you sure you want to delete "{template.name}"?
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooterComponent>
|
||||
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
onClick={() => handleDelete(template.id)}
|
||||
className="bg-destructive text-destructive-foreground"
|
||||
>
|
||||
Delete
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooterComponent>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export const Route = createRoute({
|
||||
getParentRoute: () => appRoute,
|
||||
path: "/templates",
|
||||
component: TemplatesPage,
|
||||
});
|
||||
@@ -1,11 +1,11 @@
|
||||
import { createRoute, useNavigate } from "@tanstack/react-router";
|
||||
import { Route as rootRoute } from "./__root";
|
||||
import { useState } from "react";
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Loader2, Sparkles } from "lucide-react";
|
||||
import { Loader2 } from "lucide-react";
|
||||
import { useAuthStore } from "@/lib/stores/use-auth-store";
|
||||
|
||||
function LoginPage() {
|
||||
@@ -42,73 +42,105 @@ function LoginPage() {
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex min-h-screen items-center justify-center bg-background px-4">
|
||||
<div className="w-full max-w-sm">
|
||||
<div className="mb-6 flex flex-col items-center gap-3 text-center">
|
||||
<div className="flex min-h-screen bg-background">
|
||||
{/* Left branding side - hidden on mobile */}
|
||||
<div className="relative hidden w-1/2 flex-col items-center justify-center bg-gradient-to-br from-background via-background to-muted/50 lg:flex">
|
||||
{/* Decorative dots */}
|
||||
<div className="pointer-events-none absolute inset-0 overflow-hidden">
|
||||
<div className="absolute left-1/4 top-1/4 h-2 w-2 rounded-full bg-rose-500/40" />
|
||||
<div className="absolute right-1/3 top-1/3 h-3 w-3 rounded-full bg-amber-500/30" />
|
||||
<div className="absolute bottom-1/4 left-1/3 h-2.5 w-2.5 rounded-full bg-violet-500/35" />
|
||||
<div className="absolute bottom-1/3 right-1/4 h-2 w-2 rounded-full bg-emerald-500/30" />
|
||||
<div className="absolute left-1/2 top-1/2 h-1.5 w-1.5 rounded-full bg-sky-500/25" />
|
||||
</div>
|
||||
|
||||
{/* Branding content */}
|
||||
<div className="relative z-10 flex flex-col items-center gap-5 text-center">
|
||||
<span
|
||||
className="flex h-12 w-12 items-center justify-center rounded-xl shadow-md"
|
||||
className="flex h-16 w-16 items-center justify-center rounded-2xl"
|
||||
style={{ backgroundColor: "hsl(var(--accent-hsl))" }}
|
||||
>
|
||||
<Sparkles className="h-6 w-6 text-white" aria-hidden="true" />
|
||||
<span className="text-2xl font-bold text-white">E</span>
|
||||
</span>
|
||||
<div>
|
||||
<div className="flex flex-col items-center gap-2">
|
||||
<h1 className="text-3xl font-bold tracking-tight">Project E</h1>
|
||||
<p className="text-muted-foreground">Your productivity command center</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Right form side */}
|
||||
<div className="flex w-full flex-col items-center justify-center px-4 py-8 sm:px-6 lg:w-1/2">
|
||||
{/* Mobile branding - shown only on mobile */}
|
||||
<div className="mb-8 flex flex-col items-center gap-3 text-center lg:hidden">
|
||||
<span
|
||||
className="flex h-16 w-16 items-center justify-center rounded-2xl"
|
||||
style={{ backgroundColor: "hsl(var(--accent-hsl))" }}
|
||||
>
|
||||
<span className="text-2xl font-bold text-white">E</span>
|
||||
</span>
|
||||
<div className="flex flex-col items-center gap-1">
|
||||
<h1 className="text-2xl font-bold tracking-tight">Project E</h1>
|
||||
<p className="mt-1 text-sm text-muted-foreground">
|
||||
Sign in to your workspace
|
||||
</p>
|
||||
<p className="text-sm text-muted-foreground">Your productivity command center</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Card className="shadow-md">
|
||||
<CardHeader>
|
||||
<CardTitle>Sign in</CardTitle>
|
||||
<CardDescription>
|
||||
Enter your credentials to continue
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{error && (
|
||||
<div
|
||||
role="alert"
|
||||
className="mb-4 rounded-md border border-destructive/30 bg-destructive/10 p-3 text-sm text-destructive"
|
||||
>
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="email">Email</Label>
|
||||
<Input
|
||||
id="email"
|
||||
type="email"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
placeholder="you@example.com"
|
||||
autoComplete="email"
|
||||
autoFocus
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="password">Password</Label>
|
||||
<Input
|
||||
id="password"
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
autoComplete="current-password"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<Button type="submit" className="w-full" disabled={isSubmitting}>
|
||||
{isSubmitting && (
|
||||
<Loader2 className="h-4 w-4 animate-spin" aria-hidden="true" />
|
||||
)}
|
||||
{isSubmitting ? "Signing in..." : "Sign in"}
|
||||
</Button>
|
||||
</form>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<div className="w-full max-w-sm">
|
||||
<Card className="border shadow-none">
|
||||
<CardHeader className="pb-4">
|
||||
<CardTitle className="text-xl">Welcome back</CardTitle>
|
||||
<p className="text-sm text-muted-foreground">Sign in to continue</p>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{error && (
|
||||
<div
|
||||
role="alert"
|
||||
className="mb-4 rounded-md border border-destructive/30 bg-destructive/10 p-3 text-sm text-destructive"
|
||||
>
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
<form onSubmit={handleSubmit} className="space-y-3">
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="email">Email</Label>
|
||||
<Input
|
||||
id="email"
|
||||
type="email"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
placeholder="you@example.com"
|
||||
autoComplete="email"
|
||||
autoFocus
|
||||
required
|
||||
className={error ? "border-destructive" : ""}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="password">Password</Label>
|
||||
<Input
|
||||
id="password"
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
autoComplete="current-password"
|
||||
required
|
||||
className={error ? "border-destructive" : ""}
|
||||
/>
|
||||
</div>
|
||||
<Button
|
||||
type="submit"
|
||||
className="w-full bg-[hsl(var(--accent-hsl))] text-white hover:bg-[hsl(var(--accent-hsl))]/90"
|
||||
disabled={isSubmitting}
|
||||
>
|
||||
{isSubmitting && (
|
||||
<Loader2 className="h-4 w-4 animate-spin" aria-hidden="true" />
|
||||
)}
|
||||
{isSubmitting ? "Signing in..." : "Sign in"}
|
||||
</Button>
|
||||
</form>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -8,6 +8,7 @@ export default {
|
||||
extend: {
|
||||
fontFamily: {
|
||||
sans: ["var(--font-sans)"],
|
||||
mono: ["var(--font-mono)"],
|
||||
},
|
||||
colors: {
|
||||
border: "hsl(var(--border))",
|
||||
@@ -43,6 +44,13 @@ export default {
|
||||
DEFAULT: "hsl(var(--card))",
|
||||
foreground: "hsl(var(--card-foreground))",
|
||||
},
|
||||
sidebar: {
|
||||
DEFAULT: "hsl(var(--sidebar-bg))",
|
||||
border: "hsl(var(--sidebar-border))",
|
||||
},
|
||||
topbar: {
|
||||
DEFAULT: "hsl(var(--topbar-bg))",
|
||||
},
|
||||
},
|
||||
borderRadius: {
|
||||
lg: "var(--radius)",
|
||||
|
||||
Reference in New Issue
Block a user