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:
2026-09-09 00:38:53 +00:00
parent 6d01eecccc
commit f0f7a0b524
38 changed files with 1998 additions and 765 deletions
+3
View File
@@ -5,6 +5,9 @@
<link rel="icon" type="image/svg+xml" href="/vite.svg" /> <link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Project E</title> <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> </head>
<body> <body>
<div id="root"></div> <div id="root"></div>
@@ -43,35 +43,35 @@ export function EntityDetailPage({
const navigate = useNavigate(); const navigate = useNavigate();
return ( return (
<div className={cn("mx-auto space-y-6 p-0", containerClassName || "max-w-4xl")}> <div className={cn("mx-auto space-y-4 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="sticky top-0 z-10 -mx-1 mb-2 border-b bg-background/95 py-2 backdrop-blur">
<Button <Button
variant="ghost" variant="ghost"
size="sm" size="sm"
onClick={() => navigate({ to: backTo.to })} 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} {backTo.label}
</Button> </Button>
<div className="flex items-start justify-between gap-4"> <div className="flex items-start justify-between gap-4">
<div className="flex items-start gap-3"> <div className="flex items-start gap-2.5">
{icon && <div className="mt-1 text-muted-foreground">{icon}</div>} {icon && <div className="mt-0.5 text-muted-foreground">{icon}</div>}
<div className="space-y-1.5"> <div className="space-y-1">
<h1 className="text-2xl font-bold leading-tight">{title}</h1> <h1 className="text-xl font-bold leading-tight">{title}</h1>
{badges && ( {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>
</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>
</div> </div>
<Tabs defaultValue={defaultTab || tabs[0]?.value}> <Tabs defaultValue={defaultTab || tabs[0]?.value}>
<TabsList className="flex-wrap gap-1"> <TabsList className="flex-wrap gap-0.5">
{tabs.map((tab) => ( {tabs.map((tab) => (
<TabsTrigger key={tab.value} value={tab.value}> <TabsTrigger key={tab.value} value={tab.value} className="text-xs">
{tab.label} {tab.label}
</TabsTrigger> </TabsTrigger>
))} ))}
@@ -12,7 +12,6 @@ import {
type LucideIcon, type LucideIcon,
} from "lucide-react"; } from "lucide-react";
import { formatDistanceToNow, parseISO } from "date-fns"; import { formatDistanceToNow, parseISO } from "date-fns";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { ErrorState, LoadingState } from "@/components/state"; import { ErrorState, LoadingState } from "@/components/state";
import { useApiQuery } from "@/lib/api"; import { useApiQuery } from "@/lib/api";
import type { ActivityItem } from "@/lib/types"; import type { ActivityItem } from "@/lib/types";
@@ -66,15 +65,15 @@ function ActivityRow({ item }: { item: ActivityItem }) {
const summary = changesSummary(item.action, item.changes); const summary = changesSummary(item.action, item.changes);
return ( return (
<div className="flex items-start gap-3"> <div className="flex items-start gap-2.5 py-2">
<div className="flex h-7 w-7 shrink-0 items-center justify-center rounded-full bg-muted"> <div className="flex h-6 w-6 shrink-0 items-center justify-center rounded-full bg-muted">
<Icon className="h-3.5 w-3.5 text-muted-foreground" /> <Icon className="h-3 w-3 text-muted-foreground" />
</div> </div>
<div className="min-w-0 flex-1"> <div className="min-w-0 flex-1">
<p className="flex flex-wrap items-baseline gap-x-1.5 gap-y-0.5"> <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 font-medium text-foreground">{item.actor}</span>
<span className="text-sm text-muted-foreground">{meta.label}</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 })} {formatDistanceToNow(parseISO(item.createdAt), { addSuffix: true })}
</span> </span>
</p> </p>
@@ -102,29 +101,24 @@ export function EntityActivity({ entityType, entityId, limit }: EntityActivityPr
const items = data?.items ?? []; const items = data?.items ?? [];
return ( return (
<Card> <div>
<CardHeader> {isLoading ? <LoadingState label="Loading activity..." /> : null}
<CardTitle>Activity</CardTitle> {isError ? (
</CardHeader> <ErrorState
<CardContent> message={error instanceof Error ? error.message : "Failed to load activity"}
{isLoading ? <LoadingState label="Loading activity..." /> : null} onRetry={() => refetch()}
{isError ? ( />
<ErrorState ) : null}
message={error instanceof Error ? error.message : "Failed to load activity"} {!isLoading && !isError && items.length === 0 ? (
onRetry={() => refetch()} <p className="py-8 text-center text-sm text-muted-foreground">No activity yet</p>
/> ) : null}
) : null} {!isLoading && !isError && items.length > 0 ? (
{!isLoading && !isError && items.length === 0 ? ( <div className="divide-y">
<p className="py-8 text-center text-sm text-muted-foreground">No activity yet</p> {items.map((item) => (
) : null} <ActivityRow key={item.id} item={item} />
{!isLoading && !isError && items.length > 0 ? ( ))}
<div className="space-y-3"> </div>
{items.map((item) => ( ) : null}
<ActivityRow key={item.id} item={item} /> </div>
))}
</div>
) : null}
</CardContent>
</Card>
); );
} }
@@ -5,7 +5,6 @@ import { toast } from "sonner";
import { formatDistanceToNow, parseISO } from "date-fns"; import { formatDistanceToNow, parseISO } from "date-fns";
import { Avatar, AvatarFallback } from "@/components/ui/avatar"; import { Avatar, AvatarFallback } from "@/components/ui/avatar";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Textarea } from "@/components/ui/textarea"; import { Textarea } from "@/components/ui/textarea";
import { ErrorState, LoadingState } from "@/components/state"; import { ErrorState, LoadingState } from "@/components/state";
import { api, useApiMutation, useApiQuery } from "@/lib/api"; import { api, useApiMutation, useApiQuery } from "@/lib/api";
@@ -166,7 +165,7 @@ function CommentThread({ node, onDelete, deletePending }: CommentThreadProps) {
<div className="min-w-0 flex-1"> <div className="min-w-0 flex-1">
<p className="flex flex-wrap items-baseline gap-x-2 gap-y-0.5"> <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-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 })} {formatDistanceToNow(parseISO(comment.createdAt), { addSuffix: true })}
</span> </span>
</p> </p>
@@ -250,37 +249,32 @@ export function EntityComments({ entityType, entityId }: EntityCommentsProps) {
const tree = useMemo(() => buildCommentTree(data?.items ?? []), [data?.items]); const tree = useMemo(() => buildCommentTree(data?.items ?? []), [data?.items]);
return ( return (
<Card> <div className="space-y-4">
<CardHeader> <CommentComposer entityType={entityType} entityId={entityId} />
<CardTitle>Comments</CardTitle> {isLoading ? <LoadingState label="Loading comments..." /> : null}
</CardHeader> {isError ? (
<CardContent className="space-y-4"> <ErrorState
<CommentComposer entityType={entityType} entityId={entityId} /> message={error instanceof Error ? error.message : "Failed to load comments"}
{isLoading ? <LoadingState label="Loading comments..." /> : null} onRetry={() => refetch()}
{isError ? ( />
<ErrorState ) : null}
message={error instanceof Error ? error.message : "Failed to load comments"} {!isLoading && !isError && tree.length === 0 ? (
onRetry={() => refetch()} <p className="py-8 text-center text-sm text-muted-foreground">
/> No comments yet start the conversation.
) : null} </p>
{!isLoading && !isError && tree.length === 0 ? ( ) : null}
<p className="py-8 text-center text-sm text-muted-foreground"> {!isLoading && !isError && tree.length > 0 ? (
No comments yet start the conversation. <div className="divide-y">
</p> {tree.map((node) => (
) : null} <CommentThread
{!isLoading && !isError && tree.length > 0 ? ( key={node.comment.id}
<div className="space-y-4"> node={node}
{tree.map((node) => ( onDelete={(id) => deleteMutation.mutate(id)}
<CommentThread deletePending={deleteMutation.isPending}
key={node.comment.id} />
node={node} ))}
onDelete={(id) => deleteMutation.mutate(id)} </div>
deletePending={deleteMutation.isPending} ) : null}
/> </div>
))}
</div>
) : null}
</CardContent>
</Card>
); );
} }
@@ -6,14 +6,15 @@ import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@
import { X } from "lucide-react"; import { X } from "lucide-react";
import type { Tag, PaginatedResponse } from "@/lib/types"; 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", task: "tasks",
habit: "habits", habit: "habits",
note: "notes", note: "notes",
project: "projects",
}; };
interface TagManagerProps { interface TagManagerProps {
entityType: "task" | "habit" | "note"; entityType: "task" | "habit" | "note" | "project";
entityId: string; entityId: string;
tags: Tag[]; tags: Tag[];
} }
@@ -14,8 +14,9 @@ import {
Search, Search,
Sun, Sun,
Moon, Moon,
Palette,
LogOut, LogOut,
Inbox,
FileBarChart,
type LucideIcon, type LucideIcon,
} from "lucide-react"; } from "lucide-react";
import { import {
@@ -27,7 +28,7 @@ import {
CommandList, CommandList,
CommandSeparator, CommandSeparator,
} from "@/components/ui/command"; } 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 { useCreateDialogStore } from "@/lib/stores/use-create-dialog-store";
import { useApiDomain } from "@/lib/stores/use-active-domain-store"; import { useApiDomain } from "@/lib/stores/use-active-domain-store";
@@ -38,6 +39,7 @@ interface NavItem {
} }
const navItems: NavItem[] = [ const navItems: NavItem[] = [
{ label: "Inbox", href: "/inbox", icon: Inbox },
{ label: "Dashboard", href: "/", icon: LayoutDashboard }, { label: "Dashboard", href: "/", icon: LayoutDashboard },
{ label: "Tasks", href: "/tasks", icon: ListTodo }, { label: "Tasks", href: "/tasks", icon: ListTodo },
{ label: "Habits", href: "/habits", icon: Flame }, { label: "Habits", href: "/habits", icon: Flame },
@@ -45,6 +47,8 @@ const navItems: NavItem[] = [
{ label: "Notes", href: "/notes", icon: NotebookPen }, { label: "Notes", href: "/notes", icon: NotebookPen },
{ label: "Graph", href: "/graph", icon: Share2 }, { label: "Graph", href: "/graph", icon: Share2 },
{ label: "Calendar", href: "/calendar", icon: CalendarDays }, { 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: "Agent Activity", href: "/agents/activity", icon: Bot },
{ label: "Settings", href: "/settings", icon: Settings }, { label: "Settings", href: "/settings", icon: Settings },
]; ];
@@ -73,7 +77,7 @@ function addRecentPage(href: string) {
export function CommandPalette() { export function CommandPalette() {
const navigate = useNavigate(); const navigate = useNavigate();
const location = useLocation(); const location = useLocation();
const { mode, setMode, accent, setAccent } = useThemeStore(); const { mode, setMode } = useThemeStore();
const activeDomainId = useApiDomain(); const activeDomainId = useApiDomain();
const [open, setOpen] = useState(false); const [open, setOpen] = useState(false);
const [searchResults, setSearchResults] = useState< const [searchResults, setSearchResults] = useState<
@@ -152,13 +156,6 @@ export function CommandPalette() {
icon: mode === "dark" ? Sun : Moon, icon: mode === "dark" ? Sun : Moon,
action: () => setMode(mode === "dark" ? "light" : "dark"), 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 // Settings actions
@@ -10,6 +10,7 @@ import {
const shortcutGroups = [ const shortcutGroups = [
{ {
heading: "Navigation", heading: "Navigation",
color: "text-blue-500",
shortcuts: [ shortcuts: [
{ keys: "g then d", description: "Go to Dashboard" }, { keys: "g then d", description: "Go to Dashboard" },
{ keys: "g then t", description: "Go to Tasks" }, { keys: "g then t", description: "Go to Tasks" },
@@ -23,6 +24,7 @@ const shortcutGroups = [
}, },
{ {
heading: "Create", heading: "Create",
color: "text-amber-500",
shortcuts: [ shortcuts: [
{ keys: "n then t", description: "New task" }, { keys: "n then t", description: "New task" },
{ keys: "n then h", description: "New habit" }, { keys: "n then h", description: "New habit" },
@@ -33,6 +35,7 @@ const shortcutGroups = [
}, },
{ {
heading: "General", heading: "General",
color: "text-muted-foreground",
shortcuts: [ shortcuts: [
{ keys: "⌘K / Ctrl+K", description: "Open command palette" }, { keys: "⌘K / Ctrl+K", description: "Open command palette" },
{ keys: "/", description: "Open command palette" }, { keys: "/", description: "Open command palette" },
@@ -43,6 +46,7 @@ const shortcutGroups = [
}, },
{ {
heading: "Command Palette", heading: "Command Palette",
color: "text-muted-foreground",
shortcuts: [ shortcuts: [
{ keys: "↑↓", description: "Navigate items" }, { keys: "↑↓", description: "Navigate items" },
{ keys: "Enter", description: "Select item" }, { keys: "Enter", description: "Select item" },
@@ -62,24 +66,24 @@ export function ShortcutsHelp() {
return ( return (
<Dialog open={open} onOpenChange={setOpen}> <Dialog open={open} onOpenChange={setOpen}>
<DialogContent className="max-w-md"> <DialogContent className="max-w-md border">
<DialogHeader> <DialogHeader>
<DialogTitle>Keyboard Shortcuts</DialogTitle> <DialogTitle>Keyboard Shortcuts</DialogTitle>
<DialogDescription> <DialogDescription>
Use these shortcuts to navigate quickly. Press two-key combos like "g then t" in sequence within 1.5 seconds. Use these shortcuts to navigate quickly. Press two-key combos like "g then t" in sequence within 1.5 seconds.
</DialogDescription> </DialogDescription>
</DialogHeader> </DialogHeader>
<div className="space-y-4"> <div className="space-y-3">
{shortcutGroups.map((group) => ( {shortcutGroups.map((group) => (
<div key={group.heading}> <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} {group.heading}
</h4> </h4>
<div className="space-y-2"> <div className="space-y-1">
{group.shortcuts.map((s) => ( {group.shortcuts.map((s) => (
<div <div
key={s.keys} 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> <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"> <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">
+69 -33
View File
@@ -9,7 +9,6 @@ import {
Flame, Flame,
FolderKanban, FolderKanban,
NotebookPen, NotebookPen,
BookOpen,
Share2, Share2,
CalendarDays, CalendarDays,
Search, Search,
@@ -21,6 +20,9 @@ import {
ChevronRight, ChevronRight,
LogOut, LogOut,
User, User,
Inbox,
FileBarChart,
FileStack,
} from "lucide-react"; } from "lucide-react";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { ScrollArea } from "@/components/ui/scroll-area"; import { ScrollArea } from "@/components/ui/scroll-area";
@@ -56,6 +58,7 @@ interface NavItem {
} }
const navItems: NavItem[] = [ const navItems: NavItem[] = [
{ href: "/inbox", label: "Inbox", icon: Inbox, shortcut: "g i" },
{ href: "/", label: "Dashboard", icon: LayoutDashboard, shortcut: "g d" }, { href: "/", label: "Dashboard", icon: LayoutDashboard, shortcut: "g d" },
{ href: "/tasks", label: "Tasks", icon: ListTodo, shortcut: "g t" }, { href: "/tasks", label: "Tasks", icon: ListTodo, shortcut: "g t" },
{ href: "/habits", label: "Habits", icon: Flame, shortcut: "g h" }, { 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: "/graph", label: "Graph", icon: Share2, shortcut: "g g" },
{ href: "/search", label: "Search", icon: Search }, { href: "/search", label: "Search", icon: Search },
{ href: "/analytics", label: "Analytics", icon: BarChart3 }, { href: "/analytics", label: "Analytics", icon: BarChart3 },
{ href: "/reports", label: "Reports", icon: FileBarChart, shortcut: "g r" },
{ href: "/agents/activity", label: "Agent Activity", icon: Bot }, { href: "/agents/activity", label: "Agent Activity", icon: Bot },
{ href: "/daily", label: "Daily Notes", icon: PenLine }, { href: "/daily", label: "Daily Notes", icon: PenLine },
{ href: "/templates", label: "Templates", icon: FileStack },
]; ];
const bottomItems: NavItem[] = [ const bottomItems: NavItem[] = [
{ href: "/settings", label: "Settings", icon: Settings, shortcut: "g s" }, { 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() { export function Sidebar() {
const location = useLocation(); const location = useLocation();
const navigate = useNavigate(); const navigate = useNavigate();
@@ -81,9 +110,6 @@ export function Sidebar() {
const userName = user?.name || user?.email?.split("@")[0] || "User"; const userName = user?.name || user?.email?.split("@")[0] || "User";
const userInitials = (user?.name || user?.email || "U").slice(0, 2).toUpperCase(); 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">(() => const [sidebarPos, setSidebarPos] = useState<"left" | "right">(() =>
typeof window !== "undefined" && localStorage.getItem("sidebar-position") === "right" typeof window !== "undefined" && localStorage.getItem("sidebar-position") === "right"
? "right" ? "right"
@@ -106,25 +132,31 @@ export function Sidebar() {
const renderNavLink = (item: NavItem, isCollapsed: boolean, onNavigate?: () => void) => { const renderNavLink = (item: NavItem, isCollapsed: boolean, onNavigate?: () => void) => {
const active = isActive(item.href); const active = isActive(item.href);
const key = entityKey(item.href);
const iconColor = ICON_COLORS[key] ?? "hsl(var(--foreground))";
const link = ( const link = (
<Link <Link
key={item.href} key={item.href}
to={item.href} to={item.href}
onClick={onNavigate} onClick={onNavigate}
className={cn( 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 active
? "bg-accent text-accent-foreground" ? "border-l-[3px] border-l-[hsl(var(--accent-hsl))] bg-accent/50 text-accent-foreground"
: "text-muted-foreground hover:bg-accent hover: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 && ( {!isCollapsed && (
<> <>
<span className="flex-1">{item.label}</span> <span className="flex-1 truncate">{item.label}</span>
{item.shortcut && ( {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} {item.shortcut}
</kbd> </kbd>
)} )}
@@ -136,24 +168,26 @@ export function Sidebar() {
if (isCollapsed) { if (isCollapsed) {
return ( return (
<Tooltip key={item.href}> <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> <TooltipContent side={sidebarPos === "right" ? "left" : "right"}>{item.label}</TooltipContent>
</Tooltip> </Tooltip>
); );
} }
return <div key={item.href} className="group/item">{link}</div>; return link;
}; };
const navigation = (isCollapsed: boolean, onNavigate?: () => void) => ( const navigation = (isCollapsed: boolean, onNavigate?: () => void) => (
<ScrollArea className="flex-1 py-2"> <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))} {navItems.map((item) => renderNavLink(item, isCollapsed, onNavigate))}
</nav> </nav>
<Separator className="my-3" /> <Separator className="my-2" />
<nav className="flex flex-col gap-1 px-2" aria-label="Workspace"> <nav className="flex flex-col gap-0.5 px-2" aria-label="Workspace">
{!isCollapsed && ( {!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 Workspace
</p> </p>
)} )}
@@ -166,26 +200,28 @@ export function Sidebar() {
<TooltipProvider delayDuration={0}> <TooltipProvider delayDuration={0}>
<aside <aside
className={cn( 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", 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" aria-label="Main navigation"
> >
{/* Logo + collapse */} {/* 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 && ( {!collapsed && (
<Link to="/" className="flex items-center gap-2 font-semibold"> <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 E
</span> </span>
Project E <span className="text-sm">Project E</span>
</Link> </Link>
)} )}
<Button <Button
variant="ghost" variant="ghost"
size="icon" size="icon"
className="h-11 w-11 shrink-0" className="h-8 w-8 shrink-0"
onClick={toggle} onClick={toggle}
aria-label={collapsed ? "Expand sidebar" : "Collapse sidebar"} aria-label={collapsed ? "Expand sidebar" : "Collapse sidebar"}
> >
@@ -202,13 +238,13 @@ export function Sidebar() {
{navigation(collapsed)} {navigation(collapsed)}
{/* User avatar + logout at bottom */} {/* User avatar + logout at bottom */}
<div className="border-t p-2"> <div className="border-t border-sidebar-border p-2">
{collapsed ? ( {collapsed ? (
<DropdownMenu> <DropdownMenu>
<DropdownMenuTrigger asChild> <DropdownMenuTrigger asChild>
<Button variant="ghost" size="icon" className="h-10 w-10"> <Button variant="ghost" size="icon" className="h-9 w-9">
<Avatar className="h-8 w-8"> <Avatar className="h-7 w-7">
<AvatarFallback className="text-xs">{userInitials}</AvatarFallback> <AvatarFallback className="text-[10px]">{userInitials}</AvatarFallback>
</Avatar> </Avatar>
</Button> </Button>
</DropdownMenuTrigger> </DropdownMenuTrigger>
@@ -231,9 +267,9 @@ export function Sidebar() {
) : ( ) : (
<DropdownMenu> <DropdownMenu>
<DropdownMenuTrigger asChild> <DropdownMenuTrigger asChild>
<Button variant="ghost" className="w-full justify-start gap-3 px-3"> <Button variant="ghost" className="w-full justify-start gap-2.5 px-3">
<Avatar className="h-8 w-8"> <Avatar className="h-7 w-7">
<AvatarFallback className="text-xs">{userInitials}</AvatarFallback> <AvatarFallback className="text-[10px]">{userInitials}</AvatarFallback>
</Avatar> </Avatar>
<span className="text-sm font-medium truncate">{userName}</span> <span className="text-sm font-medium truncate">{userName}</span>
</Button> </Button>
@@ -260,12 +296,12 @@ export function Sidebar() {
{/* Mobile sheet */} {/* Mobile sheet */}
<Sheet open={mobileOpen} onOpenChange={setMobileOpen}> <Sheet open={mobileOpen} onOpenChange={setMobileOpen}>
<SheetContent side={sidebarPos === "right" ? "right" : "left"} className="flex w-72 flex-col p-0 md:hidden"> <SheetContent side={sidebarPos === "right" ? "right" : "left"} className="flex w-72 flex-col p-0 bg-sidebar md:hidden">
<SheetHeader className="border-b px-4 py-4 pr-12"> <SheetHeader className="border-b border-sidebar-border px-4 py-3 pr-12">
<SheetTitle>Project E</SheetTitle> <SheetTitle>Project E</SheetTitle>
<SheetDescription>Navigate your workspace.</SheetDescription> <SheetDescription>Navigate your workspace.</SheetDescription>
</SheetHeader> </SheetHeader>
<div className="border-b p-4"> <div className="border-b border-sidebar-border p-3">
<DomainPicker /> <DomainPicker />
</div> </div>
{navigation(false, () => setMobileOpen(false))} {navigation(false, () => setMobileOpen(false))}
@@ -1,13 +1,12 @@
import { useEffect } from "react"; 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 }) { export function ThemeProvider({ children }: { children: React.ReactNode }) {
const { mode, accent } = useThemeStore(); const { mode } = useThemeStore();
useEffect(() => { useEffect(() => {
const root = document.documentElement; const root = document.documentElement;
// Apply dark/light mode
if (mode === "system") { if (mode === "system") {
const mediaQuery = window.matchMedia("(prefers-color-scheme: dark)"); const mediaQuery = window.matchMedia("(prefers-color-scheme: dark)");
const handler = (e: MediaQueryListEvent | MediaQueryList) => { const handler = (e: MediaQueryListEvent | MediaQueryList) => {
@@ -22,11 +21,5 @@ export function ThemeProvider({ children }: { children: React.ReactNode }) {
} }
}, [mode]); }, [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}</>; return <>{children}</>;
} }
+13 -11
View File
@@ -67,7 +67,7 @@ export function Topbar() {
return ( return (
<header <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" role="banner"
> >
{/* Mobile menu */} {/* Mobile menu */}
@@ -78,19 +78,20 @@ export function Topbar() {
onClick={() => setMobileOpen(true)} onClick={() => setMobileOpen(true)}
aria-label="Open navigation menu" aria-label="Open navigation menu"
> >
<Menu className="h-5 w-5" /> <Menu className="h-4 w-4" />
</Button> </Button>
{/* Search input */} {/* Search input */}
<div className="flex-1 md:max-w-sm"> <div className="flex-1 md:max-w-sm">
<Button <Button
variant="outline" variant="outline"
size="sm"
className="w-full justify-start text-muted-foreground" className="w-full justify-start text-muted-foreground"
onClick={openPalette} onClick={openPalette}
aria-label="Open search (Cmd+K)" aria-label="Open search (Cmd+K)"
> >
<Search className="mr-2 h-4 w-4" aria-hidden="true" /> <Search className="mr-2 h-4 w-4 shrink-0" aria-hidden="true" />
Search or jump to...{" "} <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"> <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 K
</kbd> </kbd>
@@ -101,7 +102,7 @@ export function Topbar() {
<DomainPicker /> <DomainPicker />
{/* Right side */} {/* Right side */}
<div className="ml-auto flex items-center gap-2"> <div className="ml-auto flex items-center gap-1">
{/* Quick add */} {/* Quick add */}
<TooltipProvider> <TooltipProvider>
<Tooltip> <Tooltip>
@@ -109,10 +110,11 @@ export function Topbar() {
<Button <Button
variant="ghost" variant="ghost"
size="icon" size="icon"
className="h-8 w-8"
onClick={openPalette} onClick={openPalette}
aria-label="Quick add" aria-label="Quick add"
> >
<Plus className="h-5 w-5" /> <Plus className="h-4 w-4" />
</Button> </Button>
</TooltipTrigger> </TooltipTrigger>
<TooltipContent>Quick add (Cmd+K)</TooltipContent> <TooltipContent>Quick add (Cmd+K)</TooltipContent>
@@ -125,8 +127,8 @@ export function Topbar() {
<Tooltip> <Tooltip>
<TooltipTrigger asChild> <TooltipTrigger asChild>
<DropdownMenuTrigger asChild> <DropdownMenuTrigger asChild>
<Button variant="ghost" size="icon" className="relative" aria-label="Notifications"> <Button variant="ghost" size="icon" className="h-8 w-8 relative" aria-label="Notifications">
<Bell className="h-5 w-5" /> <Bell className="h-4 w-4" />
{count > 0 && ( {count > 0 && (
<span <span
aria-hidden="true" aria-hidden="true"
@@ -177,9 +179,9 @@ export function Topbar() {
{/* User avatar */} {/* User avatar */}
<DropdownMenu> <DropdownMenu>
<DropdownMenuTrigger asChild> <DropdownMenuTrigger asChild>
<Button variant="ghost" size="icon" className="h-9 w-9"> <Button variant="ghost" size="icon" className="h-8 w-8">
<Avatar className="h-8 w-8"> <Avatar className="h-7 w-7">
<AvatarFallback className="text-xs">U</AvatarFallback> <AvatarFallback className="text-[10px]">{userInitials}</AvatarFallback>
</Avatar> </Avatar>
</Button> </Button>
</DropdownMenuTrigger> </DropdownMenuTrigger>
+16 -14
View File
@@ -8,9 +8,9 @@ import { Button } from "@/components/ui/button";
*/ */
export function LoadingState({ label }: { label?: string }) { export function LoadingState({ label }: { label?: string }) {
return ( return (
<div className="flex flex-col items-center justify-center gap-3 py-12 text-muted-foreground"> <div className="flex flex-col items-center justify-center gap-2 py-8 text-muted-foreground">
<Loader2 className="h-6 w-6 animate-spin" /> <Loader2 className="h-5 w-5 animate-spin" />
{label ? <p className="text-sm">{label}</p> : null} {label ? <p className="text-xs">{label}</p> : null}
</div> </div>
); );
} }
@@ -24,22 +24,24 @@ export function EmptyState({
title, title,
description, description,
action, action,
color,
}: { }: {
icon?: LucideIcon; icon?: LucideIcon;
title: string; title: string;
description?: string; description?: string;
action?: ReactNode; action?: ReactNode;
color?: string;
}) { }) {
return ( return (
<div className="flex flex-col items-center justify-center gap-3 py-12 text-center"> <div className="flex flex-col items-center justify-center gap-2 py-8 text-center">
<div className="flex h-12 w-12 items-center justify-center rounded-full bg-muted"> <div className="flex h-10 w-10 items-center justify-center rounded-full bg-muted">
<Icon className="h-6 w-6 text-muted-foreground" /> <Icon className={`h-5 w-5 ${color ?? "text-muted-foreground"}`} />
</div> </div>
<div className="space-y-1"> <div className="space-y-0.5">
<p className="font-semibold">{title}</p> <p className="text-sm font-semibold">{title}</p>
{description ? <p className="text-sm text-muted-foreground">{description}</p> : null} {description ? <p className="text-xs text-muted-foreground">{description}</p> : null}
</div> </div>
{action ? <div className="pt-1">{action}</div> : null} {action ? <div className="pt-0.5">{action}</div> : null}
</div> </div>
); );
} }
@@ -50,12 +52,12 @@ export function EmptyState({
*/ */
export function ErrorState({ message, onRetry }: { message: string; onRetry?: () => void }) { export function ErrorState({ message, onRetry }: { message: string; onRetry?: () => void }) {
return ( return (
<div className="flex flex-col items-center justify-center gap-3 py-12 text-center"> <div className="flex flex-col items-center justify-center gap-2 py-8 text-center">
<AlertCircle className="h-8 w-8 text-destructive" /> <AlertCircle className="h-5 w-5 text-destructive" />
<p className="text-sm font-medium text-destructive">{message}</p> <p className="text-xs font-medium text-destructive font-mono">{message}</p>
{onRetry ? ( {onRetry ? (
<Button variant="outline" size="sm" onClick={onRetry}> <Button variant="outline" size="sm" onClick={onRetry}>
<RotateCw className="h-4 w-4" /> <RotateCw className="h-3 w-3" />
Retry Retry
</Button> </Button>
) : null} ) : null}
@@ -34,6 +34,7 @@ export function useKeyboardShortcuts() {
// g+letter navigation sequences // g+letter navigation sequences
const navMap: Record<string, string> = { const navMap: Record<string, string> = {
"g i": "/inbox",
"g d": "/", "g d": "/",
"g t": "/tasks", "g t": "/tasks",
"g h": "/habits", "g h": "/habits",
@@ -41,6 +42,7 @@ export function useKeyboardShortcuts() {
"g n": "/notes", "g n": "/notes",
"g c": "/calendar", "g c": "/calendar",
"g g": "/graph", "g g": "/graph",
"g r": "/reports",
"g s": "/settings", "g s": "/settings",
}; };
+52 -46
View File
@@ -1,44 +1,50 @@
@import url("https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap");
@tailwind base; @tailwind base;
@tailwind components; @tailwind components;
@tailwind utilities; @tailwind utilities;
@layer base { @layer base {
:root { :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, --font-sans: "Inter", system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto,
"Helvetica Neue", Arial, sans-serif; "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 /* Density — compact is the new default */
calc() padding on <main> in the app layout). 1 = comfortable. */ --density-scale: 0.85;
--density-scale: 1;
--background: 0 0% 100%; /* Light mode — clean, near-white with subtle warmth */
--foreground: 222.2 84% 4.9%; --background: 0 0% 99%;
--foreground: 222 47% 5%;
--card: 0 0% 100%; --card: 0 0% 100%;
--card-foreground: 222.2 84% 4.9%; --card-foreground: 222 47% 5%;
--popover: 0 0% 100%; --popover: 0 0% 100%;
--popover-foreground: 222.2 84% 4.9%; --popover-foreground: 222 47% 5%;
--primary: 222.2 47.4% 11.2%; --primary: 222 47% 11%;
--primary-foreground: 210 40% 98%; --primary-foreground: 0 0% 98%;
--secondary: 210 40% 96.1%; --secondary: 220 14% 96%;
--secondary-foreground: 222.2 47.4% 11.2%; --secondary-foreground: 222 47% 11%;
--muted: 210 40% 96.1%; --muted: 220 14% 96%;
--muted-foreground: 215.4 16.3% 46.9%; --muted-foreground: 220 9% 46%;
--accent: 210 40% 96.1%; --accent: 220 14% 96%;
--accent-foreground: 222.2 47.4% 11.2%; --accent-foreground: 222 47% 11%;
--destructive: 0 84.2% 60.2%; --destructive: 0 84% 60%;
--destructive-foreground: 210 40% 98%; --destructive-foreground: 0 0% 98%;
--border: 214.3 31.8% 91.4%; --border: 220 13% 91%;
--input: 214.3 31.8% 91.4%; --input: 220 13% 91%;
--ring: 222.2 84% 4.9%; --ring: 217 91% 60%;
--radius: 0.5rem; --radius: 0.375rem;
/* Interactive accent — pinned vibrant blue */
--accent-hsl: 217 91% 60%; --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 /* Density modes */
localStorage on app load). They only flip --density-scale, which the
layout padding and the space-y utilities below multiply by. */
.density-compact { .density-compact {
--density-scale: 0.85; --density-scale: 0.85;
} }
@@ -47,27 +53,33 @@
--density-scale: 1.15; --density-scale: 1.15;
} }
/* Dark mode — deep navy with elevated surfaces */
.dark { .dark {
--background: 222.2 84% 4.9%; --background: 222 47% 5%;
--foreground: 210 40% 98%; --foreground: 210 40% 98%;
--card: 222.2 84% 4.9%; --card: 222 40% 8%;
--card-foreground: 210 40% 98%; --card-foreground: 210 40% 98%;
--popover: 222.2 84% 4.9%; --popover: 222 40% 8%;
--popover-foreground: 210 40% 98%; --popover-foreground: 210 40% 98%;
--primary: 210 40% 98%; --primary: 210 40% 98%;
--primary-foreground: 222.2 47.4% 11.2%; --primary-foreground: 222 47% 11%;
--secondary: 217.2 32.6% 17.5%; --secondary: 217 33% 14%;
--secondary-foreground: 210 40% 98%; --secondary-foreground: 210 40% 98%;
--muted: 217.2 32.6% 17.5%; --muted: 217 33% 14%;
--muted-foreground: 215 20.2% 65.1%; --muted-foreground: 215 20% 65%;
--accent: 217.2 32.6% 17.5%; --accent: 217 33% 14%;
--accent-foreground: 210 40% 98%; --accent-foreground: 210 40% 98%;
--destructive: 0 62.8% 30.6%; --destructive: 0 63% 31%;
--destructive-foreground: 210 40% 98%; --destructive-foreground: 210 40% 98%;
--border: 217.2 32.6% 17.5%; --border: 217 33% 17%;
--input: 217.2 32.6% 17.5%; --input: 217 33% 17%;
--ring: 212.7 26.8% 83.9%; --ring: 217 91% 60%;
--accent-hsl: 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 /* Reduced motion */
<html>. This standard override collapses animation/transition durations so
users who enable it get an effectively static UI. */
.reduce-motion *, .reduce-motion *,
.reduce-motion *::before, .reduce-motion *::before,
.reduce-motion *::after { .reduce-motion *::after {
@@ -95,11 +105,7 @@
} }
@layer utilities { @layer utilities {
/* Density: multiply the vertical rhythm between stacked sections inside page /* Density: multiply vertical rhythm inside <main> */
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-compact main .space-y-1 > :not([hidden]) ~ :not([hidden]), .density-compact main .space-y-1 > :not([hidden]) ~ :not([hidden]),
.density-spacious 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)); margin-top: calc(0.25rem * var(--density-scale));
+4 -4
View File
@@ -23,10 +23,10 @@ export const TASK_STATUS: Record<string, StatusToken> = {
/** Task priority. Badges use a soft tint (matching text + translucent bg). */ /** Task priority. Badges use a soft tint (matching text + translucent bg). */
export const PRIORITY: Record<string, { label: string; badge: string }> = { export const PRIORITY: Record<string, { label: string; badge: string }> = {
low: { label: "Low", badge: "text-slate-500 bg-slate-500/10" }, low: { label: "Low", badge: "text-slate-600 bg-slate-500/15" },
medium: { label: "Medium", badge: "text-blue-500 bg-blue-500/10" }, medium: { label: "Medium", badge: "text-blue-600 bg-blue-500/15" },
high: { label: "High", badge: "text-orange-500 bg-orange-500/10" }, high: { label: "High", badge: "text-orange-600 bg-orange-500/15" },
urgent: { label: "Urgent", badge: "text-red-500 bg-red-500/10" }, urgent: { label: "Urgent", badge: "text-red-600 bg-red-500/15" },
}; };
/** Project lifecycle statuses. */ /** Project lifecycle statuses. */
@@ -2,33 +2,17 @@ import { create } from "zustand";
import { persist } from "zustand/middleware"; import { persist } from "zustand/middleware";
export type ThemeMode = "light" | "dark" | "system"; export type ThemeMode = "light" | "dark" | "system";
export type AccentColor = "slate" | "blue" | "green" | "purple" | "orange" | "rose";
interface ThemeState { interface ThemeState {
mode: ThemeMode; mode: ThemeMode;
accent: AccentColor;
setMode: (mode: ThemeMode) => void; 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>()( export const useThemeStore = create<ThemeState>()(
persist( persist(
(set) => ({ (set) => ({
mode: "dark", mode: "dark",
accent: "blue",
setMode: (mode) => set({ mode }), setMode: (mode) => set({ mode }),
setAccent: (accent) => set({ accent }),
}), }),
{ {
name: "project-e-theme", name: "project-e-theme",
+6
View File
@@ -14,7 +14,10 @@ import { Route as analyticsRoute } from "./routes/_app/analytics";
import { Route as agentsRoute } from "./routes/_app/agents"; import { Route as agentsRoute } from "./routes/_app/agents";
import { Route as agentActivityRoute } from "./routes/_app/agents/activity"; import { Route as agentActivityRoute } from "./routes/_app/agents/activity";
import { Route as dailyRoute } from "./routes/_app/daily"; 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 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 taskDetailRoute } from "./routes/_app/tasks/$id";
import { Route as habitDetailRoute } from "./routes/_app/habits/$id"; import { Route as habitDetailRoute } from "./routes/_app/habits/$id";
import { Route as noteDetailRoute } from "./routes/_app/notes/$id"; import { Route as noteDetailRoute } from "./routes/_app/notes/$id";
@@ -33,7 +36,10 @@ const appChildren = [
agentsRoute, agentsRoute,
agentActivityRoute, agentActivityRoute,
dailyRoute, dailyRoute,
inboxRoute,
reportsRoute,
settingsRoute, settingsRoute,
templatesRoute,
taskDetailRoute, taskDetailRoute,
habitDetailRoute, habitDetailRoute,
noteDetailRoute, noteDetailRoute,
+1 -1
View File
@@ -33,7 +33,7 @@ function AppLayout() {
<Topbar /> <Topbar />
<main <main
id="main-content" 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} tabIndex={-1}
> >
<Outlet /> <Outlet />
+40 -30
View File
@@ -25,6 +25,11 @@ const ACTION_TYPES = [
{ id: "completed", label: "Completed", color: "bg-purple-500" }, { 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() { function AgentActivityPage() {
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const [agentFilter, setAgentFilter] = useState(""); const [agentFilter, setAgentFilter] = useState("");
@@ -114,20 +119,25 @@ function AgentActivityPage() {
return agent?.name || "Unknown Agent"; 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 ( return (
<div className="space-y-4"> <div className="space-y-3">
<div className="flex items-center justify-between"> <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"] })}> <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> </Button>
</div> </div>
{/* Filters */} {/* Filters */}
<div className="flex flex-wrap gap-2"> <div className="flex flex-wrap gap-2">
<Select value={agentFilter} onValueChange={setAgentFilter}> <Select value={agentFilter} onValueChange={setAgentFilter}>
<SelectTrigger className="w-44"> <SelectTrigger className="w-44 h-8">
<Bot className="h-4 w-4 mr-2" /> <Bot className="h-3.5 w-3.5 mr-1.5" />
<SelectValue placeholder="All agents" /> <SelectValue placeholder="All agents" />
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
@@ -138,8 +148,8 @@ function AgentActivityPage() {
</SelectContent> </SelectContent>
</Select> </Select>
<Select value={actionFilter} onValueChange={setActionFilter}> <Select value={actionFilter} onValueChange={setActionFilter}>
<SelectTrigger className="w-36"> <SelectTrigger className="w-36 h-8">
<Activity className="h-4 w-4 mr-2" /> <Activity className="h-3.5 w-3.5 mr-1.5" />
<SelectValue placeholder="All actions" /> <SelectValue placeholder="All actions" />
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
@@ -149,8 +159,8 @@ function AgentActivityPage() {
))} ))}
</SelectContent> </SelectContent>
</Select> </Select>
<Input type="date" value={dateFrom} onChange={(e) => setDateFrom(e.target.value)} className="w-36 h-9" placeholder="From" /> <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-9" placeholder="To" /> <Input type="date" value={dateTo} onChange={(e) => setDateTo(e.target.value)} className="w-36 h-8 font-mono text-xs" placeholder="To" />
</div> </div>
{/* Timeline */} {/* Timeline */}
@@ -159,36 +169,36 @@ function AgentActivityPage() {
) : activities.length === 0 ? ( ) : activities.length === 0 ? (
<EmptyState title="No activity found" description="Agent activity will appear here as agents run" /> <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) => ( {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 key={a.id || idx} className="flex items-start gap-2.5 px-3 py-2 hover:bg-muted/20 transition-colors">
<div className="flex flex-col items-center shrink-0"> {/* Colored agent indicator */}
<Avatar className="h-8 w-8"> <div className="flex flex-col items-center shrink-0 pt-0.5">
<AvatarFallback className="text-xs bg-primary/10 text-primary"> <div className={cn("w-2 h-2 rounded-full", getAgentColor(a.agentId))} />
{getAgentName(a.agentId).slice(0, 2).toUpperCase()}
</AvatarFallback>
</Avatar>
{idx < activities.length - 1 && <div className="w-px flex-1 bg-border mt-1" />}
</div> </div>
<div className="flex-1 min-w-0"> <div className="flex-1 min-w-0">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<span className="text-sm font-medium">{getAgentName(a.agentId)}</span> <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={cn(
<span className="text-sm text-muted-foreground">{a.action}</span> "text-[10px] font-mono px-1.5 py-0.5 rounded",
<Badge variant="outline" className="text-[10px]">{a.entityType}</Badge> 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> </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 && ( {!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>} {a.errorMessage && <p className="text-xs text-destructive mt-0.5">{a.errorMessage}</p>}
<div className="flex items-center gap-2 mt-1"> <div className="flex items-center gap-2 mt-0.5">
<Clock className="h-3 w-3 text-muted-foreground" /> <span className="text-[10px] font-mono text-muted-foreground">{format(parseISO(a.createdAt), "MMM d, HH:mm:ss")}</span>
<span className="text-xs text-muted-foreground">{format(parseISO(a.createdAt), "MMM d, HH:mm:ss")}</span>
{a.entityId && ( {a.entityId && (
<span className="text-xs text-muted-foreground"> <span className="text-[10px] font-mono text-muted-foreground">
<ExternalLink className="h-3 w-3 inline mr-0.5" /> <ExternalLink className="h-2.5 w-2.5 inline mr-0.5" />
{a.entityId.slice(0, 8)}... {a.entityId.slice(0, 8)}
</span> </span>
)} )}
</div> </div>
+32 -32
View File
@@ -57,9 +57,9 @@ function AnalyticsPage() {
}; };
return ( return (
<div className="space-y-4"> <div className="space-y-3">
<div className="flex items-center justify-between"> <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}> <Select value={range} onValueChange={setRange}>
<SelectTrigger className="w-32"> <SelectTrigger className="w-32">
<Calendar className="h-4 w-4 mr-2" /> <Calendar className="h-4 w-4 mr-2" />
@@ -78,27 +78,27 @@ function AnalyticsPage() {
) : analyticsError ? ( ) : analyticsError ? (
<ErrorState message={analyticsError.message || "Failed to load analytics"} onRetry={refetchAnalytics} /> <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 */} {/* Tasks completed per day */}
<Card> <Card className="border rounded-lg hover:bg-muted/20 transition-colors">
<CardHeader className="p-4 pb-0 flex flex-row items-center justify-between"> <CardHeader className="p-3 pb-0 flex flex-row items-center justify-between">
<CardTitle className="text-sm font-semibold">Tasks Completed</CardTitle> <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> <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> </CardHeader>
<CardContent className="p-4"> <CardContent className="p-3">
<LineChart data={dailyItems} xKey="date" yKey="completed" /> <LineChart data={dailyItems} xKey="date" yKey="completed" />
</CardContent> </CardContent>
</Card> </Card>
{/* Tasks created vs completed */} {/* Tasks created vs completed */}
<Card> <Card className="border rounded-lg hover:bg-muted/20 transition-colors">
<CardHeader className="p-4 pb-0 flex flex-row items-center justify-between"> <CardHeader className="p-3 pb-0 flex flex-row items-center justify-between">
<CardTitle className="text-sm font-semibold">Created vs Completed</CardTitle> <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> <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> </CardHeader>
<CardContent className="p-4"> <CardContent className="p-3">
<BarChart data={dailyItems} xKey="date" yKey="created" yKey2="completed" color="#f97316" color2="#3b82f6" /> <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-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> <span className="flex items-center gap-1"><div className="w-2.5 h-2.5 rounded bg-blue-500" /> Completed</span>
</div> </div>
@@ -106,26 +106,26 @@ function AnalyticsPage() {
</Card> </Card>
{/* Habit completion rate */} {/* Habit completion rate */}
<Card> <Card className="border rounded-lg hover:bg-muted/20 transition-colors">
<CardHeader className="p-4 pb-0 flex flex-row items-center justify-between"> <CardHeader className="p-3 pb-0 flex flex-row items-center justify-between">
<CardTitle className="text-sm font-semibold">Habit Completion</CardTitle> <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> <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> </CardHeader>
<CardContent className="p-4"> <CardContent className="p-3">
<PieChartSimple data={habitRateData} labelKey="name" valueKey="value" /> <PieChartSimple data={habitRateData} labelKey="name" valueKey="value" />
</CardContent> </CardContent>
</Card> </Card>
{/* Project progress */} {/* Project progress */}
<Card> <Card className="border rounded-lg hover:bg-muted/20 transition-colors">
<CardHeader className="p-4 pb-0 flex flex-row items-center justify-between"> <CardHeader className="p-3 pb-0 flex flex-row items-center justify-between">
<CardTitle className="text-sm font-semibold">Project Progress</CardTitle> <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> <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> </CardHeader>
<CardContent className="p-4"> <CardContent className="p-3">
{projectData?.projects.length ? ( {projectData?.projects.length ? (
<div className="space-y-2"> <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 <HorizontalBar
data={projectData.projects.map((p) => ({ name: p.name, progress: Math.round(p.progress * 100) }))} data={projectData.projects.map((p) => ({ name: p.name, progress: Math.round(p.progress * 100) }))}
xKey="name" xKey="name"
@@ -140,12 +140,12 @@ function AnalyticsPage() {
</Card> </Card>
{/* Tasks by project (pie) */} {/* Tasks by project (pie) */}
<Card> <Card className="border rounded-lg hover:bg-muted/20 transition-colors">
<CardHeader className="p-4 pb-0 flex flex-row items-center justify-between"> <CardHeader className="p-3 pb-0 flex flex-row items-center justify-between">
<CardTitle className="text-sm font-semibold">Tasks by Project</CardTitle> <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> <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> </CardHeader>
<CardContent className="p-4"> <CardContent className="p-3">
{projectData?.projects.length ? ( {projectData?.projects.length ? (
<PieChartSimple data={projectData.projects.map((p) => ({ name: p.name, value: p.totalTasks }))} labelKey="name" valueKey="value" /> <PieChartSimple data={projectData.projects.map((p) => ({ name: p.name, value: p.totalTasks }))} labelKey="name" valueKey="value" />
) : ( ) : (
@@ -155,36 +155,36 @@ function AnalyticsPage() {
</Card> </Card>
{/* Productivity heatmap */} {/* Productivity heatmap */}
<Card> <Card className="border rounded-lg hover:bg-muted/20 transition-colors">
<CardHeader className="p-4 pb-0 flex flex-row items-center justify-between"> <CardHeader className="p-3 pb-0 flex flex-row items-center justify-between">
<CardTitle className="text-sm font-semibold">Productivity Heatmap</CardTitle> <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> <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> </CardHeader>
<CardContent className="p-4"> <CardContent className="p-3">
<CalendarHeatmap data={dailyItems.map((d) => ({ date: d.date, count: d.completed }))} days={parseInt(range)} /> <CalendarHeatmap data={dailyItems.map((d) => ({ date: d.date, count: d.completed }))} days={parseInt(range)} />
</CardContent> </CardContent>
</Card> </Card>
{/* Velocity */} {/* Velocity */}
<Card> <Card className="border rounded-lg hover:bg-muted/20 transition-colors">
<CardHeader className="p-4 pb-0 flex flex-row items-center justify-between"> <CardHeader className="p-3 pb-0 flex flex-row items-center justify-between">
<CardTitle className="text-sm font-semibold">Velocity</CardTitle> <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> </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>} {velocityData?.items ? <LineChart data={velocityData.items} xKey="date" yKey="completed" /> : <p className="text-sm text-muted-foreground">No data</p>}
</CardContent> </CardContent>
</Card> </Card>
{/* Cycle time */} {/* Cycle time */}
<Card> <Card className="border rounded-lg hover:bg-muted/20 transition-colors">
<CardHeader className="p-4 pb-0 flex flex-row items-center justify-between"> <CardHeader className="p-3 pb-0 flex flex-row items-center justify-between">
<CardTitle className="text-sm font-semibold">Cycle Time</CardTitle> <CardTitle className="text-sm font-semibold">Cycle Time</CardTitle>
</CardHeader> </CardHeader>
<CardContent className="p-4"> <CardContent className="p-3">
<div className="text-center py-4"> <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-lg font-bold font-mono">{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-xs text-muted-foreground font-mono">{cycleData?.count ?? 0} tasks completed in range</p>
</div> </div>
</CardContent> </CardContent>
</Card> </Card>
+9 -9
View File
@@ -77,7 +77,7 @@ function CustomToolbar({ date, onNavigate, label }: any) {
const goToToday = () => onNavigate(Navigate.TODAY); const goToToday = () => onNavigate(Navigate.TODAY);
return ( 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"> <div className="flex items-center gap-1">
<Button variant="outline" size="icon" onClick={goToBack} aria-label="Previous"> <Button variant="outline" size="icon" onClick={goToBack} aria-label="Previous">
<ChevronLeft className="h-4 w-4" /> <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"> <Button variant="outline" size="icon" onClick={goToNext} aria-label="Next">
<ChevronRight className="h-4 w-4" /> <ChevronRight className="h-4 w-4" />
</Button> </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>
</div> </div>
); );
@@ -132,7 +132,7 @@ function EventForm({ event, onClose }: { event?: CalendarEvent; onClose: () => v
}; };
return ( return (
<form onSubmit={handleSubmit} className="space-y-4"> <form onSubmit={handleSubmit} className="space-y-3">
<div> <div>
<Label htmlFor="title">Title</Label> <Label htmlFor="title">Title</Label>
<Input id="title" value={title} onChange={(e) => setTitle(e.target.value)} placeholder="Event title" required /> <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" /> <input type="checkbox" id="allDay" checked={allDay} onChange={(e) => setAllDay(e.target.checked)} className="rounded" />
<Label htmlFor="allDay">All day</Label> <Label htmlFor="allDay">All day</Label>
</div> </div>
<div className="grid grid-cols-2 gap-4"> <div className="grid grid-cols-2 gap-3">
<div> <div>
<Label htmlFor="start">Start</Label> <Label htmlFor="start">Start</Label>
<Input id="start" type={allDay ? "date" : "datetime-local"} value={startTime} onChange={(e) => setStartTime(e.target.value)} required /> <Input id="start" type={allDay ? "date" : "datetime-local"} value={startTime} onChange={(e) => setStartTime(e.target.value)} required />
@@ -276,9 +276,9 @@ function CalendarPage() {
}, []); }, []);
return ( return (
<div className="space-y-4"> <div className="space-y-3">
<div className="flex items-center justify-between"> <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}> <Dialog open={createOpen} onOpenChange={setCreateOpen}>
<DialogTrigger asChild> <DialogTrigger asChild>
<Button aria-label="New event"><Plus className="h-4 w-4 mr-2" />New Event</Button> <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> <DialogTitle>{selectedEvent?.title || "Event"}</DialogTitle>
</DialogHeader> </DialogHeader>
{selectedEvent && ( {selectedEvent && (
<div className="space-y-4"> <div className="space-y-3">
<div className="text-sm"> <div className="text-sm">
<p><strong>Start:</strong> {new Date(selectedEvent.startTime).toLocaleString()}</p> <p><strong>Start:</strong> <span className="font-mono">{new Date(selectedEvent.startTime).toLocaleString()}</span></p>
{selectedEvent.endTime && <p><strong>End:</strong> {new Date(selectedEvent.endTime).toLocaleString()}</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>} {selectedEvent.description && <p className="mt-2">{selectedEvent.description}</p>}
</div> </div>
<EventForm event={selectedEvent} onClose={() => setEventDetailOpen(false)} /> <EventForm event={selectedEvent} onClose={() => setEventDetailOpen(false)} />
+41 -37
View File
@@ -38,19 +38,19 @@ function CalendarSidebar({ selectedDate, onSelectDate }: { selectedDate: Date; o
const noteDates = new Set(notes.map((n) => n.date.slice(0, 10))); const noteDates = new Set(notes.map((n) => n.date.slice(0, 10)));
return ( return (
<div className="w-64 shrink-0"> <div className="w-56 shrink-0">
<div className="flex items-center justify-between mb-3"> <div className="flex items-center justify-between mb-2">
<Button variant="ghost" size="icon" className="h-7 w-7" onClick={() => setCurrentMonth(subMonths(currentMonth, 1))}> <Button variant="ghost" size="icon" className="h-6 w-6" onClick={() => setCurrentMonth(subMonths(currentMonth, 1))}>
<ChevronLeft className="h-4 w-4" /> <ChevronLeft className="h-3.5 w-3.5" />
</Button> </Button>
<span className="text-sm font-semibold">{format(currentMonth, "MMMM yyyy")}</span> <span className="text-xs font-semibold font-mono">{format(currentMonth, "MMM yyyy")}</span>
<Button variant="ghost" size="icon" className="h-7 w-7" onClick={() => setCurrentMonth(addMonths(currentMonth, 1))}> <Button variant="ghost" size="icon" className="h-6 w-6" onClick={() => setCurrentMonth(addMonths(currentMonth, 1))}>
<ChevronRight className="h-4 w-4" /> <ChevronRight className="h-3.5 w-3.5" />
</Button> </Button>
</div> </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) => ( {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) => ( {Array.from({ length: startDay }).map((_, i) => (
<div key={"empty-" + i} /> <div key={"empty-" + i} />
@@ -65,21 +65,25 @@ function CalendarSidebar({ selectedDate, onSelectDate }: { selectedDate: Date; o
key={key} key={key}
onClick={() => onSelectDate(d)} onClick={() => onSelectDate(d)}
className={cn( className={cn(
"h-8 w-8 rounded-full text-xs flex items-center justify-center transition-colors relative", "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", isSelected
? "bg-primary text-primary-foreground"
: today
? "bg-primary/10 text-primary font-semibold"
: "hover:bg-muted/40 text-foreground",
)} )}
> >
{format(d, "d")} {format(d, "d")}
{hasNote && !isSelected && ( {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> </button>
); );
})} })}
</div> </div>
<div className="mt-3"> <div className="mt-2">
<Button variant="outline" size="sm" className="w-full" onClick={() => onSelectDate(new Date())}> <Button variant="outline" size="sm" className="w-full h-7 text-xs" onClick={() => onSelectDate(new Date())}>
<Calendar className="h-3.5 w-3.5 mr-2" />Today <Calendar className="h-3 w-3 mr-1.5" />Today
</Button> </Button>
</div> </div>
</div> </div>
@@ -247,19 +251,19 @@ function DailyNoteEditor({ date }: { date: Date }) {
}; };
return ( return (
<div className="flex-1 space-y-4"> <div className="flex-1 space-y-3">
<div className="flex items-center justify-between"> <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"> <div className="flex items-center gap-2">
{noteId && ( {noteId && (
<> <>
<Badge variant="secondary" className="text-[10px]"> <Badge variant="secondary" className="text-[10px] font-mono">
<Save className="h-3 w-3 mr-1" />Saved <Save className="h-3 w-3 mr-1" />Saved
</Badge> </Badge>
<AlertDialog> <AlertDialog>
<AlertDialogTrigger asChild> <AlertDialogTrigger asChild>
<Button variant="ghost" size="icon" className="h-8 w-8 text-destructive" aria-label="Delete daily note"> <Button variant="ghost" size="icon" className="h-7 w-7 text-destructive" aria-label="Delete daily note">
<Trash2 className="h-4 w-4" /> <Trash2 className="h-3.5 w-3.5" />
</Button> </Button>
</AlertDialogTrigger> </AlertDialogTrigger>
<AlertDialogContent> <AlertDialogContent>
@@ -279,10 +283,10 @@ function DailyNoteEditor({ date }: { date: Date }) {
</div> </div>
{/* Mood & Energy */} {/* Mood & Energy */}
<div className="flex gap-6"> <div className="flex gap-5">
<div> <div>
<p className="text-xs text-muted-foreground mb-1">Mood</p> <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-1"> <div role="radiogroup" aria-label="Mood" className="flex gap-0.5">
{[1, 2, 3, 4, 5, 6, 7, 8, 9, 10].map((v) => ( {[1, 2, 3, 4, 5, 6, 7, 8, 9, 10].map((v) => (
<button <button
key={v} key={v}
@@ -291,7 +295,7 @@ function DailyNoteEditor({ date }: { date: Date }) {
aria-label={`Mood ${v}`} aria-label={`Mood ${v}`}
onClick={() => handleMoodChange(v)} onClick={() => handleMoodChange(v)}
className={cn( 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" 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> </div>
<div> <div>
<p className="text-xs text-muted-foreground mb-1">Energy</p> <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-1"> <div role="radiogroup" aria-label="Energy" className="flex gap-0.5">
{[1, 2, 3, 4, 5, 6, 7, 8, 9, 10].map((v) => ( {[1, 2, 3, 4, 5, 6, 7, 8, 9, 10].map((v) => (
<button <button
key={v} key={v}
@@ -311,7 +315,7 @@ function DailyNoteEditor({ date }: { date: Date }) {
aria-label={`Energy ${v}`} aria-label={`Energy ${v}`}
onClick={() => handleEnergyChange(v)} onClick={() => handleEnergyChange(v)}
className={cn( 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" 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 */} {/* Editor */}
{isLoading ? ( {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 ? ( ) : isNew && !content ? (
<div className="text-center py-8 space-y-4"> <div className="text-center py-6 space-y-3">
<p className="text-muted-foreground">No note for this day</p> <p className="text-muted-foreground text-sm">No note for this day</p>
{tasksDue.length > 0 && <p className="text-xs text-muted-foreground">{tasksDue.length} tasks due · {habitsToday.length} habits</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"> <div className="flex gap-2 justify-center">
<Button variant="outline" onClick={() => textareaRef.current?.focus()}> <Button variant="outline" size="sm" className="h-7 text-xs" onClick={() => textareaRef.current?.focus()}>
<Plus className="h-4 w-4 mr-2" />Start Writing <Plus className="h-3 w-3 mr-1" />Start Writing
</Button> </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>
</div> </div>
) : ( ) : (
@@ -344,7 +348,7 @@ function DailyNoteEditor({ date }: { date: Date }) {
value={content} value={content}
onChange={(e) => handleContentChange(e.target.value)} onChange={(e) => handleContentChange(e.target.value)}
placeholder="Write your daily note here..." 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> </div>
@@ -357,10 +361,10 @@ function DailyNotesPage() {
const [selectedDate, setSelectedDate] = useState(new Date()); const [selectedDate, setSelectedDate] = useState(new Date());
return ( return (
<div className="flex gap-6 h-[calc(100vh-5rem)]"> <div className="flex gap-4 h-[calc(100vh-5rem)]">
<CalendarSidebar selectedDate={selectedDate} onSelectDate={setSelectedDate} /> <CalendarSidebar selectedDate={selectedDate} onSelectDate={setSelectedDate} />
<Separator orientation="vertical" /> <Separator orientation="vertical" />
<ScrollArea className="flex-1 pr-4"> <ScrollArea className="flex-1 pr-3">
<DailyNoteEditor date={selectedDate} /> <DailyNoteEditor date={selectedDate} />
</ScrollArea> </ScrollArea>
</div> </div>
+122 -109
View File
@@ -1,6 +1,6 @@
import { useState, useEffect } from "react"; import { useState, useEffect } from "react";
import { createRoute } from "@tanstack/react-router"; 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 { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { api, useApiQuery } from "@/lib/api"; import { api, useApiQuery } from "@/lib/api";
import { useRealtime } from "@/hooks/use-realtime"; import { useRealtime } from "@/hooks/use-realtime";
@@ -10,7 +10,6 @@ import { toast } from "sonner";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import { Badge } from "@/components/ui/badge"; 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 { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Label } from "@/components/ui/label"; 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"; import { format, isToday, isPast, addDays, parseISO } from "date-fns";
const WIDGET_TYPES = [ const WIDGET_TYPES = [
{ id: "tasks_due", label: "Tasks Due Today", icon: ListTodo, defaultW: 2, 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 }, { 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 }, { 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 }, { 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 }, { 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 }, { 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 }, { 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 }, { id: "productivity_chart", label: "Productivity Chart", icon: TrendingUp, defaultW: 3, defaultH: 2, accent: "text-green-500", border: "border-l-green-500" },
] as const; ] 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() { function TasksDueWidget() {
const activeDomainId = useApiDomain(); const activeDomainId = useApiDomain();
const domainSuffix = activeDomainId ? "&domain=" + activeDomainId : ""; const domainSuffix = activeDomainId ? "&domain=" + activeDomainId : "";
@@ -39,16 +48,16 @@ function TasksDueWidget() {
const today = tasks.filter((t) => t.dueDate && isToday(parseISO(t.dueDate))); 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"); const overdue = tasks.filter((t) => t.dueDate && isPast(parseISO(t.dueDate)) && t.status !== "done");
return ( return (
<div className="space-y-2"> <div className="space-y-1.5">
{today.length === 0 && overdue.length === 0 ? ( {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 && ( {overdue.length > 0 && (
<div> <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) => ( {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" /> <div className="w-1.5 h-1.5 rounded-full bg-destructive shrink-0" />
<span className="truncate flex-1">{t.title}</span> <span className="truncate flex-1">{t.title}</span>
</div> </div>
@@ -57,9 +66,9 @@ function TasksDueWidget() {
)} )}
{today.length > 0 && ( {today.length > 0 && (
<div> <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) => ( {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" /> <div className="w-1.5 h-1.5 rounded-full bg-amber-500 shrink-0" />
<span className="truncate flex-1">{t.title}</span> <span className="truncate flex-1">{t.title}</span>
</div> </div>
@@ -88,9 +97,9 @@ function HabitsTodayWidget() {
onError: (err) => toast.error(err.message || "Failed to complete habit"), onError: (err) => toast.error(err.message || "Failed to complete habit"),
}); });
return ( return (
<div className="space-y-1"> <div className="space-y-0.5">
{habits.length === 0 ? ( {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) => { habits.slice(0, 6).map((h) => {
const doneToday = (h.recentCompletions || []).some((c) => { 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 d.getUTCFullYear() === today.getUTCFullYear() && d.getUTCMonth() === today.getUTCMonth() && d.getUTCDate() === today.getUTCDate();
}); });
return ( 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 <button
onClick={() => completeMutation.mutate(h.id)} 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} 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> </button>
<span className="text-sm truncate flex-1">{h.name}</span> <span className="text-xs truncate flex-1">{h.name}</span>
{h.streakCount > 0 && ( {h.streakCount > 0 && (
<Badge variant="secondary" className="text-[10px] shrink-0"> <Badge variant="secondary" className="text-[9px] px-1 shrink-0">
<Flame className="h-2.5 w-2.5 mr-0.5" />{h.streakCount} <Flame className="h-2 w-2 mr-0" />{h.streakCount}
</Badge> </Badge>
)} )}
</div> </div>
@@ -127,14 +136,14 @@ function RecentNotesWidget() {
const { data } = useApiQuery<PaginatedResponse<Note>>(["recent-notes", activeDomainId], "/notes?limit=5&sort=-updated" + domainSuffix); const { data } = useApiQuery<PaginatedResponse<Note>>(["recent-notes", activeDomainId], "/notes?limit=5&sort=-updated" + domainSuffix);
const notes = data?.items || []; const notes = data?.items || [];
return ( return (
<div className="space-y-2"> <div className="space-y-1.5">
{notes.length === 0 ? ( {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) => ( 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="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> </div>
)) ))
)} )}
@@ -148,17 +157,17 @@ function ActiveProjectsWidget() {
const { data } = useApiQuery<PaginatedResponse<Project>>(["active-projects", activeDomainId], "/projects?limit=10&status=active" + domainSuffix); const { data } = useApiQuery<PaginatedResponse<Project>>(["active-projects", activeDomainId], "/projects?limit=10&status=active" + domainSuffix);
const projects = data?.items || []; const projects = data?.items || [];
return ( return (
<div className="space-y-3"> <div className="space-y-2">
{projects.length === 0 ? ( {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) => ( projects.slice(0, 5).map((p) => (
<div key={p.id} className="space-y-1"> <div key={p.id} className="space-y-0.5">
<div className="flex items-center justify-between text-sm"> <div className="flex items-center justify-between text-xs">
<span className="truncate flex-1">{p.name}</span> <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> </div>
<Progress value={p.progress || 0} className="h-1.5" /> <Progress value={p.progress || 0} className="h-1" />
</div> </div>
)) ))
)} )}
@@ -178,15 +187,15 @@ function UpcomingEventsWidget() {
return start >= now && start <= weekFromNow; return start >= now && start <= weekFromNow;
}).sort((a, b) => parseISO(a.startTime).getTime() - parseISO(b.startTime).getTime()); }).sort((a, b) => parseISO(a.startTime).getTime() - parseISO(b.startTime).getTime());
return ( return (
<div className="space-y-2"> <div className="space-y-1.5">
{upcoming.length === 0 ? ( {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) => ( 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" }} /> <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="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> </div>
)) ))
)} )}
@@ -202,16 +211,16 @@ function StreakCounterWidget() {
const bestStreak = Math.max(...habits.map((h) => h.streakCount || 0), 0); const bestStreak = Math.max(...habits.map((h) => h.streakCount || 0), 0);
const totalActive = habits.filter((h) => h.streakCount > 0).length; const totalActive = habits.filter((h) => h.streakCount > 0).length;
return ( 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"> <div className="flex flex-col items-center">
<Flame className="h-8 w-8 text-orange-500" /> <Flame className="h-6 w-6 text-orange-500" />
<span className="text-2xl font-bold">{bestStreak}</span> <span className="text-xl font-mono font-semibold">{bestStreak}</span>
<span className="text-xs text-muted-foreground">Best streak</span> <span className="text-[10px] text-muted-foreground">Best streak</span>
</div> </div>
<Separator orientation="vertical" /> <Separator orientation="vertical" />
<div className="flex flex-col items-center"> <div className="flex flex-col items-center">
<span className="text-2xl font-bold">{totalActive}</span> <span className="text-xl font-mono font-semibold">{totalActive}</span>
<span className="text-xs text-muted-foreground">Active streaks</span> <span className="text-[10px] text-muted-foreground">Active streaks</span>
</div> </div>
</div> </div>
); );
@@ -248,18 +257,18 @@ function QuickCaptureWidget() {
else createNote.mutate(text.trim()); else createNote.mutate(text.trim());
}; };
return ( return (
<form onSubmit={handleSubmit} className="flex gap-2"> <form onSubmit={handleSubmit} className="flex gap-1.5">
<div className="flex-1 flex gap-2"> <div className="flex-1 flex gap-1.5">
<Select value={type} onValueChange={(v) => setType(v as "task" | "note")}> <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> <SelectContent>
<SelectItem value="task">Task</SelectItem> <SelectItem value="task">Task</SelectItem>
<SelectItem value="note">Note</SelectItem> <SelectItem value="note">Note</SelectItem>
</SelectContent> </SelectContent>
</Select> </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> </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> </form>
); );
} }
@@ -269,24 +278,24 @@ function ProductivityChartWidget() {
const domainSuffix = activeDomainId ? "&domain=" + activeDomainId : ""; const domainSuffix = activeDomainId ? "&domain=" + activeDomainId : "";
const { data } = useApiQuery<any>(["productivity-chart", activeDomainId], "/analytics/productivity?range=30" + domainSuffix); const { data } = useApiQuery<any>(["productivity-chart", activeDomainId], "/analytics/productivity?range=30" + domainSuffix);
const stats = data; 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 ( return (
<div className="space-y-3"> <div className="space-y-2">
<div className="grid grid-cols-3 gap-2"> <div className="grid grid-cols-3 gap-1.5">
<div className="text-center p-2 bg-muted/50 rounded"> <div className="text-center p-1.5 bg-muted/30 rounded">
<p className="text-lg font-bold">{stats.totalTasks || 0}</p> <p className="text-base font-mono font-semibold">{stats.totalTasks || 0}</p>
<p className="text-[10px] text-muted-foreground">Total</p> <p className="text-[9px] text-muted-foreground">Total</p>
</div> </div>
<div className="text-center p-2 bg-muted/50 rounded"> <div className="text-center p-1.5 bg-muted/30 rounded">
<p className="text-lg font-bold text-green-500">{stats.completedTasks || 0}</p> <p className="text-base font-mono font-semibold text-green-500">{stats.completedTasks || 0}</p>
<p className="text-[10px] text-muted-foreground">Done</p> <p className="text-[9px] text-muted-foreground">Done</p>
</div> </div>
<div className="text-center p-2 bg-muted/50 rounded"> <div className="text-center p-1.5 bg-muted/30 rounded">
<p className="text-lg font-bold">{stats.taskCompletionRate || 0}%</p> <p className="text-base font-mono font-semibold">{stats.taskCompletionRate || 0}%</p>
<p className="text-[10px] text-muted-foreground">Rate</p> <p className="text-[9px] text-muted-foreground">Rate</p>
</div> </div>
</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> </div>
); );
} }
@@ -296,20 +305,20 @@ function StatsWidget() {
const domainSuffix = activeDomainId ? "&domain=" + activeDomainId : ""; const domainSuffix = activeDomainId ? "&domain=" + activeDomainId : "";
const { data } = useApiQuery<any>(["stats", activeDomainId], "/analytics/productivity?range=30" + domainSuffix); const { data } = useApiQuery<any>(["stats", activeDomainId], "/analytics/productivity?range=30" + domainSuffix);
const stats = data; 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 ( 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"> <div className="text-center">
<p className="text-2xl font-bold">{stats.totalTasks || 0}</p> <p className="text-xl font-mono font-semibold">{stats.totalTasks || 0}</p>
<p className="text-xs text-muted-foreground">Total Tasks</p> <p className="text-[10px] text-muted-foreground">Total Tasks</p>
</div> </div>
<div className="text-center"> <div className="text-center">
<p className="text-2xl font-bold text-green-500">{stats.completedTasks || 0}</p> <p className="text-xl font-mono font-semibold text-green-500">{stats.completedTasks || 0}</p>
<p className="text-xs text-muted-foreground">Completed</p> <p className="text-[10px] text-muted-foreground">Completed</p>
</div> </div>
<div className="text-center"> <div className="text-center">
<p className="text-2xl font-bold">{stats.taskCompletionRate || 0}%</p> <p className="text-xl font-mono font-semibold">{stats.taskCompletionRate || 0}%</p>
<p className="text-xs text-muted-foreground">Rate</p> <p className="text-[10px] text-muted-foreground">Rate</p>
</div> </div>
</div> </div>
); );
@@ -326,7 +335,7 @@ function WidgetRenderer({ type }: { type: string }) {
case "quick_capture": return <QuickCaptureWidget />; case "quick_capture": return <QuickCaptureWidget />;
case "productivity_chart": return <ProductivityChartWidget />; case "productivity_chart": return <ProductivityChartWidget />;
case "stats": return <StatsWidget />; 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 typeInfo = WIDGET_TYPES.find((t) => t.id === widget.type);
const Icon = typeInfo?.icon || Target; const Icon = typeInfo?.icon || Target;
return ( return (
<Card <div
className="h-full flex flex-col group lg:[grid-column:span_var(--w)] lg:[grid-row:span_var(--h)]" 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} 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="px-2.5 pt-2.5 pb-0 flex flex-row items-center justify-between gap-2">
<div className="flex items-center gap-2 min-w-0"> <div className="flex items-center gap-1.5 min-w-0">
<Icon className="h-4 w-4 shrink-0 text-muted-foreground" /> <Icon className={cn("h-3.5 w-3.5 shrink-0", getWidgetAccent(widget.type))} />
<CardTitle className="text-sm font-semibold truncate">{widget.title || typeInfo?.label || widget.type}</CardTitle> <span className="text-xs font-semibold truncate">{widget.title || typeInfo?.label || widget.type}</span>
</div> </div>
<div className="flex items-center gap-1 opacity-0 group-hover:opacity-100 transition-opacity shrink-0"> <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-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-5 w-5" onClick={onConfigure} aria-label="Configure widget"><Settings2 className="h-3 w-3" /></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> <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> </div>
</CardHeader> </div>
<CardContent className="p-3 flex-1 overflow-auto"> <div className="p-2.5 flex-1 overflow-auto">
<WidgetRenderer type={widget.type} /> <WidgetRenderer type={widget.type} />
</CardContent> </div>
</Card> </div>
); );
} }
@@ -359,15 +371,15 @@ function AddWidgetDialog({ open, onOpenChange, onAdd }: { open: boolean; onOpenC
return ( return (
<Dialog open={open} onOpenChange={onOpenChange}> <Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-md"> <DialogContent className="sm:max-w-md">
<DialogHeader><DialogTitle>Add Widget</DialogTitle></DialogHeader> <DialogHeader><DialogTitle className="text-sm">Add Widget</DialogTitle></DialogHeader>
<div className="grid grid-cols-2 gap-3"> <div className="grid grid-cols-2 gap-2">
{WIDGET_TYPES.map((wt) => { {WIDGET_TYPES.map((wt) => {
const Icon = wt.icon; const Icon = wt.icon;
return ( return (
<button key={wt.id} onClick={() => { onAdd(wt.id); onOpenChange(false); }} <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"> 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="h-6 w-6" /> <Icon className={cn("h-5 w-5", wt.accent)} />
<span className="text-xs font-medium">{wt.label}</span> <span className="text-[10px] font-medium">{wt.label}</span>
</button> </button>
); );
})} })}
@@ -388,17 +400,17 @@ function ConfigureWidgetDialog({ widget, open, onOpenChange, onSave }: { widget:
return ( return (
<Dialog open={open} onOpenChange={onOpenChange}> <Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent> <DialogContent>
<DialogHeader><DialogTitle>Configure Widget</DialogTitle></DialogHeader> <DialogHeader><DialogTitle className="text-sm">Configure Widget</DialogTitle></DialogHeader>
<div className="space-y-4"> <div className="space-y-3">
<div> <div>
<Label htmlFor="widget-title">Title</Label> <Label htmlFor="widget-title" className="text-xs">Title</Label>
<Input id="widget-title" value={title} onChange={(e) => setTitle(e.target.value)} placeholder="Widget title" /> <Input id="widget-title" value={title} onChange={(e) => setTitle(e.target.value)} placeholder="Widget title" className="h-8 text-xs mt-1" />
</div> </div>
<div className="grid grid-cols-2 gap-4"> <div className="grid grid-cols-2 gap-3">
<div> <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))}> <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> <SelectContent>
<SelectItem value="1">1 column</SelectItem> <SelectItem value="1">1 column</SelectItem>
<SelectItem value="2">2 columns</SelectItem> <SelectItem value="2">2 columns</SelectItem>
@@ -408,9 +420,9 @@ function ConfigureWidgetDialog({ widget, open, onOpenChange, onSave }: { widget:
</Select> </Select>
</div> </div>
<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))}> <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> <SelectContent>
<SelectItem value="1">1 row</SelectItem> <SelectItem value="1">1 row</SelectItem>
<SelectItem value="2">2 rows</SelectItem> <SelectItem value="2">2 rows</SelectItem>
@@ -421,8 +433,8 @@ function ConfigureWidgetDialog({ widget, open, onOpenChange, onSave }: { widget:
</div> </div>
</div> </div>
<div className="flex justify-end gap-2"> <div className="flex justify-end gap-2">
<Button variant="outline" onClick={() => onOpenChange(false)}>Cancel</Button> <Button variant="outline" size="sm" onClick={() => onOpenChange(false)}>Cancel</Button>
<Button onClick={handleSave}>Save</Button> <Button size="sm" onClick={handleSave}>Save</Button>
</div> </div>
</div> </div>
</DialogContent> </DialogContent>
@@ -473,26 +485,27 @@ function DashboardPage() {
}; };
const handleDelete = (id: string) => { deleteMutation.mutate(id); }; const handleDelete = (id: string) => { deleteMutation.mutate(id); };
return ( return (
<div className="space-y-4"> <div className="space-y-3">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<h1 className="text-2xl font-bold">Dashboard</h1> <h1 className="text-xl font-bold">Dashboard</h1>
<Button onClick={() => setAddOpen(true)} aria-label="Add widget"><Plus className="h-4 w-4 mr-2" />Add Widget</Button> <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> </div>
{isLoading ? ( {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 ? ( ) : widgets.length === 0 ? (
<div className="text-center py-12"> <div className="text-center py-10 border rounded-lg border-dashed">
<p className="text-muted-foreground mb-4">Your dashboard is empty. Add some widgets to get started!</p> <Zap className="h-6 w-6 mx-auto mb-2 text-muted-foreground/50" />
<Button onClick={() => { <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"]; const defaults = ["tasks_due", "habits_today", "recent_notes", "active_projects", "upcoming_events", "streak_counter", "quick_capture", "productivity_chart"];
defaults.forEach((type, i) => { defaults.forEach((type, i) => {
const typeInfo = WIDGET_TYPES.find((t) => t.id === type); 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 } }); 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>
) : ( ) : (
<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) => ( {widgets.map((w) => (
<WidgetCard key={w.id} widget={w} onConfigure={() => handleConfigure(w)} onDelete={() => handleDelete(w.id)} /> <WidgetCard key={w.id} widget={w} onConfigure={() => handleConfigure(w)} onDelete={() => handleDelete(w.id)} />
))} ))}
+27 -13
View File
@@ -342,7 +342,7 @@ function GraphPage() {
{/* Graph canvas area */} {/* Graph canvas area */}
<div ref={containerRef} className="flex-1 relative bg-muted/20"> <div ref={containerRef} className="flex-1 relative bg-muted/20">
{/* Toolbar */} {/* 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"> <div className="relative">
<Search className="absolute left-2.5 top-2.5 h-4 w-4 text-muted-foreground" /> <Search className="absolute left-2.5 top-2.5 h-4 w-4 text-muted-foreground" />
<Input <Input
@@ -356,10 +356,13 @@ function GraphPage() {
<Button variant="outline" size="icon" onClick={() => setFilterOpen(true)} aria-label="Filters"> <Button variant="outline" size="icon" onClick={() => setFilterOpen(true)} aria-label="Filters">
<Filter className="h-4 w-4" /> <Filter className="h-4 w-4" />
</Button> </Button>
<Badge variant="secondary" className="font-mono text-[10px]">
{displayNodes.length} nodes · {displayEdges.length} edges
</Badge>
</div> </div>
{/* Zoom controls */} {/* 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"> <Button variant="secondary" size="icon" onClick={handleZoomIn} aria-label="Zoom in">
<ZoomIn className="h-4 w-4" /> <ZoomIn className="h-4 w-4" />
</Button> </Button>
@@ -373,9 +376,9 @@ function GraphPage() {
{/* Performance warning */} {/* Performance warning */}
{exceeded && ( {exceeded && (
<div className="absolute top-4 right-4 z-10"> <div className="absolute top-3 right-3 z-10">
<Badge variant="destructive" className="text-xs"> <Badge variant="destructive" className="text-xs font-mono">
Showing {MAX_NODES} of {filteredNodes.length} nodes (cap reached) Showing <span className="font-semibold">{MAX_NODES}</span> of <span className="font-semibold">{filteredNodes.length}</span> nodes
</Badge> </Badge>
</div> </div>
)} )}
@@ -418,9 +421,12 @@ function GraphPage() {
<SheetTitle>Filters</SheetTitle> <SheetTitle>Filters</SheetTitle>
</SheetHeader> </SheetHeader>
<ScrollArea className="h-full pr-4"> <ScrollArea className="h-full pr-4">
<div className="space-y-6 pt-4"> <div className="space-y-4 pt-4">
<div> <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) => ( {ENTITY_TYPES.map((type) => (
<div key={type} className="flex items-center gap-2 py-1"> <div key={type} className="flex items-center gap-2 py-1">
<Checkbox <Checkbox
@@ -437,7 +443,10 @@ function GraphPage() {
</div> </div>
<Separator /> <Separator />
<div> <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) => ( {RELATIONSHIP_TYPES.map((type) => (
<div key={type} className="flex items-center gap-2 py-1"> <div key={type} className="flex items-center gap-2 py-1">
<Checkbox <Checkbox
@@ -464,12 +473,12 @@ function GraphPage() {
<SheetTitle>{selectedNode?.label || "Node"}</SheetTitle> <SheetTitle>{selectedNode?.label || "Node"}</SheetTitle>
</SheetHeader> </SheetHeader>
{selectedNode && ( {selectedNode && (
<div className="space-y-4 pt-4"> <div className="space-y-3 pt-4">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<div className="w-3 h-3 rounded-full" style={{ backgroundColor: selectedNode.color }} /> <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> </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 /> <Separator />
{NODE_TYPE_ROUTES[selectedNode.type] ? ( {NODE_TYPE_ROUTES[selectedNode.type] ? (
<Button <Button
@@ -484,7 +493,12 @@ function GraphPage() {
</p> </p>
)} )}
<Separator /> <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"> <div className="space-y-1">
{displayEdges {displayEdges
.filter((e) => e.source === selectedNode.id || e.target === selectedNode.id) .filter((e) => e.source === selectedNode.id || e.target === selectedNode.id)
@@ -510,7 +524,7 @@ function GraphPage() {
</span> </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> </div>
) : null; ) : null;
})} })}
+19 -14
View File
@@ -50,7 +50,7 @@ function HabitForm({ habit, onClose }: { habit?: Habit; onClose: () => void }) {
}; };
return ( return (
<form onSubmit={handleSubmit} className="space-y-4"> <form onSubmit={handleSubmit} className="space-y-3">
<div> <div>
<Label htmlFor="name">Name</Label> <Label htmlFor="name">Name</Label>
<Input id="name" value={name} onChange={(e) => setName(e.target.value)} placeholder="Habit name" required /> <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> <Label htmlFor="desc">Description</Label>
<Textarea id="desc" value={description} onChange={(e) => setDescription(e.target.value)} placeholder="Description (optional)" rows={2} /> <Textarea id="desc" value={description} onChange={(e) => setDescription(e.target.value)} placeholder="Description (optional)" rows={2} />
</div> </div>
<div className="grid grid-cols-2 gap-4"> <div className="grid grid-cols-2 gap-3">
<div> <div>
<Label htmlFor="freq">Frequency</Label> <Label htmlFor="freq">Frequency</Label>
<Select value={frequency} onValueChange={(v) => setFrequency(v as "daily" | "weekly" | "custom")}> <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( cells.push(
<div <div
key={key} 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 ? " ✓" : "")} title={key + (done ? " ✓" : "")}
/> />
); );
@@ -183,7 +183,7 @@ function HabitsPage() {
return ( return (
<div className="space-y-4"> <div className="space-y-4">
<div className="flex items-center justify-between"> <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}> <Dialog open={createOpen} onOpenChange={setCreateOpen}>
<DialogTrigger asChild> <DialogTrigger asChild>
<Button aria-label="New habit"><Plus className="h-4 w-4 mr-2" />New Habit</Button> <Button aria-label="New habit"><Plus className="h-4 w-4 mr-2" />New Habit</Button>
@@ -200,24 +200,24 @@ function HabitsPage() {
) : isError ? ( ) : isError ? (
<ErrorState message="Failed to load habits." onRetry={() => refetch()} /> <ErrorState message="Failed to load habits." onRetry={() => refetch()} />
) : habits.length === 0 ? ( ) : 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"> <div className="space-y-2">
{habits.map((habit) => ( {habits.map((habit) => (
<Card key={habit.id} className="cursor-pointer hover:shadow-md transition-shadow" onClick={() => openHabitDetail(habit)}> <Card key={habit.id} className="cursor-pointer border hover:bg-muted/20 transition-colors" onClick={() => openHabitDetail(habit)}>
<CardContent className="p-4"> <CardContent className="p-3">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<div className="flex items-center gap-3 flex-1 min-w-0"> <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")} /> <Flame className={cn("h-5 w-5 shrink-0", habit.streakCount > 0 ? "text-orange-500" : "text-muted-foreground")} />
<div className="min-w-0"> <div className="min-w-0">
<p className="font-medium truncate">{habit.name}</p> <p className="font-medium truncate">{habit.name}</p>
<div className="flex items-center gap-2 mt-1"> <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" /> <Flame className="h-3 w-3 inline mr-0.5" />
{habit.streakCount} day streak {habit.streakCount} day streak
</span> </span>
<Badge variant="secondary" className="text-[10px]">{habit.frequency}</Badge> <Badge variant="secondary" className="font-mono text-[10px]">{habit.frequency}</Badge>
<Badge variant="outline" className="text-[10px]">{habit.difficulty}</Badge> <Badge variant="outline" className="font-mono text-[10px]">{habit.difficulty}</Badge>
</div> </div>
</div> </div>
</div> </div>
@@ -233,6 +233,11 @@ function HabitsPage() {
})} })}
onClick={(e) => { e.stopPropagation(); completeMutation.mutate(habit.id); }} onClick={(e) => { e.stopPropagation(); completeMutation.mutate(habit.id); }}
aria-label={"Mark " + habit.name + " complete"} 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 <Check className="h-4 w-4 mr-1" />Complete
</Button> </Button>
@@ -263,15 +268,15 @@ function HabitsPage() {
<EntityDetailPanel open={panelOpen} onOpenChange={(o) => { setPanelOpen(o); if (!o) setDetailTab("overview"); }} title={selectedHabit?.name || "Habit Details"}> <EntityDetailPanel open={panelOpen} onOpenChange={(o) => { setPanelOpen(o); if (!o) setDetailTab("overview"); }} title={selectedHabit?.name || "Habit Details"}>
{selectedHabit && ( {selectedHabit && (
<div className="space-y-4"> <div className="space-y-3">
<Tabs value={detailTab} onValueChange={setDetailTab}> <Tabs value={detailTab} onValueChange={setDetailTab}>
<TabsList className="w-full"> <TabsList className="w-full">
<TabsTrigger value="overview" className="flex-1">Overview</TabsTrigger> <TabsTrigger value="overview" className="flex-1">Overview</TabsTrigger>
<TabsTrigger value="history" className="flex-1">History</TabsTrigger> <TabsTrigger value="history" className="flex-1">History</TabsTrigger>
</TabsList> </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)} /> <HabitForm habit={selectedHabit} onClose={() => setPanelOpen(false)} />
<div className="pt-4 border-t"> <div className="pt-3 border-t">
<AlertDialog> <AlertDialog>
<AlertDialogTrigger asChild> <AlertDialogTrigger asChild>
<Button variant="destructive" size="sm"><Trash2 className="h-4 w-4 mr-2" />Delete Habit</Button> <Button variant="destructive" size="sm"><Trash2 className="h-4 w-4 mr-2" />Delete Habit</Button>
@@ -289,7 +294,7 @@ function HabitsPage() {
</AlertDialog> </AlertDialog>
</div> </div>
</TabsContent> </TabsContent>
<TabsContent value="history" className="pt-4"> <TabsContent value="history" className="pt-3">
<div className="space-y-2"> <div className="space-y-2">
<h3 className="font-semibold text-sm">Completion History</h3> <h3 className="font-semibold text-sm">Completion History</h3>
{selectedHabit.recentCompletions?.length ? ( {selectedHabit.recentCompletions?.length ? (
+31 -31
View File
@@ -225,7 +225,7 @@ function Overview({ habit, patch }: { habit: Habit; patch: PatchFn }) {
const skipDays = habit.skipDays || []; const skipDays = habit.skipDays || [];
return ( return (
<div className="space-y-6"> <div className="space-y-4">
<div> <div>
<p className="mb-1 text-sm font-semibold text-muted-foreground">Description</p> <p className="mb-1 text-sm font-semibold text-muted-foreground">Description</p>
<InlineTextarea <InlineTextarea
@@ -237,7 +237,7 @@ function Overview({ habit, patch }: { habit: Habit; patch: PatchFn }) {
/> />
</div> </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"> <div className="flex items-center gap-2">
<RepeatIcon className="h-4 w-4 shrink-0 text-muted-foreground" /> <RepeatIcon className="h-4 w-4 shrink-0 text-muted-foreground" />
<InlineSelect <InlineSelect
@@ -267,7 +267,7 @@ function Overview({ habit, patch }: { habit: Habit; patch: PatchFn }) {
patch({ id: habit.id, data: { goalPerPeriod: Number(goal) || 1 } }) patch({ id: habit.id, data: { goalPerPeriod: Number(goal) || 1 } })
} }
display={(goal) => ( display={(goal) => (
<span> <span className="font-mono text-xs">
{habit.unit ? `${goal} ${habit.unit} per period` : `${goal} per period`} {habit.unit ? `${goal} ${habit.unit} per period` : `${goal} per period`}
</span> </span>
)} )}
@@ -297,7 +297,7 @@ function Overview({ habit, patch }: { habit: Habit; patch: PatchFn }) {
} }
display={(time) => display={(time) =>
time ? ( time ? (
<span>Reminder {time}</span> <span className="font-mono text-xs">Reminder {time}</span>
) : ( ) : (
<span className="text-muted-foreground/70">No reminder</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> </div>
<div className="grid grid-cols-3 gap-3 text-center"> <div className="grid grid-cols-3 gap-2 text-center">
<div className="rounded-lg bg-muted/50 p-3"> <div className="rounded-lg bg-muted/50 p-2.5">
<p className="text-2xl font-bold">{habit.streakCount}</p> <p className="text-xl font-bold font-mono">{habit.streakCount}</p>
<p className="text-xs text-muted-foreground">Current Streak</p> <p className="text-[11px] text-muted-foreground">Current Streak</p>
</div> </div>
<div className="rounded-lg bg-muted/50 p-3"> <div className="rounded-lg bg-muted/50 p-2.5">
<p className="text-2xl font-bold">{habit.bestStreak}</p> <p className="text-xl font-bold font-mono">{habit.bestStreak}</p>
<p className="text-xs text-muted-foreground">Best Streak</p> <p className="text-[11px] text-muted-foreground">Best Streak</p>
</div> </div>
<div className="rounded-lg bg-muted/50 p-3"> <div className="rounded-lg bg-muted/50 p-2.5">
<p className="text-2xl font-bold">{distinctCompletionDays}</p> <p className="text-xl font-bold font-mono">{distinctCompletionDays}</p>
<p className="text-xs text-muted-foreground">Completion This Period</p> <p className="text-[11px] text-muted-foreground">Completion This Period</p>
</div> </div>
</div> </div>
<div className="border-t pt-4"> <div className="border-t pt-3">
<TagManager entityType="habit" entityId={habit.id} tags={habit.tags || []} /> <TagManager entityType="habit" entityId={habit.id} tags={habit.tags || []} />
</div> </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"> <span className="flex items-center gap-1">
<Clock className="h-3 w-3" /> <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>
<span className="flex items-center gap-1"> <span className="flex items-center gap-1">
<Clock className="h-3 w-3" /> <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> </span>
</div> </div>
</div> </div>
@@ -389,7 +389,7 @@ function Stats({
const completionRate = Math.round((distinctCompletionDays / days) * 100); const completionRate = Math.round((distinctCompletionDays / days) * 100);
return ( return (
<div className="space-y-6"> <div className="space-y-4">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<Select value={String(days)} onValueChange={(v) => setDays(Number(v))}> <Select value={String(days)} onValueChange={(v) => setDays(Number(v))}>
<SelectTrigger className="h-8 w-40 text-sm" aria-label="Range"> <SelectTrigger className="h-8 w-40 text-sm" aria-label="Range">
@@ -404,26 +404,26 @@ function Stats({
</Select> </Select>
</div> </div>
<div className="grid grid-cols-3 gap-3 text-center"> <div className="grid grid-cols-3 gap-2 text-center">
<div className="rounded-lg bg-muted/50 p-3"> <div className="rounded-lg bg-muted/50 p-2.5">
<p className="text-2xl font-bold">{habit.streakCount}</p> <p className="text-xl font-bold font-mono">{habit.streakCount}</p>
<p className="text-xs text-muted-foreground">Current Streak</p> <p className="text-[11px] text-muted-foreground">Current Streak</p>
</div> </div>
<div className="rounded-lg bg-muted/50 p-3"> <div className="rounded-lg bg-muted/50 p-2.5">
<p className="text-2xl font-bold">{habit.bestStreak}</p> <p className="text-xl font-bold font-mono">{habit.bestStreak}</p>
<p className="text-xs text-muted-foreground">Best Streak</p> <p className="text-[11px] text-muted-foreground">Best Streak</p>
</div> </div>
<div className="rounded-lg bg-muted/50 p-3"> <div className="rounded-lg bg-muted/50 p-2.5">
<p className="text-2xl font-bold">{completionRate}%</p> <p className="text-xl font-bold font-mono">{completionRate}%</p>
<p className="text-xs text-muted-foreground">Completion Rate</p> <p className="text-[11px] text-muted-foreground">Completion Rate</p>
</div> </div>
</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"> <div className="flex items-center gap-2">
<Calendar className="h-4 w-4 text-muted-foreground" /> <Calendar className="h-4 w-4 text-muted-foreground" />
<p className="text-sm font-semibold">Completion Heatmap</p> <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 Last {days} days
</span> </span>
</div> </div>
+316
View File
@@ -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,
});
+75 -39
View File
@@ -44,11 +44,17 @@ const NoteTitleInput = memo(function NoteTitleInput({ noteId, initialTitle }: {
onMouseDown={(e) => e.stopPropagation()} onMouseDown={(e) => e.stopPropagation()}
onBlur={handleBlur} onBlur={handleBlur}
autoFocus 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 // 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 NoteEditorPane = memo(function NoteEditorPane({ note, onDelete, onOpenNote }: { note: Note; onDelete: (id: string) => void; onOpenNote: (note: Note) => void }) {
const [showBacklinks, setShowBacklinks] = useState(false); const [showBacklinks, setShowBacklinks] = useState(false);
@@ -67,19 +73,19 @@ const NoteEditorPane = memo(function NoteEditorPane({ note, onDelete, onOpenNote
return ( 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} /> <NoteTitleInput key={note.id} noteId={note.id} initialTitle={note.title} />
<div className="flex items-center gap-1 shrink-0"> <div className="flex items-center gap-0.5 shrink-0">
<Button variant="ghost" size="icon" className="h-8 w-8" onClick={() => setShowBacklinks(!showBacklinks)} aria-label="Backlinks"> <Button variant="ghost" size="icon" className="h-7 w-7" onClick={() => setShowBacklinks(!showBacklinks)} aria-label="Backlinks">
<LinkIcon className="h-4 w-4" /> <LinkIcon className="h-3.5 w-3.5" />
</Button> </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"> <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-4 w-4" /> <History className="h-3.5 w-3.5" />
</Button> </Button>
<AlertDialog> <AlertDialog>
<AlertDialogTrigger asChild> <AlertDialogTrigger asChild>
<Button variant="ghost" size="icon" className="h-8 w-8 text-destructive" aria-label="Delete note"> <Button variant="ghost" size="icon" className="h-7 w-7 text-destructive" aria-label="Delete note">
<Trash2 className="h-4 w-4" /> <Trash2 className="h-3.5 w-3.5" />
</Button> </Button>
</AlertDialogTrigger> </AlertDialogTrigger>
<AlertDialogContent> <AlertDialogContent>
@@ -95,20 +101,45 @@ const NoteEditorPane = memo(function NoteEditorPane({ note, onDelete, onOpenNote
</AlertDialog> </AlertDialog>
</div> </div>
</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"> <div className="flex-1 overflow-auto">
<NoteEditor key={note.id} initialContent={note.content || ''} onSave={handleSave} /> <NoteEditor key={note.id} initialContent={note.content || ''} onSave={handleSave} />
</div> </div>
{/* Backlinks section */} {/* Backlinks section */}
{showBacklinks && note.backlinks && note.backlinks.length > 0 && ( {showBacklinks && note.backlinks && note.backlinks.length > 0 && (
<div className="border-t p-3"> <div className="border-t px-3 py-2">
<h4 className="text-sm font-semibold mb-2">Linked from</h4> <h4 className="text-xs font-semibold mb-1 uppercase tracking-wider text-muted-foreground">Linked from</h4>
<div className="space-y-1"> <div className="space-y-0.5">
{note.backlinks.map((bl) => ( {note.backlinks.map((bl) => (
<div <div
key={bl.id} 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); }} onClick={() => { const linked: Note = { ...note, id: bl.id, title: bl.title }; onOpenNote(linked); }}
> >
<LinkIcon className="h-3 w-3 shrink-0" />
{bl.title} {bl.title}
</div> </div>
))} ))}
@@ -117,16 +148,16 @@ const NoteEditorPane = memo(function NoteEditorPane({ note, onDelete, onOpenNote
)} )}
{/* Version history */} {/* Version history */}
{showVersions && ( {showVersions && (
<div className="border-t p-3"> <div className="border-t px-3 py-2">
<h4 className="text-sm font-semibold mb-2">Version History</h4> <h4 className="text-xs font-semibold mb-1 uppercase tracking-wider text-muted-foreground">Version History</h4>
{versions.length === 0 ? ( {versions.length === 0 ? (
<p className="text-xs text-muted-foreground">No versions yet.</p> <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) => ( {versions.map((v) => (
<div key={v.id} className="flex items-center justify-between text-xs text-muted-foreground"> <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> <span className="font-mono">{format(parseISO(v.createdAt), "yyyy-MM-dd HH:mm")}</span>
{v.action && <Badge variant="secondary" className="text-[10px] capitalize">{v.action}</Badge>} {v.action && <Badge variant="secondary" className="text-[10px] capitalize font-mono">{v.action}</Badge>}
</div> </div>
))} ))}
</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"> <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 */} {/* 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="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"> <div className="relative">
<Search className="absolute left-2.5 top-2.5 h-4 w-4 text-muted-foreground" /> <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" aria-label="Search notes" /> <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> </div>
<div className="p-2"> <div className="px-3 py-2 border-b">
<Button size="sm" className="w-full" onClick={() => createMutation.mutate()} aria-label="New note"> <Button size="sm" className="w-full h-8 text-xs" onClick={() => createMutation.mutate()} aria-label="New note">
<Plus className="h-4 w-4 mr-2" />New Note <Plus className="h-3.5 w-3.5 mr-1" />New Note
</Button> </Button>
</div> </div>
<ScrollArea className="flex-1"> <ScrollArea className="flex-1">
{isLoading ? ( {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 ? ( ) : 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) => ( {notes.map((note) => (
<button <button
key={note.id} key={note.id}
onClick={() => selectNote(note)} onClick={() => selectNote(note)}
className={cn( 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" selectedNoteId === note.id ? "bg-accent text-accent-foreground" : "hover:bg-accent/50"
)} )}
> >
<div className="flex items-center gap-2"> <div className="flex items-center gap-1.5">
{note.isPinned && <Pin className="h-3 w-3 shrink-0 text-muted-foreground" />} {note.isPinned && <Pin className="h-3 w-3 shrink-0 text-amber-500" />}
<span className="truncate font-medium">{note.title}</span> <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> </div>
<p className="text-xs text-muted-foreground mt-0.5">
{new Date(note.updatedAt).toLocaleDateString()}
</p>
</button> </button>
))} ))}
</div> </div>
)} )}
{hasMoreNotes && ( {hasMoreNotes && (
<div className="p-2"> <div className="px-3 py-1.5">
<Button variant="outline" size="sm" className="w-full" onClick={loadMoreNotes} disabled={loadingMoreNotes}> <Button variant="outline" size="sm" className="w-full h-7 text-xs" onClick={loadMoreNotes} disabled={loadingMoreNotes}>
{loadingMoreNotes ? "Loading..." : "Load more notes"} {loadingMoreNotes ? "Loading..." : "Load more"}
</Button> </Button>
</div> </div>
)} )}
@@ -269,8 +305,8 @@ function NotesPage() {
) : ( ) : (
<div className="flex items-center justify-center flex-1 text-muted-foreground"> <div className="flex items-center justify-center flex-1 text-muted-foreground">
<div className="text-center"> <div className="text-center">
<FileText className="h-12 w-12 mx-auto mb-3 opacity-50" /> <FileText className="h-10 w-10 mx-auto mb-2 text-amber-400" />
<p>Select a note or create a new one</p> <p className="text-sm text-muted-foreground">Select a note or create a new one</p>
</div> </div>
</div> </div>
)} )}
+5 -5
View File
@@ -194,7 +194,7 @@ function NoteDetail() {
function EditorTab({ note, patch }: { note: Note; patch: PatchFn }) { function EditorTab({ note, patch }: { note: Note; patch: PatchFn }) {
return ( return (
<div className="space-y-6"> <div className="space-y-4">
<div> <div>
<p className="mb-1 text-sm font-semibold text-muted-foreground">Content</p> <p className="mb-1 text-sm font-semibold text-muted-foreground">Content</p>
<div className="rounded-lg border"> <div className="rounded-lg border">
@@ -207,18 +207,18 @@ function EditorTab({ note, patch }: { note: Note; patch: PatchFn }) {
</div> </div>
</div> </div>
<div className="border-t pt-4"> <div className="border-t pt-3">
<TagManager entityType="note" entityId={note.id} tags={note.tags || []} /> <TagManager entityType="note" entityId={note.id} tags={note.tags || []} />
</div> </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"> <span className="flex items-center gap-1">
<Clock className="h-3 w-3" /> <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>
<span className="flex items-center gap-1"> <span className="flex items-center gap-1">
<Clock className="h-3 w-3" /> <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> </span>
</div> </div>
</div> </div>
+148 -50
View File
@@ -1,12 +1,12 @@
import { useState } from "react"; import { useState } from "react";
import { createRoute, useNavigate } from "@tanstack/react-router"; import { createRoute, useNavigate } from "@tanstack/react-router";
import { Route as appRoute } from "../_app"; import { Route as appRoute } from "../_app";
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"; import { useMutation, useQueryClient } from "@tanstack/react-query";
import { api, useApiQuery, useApiMutation } from "@/lib/api"; import { api, useApiQuery } from "@/lib/api";
import { useApiDomain } from "@/lib/stores/use-active-domain-store"; import { useApiDomain } from "@/lib/stores/use-active-domain-store";
import { useRealtime } from "@/hooks/use-realtime"; import { useRealtime } from "@/hooks/use-realtime";
import { useOpenCreateDialog } from "@/hooks/use-open-create-dialog"; 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 { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import { Badge } from "@/components/ui/badge"; 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 { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Label } from "@/components/ui/label"; import { Label } from "@/components/ui/label";
import { Textarea } from "@/components/ui/textarea"; import { Textarea } from "@/components/ui/textarea";
import { Progress } from "@/components/ui/progress";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; 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 { 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 { EntityDetailPanel } from "@/components/entities/entity-detail-panel";
import { LoadingState, EmptyState, ErrorState } from "@/components/state"; import { LoadingState, EmptyState, ErrorState } from "@/components/state";
import { PROJECT_STATUS, TASK_STATUS } from "@/lib/status-colors"; 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() { function ProjectsPage() {
const navigate = useNavigate(); const navigate = useNavigate();
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const [view, setView] = useState<"grid" | "list">("grid");
const [createOpen, setCreateOpen] = useState(false); const [createOpen, setCreateOpen] = useState(false);
const [selectedProject, setSelectedProject] = useState<Project | null>(null); const [selectedProject, setSelectedProject] = useState<Project | null>(null);
const [panelOpen, setPanelOpen] = useState(false); const [panelOpen, setPanelOpen] = useState(false);
@@ -157,16 +226,24 @@ function ProjectsPage() {
return ( return (
<div className="space-y-4"> <div className="space-y-4">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<h1 className="text-2xl font-bold">Projects</h1> <h1 className="text-xl font-bold">Projects</h1>
<Dialog open={createOpen} onOpenChange={setCreateOpen}> <div className="flex items-center gap-2">
<DialogTrigger asChild> <Tabs value={view} onValueChange={(v) => setView(v as "grid" | "list")}>
<Button aria-label="New project"><Plus className="h-4 w-4 mr-2" />New Project</Button> <TabsList>
</DialogTrigger> <TabsTrigger value="grid" aria-label="Grid view"><LayoutGrid className="h-4 w-4" /></TabsTrigger>
<DialogContent> <TabsTrigger value="list" aria-label="List view"><List className="h-4 w-4" /></TabsTrigger>
<DialogHeader><DialogTitle>New Project</DialogTitle></DialogHeader> </TabsList>
<ProjectForm onClose={() => setCreateOpen(false)} /> </Tabs>
</DialogContent> <Dialog open={createOpen} onOpenChange={setCreateOpen}>
</Dialog> <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> </div>
{isLoading ? ( {isLoading ? (
@@ -175,43 +252,64 @@ function ProjectsPage() {
<ErrorState message="Failed to load projects." onRetry={() => refetch()} /> <ErrorState message="Failed to load projects." onRetry={() => refetch()} />
) : projects.length === 0 ? ( ) : projects.length === 0 ? (
<EmptyState title="No projects yet" description="Create your first project to get started." /> <EmptyState title="No projects yet" description="Create your first project to get started." />
) : ( ) : view === "grid" ? (
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4"> <div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-3">
{projects.map((project) => ( {projects.map((project) => (
<Card key={project.id} className="cursor-pointer hover:shadow-md transition-shadow" onClick={() => openProjectDetail(project)}> <ProjectCard
<CardHeader className="pb-2"> key={project.id}
<div className="flex items-center gap-2"> project={project}
<div className="w-3 h-3 rounded-full shrink-0" style={{ backgroundColor: project.color || "#3b82f6" }} /> onOpenDetail={openProjectDetail}
<CardTitle className="text-base truncate">{project.name}</CardTitle> onOpenPanel={openProjectPanel}
<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>
))} ))}
</div> </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 && ( {hasMoreProjects && (
@@ -232,7 +330,7 @@ function ProjectsPage() {
</TabsList> </TabsList>
<TabsContent value="overview" className="space-y-4 pt-4"> <TabsContent value="overview" className="space-y-4 pt-4">
<div className="flex items-center gap-2 mb-2"> <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> <span className="text-sm font-medium">{selectedProject.progress}%</span>
</div> </div>
<ProjectForm project={selectedProject} onClose={() => setPanelOpen(false)} /> <ProjectForm project={selectedProject} onClose={() => setPanelOpen(false)} />
+13 -21
View File
@@ -30,6 +30,7 @@ import {
} from "@/components/entities/inline-edit"; } from "@/components/entities/inline-edit";
import { EntityActivity } from "@/components/entities/entity-activity"; import { EntityActivity } from "@/components/entities/entity-activity";
import { EntityComments } from "@/components/entities/entity-comments"; import { EntityComments } from "@/components/entities/entity-comments";
import { TagManager } from "@/components/entities/tag-manager";
import { import {
AlertDialog, AlertDialog,
AlertDialogAction, AlertDialogAction,
@@ -263,7 +264,7 @@ function Overview({ project, patch }: { project: Project; patch: PatchFn }) {
const countdown = targetCountdown(project.targetDate); const countdown = targetCountdown(project.targetDate);
return ( return (
<div className="space-y-6"> <div className="space-y-4">
<div> <div>
<p className="mb-1 text-sm font-semibold text-muted-foreground">Description</p> <p className="mb-1 text-sm font-semibold text-muted-foreground">Description</p>
<InlineTextarea <InlineTextarea
@@ -275,19 +276,19 @@ function Overview({ project, patch }: { project: Project; patch: PatchFn }) {
/> />
</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"> <div className="flex items-center gap-2">
<ListTodo className="h-4 w-4 text-muted-foreground" /> <ListTodo className="h-4 w-4 text-muted-foreground" />
<p className="text-sm font-semibold">Progress</p> <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> </div>
<Progress value={project.progress} className="mt-3 h-2" /> <Progress value={project.progress} className="mt-2 h-1.5" />
<p className="mt-2 text-xs text-muted-foreground"> <p className="mt-1.5 font-mono text-[11px] text-muted-foreground">
{project.completedCount} of {project.taskCount} tasks done · {project.progress}% {project.completedCount} of {project.taskCount} tasks done · {project.progress}%
</p> </p>
</div> </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"> <div className="flex items-center gap-2">
<Calendar className="h-4 w-4 shrink-0 text-muted-foreground" /> <Calendar className="h-4 w-4 shrink-0 text-muted-foreground" />
<InlineDate <InlineDate
@@ -300,27 +301,18 @@ function Overview({ project, patch }: { project: Project; patch: PatchFn }) {
</div> </div>
</div> </div>
{project.tags && project.tags.length > 0 ? ( <div className="border-t pt-3">
<div> <TagManager entityType="project" entityId={project.id} tags={project.tags || []} />
<p className="mb-2 text-sm font-semibold text-muted-foreground">Tags</p> </div>
<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="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"> <span className="flex items-center gap-1">
<Clock className="h-3 w-3" /> <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>
<span className="flex items-center gap-1"> <span className="flex items-center gap-1">
<Clock className="h-3 w-3" /> <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> </span>
</div> </div>
</div> </div>
+416
View File
@@ -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,
});
+39 -34
View File
@@ -105,7 +105,7 @@ function SearchPage() {
}, {} as Record<string, SearchResult[]>); }, {} as Record<string, SearchResult[]>);
return ( return (
<div className="max-w-3xl mx-auto space-y-6"> <div className="max-w-3xl mx-auto space-y-4">
{/* Search bar */} {/* Search bar */}
<div className="relative"> <div className="relative">
<SearchIcon className="absolute left-3.5 top-3.5 h-5 w-5 text-muted-foreground" /> <SearchIcon className="absolute left-3.5 top-3.5 h-5 w-5 text-muted-foreground" />
@@ -153,7 +153,7 @@ function SearchPage() {
</h3> </h3>
<div className="flex flex-wrap gap-2"> <div className="flex flex-wrap gap-2">
{recentSearches.map((s, i) => ( {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} {s}
</Button> </Button>
))} ))}
@@ -163,46 +163,51 @@ function SearchPage() {
{/* Results */} {/* Results */}
{debouncedQuery && ( {debouncedQuery && (
<div className="space-y-6"> <div className="space-y-4">
{isLoading ? ( {isLoading ? (
<div className="text-center py-8 text-muted-foreground">Searching...</div> <div className="text-center py-8 text-muted-foreground">Searching...</div>
) : results.length === 0 ? ( ) : results.length === 0 ? (
<div className="text-center py-8 text-muted-foreground"> <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> </div>
) : ( ) : (
Object.entries(groupedResults).map(([type, typeResults]) => { <>
const typeDef = SEARCH_TYPES.find((t) => t.id === type); <p className="text-xs text-muted-foreground font-mono">
return ( <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"}
<div key={type}> </p>
<h3 className="text-sm font-semibold mb-2 flex items-center gap-2"> {Object.entries(groupedResults).map(([type, typeResults]) => {
<div className={cn("w-2 h-2 rounded-full", typeDef?.color)} /> const typeDef = SEARCH_TYPES.find((t) => t.id === type);
{typeDef?.label || type} return (
<Badge variant="secondary" className="text-[10px]">{typeResults.length}</Badge> <div key={type}>
</h3> <h3 className="text-sm font-semibold mb-1 flex items-center gap-2">
<div className="space-y-1"> <div className={cn("w-2 h-2 rounded-full", typeDef?.color)} />
{typeResults.map((result) => ( {typeDef?.label || type}
<div <Badge variant="secondary" className="text-[10px] font-mono">{typeResults.length}</Badge>
key={result.id + result.type} </h3>
className="flex items-center justify-between p-3 rounded-lg hover:bg-accent cursor-pointer transition-colors" <div className="space-y-0.5">
onClick={() => navigate({ to: result.type === "domain" ? "/settings" : (result.link as any) })} {typeResults.map((result) => (
> <div
<div className="min-w-0 flex-1"> key={result.id + result.type}
<p className="font-medium text-sm truncate">{result.title}</p> className="flex items-center justify-between p-2 rounded-lg hover:bg-muted/20 border border-transparent hover:border-border cursor-pointer transition-colors"
{result.snippet && ( onClick={() => navigate({ to: result.type === "domain" ? "/settings" : (result.link as any) })}
<p >
className="text-xs text-muted-foreground mt-0.5 line-clamp-2" <div className="min-w-0 flex-1">
dangerouslySetInnerHTML={{ __html: sanitizeSnippet(result.snippet) }} <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> </div>
<ArrowRight className="h-4 w-4 shrink-0 text-muted-foreground ml-2" /> ))}
</div> </div>
))}
</div> </div>
</div> );
); })}
}) </>
)} )}
</div> </div>
)} )}
+5 -22
View File
@@ -18,7 +18,7 @@ import { ScrollArea } from "@/components/ui/scroll-area";
import { Separator } from "@/components/ui/separator"; import { Separator } from "@/components/ui/separator";
import { AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle, AlertDialogTrigger } from "@/components/ui/alert-dialog"; import { AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle, AlertDialogTrigger } from "@/components/ui/alert-dialog";
import { cn } from "@/lib/utils"; 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 { useApiDomain } from "@/lib/stores/use-active-domain-store";
import type { Domain, CustomField, Webhook as WebhookType, ErrorLog, Agent, PaginatedResponse } from "@/lib/types"; 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 }, { id: "error-log", label: "Error Log", icon: AlertCircle },
] as const; ] 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> = { const SHORTCUTS_MAP: Record<string, string> = {
"Cmd+K": "Command palette", "Cmd+K": "Command palette",
"Cmd+N": "New task", "Cmd+N": "New task",
"g+i": "Go to Inbox",
"g+d": "Go to Dashboard",
"g+t": "Go to Tasks", "g+t": "Go to Tasks",
"g+h": "Go to Habits", "g+h": "Go to Habits",
"g+p": "Go to Projects", "g+p": "Go to Projects",
"g+n": "Go to Notes", "g+n": "Go to Notes",
"g+c": "Go to Calendar", "g+c": "Go to Calendar",
"g+g": "Go to Graph", "g+g": "Go to Graph",
"g+d": "Go to Dashboard", "g+r": "Go to Reports",
"g+s": "Go to Settings", "g+s": "Go to Settings",
"n+t": "New task", "n+t": "New task",
"n+h": "New habit", "n+h": "New habit",
@@ -60,7 +57,7 @@ const SHORTCUTS_MAP: Record<string, string> = {
// ─── Appearance Tab ────────────────────────────────────────────────────── // ─── Appearance Tab ──────────────────────────────────────────────────────
function AppearanceTab() { function AppearanceTab() {
const { mode, setMode, accent, setAccent } = useThemeStore(); const { mode, setMode } = useThemeStore();
const [fontSize, setFontSize] = useState(localStorage.getItem("font-size") || "normal"); const [fontSize, setFontSize] = useState(localStorage.getItem("font-size") || "normal");
const [density, setDensity] = useState(localStorage.getItem("density") || "comfortable"); const [density, setDensity] = useState(localStorage.getItem("density") || "comfortable");
const [sidebarPos, setSidebarPos] = useState(localStorage.getItem("sidebar-position") || "left"); const [sidebarPos, setSidebarPos] = useState(localStorage.getItem("sidebar-position") || "left");
@@ -103,20 +100,6 @@ function AppearanceTab() {
</div> </div>
</div> </div>
<Separator /> <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> <div>
<h3 className="text-lg font-semibold mb-3">Font Size</h3> <h3 className="text-lg font-semibold mb-3">Font Size</h3>
<Select value={fontSize} onValueChange={setFontSize}> <Select value={fontSize} onValueChange={setFontSize}>
+24 -24
View File
@@ -50,30 +50,30 @@ function SortableTaskCard({ task, stateName, stateColor, onClick, onEdit }: { ta
return ( return (
<div ref={setNodeRef} style={style} {...attributes} {...listeners}> <div ref={setNodeRef} style={style} {...attributes} {...listeners}>
<Card className="cursor-pointer hover:shadow-md transition-shadow" onClick={onClick}> <Card className="cursor-pointer border rounded-lg transition-colors hover:bg-muted/20" onClick={onClick}>
<CardContent className="p-3"> <CardContent className="p-2.5">
<div className="flex items-start gap-2"> <div className="flex items-start gap-2">
<GripVertical className="h-4 w-4 mt-1 shrink-0 text-muted-foreground" /> <GripVertical className="h-4 w-4 mt-1 shrink-0 text-muted-foreground" />
<div className="flex-1 min-w-0"> <div className="flex-1 min-w-0">
<p className="text-sm font-medium truncate">{task.title}</p> <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 && ( {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} /> <span className="h-1.5 w-1.5 rounded-full" style={stateColor ? { backgroundColor: stateColor } : undefined} />
{stateName} {stateName}
</Badge> </Badge>
)} )}
{task.dueDate && ( {task.dueDate && (
<Badge variant="outline" className="text-[10px]"> <Badge variant="outline" className="font-mono text-[10px]">
<Calendar className="h-3 w-3 mr-1" /> <Calendar className="h-3 w-3 mr-1" />
{new Date(task.dueDate).toLocaleDateString()} {new Date(task.dueDate).toLocaleDateString()}
</Badge> </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} {PRIORITY[task.priority]?.label ?? task.priority}
</Badge> </Badge>
{task.tags?.slice(0, 2).map((tag) => ( {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} {tag.name}
</Badge> </Badge>
))} ))}
@@ -172,7 +172,7 @@ function TaskForm({ task, onClose, projectId }: { task?: Task; onClose: () => vo
}; };
return ( return (
<form onSubmit={handleSubmit} className="space-y-4"> <form onSubmit={handleSubmit} className="space-y-3">
<div> <div>
<Label htmlFor="title">Title <span className="text-xs text-muted-foreground"> try "Buy milk tomorrow 5pm #groceries p1"</span></Label> <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 /> <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> <Label htmlFor="desc">Description</Label>
<Textarea id="desc" value={description} onChange={(e) => setDescription(e.target.value)} placeholder="Description (optional)" rows={3} /> <Textarea id="desc" value={description} onChange={(e) => setDescription(e.target.value)} placeholder="Description (optional)" rows={3} />
</div> </div>
<div className="grid grid-cols-2 gap-4"> <div className="grid grid-cols-2 gap-3">
{projectStates.length > 0 && ( {projectStates.length > 0 && (
<div> <div>
<Label htmlFor="state">State</Label> <Label htmlFor="state">State</Label>
@@ -453,7 +453,7 @@ function TasksPage() {
return ( return (
<div className="space-y-4"> <div className="space-y-4">
<div className="flex items-center justify-between"> <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"> <div className="flex items-center gap-2">
<Tabs value={view} onValueChange={(v) => setView(v as "board" | "list")}> <Tabs value={view} onValueChange={(v) => setView(v as "board" | "list")}>
<TabsList> <TabsList>
@@ -475,7 +475,7 @@ function TasksPage() {
</div> </div>
</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"> <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" /> <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" /> <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()} /> <ErrorState message="Failed to load tasks." onRetry={() => refetch()} />
) : view === "board" ? ( ) : view === "board" ? (
<DndContext sensors={sensors} collisionDetection={closestCorners} onDragStart={handleDragStart} onDragEnd={handleDragEnd}> <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) => ( {columns.map((col) => (
<ColumnDroppable key={col.id} id={col.id} className="bg-muted/50 rounded-lg p-3"> <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="flex items-center gap-2">
<div className={cn("w-2 h-2 rounded-full", col.colorClass)} /> <div className={cn("w-2 h-2 rounded-full", col.colorClass)} />
<h3 className="font-semibold text-sm">{col.label}</h3> <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>
</div> </div>
<SortableContext items={col.tasks.map((t) => t.id)} strategy={verticalListSortingStrategy}> <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) => { {col.tasks.map((task) => {
const st = task.stateId ? stateById.get(task.stateId) : undefined; const st = task.stateId ? stateById.get(task.stateId) : undefined;
return ( return (
@@ -572,11 +572,11 @@ function TasksPage() {
) : tasks.map((task) => { ) : tasks.map((task) => {
const st = task.stateId ? stateById.get(task.stateId) : undefined; const st = task.stateId ? stateById.get(task.stateId) : undefined;
return ( return (
<TableRow key={task.id} className="cursor-pointer" onClick={() => openTaskDetail(task)}> <TableRow key={task.id} className="cursor-pointer hover:bg-muted/30 py-2" onClick={() => openTaskDetail(task)}>
<TableCell className="font-medium">{task.title}</TableCell> <TableCell className="font-medium py-2">{task.title}</TableCell>
<TableCell> <TableCell className="py-2">
{st ? ( {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} /> <span className="h-1.5 w-1.5 rounded-full" style={st.color ? { backgroundColor: st.color } : undefined} />
{st.name} {st.name}
</Badge> </Badge>
@@ -584,13 +584,13 @@ function TasksPage() {
<span className="text-xs text-muted-foreground"></span> <span className="text-xs text-muted-foreground"></span>
)} )}
</TableCell> </TableCell>
<TableCell> <TableCell className="py-2">
<Badge variant="outline" className={cn("text-[10px]", PRIORITY[task.priority]?.badge)}>{task.priority}</Badge> <Badge variant="outline" className={cn("font-mono text-[10px]", PRIORITY[task.priority]?.badge)}>{task.priority}</Badge>
</TableCell> </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() : "-"} {task.dueDate ? new Date(task.dueDate).toLocaleDateString() : "-"}
</TableCell> </TableCell>
<TableCell> <TableCell className="py-2">
<DropdownMenu> <DropdownMenu>
<DropdownMenuTrigger asChild onClick={(e) => e.stopPropagation()}> <DropdownMenuTrigger asChild onClick={(e) => e.stopPropagation()}>
<Button variant="ghost" size="icon" className="h-8 w-8"><MoreHorizontal className="h-4 w-4" /></Button> <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"}> <EntityDetailPanel open={panelOpen} onOpenChange={setPanelOpen} title={selectedTask?.title || "Task Details"}>
{selectedTask && ( {selectedTask && (
<div className="space-y-4"> <div className="space-y-3">
<TaskForm task={selectedTask} onClose={() => setPanelOpen(false)} /> <TaskForm task={selectedTask} onClose={() => setPanelOpen(false)} />
<div className="pt-4 border-t"> <div className="pt-4 border-t">
<AlertDialog> <AlertDialog>
+8 -8
View File
@@ -300,7 +300,7 @@ function Overview({ task, patch }: { task: Task; patch: PatchFn }) {
const currentState = task.stateId ? projectStates.find((s) => s.id === task.stateId) : null; const currentState = task.stateId ? projectStates.find((s) => s.id === task.stateId) : null;
return ( return (
<div className="space-y-6"> <div className="space-y-4">
<div> <div>
<p className="mb-1 text-sm font-semibold text-muted-foreground">Description</p> <p className="mb-1 text-sm font-semibold text-muted-foreground">Description</p>
<InlineTextarea <InlineTextarea
@@ -312,7 +312,7 @@ function Overview({ task, patch }: { task: Task; patch: PatchFn }) {
/> />
</div> </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"> <div className="flex items-center gap-2">
<Calendar className="h-4 w-4 shrink-0 text-muted-foreground" /> <Calendar className="h-4 w-4 shrink-0 text-muted-foreground" />
<InlineDate <InlineDate
@@ -327,7 +327,7 @@ function Overview({ task, patch }: { task: Task; patch: PatchFn }) {
options={ESTIMATE_OPTIONS} options={ESTIMATE_OPTIONS}
displayValue={(v) => displayValue={(v) =>
v ? ( v ? (
<span>{v} min</span> <span className="font-mono text-xs">{v} min</span>
) : ( ) : (
<span className="text-muted-foreground/70">No estimate</span> <span className="text-muted-foreground/70">No estimate</span>
) )
@@ -390,25 +390,25 @@ function Overview({ task, patch }: { task: Task; patch: PatchFn }) {
{task.recurrenceRule ? ( {task.recurrenceRule ? (
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<RepeatIcon className="h-4 w-4 shrink-0 text-muted-foreground" /> <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> </div>
) : null} ) : null}
</div> </div>
<div className="border-t pt-4"> <div className="border-t pt-3">
<TagManager entityType="task" entityId={task.id} tags={task.tags || []} /> <TagManager entityType="task" entityId={task.id} tags={task.tags || []} />
</div> </div>
<CustomFieldsDisplay entityType="tasks" values={task.customFields} /> <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"> <span className="flex items-center gap-1">
<Clock className="h-3 w-3" /> <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>
<span className="flex items-center gap-1"> <span className="flex items-center gap-1">
<Clock className="h-3 w-3" /> <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> </span>
</div> </div>
</div> </div>
+277
View File
@@ -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,
});
+93 -61
View File
@@ -1,11 +1,11 @@
import { createRoute, useNavigate } from "@tanstack/react-router"; import { createRoute, useNavigate } from "@tanstack/react-router";
import { Route as rootRoute } from "./__root"; import { Route as rootRoute } from "./__root";
import { useState } from "react"; 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 { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label"; 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"; import { useAuthStore } from "@/lib/stores/use-auth-store";
function LoginPage() { function LoginPage() {
@@ -42,73 +42,105 @@ function LoginPage() {
}; };
return ( return (
<div className="flex min-h-screen items-center justify-center bg-background px-4"> <div className="flex min-h-screen bg-background">
<div className="w-full max-w-sm"> {/* Left branding side - hidden on mobile */}
<div className="mb-6 flex flex-col items-center gap-3 text-center"> <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 <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))" }} 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> </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> <h1 className="text-2xl font-bold tracking-tight">Project E</h1>
<p className="mt-1 text-sm text-muted-foreground"> <p className="text-sm text-muted-foreground">Your productivity command center</p>
Sign in to your workspace
</p>
</div> </div>
</div> </div>
<Card className="shadow-md"> <div className="w-full max-w-sm">
<CardHeader> <Card className="border shadow-none">
<CardTitle>Sign in</CardTitle> <CardHeader className="pb-4">
<CardDescription> <CardTitle className="text-xl">Welcome back</CardTitle>
Enter your credentials to continue <p className="text-sm text-muted-foreground">Sign in to continue</p>
</CardDescription> </CardHeader>
</CardHeader> <CardContent>
<CardContent> {error && (
{error && ( <div
<div role="alert"
role="alert" className="mb-4 rounded-md border border-destructive/30 bg-destructive/10 p-3 text-sm text-destructive"
className="mb-4 rounded-md border border-destructive/30 bg-destructive/10 p-3 text-sm text-destructive" >
> {error}
{error} </div>
</div> )}
)} <form onSubmit={handleSubmit} className="space-y-3">
<form onSubmit={handleSubmit} className="space-y-4"> <div className="space-y-1.5">
<div className="space-y-1.5"> <Label htmlFor="email">Email</Label>
<Label htmlFor="email">Email</Label> <Input
<Input id="email"
id="email" type="email"
type="email" value={email}
value={email} onChange={(e) => setEmail(e.target.value)}
onChange={(e) => setEmail(e.target.value)} placeholder="you@example.com"
placeholder="you@example.com" autoComplete="email"
autoComplete="email" autoFocus
autoFocus required
required className={error ? "border-destructive" : ""}
/> />
</div> </div>
<div className="space-y-1.5"> <div className="space-y-1.5">
<Label htmlFor="password">Password</Label> <Label htmlFor="password">Password</Label>
<Input <Input
id="password" id="password"
type="password" type="password"
value={password} value={password}
onChange={(e) => setPassword(e.target.value)} onChange={(e) => setPassword(e.target.value)}
autoComplete="current-password" autoComplete="current-password"
required required
/> className={error ? "border-destructive" : ""}
</div> />
<Button type="submit" className="w-full" disabled={isSubmitting}> </div>
{isSubmitting && ( <Button
<Loader2 className="h-4 w-4 animate-spin" aria-hidden="true" /> type="submit"
)} className="w-full bg-[hsl(var(--accent-hsl))] text-white hover:bg-[hsl(var(--accent-hsl))]/90"
{isSubmitting ? "Signing in..." : "Sign in"} disabled={isSubmitting}
</Button> >
</form> {isSubmitting && (
</CardContent> <Loader2 className="h-4 w-4 animate-spin" aria-hidden="true" />
</Card> )}
{isSubmitting ? "Signing in..." : "Sign in"}
</Button>
</form>
</CardContent>
</Card>
</div>
</div> </div>
</div> </div>
); );
+8
View File
@@ -8,6 +8,7 @@ export default {
extend: { extend: {
fontFamily: { fontFamily: {
sans: ["var(--font-sans)"], sans: ["var(--font-sans)"],
mono: ["var(--font-mono)"],
}, },
colors: { colors: {
border: "hsl(var(--border))", border: "hsl(var(--border))",
@@ -43,6 +44,13 @@ export default {
DEFAULT: "hsl(var(--card))", DEFAULT: "hsl(var(--card))",
foreground: "hsl(var(--card-foreground))", foreground: "hsl(var(--card-foreground))",
}, },
sidebar: {
DEFAULT: "hsl(var(--sidebar-bg))",
border: "hsl(var(--sidebar-border))",
},
topbar: {
DEFAULT: "hsl(var(--topbar-bg))",
},
}, },
borderRadius: { borderRadius: {
lg: "var(--radius)", lg: "var(--radius)",