feat: full plan execution - CI/CD, critical fixes, UX polish, secondary/advanced features, E2E + docs

Phase 0 (CI/CD): fix root typecheck to cover api+worker+web; reconcile migration
story into idempotent db:migrate (db:sync + db:triggers); add Gitea Actions
quality/deploy/smoke workflow; rewrite README/AGENTS/DEPLOY docs; add
requireWorkspaceAccess + recordActivityForEntity conventions.

Phase 1 (critical fixes): calendar delete + drag/resize DnD; canvas card CRUD +
bulk save + debounced autosave; logout route; graph edge workspaceId derivation;
real analytics endpoints (drop Math.random); task board droppable columns +
reorder persistence; Tiptap notes editor with sanitized HTML rendering; remove
insecure passkey auth; domain/owner scoping (IDOR) on all by-ID routes + search/
export/realtime scoping; command palette routing + agent mention fetch; agent
activity SSE handler; graph fly-to with tracked positions.

Phase 2 (UX polish): login on design system; Sonner toasts app-wide; shared
Loading/Empty/Error state components; working density/sidebarPos/reduce-motion
settings; Inter typography; consolidated status-colors lib; unified detail
routes; dashboard sort/realtime/responsive fixes; mobile responsive; a11y
(radiogroups, sanitized snippets, badge labels).

Phase 3 (features): daily notes timezone fix + delete + autosave + mood/energy
create; active-domain store + topbar picker; graph domain picker + navigable
entity links; tag assign/remove UI + server-side tag filter; real CSV export +
import validation; custom fields on tasks.

Phase 4 (advanced): migrate job worker into apps/worker (webhook delivery with
HMAC, recurring spawn, ai_dispatch disabled); webhook queue helper + entity
event enqueuing + test endpoint fix; recurring scheduledJobs pipeline; agents
CRUD + permission editing + activity filters; real notifications feed; MCP
polish (validation, error codes, domain scoping, dead sql leftover).

Phase 5 (E2E + docs): rewrite Playwright suite for the Vite SPA (15 specs, new
auth helpers, chromium-only in CI); add ephemeral-Postgres e2e CI job; rewrite
docs/API.md for the real Hono API.
This commit is contained in:
2026-08-10 08:53:18 +00:00
parent 6cb4b9f1b5
commit a60b75f075
99 changed files with 6238 additions and 2954 deletions
+2
View File
@@ -64,6 +64,7 @@
"react-force-graph-2d": "^1.29.1",
"react-hook-form": "^7.84.0",
"recharts": "^3.10.1",
"sonner": "^2.0.8",
"tailwind-merge": "^3.6.0",
"tailwindcss-animate": "^1.0.7",
"zod": "^4.4.3",
@@ -73,6 +74,7 @@
"@tanstack/react-query-devtools": "^5.62.0",
"@tanstack/react-router-devtools": "^1.167.0",
"@types/react": "^19.1.0",
"@types/react-big-calendar": "^1.16.3",
"@types/react-dom": "^19.1.0",
"@vitejs/plugin-react": "^4.3.4",
"autoprefixer": "^10.5.2",
@@ -0,0 +1,199 @@
import { useEffect, useRef } from "react";
import { useApiQuery } from "@/lib/api";
import { useApiDomain } from "@/lib/stores/use-active-domain-store";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Checkbox } from "@/components/ui/checkbox";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Separator } from "@/components/ui/separator";
import type { CustomField } from "@/lib/types";
export type CustomFieldsValue = Record<string, unknown>;
interface CustomFieldInputsProps {
/**
* Plural entity type used by the custom-fields API, e.g. "tasks" | "habits".
*/
entityType: string;
values: CustomFieldsValue;
onChange: (values: CustomFieldsValue) => void;
}
/**
* Renders a type-aware input for every custom field defined for an entity.
* The entity payload carries values as `customFields: { [fieldName]: value }`,
* which this component reads and writes via `values` / `onChange`.
*
* Renders nothing when no custom fields are defined for the entity.
*/
export function CustomFieldInputs({ entityType, values, onChange }: CustomFieldInputsProps) {
const activeDomainId = useApiDomain();
const { data } = useApiQuery<{ items: CustomField[]; totalItems: number }>(
["custom-fields", entityType, activeDomainId],
"/custom-fields?entity=" + encodeURIComponent(entityType) + (activeDomainId ? "&domain=" + activeDomainId : "")
);
const fields = data?.items ?? [];
// Keep the latest values in a ref so the default-seeding effect below never
// clobbers edits the user makes while definitions refetch in the background.
const valuesRef = useRef(values);
valuesRef.current = values;
// Seed defaults for fields that have no value yet (e.g. a field created after
// the task already existed, or a brand new task with field defaults).
useEffect(() => {
if (fields.length === 0) return;
let changed = false;
const next: CustomFieldsValue = { ...valuesRef.current };
for (const field of fields) {
if (next[field.name] === undefined && field.defaultValue !== null && field.defaultValue !== undefined) {
next[field.name] = field.defaultValue;
changed = true;
}
}
if (changed) onChange(next);
// `valuesRef.current` is intentionally read (not listed) so the effect only
// runs when the field definitions change.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [fields, onChange]);
if (fields.length === 0) return null;
return (
<div className="space-y-4">
<Separator />
<div>
<Label className="text-sm font-semibold text-muted-foreground">Custom Fields</Label>
<div className="mt-3 space-y-4">
{fields.map((field) => (
<FieldInput
key={field.id}
field={field}
value={values[field.name]}
onValueChange={(name, value) => onChange({ ...values, [name]: value })}
/>
))}
</div>
</div>
</div>
);
}
function FieldInput({
field,
value,
onValueChange,
}: {
field: CustomField;
value: unknown;
onValueChange: (name: string, value: unknown) => void;
}) {
const inputId = "cf-" + field.name;
if (field.type === "boolean") {
return (
<div className="flex items-center gap-2">
<Checkbox id={inputId} checked={Boolean(value)} onCheckedChange={(v) => onValueChange(field.name, v === true)} />
<Label htmlFor={inputId} className="font-normal">{field.name}</Label>
</div>
);
}
if (field.type === "multi_select") {
const selected: string[] = Array.isArray(value) ? value : [];
return (
<div className="space-y-1.5">
<Label>{field.name}</Label>
<div className="space-y-1.5">
{(field.options ?? []).map((opt) => (
<div key={opt} className="flex items-center gap-2">
<Checkbox
id={inputId + "-" + opt}
checked={selected.includes(opt)}
onCheckedChange={(v) => {
const next = v ? [...selected, opt] : selected.filter((o) => o !== opt);
onValueChange(field.name, next.length > 0 ? next : null);
}}
/>
<Label htmlFor={inputId + "-" + opt} className="font-normal">{opt}</Label>
</div>
))}
</div>
</div>
);
}
return (
<div className="space-y-1.5">
<Label htmlFor={inputId}>
{field.name}
{field.required && <span className="text-destructive"> *</span>}
</Label>
{renderStandardControl(field, value, inputId, onValueChange)}
</div>
);
}
function renderStandardControl(
field: CustomField,
value: unknown,
inputId: string,
onValueChange: (name: string, value: unknown) => void
) {
switch (field.type) {
case "number": {
let numeric: number | "" = "";
if (typeof value === "number") numeric = value;
else if (value !== undefined && value !== null) {
const parsed = Number(value);
numeric = Number.isNaN(parsed) ? "" : parsed;
}
return (
<Input
id={inputId}
type="number"
value={numeric}
onChange={(e) => onValueChange(field.name, e.target.value === "" ? null : Number(e.target.value))}
required={field.required}
/>
);
}
case "date": {
return (
<Input
id={inputId}
type="date"
value={value !== undefined && value !== null ? String(value).slice(0, 10) : ""}
onChange={(e) => onValueChange(field.name, e.target.value || null)}
required={field.required}
/>
);
}
case "select": {
return (
<Select
value={typeof value === "string" ? value : ""}
onValueChange={(v) => onValueChange(field.name, v || null)}
>
<SelectTrigger id={inputId}><SelectValue placeholder="Select..." /></SelectTrigger>
<SelectContent>
{(field.options ?? []).map((opt) => (
<SelectItem key={opt} value={opt}>{opt}</SelectItem>
))}
</SelectContent>
</Select>
);
}
default: {
return (
<Input
id={inputId}
type="text"
value={value !== undefined && value !== null ? String(value) : ""}
onChange={(e) => onValueChange(field.name, e.target.value || null)}
required={field.required}
/>
);
}
}
}
@@ -0,0 +1,62 @@
import { useApiQuery } from "@/lib/api";
import { useApiDomain } from "@/lib/stores/use-active-domain-store";
import type { CustomField } from "@/lib/types";
interface CustomFieldsDisplayProps {
/**
* Plural entity type used by the custom-fields API, e.g. "tasks" | "habits".
*/
entityType: string;
values?: Record<string, unknown>;
}
/**
* Read-only list of an entity's custom field values. Uses the field
* definitions for labels and type-aware formatting when they are available;
* falls back to raw `name: value` rendering otherwise. Skips empty values and
* renders nothing when there is nothing to show.
*/
export function CustomFieldsDisplay({ entityType, values }: CustomFieldsDisplayProps) {
const activeDomainId = useApiDomain();
const { data } = useApiQuery<{ items: CustomField[]; totalItems: number }>(
["custom-fields", entityType, activeDomainId],
"/custom-fields?entity=" + encodeURIComponent(entityType) + (activeDomainId ? "&domain=" + activeDomainId : "")
);
if (!values) return null;
const defs = new Map((data?.items ?? []).map((f) => [f.name, f]));
const entries = Object.entries(values).filter(
([, v]) => v !== undefined && v !== null && v !== "" && !(Array.isArray(v) && v.length === 0)
);
if (entries.length === 0) return null;
return (
<div>
<h3 className="text-sm font-semibold text-muted-foreground mb-2">Custom Fields</h3>
<div className="space-y-2">
{entries.map(([name, value]) => {
const field = defs.get(name);
return (
<div key={name} className="flex items-start gap-2 text-sm">
<span className="w-40 shrink-0 text-muted-foreground">{field?.name ?? name}</span>
<span className="flex-1 min-w-0 break-words">{formatValue(field, value)}</span>
</div>
);
})}
</div>
</div>
);
}
function formatValue(field: CustomField | undefined, value: unknown): string {
if (field) {
if (field.type === "boolean") return value ? "Yes" : "No";
if (field.type === "date" && typeof value === "string") {
const d = new Date(value);
return Number.isNaN(d.getTime()) ? value.slice(0, 10) : d.toLocaleDateString();
}
}
if (Array.isArray(value)) return value.join(", ");
return String(value);
}
@@ -0,0 +1,96 @@
import { useState } from "react";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { api, useApiQuery } from "@/lib/api";
import { Badge } from "@/components/ui/badge";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { X } from "lucide-react";
import type { Tag, PaginatedResponse } from "@/lib/types";
const ENTITY_ROUTES: Record<"task" | "habit" | "note", string> = {
task: "tasks",
habit: "habits",
note: "notes",
};
interface TagManagerProps {
entityType: "task" | "habit" | "note";
entityId: string;
tags: Tag[];
}
/**
* Assign/remove tags on an entity from its detail page. The entity's tags come
* from the parent's query data; mutations hit the tag junction endpoints and
* refetch the entity so badges stay in sync with the API.
*/
export function TagManager({ entityType, entityId, tags }: TagManagerProps) {
const queryClient = useQueryClient();
const [addValue, setAddValue] = useState("");
const plural = ENTITY_ROUTES[entityType];
// Always fetch fresh so tags created elsewhere show up in the add dropdown.
const { data: tagsData } = useApiQuery<PaginatedResponse<Tag>>(
["tags"],
"/tags?perPage=100&sort=name",
{ staleTime: 0 }
);
const allTags = tagsData?.items || [];
const assignedIds = new Set(tags.map((t) => t.id));
const availableTags = allTags.filter((t) => !assignedIds.has(t.id));
const refreshEntity = () => {
queryClient.invalidateQueries({ queryKey: [entityType, entityId] });
};
const assignMutation = useMutation({
mutationFn: (tagId: string) => api.post(`/${plural}/${entityId}/tags`, { tagId }),
onSuccess: refreshEntity,
});
const removeMutation = useMutation({
mutationFn: (tagId: string) => api.delete(`/${plural}/${entityId}/tags/${tagId}`),
onSuccess: refreshEntity,
});
const handleAssign = (tagId: string) => {
if (!tagId || assignedIds.has(tagId)) return;
setAddValue("");
assignMutation.mutate(tagId);
};
return (
<div>
<h3 className="text-sm font-semibold text-muted-foreground mb-2">Tags</h3>
<div className="flex flex-wrap items-center gap-2 mb-3">
{tags.length === 0 && (
<span className="text-sm text-muted-foreground">No tags</span>
)}
{tags.map((t) => (
<Badge key={t.id} variant="secondary" style={t.color ? { borderColor: t.color } : undefined}>
{t.name}
<button
type="button"
onClick={() => removeMutation.mutate(t.id)}
disabled={removeMutation.isPending}
aria-label={"Remove tag " + t.name}
className="ml-1.5 rounded-full p-0.5 text-muted-foreground hover:text-foreground hover:bg-foreground/10"
>
<X className="h-3 w-3" />
</button>
</Badge>
))}
</div>
<Select value={addValue} onValueChange={handleAssign} disabled={availableTags.length === 0}>
<SelectTrigger className="h-8 w-64 text-xs" aria-label="Add tag">
<SelectValue placeholder={availableTags.length === 0 ? "No more tags to add" : "Add tag..."} />
</SelectTrigger>
<SelectContent>
{availableTags.map((t) => (
<SelectItem key={t.id} value={t.id}>{t.name}</SelectItem>
))}
</SelectContent>
</Select>
</div>
);
}
@@ -73,9 +73,9 @@ export function CommandPalette() {
const { mode, setMode, accent, setAccent } = useThemeStore();
const [open, setOpen] = useState(false);
const [searchResults, setSearchResults] = useState<
Array<{ type: string; items: Array<{ id: string; title: string }> }>
Array<{ type: string; items: Array<{ id: string; title: string; link?: string }> }>
>([]);
const searchTimeoutRef = useRef<ReturnType<typeof setTimeout>>();
const searchTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const [isMobile, setIsMobile] = useState(false);
useEffect(() => {
@@ -178,7 +178,7 @@ export function CommandPalette() {
setSearchResults([
{
type: "Agents",
items: (data.agents || data.results || []).map((a: { id: string; name: string }) => ({
items: (data.items || []).map((a: { id: string; name: string }) => ({
id: a.id,
title: a.name,
})),
@@ -311,17 +311,17 @@ export function CommandPalette() {
<CommandItem
key={item.id}
onSelect={() => {
const typeRoute =
group.type === "tasks"
? "/tasks"
: group.type === "habits"
? "/habits"
: group.type === "projects"
? "/projects"
: group.type === "notes"
? "/notes"
: "/search";
runCommand(() => navigate({ to: `${typeRoute}/${item.id}` }));
// The search API returns singular types ("task", "note", ...)
// and each result carries a ready-made detail link (e.g.
// "/tasks/{id}"). Domains have no detail route, so land on the
// dashboard (the domain-scoped home). Agent mentions have no
// detail page either, so just dismiss the palette.
if (group.type === "Agents") {
runCommand(() => {});
return;
}
const link = group.type === "domain" ? "/" : item.link!;
runCommand(() => navigate({ to: link }));
}}
>
<Search className="mr-2 h-4 w-4" />
@@ -0,0 +1,60 @@
import { useEffect } from "react";
import { useApiQuery } from "@/lib/api";
import { useActiveDomainStore } from "@/lib/stores/use-active-domain-store";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import type { Domain, PaginatedResponse } from "@/lib/types";
export function DomainPicker() {
const activeDomainId = useActiveDomainStore((s) => s.activeDomainId);
const setActiveDomain = useActiveDomainStore((s) => s.setActiveDomain);
const { data, isLoading } = useApiQuery<PaginatedResponse<Domain>>(["domains"], "/domains");
const domains = data?.items || [];
// The persisted selection may reference a deleted domain — validate against
// the fetched list and fall back to the first domain while unset or stale.
useEffect(() => {
if (domains.length === 0) return;
if (!activeDomainId || !domains.some((d) => d.id === activeDomainId)) {
setActiveDomain(domains[0].id);
}
}, [domains, activeDomainId, setActiveDomain]);
const empty = isLoading || domains.length === 0;
return (
<div className="hidden md:block">
<Select
value={activeDomainId ?? undefined}
onValueChange={setActiveDomain}
disabled={empty}
>
<SelectTrigger className="h-8 w-40 text-xs" aria-label="Switch domain">
<SelectValue placeholder="Domain" />
</SelectTrigger>
<SelectContent>
{domains.map((domain) => (
<SelectItem key={domain.id} value={domain.id}>
<span className="flex items-center gap-2">
{domain.color && (
<span
className="h-2 w-2 shrink-0 rounded-full"
style={{ backgroundColor: domain.color }}
aria-hidden="true"
/>
)}
<span className="truncate">{domain.name}</span>
</span>
</SelectItem>
))}
</SelectContent>
</Select>
</div>
);
}
+26 -6
View File
@@ -1,3 +1,4 @@
import { useEffect, useState } from "react";
import { Link, useLocation } from "@tanstack/react-router";
import { cn } from "@/lib/utils";
import { useSidebarStore } from "@/lib/stores/use-sidebar-store";
@@ -76,6 +77,24 @@ export function Sidebar() {
const location = useLocation();
const { collapsed, toggle, mobileOpen, setMobileOpen } = useSidebarStore();
// Sidebar position (left/right) is set in Settings. Read once on mount and
// update live via the "sidebar-position-change" custom event dispatched by
// the settings page.
const [sidebarPos, setSidebarPos] = useState<"left" | "right">(() =>
typeof window !== "undefined" && localStorage.getItem("sidebar-position") === "right"
? "right"
: "left"
);
useEffect(() => {
const onSidebarPositionChange = (event: Event) => {
const detail = (event as CustomEvent<string>).detail;
if (detail === "left" || detail === "right") setSidebarPos(detail);
};
window.addEventListener("sidebar-position-change", onSidebarPositionChange);
return () => window.removeEventListener("sidebar-position-change", onSidebarPositionChange);
}, []);
const isActive = (href: string) => {
if (href === "/") return location.pathname === "/";
return location.pathname.startsWith(href);
@@ -114,7 +133,7 @@ export function Sidebar() {
return (
<Tooltip key={item.href}>
<TooltipTrigger asChild>{link}</TooltipTrigger>
<TooltipContent side="right">{item.label}</TooltipContent>
<TooltipContent side={sidebarPos === "right" ? "left" : "right"}>{item.label}</TooltipContent>
</Tooltip>
);
}
@@ -143,8 +162,9 @@ export function Sidebar() {
<TooltipProvider delayDuration={0}>
<aside
className={cn(
"hidden flex-col border-r bg-card transition-all duration-200 md:flex",
collapsed ? "w-16" : "w-60"
"hidden flex-col bg-card transition-all duration-200 md:flex",
collapsed ? "w-16" : "w-60",
sidebarPos === "right" ? "order-last border-l" : "border-r"
)}
aria-label="Main navigation"
>
@@ -188,7 +208,7 @@ export function Sidebar() {
</Avatar>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent side="right" align="start" className="w-48">
<DropdownMenuContent side={sidebarPos === "right" ? "left" : "right"} align="start" className="w-48">
<DropdownMenuItem onClick={() => {}}>
<User className="mr-2 h-4 w-4" />
Profile
@@ -214,7 +234,7 @@ export function Sidebar() {
<span className="text-sm font-medium">User</span>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent side="right" align="start" className="w-48">
<DropdownMenuContent side={sidebarPos === "right" ? "left" : "right"} align="start" className="w-48">
<DropdownMenuItem onClick={() => {}}>
<User className="mr-2 h-4 w-4" />
Profile
@@ -236,7 +256,7 @@ export function Sidebar() {
{/* Mobile sheet */}
<Sheet open={mobileOpen} onOpenChange={setMobileOpen}>
<SheetContent side="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 md:hidden">
<SheetHeader className="border-b px-4 py-4 pr-12">
<SheetTitle>Project E</SheetTitle>
<SheetDescription>Navigate your workspace.</SheetDescription>
+90 -12
View File
@@ -1,6 +1,10 @@
import { Search, Bell, Plus, Menu } from "lucide-react";
import { Search, Bell, Plus, Menu, RefreshCw } from "lucide-react";
import { Button } from "@/components/ui/button";
import { useSidebarStore } from "@/lib/stores/use-sidebar-store";
import { useApiDomain } from "@/lib/stores/use-active-domain-store";
import { useApiQuery } from "@/lib/api";
import { useQueryClient } from "@tanstack/react-query";
import { DomainPicker } from "@/components/shell/domain-picker";
import { Avatar, AvatarFallback } from "@/components/ui/avatar";
import {
DropdownMenu,
@@ -15,14 +19,45 @@ import {
TooltipProvider,
TooltipTrigger,
} from "@/components/ui/tooltip";
import { formatDistanceToNow } from "date-fns";
import type { NotificationsResponse } from "@/lib/types";
const ACTION_LABELS: Record<string, string> = {
created: "created",
updated: "updated",
deleted: "deleted",
completed: "completed",
};
function readableEntityType(entityType: string): string {
return entityType
.replace(/_/g, " ")
.replace(/\b\w/g, (ch) => ch.toUpperCase());
}
export function Topbar() {
const { setMobileOpen } = useSidebarStore();
const queryClient = useQueryClient();
const domainId = useApiDomain();
const openPalette = () => {
document.dispatchEvent(new CustomEvent("open-command-palette"));
};
const { data: notificationsData } = useApiQuery<NotificationsResponse>(
["notifications", domainId],
"/notifications?workspace_id=" + encodeURIComponent(domainId),
{ enabled: !!domainId, refetchInterval: 60_000 }
);
const notifications = notificationsData?.items || [];
const count = notificationsData?.count || 0;
const badgeLabel = count > 99 ? "99+" : String(count);
const tooltipText =
count === 0
? "No notifications"
: `${count} unread notification${count === 1 ? "" : "s"}`;
return (
<header
className="sticky top-0 z-10 flex h-14 items-center gap-4 border-b bg-card px-6"
@@ -55,6 +90,9 @@ export function Topbar() {
</Button>
</div>
{/* Domain picker */}
<DomainPicker />
{/* Right side */}
<div className="ml-auto flex items-center gap-2">
{/* Quick add */}
@@ -76,17 +114,57 @@ export function Topbar() {
{/* Notifications bell */}
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<Button variant="ghost" size="icon" className="relative" aria-label="Notifications">
<Bell className="h-5 w-5" />
<span className="absolute -right-0.5 -top-0.5 flex h-4 w-4 items-center justify-center rounded-full bg-destructive text-[10px] font-medium text-destructive-foreground">
0
</span>
</Button>
</TooltipTrigger>
<TooltipContent>No notifications</TooltipContent>
</Tooltip>
<DropdownMenu>
<Tooltip>
<TooltipTrigger asChild>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="icon" className="relative" aria-label="Notifications">
<Bell className="h-5 w-5" />
{count > 0 && (
<span
aria-hidden="true"
className="absolute -right-0.5 -top-0.5 flex h-4 min-w-4 items-center justify-center rounded-full bg-destructive px-1 text-[10px] font-medium text-destructive-foreground"
>
{badgeLabel}
</span>
)}
</Button>
</DropdownMenuTrigger>
</TooltipTrigger>
<TooltipContent>{tooltipText}</TooltipContent>
</Tooltip>
<DropdownMenuContent align="end" className="w-80">
<div className="flex items-center justify-between px-2 py-1.5">
<span className="text-sm font-medium">Notifications</span>
<Button
variant="ghost"
size="sm"
className="h-7 px-2 text-xs"
onClick={() => queryClient.invalidateQueries({ queryKey: ["notifications"] })}
>
<RefreshCw className="h-3 w-3 mr-1" />Refresh
</Button>
</div>
<DropdownMenuSeparator />
{notifications.length === 0 ? (
<div className="px-3 py-8 text-center text-sm text-muted-foreground">
No notifications
</div>
) : (
notifications.slice(0, 10).map((n) => (
<DropdownMenuItem key={n.id} className="flex cursor-default flex-col items-start gap-0.5 py-2">
<span className="text-sm capitalize">
{readableEntityType(n.entityType)}{" "}
{ACTION_LABELS[n.action] || n.action}
</span>
<span className="text-xs text-muted-foreground">
{n.actor} &middot; {formatDistanceToNow(new Date(n.createdAt), { addSuffix: true })}
</span>
</DropdownMenuItem>
))
)}
</DropdownMenuContent>
</DropdownMenu>
</TooltipProvider>
{/* User avatar */}
+64
View File
@@ -0,0 +1,64 @@
import type { ReactNode } from "react";
import { Loader2, Inbox, AlertCircle, RotateCw, type LucideIcon } from "lucide-react";
import { Button } from "@/components/ui/button";
/**
* Centered loading state with a spinner and optional label.
* Drop-in replacement for inline `<div className="py-12 text-center">Loading...</div>` markup.
*/
export function LoadingState({ label }: { label?: string }) {
return (
<div className="flex flex-col items-center justify-center gap-3 py-12 text-muted-foreground">
<Loader2 className="h-6 w-6 animate-spin" />
{label ? <p className="text-sm">{label}</p> : null}
</div>
);
}
/**
* Centered empty state: icon, title, optional description and optional CTA.
* `action` is rendered below the text (e.g. a Button that opens a create dialog).
*/
export function EmptyState({
icon: Icon = Inbox,
title,
description,
action,
}: {
icon?: LucideIcon;
title: string;
description?: string;
action?: ReactNode;
}) {
return (
<div className="flex flex-col items-center justify-center gap-3 py-12 text-center">
<div className="flex h-12 w-12 items-center justify-center rounded-full bg-muted">
<Icon className="h-6 w-6 text-muted-foreground" />
</div>
<div className="space-y-1">
<p className="font-semibold">{title}</p>
{description ? <p className="text-sm text-muted-foreground">{description}</p> : null}
</div>
{action ? <div className="pt-1">{action}</div> : null}
</div>
);
}
/**
* Centered error state with a destructive-styled message and an optional
* retry button that re-runs the failed query.
*/
export function ErrorState({ message, onRetry }: { message: string; onRetry?: () => void }) {
return (
<div className="flex flex-col items-center justify-center gap-3 py-12 text-center">
<AlertCircle className="h-8 w-8 text-destructive" />
<p className="text-sm font-medium text-destructive">{message}</p>
{onRetry ? (
<Button variant="outline" size="sm" onClick={onRetry}>
<RotateCw className="h-4 w-4" />
Retry
</Button>
) : null}
</div>
);
}
+9 -6
View File
@@ -13,7 +13,7 @@ export function useRealtime(options: UseRealtimeOptions = {}) {
const { workspaceId, enabled = true } = options;
const queryClient = useQueryClient();
const eventSourceRef = useRef<EventSource | null>(null);
const reconnectTimeoutRef = useRef<ReturnType<typeof setTimeout>>();
const reconnectTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const reconnectAttempts = useRef(0);
const handleEvent = useCallback(
@@ -23,19 +23,22 @@ export function useRealtime(options: UseRealtimeOptions = {}) {
switch (entityType) {
case "task":
queryKeys.push(["tasks"]);
queryKeys.push(["tasks"], ["tasks-due"], ["stats"], ["productivity-chart"]);
break;
case "habit":
queryKeys.push(["habits"]);
queryKeys.push(["habits"], ["habits-today"], ["streaks"]);
break;
case "project":
queryKeys.push(["projects"]);
queryKeys.push(["projects"], ["active-projects"]);
break;
case "note":
queryKeys.push(["notes"]);
queryKeys.push(["notes"], ["recent-notes"]);
break;
case "calendar_event":
queryKeys.push(["calendar-events"]);
queryKeys.push(["calendar-events"], ["upcoming-events"]);
break;
case "dashboard_widget":
queryKeys.push(["dashboard-widgets"]);
break;
case "graph_edge":
queryKeys.push(["graph"]);
+70
View File
@@ -1,9 +1,18 @@
@import url("https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap");
@tailwind base;
@tailwind components;
@tailwind utilities;
@layer base {
:root {
/* Typography — Inter via Google Fonts @import above; falls back to system-ui */
--font-sans: "Inter", system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto,
"Helvetica Neue", Arial, sans-serif;
/* Density — scales content spacing (see the density utilities below and the
calc() padding on <main> in the app layout). 1 = comfortable. */
--density-scale: 1;
--background: 0 0% 100%;
--foreground: 222.2 84% 4.9%;
--card: 0 0% 100%;
@@ -27,6 +36,17 @@
--accent-hsl: 217 91% 60%;
}
/* Density modes: toggled on <html> by the settings page (and applied from
localStorage on app load). They only flip --density-scale, which the
layout padding and the space-y utilities below multiply by. */
.density-compact {
--density-scale: 0.85;
}
.density-spacious {
--density-scale: 1.15;
}
.dark {
--background: 222.2 84% 4.9%;
--foreground: 210 40% 98%;
@@ -57,5 +77,55 @@
}
body {
@apply bg-background text-foreground;
font-family: var(--font-sans);
-webkit-font-smoothing: antialiased;
text-rendering: optimizeLegibility;
}
}
/* Real reduced-motion support: the settings page toggles `.reduce-motion` on
<html>. This standard override collapses animation/transition durations so
users who enable it get an effectively static UI. */
.reduce-motion *,
.reduce-motion *::before,
.reduce-motion *::after {
animation-duration: 0.001s !important;
transition-duration: 0.001s !important;
animation-iteration-count: 1 !important;
}
@layer utilities {
/* Density: multiply the vertical rhythm between stacked sections inside page
content. Mirrors Tailwind's own `.space-y-*` selector shape and is scoped
to <main> so the sidebar and topbar stay fixed. Combined with the calc()
padding on <main> in the app layout, the Density setting now visibly
changes spacing (0.85x compact, 1.15x spacious). */
.density-compact main .space-y-1 > :not([hidden]) ~ :not([hidden]),
.density-spacious main .space-y-1 > :not([hidden]) ~ :not([hidden]) {
margin-top: calc(0.25rem * var(--density-scale));
}
.density-compact main .space-y-2 > :not([hidden]) ~ :not([hidden]),
.density-spacious main .space-y-2 > :not([hidden]) ~ :not([hidden]) {
margin-top: calc(0.5rem * var(--density-scale));
}
.density-compact main .space-y-3 > :not([hidden]) ~ :not([hidden]),
.density-spacious main .space-y-3 > :not([hidden]) ~ :not([hidden]) {
margin-top: calc(0.75rem * var(--density-scale));
}
.density-compact main .space-y-4 > :not([hidden]) ~ :not([hidden]),
.density-spacious main .space-y-4 > :not([hidden]) ~ :not([hidden]) {
margin-top: calc(1rem * var(--density-scale));
}
.density-compact main .space-y-5 > :not([hidden]) ~ :not([hidden]),
.density-spacious main .space-y-5 > :not([hidden]) ~ :not([hidden]) {
margin-top: calc(1.25rem * var(--density-scale));
}
.density-compact main .space-y-6 > :not([hidden]) ~ :not([hidden]),
.density-spacious main .space-y-6 > :not([hidden]) ~ :not([hidden]) {
margin-top: calc(1.5rem * var(--density-scale));
}
.density-compact main .space-y-8 > :not([hidden]) ~ :not([hidden]),
.density-spacious main .space-y-8 > :not([hidden]) ~ :not([hidden]) {
margin-top: calc(2rem * var(--density-scale));
}
}
+4
View File
@@ -40,6 +40,10 @@ async function apiFetch<T>(
throw new Error(error.message);
}
// Some routes return 204 No Content (e.g. deletes). res.json() throws on an
// empty body, so return undefined for those instead.
if (res.status === 204) return undefined as T;
return res.json();
}
+70
View File
@@ -0,0 +1,70 @@
/**
* Shared semantic color tokens for entities (status, priority, type).
*
* Single source of truth — pages must import these instead of defining local
* color maps. All values are Tailwind classes unless the comment says otherwise.
*/
export interface StatusToken {
label: string;
/** Tailwind class for a small colored dot (e.g. `w-2 h-2 rounded-full`). */
dot: string;
/** Tailwind classes for a filled Badge (bg + text). */
badge: string;
}
/** Task workflow statuses (board column dots + badges). */
export const TASK_STATUS: Record<string, StatusToken> = {
todo: { label: "Todo", dot: "bg-slate-500", badge: "bg-slate-500 text-white" },
in_progress: { label: "In Progress", dot: "bg-blue-500", badge: "bg-blue-500 text-white" },
done: { label: "Done", dot: "bg-green-500", badge: "bg-green-500 text-white" },
cancelled: { label: "Cancelled", dot: "bg-red-500", badge: "bg-red-500 text-white" },
};
/** Task priority. Badges use a soft tint (matching text + translucent bg). */
export const PRIORITY: Record<string, { label: string; badge: string }> = {
low: { label: "Low", badge: "text-slate-500 bg-slate-500/10" },
medium: { label: "Medium", badge: "text-blue-500 bg-blue-500/10" },
high: { label: "High", badge: "text-orange-500 bg-orange-500/10" },
urgent: { label: "Urgent", badge: "text-red-500 bg-red-500/10" },
};
/** Project lifecycle statuses. */
export const PROJECT_STATUS: Record<string, StatusToken> = {
active: { label: "Active", dot: "bg-green-500", badge: "bg-green-500 text-white" },
paused: { label: "Paused", dot: "bg-amber-500", badge: "bg-amber-500 text-white" },
completed: { label: "Completed", dot: "bg-blue-500", badge: "bg-blue-500 text-white" },
archived: { label: "Archived", dot: "bg-slate-500", badge: "bg-slate-500 text-white" },
};
/**
* Graph node colors by entity type (hex).
*
* These are consumed by the canvas renderer (`ctx.fillStyle`) and inline
* `style={{ backgroundColor }}` props. CSS variables don't work in canvas
* `fillStyle`, so keep literal hex values here.
*/
export const ENTITY: Record<string, string> = {
task: "#3b82f6",
habit: "#10b981",
project: "#8b5cf6",
note: "#f59e0b",
section: "#ec4899",
tag: "#6b7280",
domain: "#6366f1",
};
/**
* Calendar event colors by entity type.
*
* Values are HSL triplets consumed as `hsl(${hue})` / `hsla(${hue}, 0.15)`.
* `task` resolves through the theme accent (`var(--accent-hsl)`), so events
* pick up the user's chosen accent color.
*/
export const CALENDAR_EVENT: Record<string, string> = {
task: "var(--accent-hsl, 217 91% 60%)",
habit: "142 71% 45%",
project: "271 81% 56%",
note: "24 95% 53%",
default: "215 16% 47%",
};
@@ -0,0 +1,38 @@
import { create } from "zustand";
import { persist } from "zustand/middleware";
import { useApiQuery } from "@/lib/api";
interface ActiveDomainState {
activeDomainId: string | null;
setActiveDomain: (id: string | null) => void;
}
export const useActiveDomainStore = create<ActiveDomainState>()(
persist(
(set) => ({
activeDomainId: null,
setActiveDomain: (id) => set({ activeDomainId: id }),
}),
{
name: "project-e-active-domain",
}
)
);
// Raw selector: reads the persisted value (may reference a deleted domain).
export function useActiveDomainId(): string | null {
return useActiveDomainStore((s) => s.activeDomainId);
}
// Resolved active domain id for API calls. Validates the persisted value
// against the user's domains (falling back to the first domain) so consumers
// never query a domain that no longer exists. Returns "" until domains load.
// Shares the ["domains"] query cache, so it adds no extra network requests.
export function useApiDomain(): string {
const activeDomainId = useActiveDomainId();
const { data } = useApiQuery<{ items: { id: string }[] }>(["domains"], "/domains");
const items = data?.items || [];
if (items.length === 0) return "";
if (activeDomainId && items.some((d) => d.id === activeDomainId)) return activeDomainId;
return items[0].id;
}
+42 -4
View File
@@ -18,6 +18,7 @@ export interface Task {
updatedAt: string;
deletedAt: string | null;
tags: Tag[];
customFields?: Record<string, unknown>;
subtasks?: Task[];
dependencies?: { id: string; title: string; status: string }[];
dependents?: { id: string; title: string; status: string }[];
@@ -133,6 +134,8 @@ export interface GraphNode {
label: string;
type: string;
color: string;
x?: number;
y?: number;
}
export interface GraphEdge {
@@ -259,10 +262,27 @@ export interface Agent {
domainId: string;
tags: string[];
config: Record<string, unknown>;
apiKey?: string | null;
createdAt: string;
updatedAt: string;
}
export interface Notification {
id: string;
actor: string;
action: string;
entityType: string;
entityId: string;
changes: Record<string, unknown> | null;
workspaceId: string;
createdAt: string;
}
export interface NotificationsResponse {
items: Notification[];
count: number;
}
export interface AgentActivity {
id: string;
agentId: string;
@@ -343,10 +363,28 @@ export interface HabitAnalytics {
period: number;
}
export interface ProjectAnalytics {
taskCompletionRate: number;
totalTasks: number;
completedTasks: number;
export interface DailyAnalyticsItem {
date: string;
created: number;
completed: number;
}
export interface DailyAnalytics {
items: DailyAnalyticsItem[];
period: number;
}
export interface ProjectProgress {
id: string;
name: string;
totalTasks: number;
completedTasks: number;
progress: number;
}
export interface ProjectAnalytics {
projects: ProjectProgress[];
totalProjects: number;
period: number;
}
+2
View File
@@ -1,10 +1,12 @@
import { createRootRoute, Outlet } from "@tanstack/react-router";
import { TanStackRouterDevtools } from "@tanstack/react-router-devtools";
import { Toaster } from "@/components/ui/sonner";
export const Route = createRootRoute({
component: () => (
<div className="min-h-screen bg-background text-foreground">
<Outlet />
<Toaster />
{import.meta.env.DEV && <TanStackRouterDevtools />}
</div>
),
+14 -1
View File
@@ -1,3 +1,4 @@
import { useEffect } from "react";
import { createRoute, Outlet } from "@tanstack/react-router";
import { Route as rootRoute } from "./__root";
import { Sidebar } from "@/components/shell/sidebar";
@@ -9,6 +10,18 @@ import { useKeyboardShortcuts } from "@/hooks/use-keyboard-shortcuts";
function AppLayout() {
useKeyboardShortcuts();
// Apply persisted appearance preferences (density, reduced motion) right
// after the first paint. The settings page updates these live while open;
// this covers reloads where the settings page was never visited.
useEffect(() => {
const root = document.documentElement;
root.classList.remove("density-compact", "density-spacious");
const density = localStorage.getItem("density");
if (density === "compact") root.classList.add("density-compact");
if (density === "spacious") root.classList.add("density-spacious");
if (localStorage.getItem("reduced-motion") === "true") root.classList.add("reduce-motion");
}, []);
return (
<div className="flex min-h-screen">
<Sidebar />
@@ -16,7 +29,7 @@ function AppLayout() {
<Topbar />
<main
id="main-content"
className="flex-1 overflow-auto p-4 md:p-6"
className="flex-1 overflow-auto p-[calc(1rem*var(--density-scale))] md:p-[calc(1.5rem*var(--density-scale))]"
tabIndex={-1}
>
<Outlet />
+17 -4
View File
@@ -5,6 +5,7 @@ import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { api, useApiQuery, useApiMutation } from "@/lib/api";
import { Bot, Filter, Calendar, RefreshCw, ExternalLink, Clock, Activity } from "lucide-react";
import { Button } from "@/components/ui/button";
import { LoadingState, EmptyState } from "@/components/state";
import { Input } from "@/components/ui/input";
import { Badge } from "@/components/ui/badge";
import { Card, CardContent } from "@/components/ui/card";
@@ -57,8 +58,20 @@ function AgentActivityPage() {
es.onmessage = (event) => {
try {
const data = JSON.parse(event.data);
if (data.type === "agent_activity" || data.type === "activity") {
setLiveActivities((prev) => [data.payload, ...prev].slice(0, 5));
// Realtime events are flat: { type: entityType, action, id, workspace_id }.
// Match only agent events so unrelated task/habit/etc. activity doesn't leak in.
if (data.type === "agent") {
const entry: AgentActivity = {
id: `${data.id}-live-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
agentId: data.id,
action: data.action,
description: `Live update: ${data.action}`,
entityType: "agent",
entityId: data.id,
metadata: null,
createdAt: new Date().toISOString(),
};
setLiveActivities((prev) => [entry, ...prev].slice(0, 5));
}
} catch {}
};
@@ -117,9 +130,9 @@ function AgentActivityPage() {
{/* Timeline */}
{isLoading ? (
<div className="flex items-center justify-center py-12 text-muted-foreground">Loading activity...</div>
<LoadingState label="Loading activity..." />
) : activities.length === 0 ? (
<div className="text-center py-12 text-muted-foreground">No activity found</div>
<EmptyState title="No activity found" description="Agent activity will appear here as agents run" />
) : (
<div className="space-y-1">
{activities.map((a, idx) => (
+67 -51
View File
@@ -1,16 +1,16 @@
import { useState, useMemo } from "react";
import { createRoute } from "@tanstack/react-router";
import { Route as appRoute } from "../_app";
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { api, useApiQuery, useApiMutation } from "@/lib/api";
import { Download, Calendar, TrendingUp, BarChart3, PieChart, Activity, Grid3X3 } from "lucide-react";
import { useApiQuery } from "@/lib/api";
import { useApiDomain } from "@/lib/stores/use-active-domain-store";
import { Download, Calendar } from "lucide-react";
import { Button } from "@/components/ui/button";
import { LoadingState, ErrorState } from "@/components/state";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { cn } from "@/lib/utils";
import type { ProductivityData, HabitAnalytics, ProjectAnalytics } from "@/lib/types";
import { format, subDays, parseISO, startOfMonth, eachDayOfInterval } from "date-fns";
import type { DailyAnalytics, HabitAnalytics, ProjectAnalytics } from "@/lib/types";
import { format, subDays } from "date-fns";
// Simple SVG-based charts (no recharts dependency needed for basic charts)
function LineChart({ data, xKey, yKey, color = "#3b82f6", height = 120 }: { data: any[]; xKey: string; yKey: string; color?: string; height?: number }) {
@@ -34,19 +34,27 @@ function LineChart({ data, xKey, yKey, color = "#3b82f6", height = 120 }: { data
);
}
function BarChart({ data, xKey, yKey, color = "#3b82f6", height = 120 }: { data: any[]; xKey: string; yKey: string; color?: string; height?: number }) {
function BarChart({ data, xKey, yKey, yKey2, color = "#3b82f6", color2 = "#f97316", height = 120 }: { data: any[]; xKey: string; yKey: string; yKey2?: string; color?: string; color2?: string; height?: number }) {
if (!data.length) return <p className="text-sm text-muted-foreground text-center py-8">No data</p>;
const maxVal = Math.max(...data.map((d) => d[yKey]), 1);
const barWidth = Math.max(20, Math.min(40, (300 / data.length)));
const width = Math.max(data.length * (barWidth + 4) + 40, 200);
const valOf = (d: any, key?: string) => (key ? ((d[key] as number) ?? 0) : 0);
const maxVal = Math.max(...data.map((d) => Math.max(valOf(d, yKey), valOf(d, yKey2))), 1);
const series = yKey2 ? 2 : 1;
const barWidth = Math.max(20, Math.min(40, (300 / data.length) / series));
const width = Math.max(data.length * (barWidth * series + 4) + 40, 200);
return (
<svg viewBox={`0 0 ${width} ${height}`} className="w-full h-auto" aria-label="Bar chart">
{data.map((d, i) => {
const barH = (d[yKey] / maxVal) * (height - 30);
const x = i * (barWidth + 4) + 20;
const barH = (valOf(d, yKey) / maxVal) * (height - 30);
const x = i * (barWidth * series + 4) + 20;
const y = height - 20 - barH;
return <rect key={i} x={x} y={y} width={barWidth} height={barH} fill={color} rx="2" />;
})}
{yKey2 && data.map((d, i) => {
const barH = (valOf(d, yKey2) / maxVal) * (height - 30);
const x = i * (barWidth * series + 4) + 20 + barWidth;
const y = height - 20 - barH;
return <rect key={i} x={x} y={y} width={barWidth} height={barH} fill={color2} rx="2" />;
})}
</svg>
);
}
@@ -129,23 +137,22 @@ function CalendarHeatmap({ data, days = 30 }: { data: any[]; days?: number }) {
function AnalyticsPage() {
const [range, setRange] = useState("30");
const activeDomainId = useApiDomain();
const domainSuffix = activeDomainId ? "&domain=" + activeDomainId : "";
const { data: prodData } = useApiQuery<ProductivityData>(["analytics-productivity", range], "/analytics/productivity?range=" + range);
const { data: habitData } = useApiQuery<HabitAnalytics>(["analytics-habits", range], "/analytics/habits?range=" + range);
const { data: projectData } = useApiQuery<ProjectAnalytics>(["analytics-projects", range], "/analytics/projects?range=" + range);
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);
// Generate mock daily data for charts (real API returns aggregated, we simulate daily breakdown)
const dailyData = useMemo(() => {
const days = parseInt(range);
return Array.from({ length: days }, (_, i) => {
const d = subDays(new Date(), days - 1 - i);
return {
date: format(d, "yyyy-MM-dd"),
completed: Math.floor(Math.random() * 5),
created: Math.floor(Math.random() * 8) + 1,
};
});
}, [range]);
const analyticsLoading = habitsLoading || projectsLoading || dailyLoading;
const analyticsError = habitsError || projectsError || dailyError;
const refetchAnalytics = () => {
refetchHabits();
refetchProjects();
refetchDaily();
};
const dailyItems = dailyData?.items || [];
const habitRateData = useMemo(() => {
return [
@@ -182,15 +189,20 @@ function AnalyticsPage() {
</Select>
</div>
{analyticsLoading ? (
<LoadingState label="Loading analytics..." />
) : analyticsError ? (
<ErrorState message={analyticsError.message || "Failed to load analytics"} onRetry={refetchAnalytics} />
) : (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{/* Tasks completed per day */}
<Card>
<CardHeader className="p-4 pb-0 flex flex-row items-center justify-between">
<CardTitle className="text-sm font-semibold">Tasks Completed</CardTitle>
<Button variant="ghost" size="icon" className="h-6 w-6" onClick={() => downloadCSV("tasks-completed.csv", [["Date", "Completed"], ...dailyData.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>
<CardContent className="p-4">
<LineChart data={dailyData} xKey="date" yKey="completed" />
<LineChart data={dailyItems} xKey="date" yKey="completed" />
</CardContent>
</Card>
@@ -198,10 +210,10 @@ function AnalyticsPage() {
<Card>
<CardHeader className="p-4 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"], ...dailyData.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>
<CardContent className="p-4">
<BarChart data={dailyData} xKey="date" yKey="created" color="#f97316" />
<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">
<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>
@@ -224,34 +236,37 @@ function AnalyticsPage() {
<Card>
<CardHeader className="p-4 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", [["Metric", "Value"], ["Rate", String(projectData?.taskCompletionRate || 0)], ["Total", String(projectData?.totalTasks || 0)], ["Completed", String(projectData?.completedTasks || 0)]])}><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>
<CardContent className="p-4">
<div className="grid grid-cols-3 gap-2 text-center">
<div className="p-2 bg-muted/50 rounded">
<p className="text-lg font-bold">{projectData?.taskCompletionRate || 0}%</p>
<p className="text-[10px] text-muted-foreground">Rate</p>
{projectData?.projects.length ? (
<div className="space-y-2">
<p className="text-xs text-muted-foreground">{projectData.totalProjects} project{projectData.totalProjects === 1 ? "" : "s"} · progress is % of tasks done</p>
<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)}
/>
</div>
<div className="p-2 bg-muted/50 rounded">
<p className="text-lg font-bold">{projectData?.totalTasks || 0}</p>
<p className="text-[10px] text-muted-foreground">Total</p>
</div>
<div className="p-2 bg-muted/50 rounded">
<p className="text-lg font-bold text-green-500">{projectData?.completedTasks || 0}</p>
<p className="text-[10px] text-muted-foreground">Done</p>
</div>
</div>
) : (
<p className="text-sm text-muted-foreground text-center py-8">No projects yet</p>
)}
</CardContent>
</Card>
{/* Time spent per domain (pie) */}
{/* Tasks by project (pie) */}
<Card>
<CardHeader className="p-4 pb-0 flex flex-row items-center justify-between">
<CardTitle className="text-sm font-semibold">Time per Domain</CardTitle>
<Button variant="ghost" size="icon" className="h-6 w-6" onClick={() => downloadCSV("time-per-domain.csv", [["Domain", "Tasks"], ["Default", String(prodData?.totalTasks || 0)]])}><Download className="h-3.5 w-3.5" /></Button>
<CardTitle className="text-sm font-semibold">Tasks by Project</CardTitle>
<Button variant="ghost" size="icon" className="h-6 w-6" onClick={() => downloadCSV("tasks-by-project.csv", [["Project", "Total Tasks"], ...(projectData?.projects || []).map((p) => [p.name, String(p.totalTasks)])])}><Download className="h-3.5 w-3.5" /></Button>
</CardHeader>
<CardContent className="p-4">
<PieChartSimple data={[{ name: "Default", value: prodData?.totalTasks || 1 }]} labelKey="name" valueKey="value" />
{projectData?.projects.length ? (
<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>
@@ -259,13 +274,14 @@ function AnalyticsPage() {
<Card>
<CardHeader className="p-4 pb-0 flex flex-row items-center justify-between">
<CardTitle className="text-sm font-semibold">Productivity Heatmap</CardTitle>
<Button variant="ghost" size="icon" className="h-6 w-6" onClick={() => downloadCSV("heatmap.csv", [["Date", "Count"], ...dailyData.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>
<CardContent className="p-4">
<CalendarHeatmap data={dailyData.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>
</Card>
</div>
)}
</div>
);
}
+86 -45
View File
@@ -1,11 +1,14 @@
import { useState, useMemo, useCallback, useEffect, useRef } from "react";
import { createRoute } from "@tanstack/react-router";
import { Route as appRoute } from "../_app";
import { useQueryClient } from "@tanstack/react-query";
import { useQueryClient, useMutation } from "@tanstack/react-query";
import { api, useApiQuery, useApiMutation } from "@/lib/api";
import { useApiDomain } from "@/lib/stores/use-active-domain-store";
import { useRealtime } from "@/hooks/use-realtime";
import { Plus, Trash2, ChevronLeft, ChevronRight } from "lucide-react";
import { toast } from "sonner";
import { Button } from "@/components/ui/button";
import { LoadingState, EmptyState } from "@/components/state";
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger } from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
@@ -14,9 +17,10 @@ import type { CalendarEvent } from "@/lib/types";
import { format, parseISO, addDays, startOfWeek, getDay } from "date-fns";
// react-big-calendar
import { Calendar, dateFnsLocalizer, Views, Navigate } from "react-big-calendar";
// import withDragAndDrop from "react-big-calendar/lib/addons/dragAndDrop";
import { Calendar, dateFnsLocalizer, Navigate, type View, type stringOrDate } from "react-big-calendar";
import withDragAndDrop from "react-big-calendar/lib/addons/dragAndDrop";
import "react-big-calendar/lib/css/react-big-calendar.css";
import "react-big-calendar/lib/addons/dragAndDrop/styles.css";
const localizer = dateFnsLocalizer({
startOfWeek,
@@ -25,7 +29,10 @@ const localizer = dateFnsLocalizer({
locales: {},
});
// const DragAndDropCalendar = withDragAndDrop(Calendar);
// Mapped calendar event shape used by react-big-calendar (adds Date start/end accessors)
type CalendarViewEvent = CalendarEvent & { start: Date; end: Date };
const DragAndDropCalendar = withDragAndDrop<CalendarViewEvent>(Calendar);
// Color palette: tasks=accent, events by domain
const EVENT_COLORS: Record<string, string> = {
@@ -95,11 +102,21 @@ function EventForm({ event, onClose }: { event?: CalendarEvent; onClose: () => v
const [color, setColor] = useState(event?.color || "#3b82f6");
const createMutation = useApiMutation<CalendarEvent, any>("post", "/calendar/events", {
onSuccess: () => { queryClient.invalidateQueries({ queryKey: ["calendar-events"] }); onClose(); },
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["calendar-events"] });
toast.success("Event created");
onClose();
},
onError: (err) => toast.error(err.message || "Failed to create event"),
});
const updateMutation = useApiMutation<CalendarEvent, any>("patch", event ? `/calendar/events/${event.id}` : "", {
onSuccess: () => { queryClient.invalidateQueries({ queryKey: ["calendar-events"] }); onClose(); },
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["calendar-events"] });
toast.success("Event updated");
onClose();
},
onError: (err) => toast.error(err.message || "Failed to update event"),
});
const handleSubmit = (e: React.FormEvent) => {
@@ -147,7 +164,7 @@ function EventForm({ event, onClose }: { event?: CalendarEvent; onClose: () => v
function CalendarPage() {
const queryClient = useQueryClient();
const [date, setDate] = useState(new Date());
const [view, setView] = useState<string>("month");
const [view, setView] = useState<View>("month");
const [createOpen, setCreateOpen] = useState(false);
const [selectedEvent, setSelectedEvent] = useState<CalendarEvent | null>(null);
const [eventDetailOpen, setEventDetailOpen] = useState(false);
@@ -163,19 +180,27 @@ function CalendarPage() {
}, []);
const { data: eventsData } = useApiQuery<{ items: CalendarEvent[]; totalItems: number }>(
["calendar-events", date.toISOString()],
`/calendar/events?from=${new Date(0).toISOString()}&to=${new Date("2100-01-01").toISOString()}`
const activeDomainId = useApiDomain();
const { data: eventsData, isLoading: eventsLoading } = useApiQuery<{ items: CalendarEvent[]; totalItems: number }>(
["calendar-events", activeDomainId, date.toISOString()],
`/calendar/events?from=${new Date(0).toISOString()}&to=${new Date("2100-01-01").toISOString()}` + (activeDomainId ? "&domain=" + activeDomainId : "")
);
const events = eventsData?.items || [];
const deleteMutation = useApiMutation<any, string>("delete", "", {
onSuccess: () => { queryClient.invalidateQueries({ queryKey: ["calendar-events"] }); setEventDetailOpen(false); },
const deleteMutation = useMutation({
mutationFn: (id: string) => api.delete("/calendar/events/" + id),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["calendar-events"] });
setEventDetailOpen(false);
toast.success("Event deleted");
},
onError: (err) => toast.error(err.message || "Failed to delete event"),
});
// Map API events to react-big-calendar format
const calendarEvents = useMemo(() => {
const calendarEvents = useMemo<CalendarViewEvent[]>(() => {
return events.map((evt) => ({
...evt,
start: parseISO(evt.startTime),
@@ -193,10 +218,10 @@ function CalendarPage() {
}, []);
const handleEventDrop = useCallback(
({ event, start, end }: { event: any; start: Date; end: Date }) => {
({ event, start, end }: { event: any; start: stringOrDate; end: stringOrDate }) => {
api.patch(`/calendar/events/${event.id}`, {
startTime: start.toISOString(),
endTime: end.toISOString(),
startTime: start instanceof Date ? start.toISOString() : new Date(start).toISOString(),
endTime: end instanceof Date ? end.toISOString() : new Date(end).toISOString(),
}).then(() => {
queryClient.invalidateQueries({ queryKey: ["calendar-events"] });
});
@@ -205,10 +230,10 @@ function CalendarPage() {
);
const handleEventResize = useCallback(
({ event, start, end }: { event: any; start: Date; end: Date }) => {
({ event, start, end }: { event: any; start: stringOrDate; end: stringOrDate }) => {
api.patch(`/calendar/events/${event.id}`, {
startTime: start.toISOString(),
endTime: end.toISOString(),
startTime: start instanceof Date ? start.toISOString() : new Date(start).toISOString(),
endTime: end instanceof Date ? end.toISOString() : new Date(end).toISOString(),
}).then(() => {
queryClient.invalidateQueries({ queryKey: ["calendar-events"] });
});
@@ -221,7 +246,7 @@ function CalendarPage() {
}, []);
const handleViewChange = useCallback((newView: string) => {
setView(newView);
setView(newView as View);
}, []);
return (
@@ -245,38 +270,54 @@ function CalendarPage() {
key={name}
variant={view === name ? "default" : "outline"}
size="sm"
onClick={() => setView(name)}
onClick={() => setView(name as View)}
className="capitalize"
>
{name}
</Button>
))}
</div>
<div className="rbc-calendar-container" style={{ minHeight: isMobile ? 400 : 600 }}>
<Calendar
key={view}
localizer={localizer}
events={calendarEvents}
startAccessor="start"
endAccessor="end"
date={date}
view={view}
onNavigate={handleNavigate}
onSelectEvent={handleSelectEvent}
onSelectSlot={handleSelectSlot}
selectable
popup
showMultiDayTimes
components={{
event: EventComponent,
toolbar: (props: any) => <CustomToolbar {...props} />,
}}
views={["month", "week", "work_week", "day", "agenda"]}
step={30}
timeslots={2}
style={{ height: isMobile ? 400 : 600 }}
{eventsLoading && events.length === 0 ? (
<LoadingState label="Loading events..." />
) : !eventsLoading && events.length === 0 ? (
<EmptyState
title="No events yet"
description="Create your first event to get started"
action={
<Button onClick={() => setCreateOpen(true)}>
<Plus className="h-4 w-4 mr-2" />New Event
</Button>
}
/>
</div>
) : (
<div className="rbc-calendar-container" style={{ minHeight: isMobile ? 400 : 600 }}>
<DragAndDropCalendar
key={view}
localizer={localizer}
events={calendarEvents}
startAccessor="start"
endAccessor="end"
date={date}
view={view}
onNavigate={handleNavigate}
onSelectEvent={handleSelectEvent}
onSelectSlot={handleSelectSlot}
onEventDrop={handleEventDrop}
onEventResize={handleEventResize}
selectable
popup
showMultiDayTimes
components={{
event: EventComponent,
toolbar: (props: any) => <CustomToolbar {...props} />,
}}
views={["month", "week", "work_week", "day", "agenda"]}
step={30}
timeslots={2}
style={{ height: isMobile ? 400 : 600 }}
/>
</div>
)}
{/* Event detail dialog */}
<Dialog open={eventDetailOpen} onOpenChange={setEventDetailOpen}>
+104 -31
View File
@@ -1,7 +1,7 @@
import { useState, useCallback, useRef, useEffect } from "react";
import { createRoute } from "@tanstack/react-router";
import { createRoute, useNavigate } from "@tanstack/react-router";
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 { Plus, Trash2, GripVertical, Type, Heading1, Heading2, List, CheckSquare, Code, Image, FileText, ArrowUp, ArrowDown, Bold, Italic } from "lucide-react";
import { Button } from "@/components/ui/button";
@@ -186,13 +186,26 @@ function BlockEditor({ block, onChange, onDelete, onMoveUp, onMoveDown }: {
// ─── Canvas Editor ────────────────────────────────────────────────────────
function CanvasEditor({ canvas, onBack }: { canvas: Canvas; onBack: () => void }) {
type Block = { id: string; type: string; content: string };
export function CanvasEditor({ canvas, onBack }: { canvas: Canvas; onBack: () => void }) {
const queryClient = useQueryClient();
const [blocks, setBlocks] = useState<Array<{ id: string; type: string; content: string }>>(
const [blocks, setBlocks] = useState<Block[]>(
canvas.cards?.map((c) => ({ id: c.id, type: c.type, content: c.content })) || [{ id: "new-1", type: "text", content: "" }]
);
const [title, setTitle] = useState(canvas.name);
const [saveState, setSaveState] = useState<"idle" | "saving" | "saved" | "error">("idle");
const blockIdCounter = useRef(blocks.length + 1);
const lastSavedTitle = useRef(canvas.name);
const saveTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
const isDirty = useRef(false);
const lastSeen = useRef<{ blocks: Block[]; title: string }>({ blocks, title });
// Keep latest values reachable from the debounced save without stale closures
const blocksRef = useRef(blocks);
blocksRef.current = blocks;
const titleRef = useRef(title);
titleRef.current = title;
// Listen for block type changes
useEffect(() => {
@@ -204,10 +217,84 @@ function CanvasEditor({ canvas, onBack }: { canvas: Canvas; onBack: () => void }
return () => window.removeEventListener("change-block-type", handler);
}, []);
const updateMutation = useMutation({
mutationFn: (data: any) => api.patch("/canvas/" + canvas.id, data),
onSuccess: () => queryClient.invalidateQueries({ queryKey: ["canvas"] }),
});
// Persist the full block list via the bulk endpoint, then adopt the
// server-generated ids for newly created blocks (content stays local).
const persist = useCallback(async () => {
const currentBlocks = blocksRef.current;
const currentTitle = titleRef.current;
setSaveState("saving");
try {
const payload = {
cards: currentBlocks.map((b, i) => ({
...(b.id.startsWith("new-") || b.id.startsWith("block-") ? {} : { id: b.id }),
type: b.type,
content: b.content,
zIndex: i,
})),
};
const result = await api.put<{ cards: { id: string }[] }>("/canvas/" + canvas.id + "/cards", payload);
setBlocks((prev) => {
if (result.cards.length !== prev.length) return prev;
let changed = false;
const next = prev.map((b, i) => {
const newId = result.cards[i]?.id;
if (newId && b.id !== newId) {
changed = true;
return { ...b, id: newId };
}
return b;
});
return changed ? next : prev;
});
if (currentTitle !== lastSavedTitle.current) {
await api.patch("/canvas/" + canvas.id, { name: currentTitle });
lastSavedTitle.current = currentTitle;
}
queryClient.invalidateQueries({ queryKey: ["canvas"] });
isDirty.current = false;
setSaveState("saved");
} catch (error) {
console.error("[canvas] save failed:", error);
setSaveState("error");
}
}, [canvas.id, queryClient]);
// Debounced autosave: persist shortly after blocks/title stop changing
useEffect(() => {
if (lastSeen.current.blocks === blocks && lastSeen.current.title === title) {
return;
}
lastSeen.current = { blocks, title };
isDirty.current = true;
if (saveTimer.current) clearTimeout(saveTimer.current);
saveTimer.current = setTimeout(() => {
saveTimer.current = null;
persist();
}, 800);
return () => {
if (saveTimer.current) {
clearTimeout(saveTimer.current);
saveTimer.current = null;
}
};
}, [blocks, title, persist]);
// Flush any unsaved edits when leaving the editor
useEffect(() => {
return () => {
if (isDirty.current) {
persist();
}
};
}, [persist]);
const handleSave = () => {
if (saveTimer.current) {
clearTimeout(saveTimer.current);
saveTimer.current = null;
}
persist();
};
const handleBlockChange = (id: string, content: string) => {
setBlocks((prev) => prev.map((b) => b.id === id ? { ...b, content } : b));
@@ -240,10 +327,6 @@ function CanvasEditor({ canvas, onBack }: { canvas: Canvas; onBack: () => void }
setBlocks((prev) => [...prev, { id: "block-" + blockIdCounter.current, type, content: "" }]);
};
const handleSave = () => {
updateMutation.mutate({ name: title });
};
return (
<div className="max-w-3xl mx-auto space-y-4">
<div className="flex items-center justify-between">
@@ -252,11 +335,15 @@ function CanvasEditor({ canvas, onBack }: { canvas: Canvas; onBack: () => void }
<Input
value={title}
onChange={(e) => setTitle(e.target.value)}
onBlur={handleSave}
className="text-xl font-bold border-none bg-transparent h-auto px-0 focus-visible:ring-0"
/>
</div>
<Button size="sm" onClick={handleSave} disabled={updateMutation.isPending}>Save</Button>
<div className="flex items-center gap-2">
{saveState === "saving" && <span className="text-xs text-muted-foreground">Saving&hellip;</span>}
{saveState === "saved" && <span className="text-xs text-muted-foreground">Saved</span>}
{saveState === "error" && <span className="text-xs text-destructive">Save failed</span>}
<Button size="sm" onClick={handleSave} disabled={saveState === "saving"}>Save</Button>
</div>
</div>
<Separator />
<div className="space-y-1">
@@ -292,7 +379,7 @@ function CanvasEditor({ canvas, onBack }: { canvas: Canvas; onBack: () => void }
function CanvasList() {
const queryClient = useQueryClient();
const [selectedCanvas, setSelectedCanvas] = useState<Canvas | null>(null);
const navigate = useNavigate();
const [createOpen, setCreateOpen] = useState(false);
const [newName, setNewName] = useState("");
@@ -305,7 +392,7 @@ function CanvasList() {
queryClient.invalidateQueries({ queryKey: ["canvas"] });
setCreateOpen(false);
setNewName("");
setSelectedCanvas(canvas);
navigate({ to: "/canvas/$id", params: { id: canvas.id } });
},
});
@@ -314,20 +401,6 @@ function CanvasList() {
onSuccess: () => queryClient.invalidateQueries({ queryKey: ["canvas"] }),
});
const openCanvas = async (id: string) => {
try {
const detail = await api.get<Canvas>("/canvas/" + id);
setSelectedCanvas(detail);
} catch {
const c = canvases.find((c) => c.id === id);
if (c) setSelectedCanvas(c);
}
};
if (selectedCanvas) {
return <CanvasEditor canvas={selectedCanvas} onBack={() => setSelectedCanvas(null)} />;
}
return (
<div className="space-y-4">
<div className="flex items-center justify-between">
@@ -356,7 +429,7 @@ function CanvasList() {
) : (
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
{canvases.map((c) => (
<Card key={c.id} className="cursor-pointer hover:shadow-md transition-shadow" onClick={() => openCanvas(c.id)}>
<Card key={c.id} className="cursor-pointer hover:shadow-md transition-shadow" onClick={() => navigate({ to: "/canvas/$id", params: { id: c.id } })}>
<CardHeader className="p-4 pb-2">
<CardTitle className="text-sm font-semibold truncate">{c.name}</CardTitle>
</CardHeader>
+2 -50
View File
@@ -1,13 +1,8 @@
import { createRoute, useParams, useNavigate } from "@tanstack/react-router";
import { Route as appRoute } from "../../_app";
import { useApiQuery } from "@/lib/api";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Separator } from "@/components/ui/separator";
import { ArrowLeft, Layout, Layers } from "lucide-react";
import type { Canvas } from "@/lib/types";
import { format, parseISO } from "date-fns";
import { CanvasEditor } from "../canvas";
function CanvasDetail() {
const { id } = useParams({ from: Route.id });
@@ -17,50 +12,7 @@ function CanvasDetail() {
if (isLoading) return <div className="p-8 text-center text-muted-foreground">Loading...</div>;
if (!canvas) return <div className="p-8 text-center text-muted-foreground">Canvas not found</div>;
const blockCount = canvas.cards?.length || 0;
return (
<div className="max-w-2xl mx-auto p-6 space-y-6">
<Button variant="ghost" onClick={() => navigate({ to: "/canvas" })} className="w-fit">
<ArrowLeft className="h-4 w-4 mr-2" /> Back to Canvas
</Button>
<Card>
<CardHeader>
<div className="flex items-center gap-3">
<Layout className="h-6 w-6 text-muted-foreground" />
<CardTitle className="text-2xl">{canvas.name}</CardTitle>
<Badge variant="secondary">{canvas.mode}</Badge>
</div>
</CardHeader>
<CardContent className="space-y-4">
{canvas.description && (
<div>
<h3 className="text-sm font-semibold text-muted-foreground mb-1">Description</h3>
<p className="text-sm whitespace-pre-wrap">{canvas.description}</p>
</div>
)}
<Separator />
<div className="grid grid-cols-2 gap-4 text-center">
<div className="p-3 bg-muted/50 rounded-lg">
<Layers className="h-5 w-5 mx-auto mb-1 text-muted-foreground" />
<p className="text-2xl font-bold">{blockCount}</p>
<p className="text-xs text-muted-foreground">Blocks</p>
</div>
<div className="p-3 bg-muted/50 rounded-lg">
<Layout className="h-5 w-5 mx-auto mb-1 text-muted-foreground" />
<p className="text-2xl font-bold capitalize">{canvas.mode}</p>
<p className="text-xs text-muted-foreground">Mode</p>
</div>
</div>
<Separator />
<div className="text-xs text-muted-foreground space-y-1">
<p>Created: {format(parseISO(canvas.createdAt), "MMM d, yyyy HH:mm")}</p>
<p>Updated: {format(parseISO(canvas.updatedAt), "MMM d, yyyy HH:mm")}</p>
</div>
</CardContent>
</Card>
</div>
);
return <CanvasEditor key={canvas.id} canvas={canvas} onBack={() => navigate({ to: "/canvas" })} />;
}
export const Route = createRoute({
+112 -15
View File
@@ -3,17 +3,18 @@ import { createRoute } from "@tanstack/react-router";
import { Route as appRoute } from "../_app";
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { api, useApiQuery, useApiMutation } from "@/lib/api";
import { Calendar, ChevronLeft, ChevronRight, Plus, Save, Smile, Zap } from "lucide-react";
import { Calendar, ChevronLeft, ChevronRight, Plus, Save, Trash2 } from "lucide-react";
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 { AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle, AlertDialogTrigger } from "@/components/ui/alert-dialog";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Separator } from "@/components/ui/separator";
import { ScrollArea } from "@/components/ui/scroll-area";
import { cn } from "@/lib/utils";
import type { DailyNote } from "@/lib/types";
import { format, parseISO, startOfMonth, endOfMonth, eachDayOfInterval, getDay, isSameDay, isToday, addMonths, subMonths } from "date-fns";
import { format, startOfMonth, endOfMonth, eachDayOfInterval, getDay, isSameDay, isToday, addMonths, subMonths } from "date-fns";
// ─── Calendar Sidebar ────────────────────────────────────────────────────
@@ -27,7 +28,12 @@ function CalendarSidebar({ selectedDate, onSelectDate }: { selectedDate: Date; o
// Check which dates have notes
const { data } = useApiQuery<{ items: DailyNote[]; totalItems: number }>(["daily-notes-list"], "/daily-notes");
const notes = data?.items || [];
const noteDates = new Set(notes.map((n) => format(parseISO(n.date), "yyyy-MM-dd")));
// The API stores daily notes at UTC midnight (YYYY-MM-DDT00:00:00.000Z).
// Slicing off the time portion yields the calendar date the note belongs to
// regardless of the browser's timezone. parseISO + format would re-render the
// UTC instant in the local zone and shift the marker to the previous day for
// users west of UTC.
const noteDates = new Set(notes.map((n) => n.date.slice(0, 10)));
return (
<div className="w-64 shrink-0">
@@ -88,8 +94,16 @@ function DailyNoteEditor({ date }: { date: Date }) {
const [energy, setEnergy] = useState<number | null>(null);
const [noteId, setNoteId] = useState<string | null>(null);
const [isNew, setIsNew] = useState(false);
const [saveTimer, setSaveTimer] = useState<ReturnType<typeof setTimeout> | null>(null);
const textareaRef = useRef<HTMLTextAreaElement>(null);
const noteIdRef = useRef<string | null>(null);
const saveTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const prevDateStrRef = useRef(dateStr);
// Mirror noteId into a ref so a pending autosave timer can always read the
// latest id. Without this, a timer scheduled while no note existed yet would
// fire with a stale null and double-create the note once createMutation
// resolves (noteId is set asynchronously in onSuccess).
noteIdRef.current = noteId;
const { data: note, isLoading } = useApiQuery<DailyNote | null>(
["daily-note", dateStr],
@@ -97,6 +111,17 @@ function DailyNoteEditor({ date }: { date: Date }) {
);
useEffect(() => {
// Switching days must cancel any pending autosave so it can't fire against
// the newly loaded note (or with the previous day's closure state). The
// guard on prevDateStrRef keeps refetches of the same day from wiping a
// debounce that is still in flight.
if (prevDateStrRef.current !== dateStr) {
prevDateStrRef.current = dateStr;
if (saveTimerRef.current) {
clearTimeout(saveTimerRef.current);
saveTimerRef.current = null;
}
}
if (note) {
setContent(note.content || "");
setMood(note.mood);
@@ -112,6 +137,17 @@ function DailyNoteEditor({ date }: { date: Date }) {
}
}, [note, isLoading, dateStr]);
// Clear any pending autosave when the editor unmounts so a stale timer can't
// fire after navigation away from the page.
useEffect(() => {
return () => {
if (saveTimerRef.current) {
clearTimeout(saveTimerRef.current);
saveTimerRef.current = null;
}
};
}, []);
const createMutation = useMutation({
mutationFn: (data: any) => api.post<DailyNote>("/daily-notes", data),
onSuccess: (saved) => {
@@ -131,16 +167,17 @@ function DailyNoteEditor({ date }: { date: Date }) {
});
const autoSave = useCallback((newContent: string, newMood: number | null, newEnergy: number | null) => {
if (saveTimer) clearTimeout(saveTimer);
const timer = setTimeout(() => {
if (noteId) {
updateMutation.mutate({ id: noteId, data: { content: newContent, mood: newMood, energy: newEnergy } });
if (saveTimerRef.current) clearTimeout(saveTimerRef.current);
saveTimerRef.current = setTimeout(() => {
saveTimerRef.current = null;
const id = noteIdRef.current;
if (id) {
updateMutation.mutate({ id, data: { content: newContent, mood: newMood, energy: newEnergy } });
} else if (newContent.trim()) {
createMutation.mutate({ date: dateStr, content: newContent, mood: newMood, energy: newEnergy });
}
}, 1500);
setSaveTimer(timer);
}, [noteId, dateStr, saveTimer]);
}, [dateStr, updateMutation, createMutation]);
const handleContentChange = (value: string) => {
setContent(value);
@@ -151,6 +188,10 @@ function DailyNoteEditor({ date }: { date: Date }) {
setMood(value);
if (noteId) {
updateMutation.mutate({ id: noteId, data: { mood: value } });
} else if (isNew && !createMutation.isPending) {
// No note exists for this day yet — create it so the mood is recorded
// even before any content is typed.
createMutation.mutate({ date: dateStr, content: content, mood: value, energy: energy });
}
};
@@ -158,18 +199,68 @@ function DailyNoteEditor({ date }: { date: Date }) {
setEnergy(value);
if (noteId) {
updateMutation.mutate({ id: noteId, data: { energy: value } });
} else if (isNew && !createMutation.isPending) {
// No note exists for this day yet — create it so the energy is recorded
// even before any content is typed.
createMutation.mutate({ date: dateStr, content: content, mood: mood, energy: value });
}
};
const deleteMutation = useMutation({
mutationFn: async (id: string) => {
try {
await api.delete("/daily-notes/" + id);
} catch (error) {
// The API responds 204 No Content, which has no JSON body, so api.delete
// (which resolves res.json()) rejects with a SyntaxError on the empty
// body even though the server-side delete succeeded. Re-throw anything
// else (real HTTP/network failures).
if (!(error instanceof SyntaxError)) throw error;
}
},
onSuccess: () => {
setContent("");
setMood(null);
setEnergy(null);
setNoteId(null);
setIsNew(true);
queryClient.invalidateQueries({ queryKey: ["daily-note", dateStr] });
queryClient.invalidateQueries({ queryKey: ["daily-notes-list"] });
},
});
const handleDelete = () => {
if (noteId) deleteMutation.mutate(noteId);
};
return (
<div className="flex-1 space-y-4">
<div className="flex items-center justify-between">
<h2 className="text-xl font-bold">{format(date, "EEEE, MMMM d, yyyy")}</h2>
<div className="flex items-center gap-2">
{noteId && (
<Badge variant="secondary" className="text-[10px]">
<Save className="h-3 w-3 mr-1" />Saved
</Badge>
<>
<Badge variant="secondary" className="text-[10px]">
<Save className="h-3 w-3 mr-1" />Saved
</Badge>
<AlertDialog>
<AlertDialogTrigger asChild>
<Button variant="ghost" size="icon" className="h-8 w-8 text-destructive" aria-label="Delete daily note">
<Trash2 className="h-4 w-4" />
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Delete Daily Note</AlertDialogTitle>
<AlertDialogDescription>Are you sure you want to delete this daily note? This cannot be undone.</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction onClick={handleDelete} className="bg-destructive text-destructive-foreground">Delete</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</>
)}
</div>
</div>
@@ -178,10 +269,13 @@ function DailyNoteEditor({ date }: { date: Date }) {
<div className="flex gap-6">
<div>
<p className="text-xs text-muted-foreground mb-1">Mood</p>
<div className="flex gap-1">
<div role="radiogroup" aria-label="Mood" className="flex gap-1">
{[1, 2, 3, 4, 5, 6, 7, 8, 9, 10].map((v) => (
<button
key={v}
role="radio"
aria-checked={mood === v}
aria-label={`Mood ${v}`}
onClick={() => handleMoodChange(v)}
className={cn(
"w-6 h-6 rounded text-[10px] font-medium transition-colors",
@@ -195,10 +289,13 @@ function DailyNoteEditor({ date }: { date: Date }) {
</div>
<div>
<p className="text-xs text-muted-foreground mb-1">Energy</p>
<div className="flex gap-1">
<div role="radiogroup" aria-label="Energy" className="flex gap-1">
{[1, 2, 3, 4, 5, 6, 7, 8, 9, 10].map((v) => (
<button
key={v}
role="radio"
aria-checked={energy === v}
aria-label={`Energy ${v}`}
onClick={() => handleEnergyChange(v)}
className={cn(
"w-6 h-6 rounded text-[10px] font-medium transition-colors",
+117 -26
View File
@@ -1,11 +1,13 @@
import { useState, useRef, useCallback, useEffect, useMemo } from "react";
import { createRoute } from "@tanstack/react-router";
import { createRoute, useNavigate } from "@tanstack/react-router";
import { Route as appRoute } from "../_app";
import { useQueryClient } from "@tanstack/react-query";
import { api, useApiQuery } from "@/lib/api";
import { useActiveDomainId } from "@/lib/stores/use-active-domain-store";
import { useRealtime } from "@/hooks/use-realtime";
import { Search, ZoomIn, ZoomOut, RotateCcw, Filter, X } from "lucide-react";
import { Button } from "@/components/ui/button";
import { LoadingState, EmptyState } from "@/components/state";
import { Input } from "@/components/ui/input";
import { Badge } from "@/components/ui/badge";
import { Separator } from "@/components/ui/separator";
@@ -30,12 +32,26 @@ const ENTITY_COLORS: Record<string, string> = {
domain: "#6366f1",
};
// Graph node types that have a detail page. section/tag/domain nodes appear in
// the graph but have no detail route, so they are intentionally absent.
const NODE_TYPE_ROUTES: Record<string, string> = {
task: "/tasks/$id",
habit: "/habits/$id",
project: "/projects/$id",
note: "/notes/$id",
};
const MAX_NODES = 500;
function GraphPage() {
const queryClient = useQueryClient();
const navigate = useNavigate();
const containerRef = useRef<HTMLDivElement>(null);
const graphRef = useRef<any>(undefined);
// Simulated node positions, keyed by node id. react-force-graph assigns x/y to
// the node objects it renders during the simulation; the raw API nodes
// (displayNodes) never gain coordinates, so fly-to must look here instead.
const positionsRef = useRef(new Map<string, { x: number; y: number }>());
const [dimensions, setDimensions] = useState({ width: 800, height: 600 });
const [search, setSearch] = useState("");
const [filterOpen, setFilterOpen] = useState(false);
@@ -70,9 +86,16 @@ function GraphPage() {
["domains"],
"/domains"
);
const activeDomainId = domainsData?.items?.[0]?.id || "";
// Use the active domain from the store, falling back to the first domain
// while unset. Validate against the fetched list so a persisted id that no
// longer exists doesn't produce a query for a deleted domain.
const storedDomainId = useActiveDomainId();
const activeDomainId =
(storedDomainId && domainsData?.items?.some((d) => d.id === storedDomainId) ? storedDomainId : null) ||
domainsData?.items?.[0]?.id ||
"";
const { data: nodesData } = useApiQuery<{ items: GraphNode[]; totalItems: number }>(
const { data: nodesData, isLoading: nodesLoading } = useApiQuery<{ items: GraphNode[]; totalItems: number }>(
["graph", "nodes", activeDomainId],
"/graph/nodes?domain=" + activeDomainId,
{ enabled: !!activeDomainId }
@@ -154,14 +177,34 @@ function GraphPage() {
};
// Search: fly to node
const trackNodePosition = useCallback((node: any) => {
if (node && typeof node.x === "number" && typeof node.y === "number") {
positionsRef.current.set(String(node.id), { x: node.x, y: node.y });
}
}, []);
// Snapshot every simulated node's position once the force simulation settles.
const snapshotPositions = useCallback(() => {
const nodes = graphRef.current?.graphData()?.nodes;
if (!nodes) return;
for (const node of nodes) {
trackNodePosition(node);
}
}, [trackNodePosition]);
const handleSearch = useCallback(() => {
if (!search.trim() || !graphRef.current) return;
const found = displayNodes.find(
(n) => n.label.toLowerCase().includes(search.toLowerCase())
);
if (found) {
graphRef.current.centerAt(found.x, found.y, 1000);
if (!found) return;
const pos = positionsRef.current.get(found.id);
if (pos) {
graphRef.current.centerAt(pos.x, pos.y, 1000);
graphRef.current.zoom(3, 1000);
} else {
// No coordinates yet (e.g. simulation still warming up) — fit the view instead.
graphRef.current.zoomToFit(1000, 50);
}
}, [search, displayNodes]);
@@ -175,6 +218,15 @@ function GraphPage() {
setDetailOpen(true);
}, []);
// Close the detail panel and navigate to the node's detail page when one
// exists (task/habit/project/note). No-ops for types without a detail route.
const handleOpenEntity = useCallback((node: GraphNode) => {
const to = NODE_TYPE_ROUTES[node.type];
if (!to) return;
setDetailOpen(false);
navigate({ to, params: { id: node.id } });
}, [navigate]);
// Node hover → highlight
const handleNodeHover = useCallback((node: any | null) => {
setHoveredNode(node as GraphNode | null);
@@ -273,7 +325,7 @@ function GraphPage() {
{/* Graph canvas area */}
<div ref={containerRef} className="flex-1 relative bg-muted/20">
{/* Toolbar */}
<div className="absolute top-4 left-4 z-10 flex items-center gap-2">
<div className="absolute top-4 left-4 z-10 flex flex-wrap items-center gap-2">
<div className="relative">
<Search className="absolute left-2.5 top-2.5 h-4 w-4 text-muted-foreground" />
<Input
@@ -281,7 +333,7 @@ function GraphPage() {
value={search}
onChange={(e) => setSearch(e.target.value)}
onKeyDown={handleSearchKeyDown}
className="pl-8 w-64 bg-background/90 backdrop-blur"
className="pl-8 w-40 sm:w-64 bg-background/90 backdrop-blur"
/>
</div>
<Button variant="outline" size="icon" onClick={() => setFilterOpen(true)} aria-label="Filters">
@@ -312,24 +364,35 @@ function GraphPage() {
)}
{/* Force graph */}
<ForceGraph2D
ref={graphRef}
graphData={graphData}
width={dimensions.width}
height={dimensions.height}
nodeCanvasObject={nodeCanvasObject}
linkCanvasObject={linkCanvasObject}
linkDirectionalArrowLength={0}
linkDirectionalArrowRelPos={0.5}
onNodeClick={handleNodeClick}
onNodeHover={handleNodeHover}
nodeRelSize={6}
d3AlphaDecay={0.02}
d3VelocityDecay={0.3}
cooldownTicks={100}
warmupTicks={40}
backgroundColor="transparent"
/>
{activeDomainId && nodesLoading ? (
<LoadingState label="Loading graph..." />
) : displayNodes.length === 0 ? (
<EmptyState
title="No graph data yet"
description="Create tasks, habits, or projects to see them connected here"
/>
) : (
<ForceGraph2D
ref={graphRef}
graphData={graphData}
width={dimensions.width}
height={dimensions.height}
nodeCanvasObject={nodeCanvasObject}
linkCanvasObject={linkCanvasObject}
linkDirectionalArrowLength={0}
linkDirectionalArrowRelPos={0.5}
onNodeClick={handleNodeClick}
onNodeHover={handleNodeHover}
onNodeDrag={trackNodePosition}
onEngineStop={snapshotPositions}
nodeRelSize={6}
d3AlphaDecay={0.02}
d3VelocityDecay={0.3}
cooldownTicks={100}
warmupTicks={40}
backgroundColor="transparent"
/>
)}
</div>
{/* Filter panel */}
@@ -391,6 +454,19 @@ function GraphPage() {
</div>
<p className="text-sm text-muted-foreground">ID: {selectedNode.id}</p>
<Separator />
{NODE_TYPE_ROUTES[selectedNode.type] ? (
<Button
className="w-full"
onClick={() => handleOpenEntity(selectedNode)}
>
Open {selectedNode.type.charAt(0).toUpperCase() + selectedNode.type.slice(1)}
</Button>
) : (
<p className="text-xs text-muted-foreground">
No detail page for {selectedNode.type}
</p>
)}
<Separator />
<h4 className="text-sm font-semibold">Connected nodes</h4>
<div className="space-y-1">
{displayEdges
@@ -401,7 +477,22 @@ function GraphPage() {
return connected ? (
<div key={i} className="flex items-center gap-2 text-sm py-1">
<div className="w-2 h-2 rounded-full" style={{ backgroundColor: connected.color }} />
<span className="truncate flex-1">{connected.label}</span>
{NODE_TYPE_ROUTES[connected.type] ? (
<button
type="button"
onClick={() => handleOpenEntity(connected)}
className="truncate flex-1 text-left hover:underline"
>
{connected.label}
</button>
) : (
<>
<span className="truncate flex-1">{connected.label}</span>
<span className="shrink-0 text-[10px] text-muted-foreground">
No detail page for {connected.type}
</span>
</>
)}
<Badge variant="outline" className="text-[10px]">{e.type.replace(/_/g, " ")}</Badge>
</div>
) : null;
+30 -10
View File
@@ -1,10 +1,11 @@
import { useState } from "react";
import { createRoute } from "@tanstack/react-router";
import { createRoute, useNavigate } from "@tanstack/react-router";
import { Route as appRoute } from "../_app";
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { api, useApiQuery, useApiMutation } from "@/lib/api";
import { useApiDomain } from "@/lib/stores/use-active-domain-store";
import { useRealtime } from "@/hooks/use-realtime";
import { Plus, Flame, Trash2, Check, Calendar, TrendingUp } from "lucide-react";
import { Plus, Flame, Trash2, Check, Pencil } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Badge } from "@/components/ui/badge";
@@ -15,6 +16,7 @@ import { Label } from "@/components/ui/label";
import { Textarea } from "@/components/ui/textarea";
import { AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle, AlertDialogTrigger } from "@/components/ui/alert-dialog";
import { EntityDetailPanel } from "@/components/entities/entity-detail-panel";
import { LoadingState, EmptyState, ErrorState } from "@/components/state";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { cn } from "@/lib/utils";
import type { Habit, HabitCompletion, PaginatedResponse } from "@/lib/types";
@@ -58,7 +60,7 @@ function HabitForm({ habit, onClose }: { habit?: Habit; onClose: () => void }) {
<div className="grid grid-cols-2 gap-4">
<div>
<Label htmlFor="freq">Frequency</Label>
<Select value={frequency} onValueChange={setFrequency}>
<Select value={frequency} onValueChange={(v) => setFrequency(v as "daily" | "weekly" | "custom")}>
<SelectTrigger id="freq"><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="daily">Daily</SelectItem>
@@ -69,7 +71,7 @@ function HabitForm({ habit, onClose }: { habit?: Habit; onClose: () => void }) {
</div>
<div>
<Label htmlFor="diff">Difficulty</Label>
<Select value={difficulty} onValueChange={setDifficulty}>
<Select value={difficulty} onValueChange={(v) => setDifficulty(v as "easy" | "medium" | "hard")}>
<SelectTrigger id="diff"><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="easy">Easy</SelectItem>
@@ -113,6 +115,7 @@ function MiniGrid({ completions, days = 7 }: { completions: HabitCompletion[]; d
}
function HabitsPage() {
const navigate = useNavigate();
const queryClient = useQueryClient();
const [createOpen, setCreateOpen] = useState(false);
const [selectedHabit, setSelectedHabit] = useState<Habit | null>(null);
@@ -121,9 +124,11 @@ function HabitsPage() {
useRealtime({ enabled: true });
const { data: habitsData, isLoading } = useApiQuery<PaginatedResponse<Habit>>(
["habits"],
"/habits?limit=200"
const activeDomainId = useApiDomain();
const { data: habitsData, isLoading, isError, refetch } = useApiQuery<PaginatedResponse<Habit>>(
["habits", activeDomainId],
"/habits?limit=200" + (activeDomainId ? "&domain=" + activeDomainId : "")
);
const habits = habitsData?.items || [];
@@ -138,7 +143,11 @@ function HabitsPage() {
onSuccess: () => { queryClient.invalidateQueries({ queryKey: ["habits"] }); setPanelOpen(false); },
});
const openHabitDetail = async (habit: Habit) => {
const openHabitDetail = (habit: Habit) => {
navigate({ to: "/habits/$id", params: { id: habit.id } });
};
const openHabitPanel = async (habit: Habit) => {
try {
const detail = await api.get<Habit>("/habits/" + habit.id);
setSelectedHabit(detail);
@@ -164,9 +173,11 @@ function HabitsPage() {
</div>
{isLoading ? (
<div className="flex items-center justify-center py-12 text-muted-foreground">Loading habits...</div>
<LoadingState label="Loading habits..." />
) : isError ? (
<ErrorState message="Failed to load habits." onRetry={() => refetch()} />
) : habits.length === 0 ? (
<div className="text-center py-12 text-muted-foreground">No habits yet. Create your first one!</div>
<EmptyState icon={Flame} title="No habits yet" description="Create your first one!" />
) : (
<div className="space-y-2">
{habits.map((habit) => (
@@ -197,6 +208,15 @@ function HabitsPage() {
>
<Check className="h-4 w-4 mr-1" />Complete
</Button>
<Button
variant="ghost"
size="icon"
className="h-8 w-8"
onClick={(e) => { e.stopPropagation(); openHabitPanel(habit); }}
aria-label={"Edit " + habit.name}
>
<Pencil className="h-4 w-4" />
</Button>
</div>
</div>
</CardContent>
+8 -1
View File
@@ -5,7 +5,8 @@ import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Separator } from "@/components/ui/separator";
import { ArrowLeft, Flame, Calendar } from "lucide-react";
import { TagManager } from "@/components/entities/tag-manager";
import { ArrowLeft, Flame, Calendar, Clock } from "lucide-react";
import type { Habit, HabitCompletion } from "@/lib/types";
import { format, parseISO } from "date-fns";
@@ -33,6 +34,10 @@ function HabitDetail() {
</div>
</CardHeader>
<CardContent className="space-y-4">
<div className="flex items-center gap-4 text-xs text-muted-foreground">
<span className="flex items-center gap-1"><Clock className="h-3 w-3" /> Created {format(parseISO(habit.createdAt), "MMM d, yyyy HH:mm")}</span>
<span className="flex items-center gap-1"><Clock className="h-3 w-3" /> Updated {format(parseISO(habit.updatedAt), "MMM d, yyyy HH:mm")}</span>
</div>
{habit.description && (
<div>
<h3 className="text-sm font-semibold text-muted-foreground mb-1">Description</h3>
@@ -55,6 +60,8 @@ function HabitDetail() {
</div>
</div>
<Separator />
<TagManager entityType="habit" entityId={habit.id} tags={habit.tags || []} />
<Separator />
<div>
<h3 className="text-sm font-semibold text-muted-foreground mb-2">Recent Completions</h3>
{completions.length === 0 ? (
+40 -9
View File
@@ -5,6 +5,7 @@ import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { api, useApiQuery, useApiMutation } from "@/lib/api";
import { useRealtime } from "@/hooks/use-realtime";
import { Plus, Settings2, Trash2, ListTodo, Flame, FileText, FolderKanban, Calendar, Zap, TrendingUp, Target } from "lucide-react";
import { toast } from "sonner";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Badge } from "@/components/ui/badge";
@@ -30,7 +31,7 @@ const WIDGET_TYPES = [
] as const;
function TasksDueWidget() {
const { data } = useApiQuery<PaginatedResponse<Task>>(["tasks-due"], "/tasks?limit=10&status=todo,in_progress&sort=dueDate");
const { data } = useApiQuery<PaginatedResponse<Task>>(["tasks-due"], "/tasks?limit=10&status=todo,in_progress&sort=due_date");
const tasks = data?.items || [];
const today = tasks.filter((t) => t.dueDate && isToday(parseISO(t.dueDate)));
const overdue = tasks.filter((t) => t.dueDate && isPast(parseISO(t.dueDate)) && t.status !== "done");
@@ -74,7 +75,12 @@ function HabitsTodayWidget() {
const queryClient = useQueryClient();
const completeMutation = useMutation({
mutationFn: (id: string) => api.post("/habits/" + id + "/complete", {}),
onSuccess: () => queryClient.invalidateQueries({ queryKey: ["habits-today"] }),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["habits-today"] });
queryClient.invalidateQueries({ queryKey: ["streaks"] });
toast.success("Habit completed");
},
onError: (err) => toast.error(err.message || "Failed to complete habit"),
});
return (
<div className="space-y-1">
@@ -197,11 +203,21 @@ function QuickCaptureWidget() {
const [type, setType] = useState<"task" | "note">("task");
const createTask = useMutation({
mutationFn: (title: string) => api.post<Task>("/tasks", { title, status: "todo", priority: "medium" }),
onSuccess: () => { queryClient.invalidateQueries({ queryKey: ["tasks-due"] }); setText(""); },
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["tasks-due"] });
setText("");
toast.success("Task added");
},
onError: (err) => toast.error(err.message || "Failed to create task"),
});
const createNote = useMutation({
mutationFn: (title: string) => api.post<Note>("/notes", { title, content: "" }),
onSuccess: () => { queryClient.invalidateQueries({ queryKey: ["recent-notes"] }); setText(""); },
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["recent-notes"] });
setText("");
toast.success("Note added");
},
onError: (err) => toast.error(err.message || "Failed to create note"),
});
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
@@ -293,7 +309,10 @@ function WidgetCard({ widget, onConfigure, onDelete }: { widget: DashboardWidget
const typeInfo = WIDGET_TYPES.find((t) => t.id === widget.type);
const Icon = typeInfo?.icon || Target;
return (
<Card className="h-full flex flex-col group" style={{ gridColumn: "span " + (widget.layout.w || 2), gridRow: "span " + (widget.layout.h || 2) }}>
<Card
className="h-full flex flex-col group lg:[grid-column:span_var(--w)] lg:[grid-row:span_var(--h)]"
style={{ "--w": Math.min(widget.layout.w || 2, 4), "--h": widget.layout.h || 2 } as React.CSSProperties}
>
<CardHeader className="p-3 pb-0 flex flex-row items-center justify-between gap-2">
<div className="flex items-center gap-2 min-w-0">
<Icon className="h-4 w-4 shrink-0 text-muted-foreground" />
@@ -397,15 +416,27 @@ function DashboardPage() {
const widgets = widgetsData?.items || [];
const createMutation = useMutation({
mutationFn: (data: any) => api.post<DashboardWidget>("/dashboard/widgets", data),
onSuccess: () => queryClient.invalidateQueries({ queryKey: ["dashboard-widgets"] }),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["dashboard-widgets"] });
toast.success("Widget added");
},
onError: (err) => toast.error(err.message || "Failed to add widget"),
});
const updateMutation = useMutation({
mutationFn: ({ id, data }: { id: string; data: any }) => api.patch<DashboardWidget>("/dashboard/widgets/" + id, data),
onSuccess: () => queryClient.invalidateQueries({ queryKey: ["dashboard-widgets"] }),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["dashboard-widgets"] });
toast.success("Widget updated");
},
onError: (err) => toast.error(err.message || "Failed to update widget"),
});
const deleteMutation = useMutation({
mutationFn: (id: string) => api.delete("/dashboard/widgets/" + id),
onSuccess: () => queryClient.invalidateQueries({ queryKey: ["dashboard-widgets"] }),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["dashboard-widgets"] });
toast.success("Widget removed");
},
onError: (err) => toast.error(err.message || "Failed to remove widget"),
});
const handleAddWidget = (type: string) => {
const typeInfo = WIDGET_TYPES.find((t) => t.id === type);
@@ -431,7 +462,7 @@ function DashboardPage() {
<Button onClick={() => handleAddWidget("tasks_due")}><Plus className="h-4 w-4 mr-2" />Add Default Widgets</Button>
</div>
) : (
<div className="grid gap-4" style={{ gridTemplateColumns: "repeat(12, 1fr)", gridAutoRows: "minmax(120px, auto)" }}>
<div className="grid grid-cols-1 gap-4 auto-rows-[minmax(120px,auto)] sm:grid-cols-2 lg:grid-cols-4">
{widgets.map((w) => (
<WidgetCard key={w.id} widget={w} onConfigure={() => handleConfigure(w)} onDelete={() => handleDelete(w.id)} />
))}
+87 -30
View File
@@ -3,6 +3,7 @@ import { createRoute } 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 { Plus, Trash2, Search, Pin, FileText, Link as LinkIcon, History } from "lucide-react";
import { Button } from "@/components/ui/button";
@@ -11,38 +12,92 @@ import { AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent,
import { ScrollArea } from "@/components/ui/scroll-area";
import { cn } from "@/lib/utils";
import type { Note, PaginatedResponse } from "@/lib/types";
import { useEditor, EditorContent } from "@tiptap/react";
import StarterKit from "@tiptap/starter-kit";
import Link from "@tiptap/extension-link";
import Placeholder from "@tiptap/extension-placeholder";
// Simple TipTap-like editor using contentEditable - saves on blur only
const AUTOSAVE_DEBOUNCE_MS = 800;
// TipTap-based note editor. Autosaves with a debounce (plus a save-on-blur and a
// flush-on-unmount safety net) and deliberately does NOT stop propagation of
// key/mouse events, so global shortcuts (command palette, etc.) keep working.
const NoteEditor = memo(function NoteEditor({ initialContent, onSave, placeholder = "Start writing..." }: { initialContent: string; onSave: (html: string) => void; placeholder?: string }) {
const editorRef = useRef<HTMLDivElement>(null);
const [isPlaceholder, setIsPlaceholder] = useState(!initialContent);
const latestHtmlRef = useRef(initialContent || "");
const dirtyRef = useRef(false);
const saveTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const editor = useEditor(
{
extensions: [
StarterKit.configure({ link: false }),
Link.configure({ openOnClick: false }),
Placeholder.configure({ placeholder }),
],
content: initialContent || "",
editorProps: {
attributes: {
class: "focus:outline-none min-h-[300px] p-3",
},
},
},
[placeholder, initialContent]
);
useEffect(() => {
if (editorRef.current && !editorRef.current.innerHTML) {
editorRef.current.innerHTML = initialContent || "";
}
setIsPlaceholder(!initialContent);
}, []);
if (!editor) return;
const handleBlur = () => {
const html = editorRef.current?.innerHTML || "";
onSave(html);
};
const flushSave = () => {
if (saveTimerRef.current) {
clearTimeout(saveTimerRef.current);
saveTimerRef.current = null;
}
if (!dirtyRef.current) return;
dirtyRef.current = false;
onSave(latestHtmlRef.current);
};
const handleUpdate = () => {
latestHtmlRef.current = editor.getHTML();
dirtyRef.current = true;
if (saveTimerRef.current) clearTimeout(saveTimerRef.current);
saveTimerRef.current = setTimeout(flushSave, AUTOSAVE_DEBOUNCE_MS);
};
editor.on("update", handleUpdate);
editor.on("blur", flushSave);
return () => {
editor.off("update", handleUpdate);
editor.off("blur", flushSave);
if (saveTimerRef.current) {
clearTimeout(saveTimerRef.current);
saveTimerRef.current = null;
}
// Flush any unsaved edits on unmount so switching notes doesn't drop typing.
if (dirtyRef.current) {
dirtyRef.current = false;
onSave(latestHtmlRef.current);
}
};
}, [editor, onSave]);
if (!editor) return null;
return (
<div className="relative min-h-[300px]">
{isPlaceholder && (
<div className="absolute top-0 left-0 text-muted-foreground pointer-events-none p-3 text-sm">{placeholder}</div>
)}
<div
ref={editorRef}
contentEditable
suppressContentEditableWarning
className="prose prose-sm dark:prose-invert max-w-none p-3 focus:outline-none min-h-[300px]"
onMouseDown={(e) => e.stopPropagation()}
onKeyDown={(e) => e.stopPropagation()}
onBlur={handleBlur}
/>
<div className="note-editor relative min-h-[300px]">
{/* Placeholder needs its ::before styling; the @tailwindcss/typography plugin
is not installed, so this is scoped CSS for the empty-editor state. */}
<style>{`
.note-editor p.is-editor-empty:first-child::before {
content: attr(data-placeholder);
color: hsl(var(--muted-foreground));
float: left;
height: 0;
pointer-events: none;
}
`}</style>
<EditorContent editor={editor} />
</div>
);
});
@@ -159,9 +214,11 @@ function NotesPage() {
useRealtime({ enabled: true });
const activeDomainId = useApiDomain();
const { data: notesData, isLoading } = useApiQuery<PaginatedResponse<Note>>(
["notes", search],
"/notes?limit=200" + (search ? "&search=" + encodeURIComponent(search) : "")
["notes", activeDomainId, search],
"/notes?limit=200" + (activeDomainId ? "&domain=" + activeDomainId : "") + (search ? "&search=" + encodeURIComponent(search) : "")
);
const notes = notesData?.items || [];
@@ -201,9 +258,9 @@ function NotesPage() {
}, [deleteMutation]);
return (
<div className="flex 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 */}
<div className="w-72 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="relative">
<Search className="absolute left-2.5 top-2.5 h-4 w-4 text-muted-foreground" />
@@ -246,7 +303,7 @@ function NotesPage() {
</div>
{/* Right pane - editor (memoized, won't re-render on parent state changes) */}
<div className="flex-1 flex flex-col">
<div className="flex-1 flex flex-col min-h-64 md:min-h-0">
{selectedNote ? (
<NoteEditorPane key={selectedNote.id} note={selectedNote} onDelete={handleDeleteNote} />
) : (
+42 -12
View File
@@ -5,10 +5,21 @@ import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Separator } from "@/components/ui/separator";
import { TagManager } from "@/components/entities/tag-manager";
import { ArrowLeft, FileText, Clock } from "lucide-react";
import type { Note } from "@/lib/types";
import { format, parseISO } from "date-fns";
// Note content is Tiptap-generated HTML stored by the API. Lightweight sanitizer
// applied before rendering via dangerouslySetInnerHTML: drops script/style
// blocks, inline event handlers, and javascript: URLs.
const sanitizeNoteHtml = (html: string): string =>
html
.replace(/<script\b[^>]*>[\s\S]*?<\/script\s*>/gi, "")
.replace(/<style\b[^>]*>[\s\S]*?<\/style\s*>/gi, "")
.replace(/\son[a-z]+\s*=\s*(?:"[^"]*"|'[^']*'|[^\s>]+)/gi, "")
.replace(/(\shref|\ssrc)\s*=\s*(?:"|')\s*javascript:[^"']*(?:"|')/gi, ' $1=""');
function NoteDetail() {
const { id } = useParams({ from: Route.id });
const navigate = useNavigate();
@@ -36,19 +47,38 @@ function NoteDetail() {
<span className="flex items-center gap-1"><Clock className="h-3 w-3" /> Updated {format(parseISO(note.updatedAt), "MMM d, yyyy HH:mm")}</span>
</div>
<Separator />
<div className="prose prose-sm dark:prose-invert max-w-none">
<p className="text-sm whitespace-pre-wrap">{note.content || "No content"}</p>
{/* The @tailwindcss/typography plugin isn't installed, so style the
Tiptap output with scoped CSS instead of `prose` classes. */}
<div className="note-detail-content max-w-none">
<style>{`
.note-detail-content { line-height: 1.75; }
.note-detail-content h1 { font-size: 1.75rem; font-weight: 700; line-height: 1.25; margin: 1.5rem 0 0.75rem; }
.note-detail-content h2 { font-size: 1.5rem; font-weight: 700; line-height: 1.3; margin: 1.5rem 0 0.75rem; }
.note-detail-content h3 { font-size: 1.25rem; font-weight: 600; line-height: 1.4; margin: 1.25rem 0 0.5rem; }
.note-detail-content h4 { font-size: 1.125rem; font-weight: 600; line-height: 1.4; margin: 1rem 0 0.5rem; }
.note-detail-content h5, .note-detail-content h6 { font-size: 1rem; font-weight: 600; margin: 1rem 0 0.5rem; }
.note-detail-content p { margin: 0.75rem 0; }
.note-detail-content a { color: hsl(var(--primary)); text-decoration: underline; }
.note-detail-content ul { list-style: disc; padding-left: 1.5rem; margin: 0.75rem 0; }
.note-detail-content ol { list-style: decimal; padding-left: 1.5rem; margin: 0.75rem 0; }
.note-detail-content li { margin: 0.25rem 0; }
.note-detail-content li p { margin: 0; }
.note-detail-content blockquote { border-left: 3px solid hsl(var(--border)); padding-left: 1rem; margin: 1rem 0; color: hsl(var(--muted-foreground)); }
.note-detail-content hr { border: 0; border-top: 1px solid hsl(var(--border)); margin: 1.5rem 0; }
.note-detail-content code { font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: 0.875em; background: hsl(var(--muted)); padding: 0.125rem 0.375rem; border-radius: 0.25rem; }
.note-detail-content pre { background: hsl(var(--muted)); padding: 1rem; border-radius: 0.5rem; overflow-x: auto; margin: 1rem 0; }
.note-detail-content pre code { background: transparent; padding: 0; font-size: 0.875rem; }
.note-detail-content ul[data-type="taskList"] { list-style: none; padding-left: 0.25rem; }
.note-detail-content ul[data-type="taskList"] li { display: flex; align-items: flex-start; gap: 0.5rem; }
.note-detail-content ul[data-type="taskList"] li p { flex: 1; }
`}</style>
{note.content ? (
<div dangerouslySetInnerHTML={{ __html: sanitizeNoteHtml(note.content) }} />
) : (
<p className="text-sm text-muted-foreground">No content</p>
)}
</div>
{note.tags && note.tags.length > 0 && (
<div>
<h3 className="text-sm font-semibold text-muted-foreground mb-2">Tags</h3>
<div className="flex flex-wrap gap-2">
{note.tags.map((t: any) => (
<Badge key={t.id || t.name} variant="secondary">{t.name || t}</Badge>
))}
</div>
</div>
)}
<TagManager entityType="note" entityId={note.id} tags={note.tags || []} />
</CardContent>
</Card>
</div>
+32 -10
View File
@@ -3,8 +3,9 @@ import { createRoute, useNavigate } from "@tanstack/react-router";
import { Route as appRoute } from "../_app";
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { api, useApiQuery, useApiMutation } from "@/lib/api";
import { useApiDomain } from "@/lib/stores/use-active-domain-store";
import { useRealtime } from "@/hooks/use-realtime";
import { Plus, Trash2, FolderKanban, Users, Calendar, ListTodo, GripVertical } from "lucide-react";
import { Plus, Pencil, Trash2, FolderKanban, Users, Calendar, ListTodo, GripVertical } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Badge } from "@/components/ui/badge";
@@ -18,6 +19,8 @@ import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle, AlertDialogTrigger } from "@/components/ui/alert-dialog";
import { Avatar, AvatarFallback } from "@/components/ui/avatar";
import { EntityDetailPanel } from "@/components/entities/entity-detail-panel";
import { LoadingState, EmptyState, ErrorState } from "@/components/state";
import { PROJECT_STATUS, TASK_STATUS } from "@/lib/status-colors";
import { cn } from "@/lib/utils";
import type { Project, Section, Task, PaginatedResponse } from "@/lib/types";
@@ -61,7 +64,7 @@ function ProjectForm({ project, onClose }: { project?: Project; onClose: () => v
<div className="grid grid-cols-2 gap-4">
<div>
<Label htmlFor="status">Status</Label>
<Select value={status} onValueChange={setStatus}>
<Select value={status} onValueChange={(v) => setStatus(v as "active" | "paused" | "completed" | "archived")}>
<SelectTrigger id="status"><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="active">Active</SelectItem>
@@ -100,9 +103,11 @@ function ProjectsPage() {
useRealtime({ enabled: true });
const { data: projectsData, isLoading } = useApiQuery<PaginatedResponse<Project>>(
["projects"],
"/projects?limit=200"
const activeDomainId = useApiDomain();
const { data: projectsData, isLoading, isError, refetch } = useApiQuery<PaginatedResponse<Project>>(
["projects", activeDomainId],
"/projects?limit=200" + (activeDomainId ? "&domain=" + activeDomainId : "")
);
const projects = projectsData?.items || [];
@@ -112,7 +117,11 @@ function ProjectsPage() {
onSuccess: () => { queryClient.invalidateQueries({ queryKey: ["projects"] }); setPanelOpen(false); },
});
const openProjectDetail = async (project: Project) => {
const openProjectDetail = (project: Project) => {
navigate({ to: "/projects/$id", params: { id: project.id } });
};
const openProjectPanel = async (project: Project) => {
try {
const detail = await api.get<Project>("/projects/" + project.id);
setSelectedProject(detail);
@@ -138,9 +147,11 @@ function ProjectsPage() {
</div>
{isLoading ? (
<div className="flex items-center justify-center py-12 text-muted-foreground">Loading projects...</div>
<LoadingState label="Loading projects..." />
) : isError ? (
<ErrorState message="Failed to load projects." onRetry={() => refetch()} />
) : projects.length === 0 ? (
<div className="text-center py-12 text-muted-foreground">No projects yet.</div>
<EmptyState title="No projects yet" description="Create your first project to get started." />
) : (
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
{projects.map((project) => (
@@ -149,7 +160,18 @@ function ProjectsPage() {
<div className="flex items-center gap-2">
<div className="w-3 h-3 rounded-full shrink-0" style={{ backgroundColor: project.color || "#3b82f6" }} />
<CardTitle className="text-base truncate">{project.name}</CardTitle>
<Badge variant="secondary" className="ml-auto text-[10px]">{project.status}</Badge>
<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>
@@ -208,7 +230,7 @@ function ProjectsPage() {
{selectedProject.tasks.map((task: any) => (
<div key={task.id} className="flex items-center justify-between text-sm py-1.5 border-b last:border-0">
<span className="truncate">{task.title}</span>
<Badge variant="secondary" className="text-[10px] shrink-0">{task.status}</Badge>
<Badge className={cn("text-[10px] shrink-0", TASK_STATUS[task.status]?.badge)}>{TASK_STATUS[task.status]?.label ?? task.status}</Badge>
</div>
))}
</div>
+8 -11
View File
@@ -5,18 +5,11 @@ import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Separator } from "@/components/ui/separator";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { Progress } from "@/components/ui/progress";
import { ArrowLeft, Calendar, ListTodo, Activity } from "lucide-react";
import { ArrowLeft, Calendar, Clock, ListTodo, Activity } from "lucide-react";
import type { Project } from "@/lib/types";
import { format, parseISO } from "date-fns";
const STATUS_COLORS: Record<string, string> = {
active: "bg-green-500",
paused: "bg-amber-500",
completed: "bg-blue-500",
archived: "bg-slate-500",
};
import { PROJECT_STATUS } from "@/lib/status-colors";
function ProjectDetail() {
const { id } = useParams({ from: Route.id });
@@ -27,7 +20,7 @@ function ProjectDetail() {
if (!project) return <div className="p-8 text-center text-muted-foreground">Project not found</div>;
return (
<div className="max-w-4xl mx-auto p-6 space-y-6">
<div className="max-w-2xl mx-auto p-6 space-y-6">
<Button variant="ghost" onClick={() => navigate({ to: "/projects" })} className="w-fit">
<ArrowLeft className="h-4 w-4 mr-2" /> Back to Projects
</Button>
@@ -36,10 +29,14 @@ function ProjectDetail() {
<div className="flex items-center gap-3">
<div className="w-4 h-4 rounded-full" style={{ backgroundColor: project.color || "#3b82f6" }} />
<CardTitle className="text-2xl">{project.name}</CardTitle>
<Badge className={STATUS_COLORS[project.status] || "bg-slate-500"}>{project.status}</Badge>
<Badge className={PROJECT_STATUS[project.status]?.badge}>{PROJECT_STATUS[project.status]?.label ?? project.status}</Badge>
</div>
</CardHeader>
<CardContent className="space-y-4">
<div className="flex items-center gap-4 text-xs text-muted-foreground">
<span className="flex items-center gap-1"><Clock className="h-3 w-3" /> Created {format(parseISO(project.createdAt), "MMM d, yyyy HH:mm")}</span>
<span className="flex items-center gap-1"><Clock className="h-3 w-3" /> Updated {format(parseISO(project.updatedAt), "MMM d, yyyy HH:mm")}</span>
</div>
{project.description && (
<div>
<h3 className="text-sm font-semibold text-muted-foreground mb-1">Description</h3>
+13 -1
View File
@@ -20,6 +20,18 @@ const SEARCH_TYPES = [
{ id: "domain", label: "Domains", color: "bg-indigo-500" },
];
// Sanitize snippet HTML before it hits dangerouslySetInnerHTML. The API's
// ts_headline output is safe text with matches wrapped in <mark>...</mark>.
// Allow ONLY <mark> open/close tags (and only without event handler / href /
// src attributes) so no other element, script, or attribute can be injected.
const sanitizeSnippet = (html: string) =>
html
.replace(/<script\b[^>]*>[\s\S]*?<\/script\s*>/gi, "")
.replace(/<\/?([a-zA-Z][a-zA-Z0-9-]*)(\s[^<>]*)?>/g, (full, tag) => {
if (tag.toLowerCase() === "mark" && !/<[^>]*(?:on\w+=|href=|src=)/i.test(full)) return full;
return "";
});
function SearchPage() {
const navigate = useNavigate();
const [query, setQuery] = useState("");
@@ -161,7 +173,7 @@ function SearchPage() {
{result.snippet && (
<p
className="text-xs text-muted-foreground mt-0.5 line-clamp-2"
dangerouslySetInnerHTML={{ __html: result.snippet }}
dangerouslySetInnerHTML={{ __html: sanitizeSnippet(result.snippet) }}
/>
)}
</div>
+246 -35
View File
@@ -4,6 +4,7 @@ import { Route as appRoute } from "../_app";
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { api, useApiQuery, useApiMutation } from "@/lib/api";
import { Plus, Trash2, Pencil, Palette, Sun, Moon, Monitor, Type, Maximize, Sidebar, Eye, Globe, Tag, List, Key, Bot, Webhook, Upload, Download, AlertCircle, Check, X, RefreshCw, TestTube } 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";
@@ -13,7 +14,6 @@ import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger, Dialog
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Label } from "@/components/ui/label";
import { Switch } from "@/components/ui/switch";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { ScrollArea } from "@/components/ui/scroll-area";
import { Separator } from "@/components/ui/separator";
import { AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle, AlertDialogTrigger } from "@/components/ui/alert-dialog";
@@ -62,8 +62,21 @@ function AppearanceTab() {
const [reducedMotion, setReducedMotion] = useState(localStorage.getItem("reduced-motion") === "true");
useEffect(() => { localStorage.setItem("font-size", fontSize); document.documentElement.style.fontSize = fontSize === "large" ? "18px" : fontSize === "small" ? "13px" : "16px"; }, [fontSize]);
useEffect(() => { localStorage.setItem("density", density); }, [density]);
useEffect(() => { localStorage.setItem("sidebar-position", sidebarPos); }, [sidebarPos]);
// Density actually changes spacing now: toggle the density-* classes on <html>
// (see the density utilities + --density-scale in index.css) and persist.
useEffect(() => {
localStorage.setItem("density", density);
const root = document.documentElement;
root.classList.remove("density-compact", "density-spacious");
if (density === "compact") root.classList.add("density-compact");
if (density === "spacious") root.classList.add("density-spacious");
}, [density]);
// sidebarPos actually repositions the sidebar now: persist it and tell the
// app shell (sidebar.tsx) to re-read it without a reload.
useEffect(() => {
localStorage.setItem("sidebar-position", sidebarPos);
window.dispatchEvent(new CustomEvent("sidebar-position-change", { detail: sidebarPos }));
}, [sidebarPos]);
useEffect(() => { localStorage.setItem("reduced-motion", String(reducedMotion)); document.documentElement.classList.toggle("reduce-motion", reducedMotion); }, [reducedMotion]);
return (
@@ -420,16 +433,63 @@ function AgentsTab() {
const agents = data?.items || [];
const [createOpen, setCreateOpen] = useState(false);
const [form, setForm] = useState({ name: "", description: "", permissionTier: "read_only" });
const [editAgent, setEditAgent] = useState<Agent | null>(null);
const [editForm, setEditForm] = useState({
name: "",
description: "",
status: "active",
permissionTier: "read_only",
customPermissions: "",
});
const createMutation = useMutation({
mutationFn: (d: any) => api.post<Agent>("/agents", d),
onSuccess: () => { queryClient.invalidateQueries({ queryKey: ["agents"] }); setCreateOpen(false); },
});
const updateMutation = useMutation({
mutationFn: ({ id, data: d }: { id: string; data: any }) => api.patch<Agent>("/agents/" + id, d),
onSuccess: () => { queryClient.invalidateQueries({ queryKey: ["agents"] }); setEditAgent(null); },
});
const deleteMutation = useMutation({
mutationFn: (id: string) => api.delete("/agents/" + id),
onSuccess: () => queryClient.invalidateQueries({ queryKey: ["agents"] }),
});
const openEdit = (a: Agent) => {
setEditAgent(a);
setEditForm({
name: a.name,
description: a.description || "",
status: a.status,
permissionTier: a.permissionTier,
customPermissions: (a.customPermissions || []).join(", "),
});
};
const handleSaveEdit = () => {
if (!editAgent) return;
const data: any = {
name: editForm.name.trim(),
description: editForm.description || null,
status: editForm.status,
permissionTier: editForm.permissionTier,
};
// customPermissions only apply to the "custom" tier; clear them otherwise so
// a downgrade doesn't leave stale permissions in the database.
data.customPermissions = editForm.permissionTier === "custom"
? editForm.customPermissions.split(",").map((s) => s.trim()).filter(Boolean)
: [];
updateMutation.mutate({ id: editAgent.id, data });
};
const permissionLabels: Record<string, string> = {
full_access: "Full Access",
read_only: "Read Only",
content_creator: "Content Creator",
task_manager: "Task Manager",
custom: "Custom",
};
return (
<div className="space-y-4">
<div className="flex items-center justify-between">
@@ -444,11 +504,9 @@ function AgentsTab() {
<div><Label>Permission Tier</Label><Select value={form.permissionTier} onValueChange={(v) => setForm({ ...form, permissionTier: v })}>
<SelectTrigger><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="full_access">Full Access</SelectItem>
<SelectItem value="read_only">Read Only</SelectItem>
<SelectItem value="content_creator">Content Creator</SelectItem>
<SelectItem value="task_manager">Task Manager</SelectItem>
<SelectItem value="custom">Custom</SelectItem>
{Object.entries(permissionLabels).map(([value, label]) => (
<SelectItem key={value} value={value}>{label}</SelectItem>
))}
</SelectContent>
</Select></div>
<Button onClick={() => createMutation.mutate(form)} disabled={!form.name.trim() || createMutation.isPending}>Create</Button>
@@ -459,20 +517,73 @@ function AgentsTab() {
<div className="space-y-2">
{agents.map((a) => (
<div key={a.id} className="flex items-center justify-between p-3 rounded-lg border">
<div>
<p className="font-medium">{a.name}</p>
<p className="text-xs text-muted-foreground">{a.permissionTier.replace(/_/g, " ")} &middot; {a.status}</p>
<div className="min-w-0">
<p className="font-medium truncate">{a.name}</p>
{a.description && <p className="text-xs text-muted-foreground truncate">{a.description}</p>}
<p className="text-xs text-muted-foreground">
{permissionLabels[a.permissionTier] || a.permissionTier} &middot; {a.status}
</p>
{a.permissionTier === "custom" && a.customPermissions.length > 0 && (
<div className="flex flex-wrap gap-1 mt-1">
{a.customPermissions.map((p) => <Badge key={p} variant="secondary" className="text-[10px]">{p}</Badge>)}
</div>
)}
</div>
<div className="flex gap-1 shrink-0">
<Button variant="ghost" size="icon" className="h-8 w-8" onClick={() => openEdit(a)} aria-label={"Edit " + a.name}><Pencil className="h-4 w-4" /></Button>
<AlertDialog>
<AlertDialogTrigger asChild><Button variant="ghost" size="icon" className="h-8 w-8 text-destructive"><Trash2 className="h-4 w-4" /></Button></AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader><AlertDialogTitle>Delete Agent</AlertDialogTitle><AlertDialogDescription>Are you sure?</AlertDialogDescription></AlertDialogHeader>
<AlertDialogFooter><AlertDialogCancel>Cancel</AlertDialogCancel><AlertDialogAction onClick={() => deleteMutation.mutate(a.id)} className="bg-destructive">Delete</AlertDialogAction></AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
<AlertDialog>
<AlertDialogTrigger asChild><Button variant="ghost" size="icon" className="text-destructive"><Trash2 className="h-4 w-4" /></Button></AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader><AlertDialogTitle>Delete Agent</AlertDialogTitle><AlertDialogDescription>Are you sure?</AlertDialogDescription></AlertDialogHeader>
<AlertDialogFooter><AlertDialogCancel>Cancel</AlertDialogCancel><AlertDialogAction onClick={() => deleteMutation.mutate(a.id)} className="bg-destructive">Delete</AlertDialogAction></AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
))}
</div>
{editAgent && (
<Dialog open={!!editAgent} onOpenChange={(o) => { if (!o) setEditAgent(null); }}>
<DialogContent>
<DialogHeader><DialogTitle>Edit Agent</DialogTitle></DialogHeader>
<div className="space-y-4">
<div><Label>Name</Label><Input value={editForm.name} onChange={(e) => setEditForm({ ...editForm, name: e.target.value })} /></div>
<div><Label>Description</Label><Textarea value={editForm.description} onChange={(e) => setEditForm({ ...editForm, description: e.target.value })} /></div>
<div><Label>Status</Label><Select value={editForm.status} onValueChange={(v) => setEditForm({ ...editForm, status: v })}>
<SelectTrigger><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="active">Active</SelectItem>
<SelectItem value="disabled">Disabled</SelectItem>
</SelectContent>
</Select></div>
<div><Label>Permission Tier</Label><Select value={editForm.permissionTier} onValueChange={(v) => setEditForm({ ...editForm, permissionTier: v })}>
<SelectTrigger><SelectValue /></SelectTrigger>
<SelectContent>
{Object.entries(permissionLabels).map(([value, label]) => (
<SelectItem key={value} value={value}>{label}</SelectItem>
))}
</SelectContent>
</Select></div>
{editForm.permissionTier === "custom" && (
<div><Label>Custom Permissions (comma-separated)</Label><Input value={editForm.customPermissions} onChange={(e) => setEditForm({ ...editForm, customPermissions: e.target.value })} placeholder="tasks.write, notes.read, ..." /></div>
)}
{editAgent.apiKey && (
<div>
<Label>API Key</Label>
<div className="flex items-center gap-2">
<Input value={editAgent.apiKey} readOnly className="font-mono text-xs" aria-label="Agent API key" />
<Button variant="outline" size="sm" className="h-9 shrink-0" onClick={() => { navigator.clipboard.writeText(editAgent.apiKey as string); toast.success("API key copied"); }}>
Copy
</Button>
</div>
<p className="text-xs text-muted-foreground mt-1">Shown only here store it securely before rotating.</p>
</div>
)}
<Button onClick={handleSaveEdit} disabled={!editForm.name.trim() || updateMutation.isPending}>Save</Button>
</div>
</DialogContent>
</Dialog>
)}
</div>
);
}
@@ -487,7 +598,7 @@ function WebhooksTab() {
const [form, setForm] = useState({ name: "", url: "", events: "task.created,note.created" });
const createMutation = useMutation({
mutationFn: (d: any) => api.post<Webhook>("/webhooks", d),
mutationFn: (d: any) => api.post<WebhookType>("/webhooks", d),
onSuccess: () => { queryClient.invalidateQueries({ queryKey: ["webhooks"] }); setCreateOpen(false); },
});
const deleteMutation = useMutation({
@@ -546,6 +657,46 @@ function WebhooksTab() {
// ─── Import & Export Tab ─────────────────────────────────────────────────
// CSV export helpers. The export API always returns JSON; when the user picks
// CSV we convert each collection client-side. Nested values (tags arrays,
// customFields objects, etc.) are JSON-stringified into a single cell.
/** Escape a single CSV field per RFC 4180: wrap in quotes when needed, double inner quotes. */
function csvEscape(value: string): string {
if (/[",\n\r]/.test(value)) {
return '"' + value.replace(/"/g, '""') + '"';
}
return value;
}
/** Render one cell: null/undefined → empty, scalars pass through, objects/arrays get JSON-stringified. */
function csvCell(value: unknown): string {
if (value === null || value === undefined) return "";
if (typeof value === "string") return csvEscape(value);
if (typeof value === "number" || typeof value === "boolean") return String(value);
return csvEscape(JSON.stringify(value));
}
/** Build a CSV document (header + one row per object) from an array of flat rows. */
function rowsToCSV(rows: Record<string, unknown>[]): string {
if (rows.length === 0) return "";
const columns = [...new Set(rows.flatMap((r) => Object.keys(r)))];
const header = columns.map(csvEscape).join(",");
const body = rows.map((r) => columns.map((c) => csvCell(r[c])).join(","));
return [header, ...body].join("\r\n") + "\r\n";
}
function downloadBlob(blob: Blob, filename: string) {
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = filename;
document.body.appendChild(a);
a.click();
a.remove();
URL.revokeObjectURL(url);
}
function ImportExportTab() {
const queryClient = useQueryClient();
const [importData, setImportData] = useState("");
@@ -554,22 +705,76 @@ function ImportExportTab() {
const [exportCollections, setExportCollections] = useState<string[]>(["tasks", "habits", "projects", "notes"]);
const importMutation = useMutation({
mutationFn: (data: any) => api.post("/import", data),
onSuccess: (res) => { setImportResult(res); queryClient.invalidateQueries(); },
mutationFn: (data: any) => api.post<any>("/import", data),
onSuccess: (res) => {
setImportResult(res);
queryClient.invalidateQueries();
if (res.success) {
toast.success("Imported " + res.imported + " items");
} else {
toast.error("Imported " + (res.imported ?? 0) + " items, " + (res.failed ?? 0) + " failed");
}
},
onError: (err) => {
const message = err.message || "Import failed";
setImportResult({ success: false, error: message });
toast.error(message);
},
});
const handleImport = () => {
let parsed: any;
try {
parsed = JSON.parse(importData);
} catch {
const message = "Invalid JSON in import data";
setImportResult({ success: false, error: message });
toast.error(message);
return;
}
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
const message = "Invalid format: expected an object with a version field";
setImportResult({ success: false, error: message });
toast.error(message);
return;
}
if (!parsed.version) {
const message = "Invalid format: missing version";
setImportResult({ success: false, error: message });
toast.error(message);
return;
}
importMutation.mutate(parsed);
};
const handleExport = async () => {
try {
const data = await api.post<any>("/export", { collections: exportCollections });
const blob = new Blob([JSON.stringify(data, null, 2)], { type: "application/json" });
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = "project-e-export.json";
a.click();
URL.revokeObjectURL(url);
if (exportFormat === "csv") {
// One CSV file per selected collection; empty collections are skipped.
let exported = 0;
for (const collection of exportCollections) {
const rows = Array.isArray(data[collection]) ? data[collection] : [];
if (rows.length === 0) continue;
const csv = rowsToCSV(rows);
downloadBlob(new Blob([csv], { type: "text/csv;charset=utf-8" }), "project-e-export-" + collection + ".csv");
exported++;
}
if (exported === 0) {
toast.error("No data to export for the selected collections");
} else {
toast.success("Exported " + exported + " CSV file" + (exported === 1 ? "" : "s"));
}
return;
}
// JSON export — unchanged.
downloadBlob(new Blob([JSON.stringify(data, null, 2)], { type: "application/json" }), "project-e-export.json");
} catch (e) {
const message = e instanceof Error ? e.message : "Export failed";
console.error("Export failed", e);
toast.error(message);
}
};
@@ -583,12 +788,16 @@ function ImportExportTab() {
<h3 className="text-lg font-semibold mb-3">Import</h3>
<p className="text-sm text-muted-foreground mb-3">Paste JSON data to import. Format: {"{"} "version": "1.0", "tasks": [...], "habits": [...], "projects": [...], "notes": [...] {"}"}</p>
<Textarea value={importData} onChange={(e) => setImportData(e.target.value)} placeholder='{"version": "1.0", "tasks": [...]}' rows={6} className="font-mono text-sm" />
<Button className="mt-2" onClick={() => { try { importMutation.mutate(JSON.parse(importData)); } catch { setImportResult({ success: false, error: "Invalid JSON" }); } }} disabled={!importData.trim() || importMutation.isPending}>
<Button className="mt-2" onClick={handleImport} disabled={!importData.trim() || importMutation.isPending}>
<Upload className="h-4 w-4 mr-2" />Import
</Button>
{importResult && (
<div className={cn("mt-3 p-3 rounded-lg text-sm", importResult.success ? "bg-green-500/10 text-green-600" : "bg-red-500/10 text-red-600")}>
{importResult.success ? "Imported " + importResult.imported + " items" : "Import failed: " + (importResult.error || "Unknown error")}
{importResult.success
? "Imported " + importResult.imported + " items"
: importResult.error
? "Import failed: " + importResult.error
: "Imported " + (importResult.imported ?? 0) + " items, " + (importResult.failed ?? 0) + " failed"}
</div>
)}
</div>
@@ -691,9 +900,9 @@ function SettingsPage() {
const [activeTab, setActiveTab] = useState("appearance");
return (
<div className="flex gap-6 h-[calc(100vh-5rem)]">
{/* Sidebar tabs */}
<div className="w-56 shrink-0 space-y-1">
<div className="flex flex-col md:flex-row gap-2 md:gap-6 h-auto md:h-[calc(100vh-5rem)]">
{/* Tab bar - horizontal scrollable on mobile, vertical sidebar on md+ */}
<div className="flex md:flex-col gap-1 md:gap-0 overflow-x-auto md:overflow-visible pb-1 md:pb-0 md:w-56 md:shrink-0 md:space-y-1 shrink-0">
{SETTINGS_TABS.map((tab) => {
const Icon = tab.icon;
return (
@@ -701,7 +910,8 @@ function SettingsPage() {
key={tab.id}
onClick={() => setActiveTab(tab.id)}
className={cn(
"w-full flex items-center gap-2.5 px-3 py-2 rounded-lg text-sm font-medium transition-colors text-left",
"flex items-center gap-2.5 px-3 py-2 rounded-lg text-sm font-medium transition-colors text-left whitespace-nowrap shrink-0",
"md:w-full",
activeTab === tab.id ? "bg-primary/10 text-primary" : "text-muted-foreground hover:text-foreground hover:bg-muted/50"
)}
>
@@ -711,7 +921,8 @@ function SettingsPage() {
);
})}
</div>
<Separator orientation="vertical" />
<Separator className="md:hidden" />
<Separator orientation="vertical" className="hidden md:block" />
{/* Content */}
<div className="flex-1 overflow-auto">
<ScrollArea className="h-full pr-4">
+140 -32
View File
@@ -3,11 +3,12 @@ import { createRoute, useNavigate } from "@tanstack/react-router";
import { Route as appRoute } from "../_app";
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { api, useApiQuery, useApiMutation } from "@/lib/api";
import { useApiDomain } from "@/lib/stores/use-active-domain-store";
import { useRealtime } from "@/hooks/use-realtime";
import { DndContext, DragOverlay, closestCorners, KeyboardSensor, PointerSensor, useSensor, useSensors, type DragEndEvent, type DragStartEvent } from "@dnd-kit/core";
import { DndContext, DragOverlay, closestCorners, KeyboardSensor, PointerSensor, useSensor, useSensors, useDroppable, type DragEndEvent, type DragStartEvent } from "@dnd-kit/core";
import { SortableContext, verticalListSortingStrategy, useSortable } from "@dnd-kit/sortable";
import { CSS } from "@dnd-kit/utilities";
import { Plus, GripVertical, Trash2, Calendar, Clock, ListTodo, Layout as LayoutIcon, Search, Filter, MoreHorizontal } from "lucide-react";
import { Plus, GripVertical, Pencil, Trash2, Calendar, Clock, ListTodo, Layout as LayoutIcon, Search, Filter, MoreHorizontal } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Badge } from "@/components/ui/badge";
@@ -23,24 +24,20 @@ import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/comp
import { EntityDetailPanel } from "@/components/entities/entity-detail-panel";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuSeparator, DropdownMenuTrigger } from "@/components/ui/dropdown-menu";
import { LoadingState, EmptyState, ErrorState } from "@/components/state";
import { CustomFieldInputs } from "@/components/custom-fields/custom-field-inputs";
import { TASK_STATUS, PRIORITY } from "@/lib/status-colors";
import type { Task, PaginatedResponse } from "@/lib/types";
import { cn } from "@/lib/utils";
const STATUS_COLUMNS = [
{ id: "todo", label: "Todo", color: "bg-slate-500" },
{ id: "in_progress", label: "In Progress", color: "bg-blue-500" },
{ id: "done", label: "Done", color: "bg-green-500" },
{ id: "cancelled", label: "Cancelled", color: "bg-red-500" },
{ id: "todo", label: "Todo" },
{ id: "in_progress", label: "In Progress" },
{ id: "done", label: "Done" },
{ id: "cancelled", label: "Cancelled" },
];
const PRIORITY_COLORS: Record<string, string> = {
urgent: "text-red-500 bg-red-500/10",
high: "text-orange-500 bg-orange-500/10",
medium: "text-blue-500 bg-blue-500/10",
low: "text-slate-500 bg-slate-500/10",
};
function SortableTaskCard({ task, onClick }: { task: Task; onClick: () => void }) {
function SortableTaskCard({ task, onClick, onEdit }: { task: Task; onClick: () => void; onEdit?: () => void }) {
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({ id: task.id });
const style = {
@@ -64,8 +61,8 @@ function SortableTaskCard({ task, onClick }: { task: Task; onClick: () => void }
{new Date(task.dueDate).toLocaleDateString()}
</Badge>
)}
<Badge variant="secondary" className={cn("text-[10px]", PRIORITY_COLORS[task.priority])}>
{task.priority}
<Badge variant="secondary" className={cn("text-[10px]", PRIORITY[task.priority]?.badge)}>
{PRIORITY[task.priority]?.label ?? task.priority}
</Badge>
{task.tags?.slice(0, 2).map((tag) => (
<Badge key={tag.id} variant="outline" className="text-[10px]" style={{ borderColor: tag.color || undefined }}>
@@ -74,6 +71,17 @@ function SortableTaskCard({ task, onClick }: { task: Task; onClick: () => void }
))}
</div>
</div>
{onEdit && (
<Button
variant="ghost"
size="icon"
className="h-7 w-7 -mt-1 -mr-1 shrink-0"
onClick={(e) => { e.stopPropagation(); onEdit(); }}
aria-label={"Edit " + task.title}
>
<Pencil className="h-3.5 w-3.5" />
</Button>
)}
</div>
</CardContent>
</Card>
@@ -81,6 +89,15 @@ function SortableTaskCard({ task, onClick }: { task: Task; onClick: () => void }
);
}
function ColumnDroppable({ id, className, children }: { id: string; className?: string; children: React.ReactNode }) {
const { setNodeRef, isOver } = useDroppable({ id });
return (
<div ref={setNodeRef} className={cn(className, isOver && "ring-2 ring-primary/40 bg-primary/10")}>
{children}
</div>
);
}
function TaskForm({ task, onClose }: { task?: Task; onClose: () => void }) {
const queryClient = useQueryClient();
const [title, setTitle] = useState(task?.title || "");
@@ -88,6 +105,7 @@ function TaskForm({ task, onClose }: { task?: Task; onClose: () => void }) {
const [status, setStatus] = useState(task?.status || "todo");
const [priority, setPriority] = useState(task?.priority || "medium");
const [dueDate, setDueDate] = useState(task?.dueDate ? task.dueDate.slice(0, 10) : "");
const [customFieldValues, setCustomFieldValues] = useState<Record<string, unknown>>(() => ({ ...(task?.customFields ?? {}) }));
const createMutation = useMutation({
mutationFn: (data: any) => api.post<Task>("/tasks", data),
@@ -110,6 +128,8 @@ function TaskForm({ task, onClose }: { task?: Task; onClose: () => void }) {
if (!title.trim()) return;
const data: any = { title: title.trim(), description: description || null, status, priority };
if (dueDate) data.dueDate = new Date(dueDate).toISOString();
const customFields = { ...customFieldValues };
if (Object.keys(customFields).length > 0) data.customFields = customFields;
if (task) {
updateMutation.mutate(data);
} else {
@@ -130,7 +150,7 @@ function TaskForm({ task, onClose }: { task?: Task; onClose: () => void }) {
<div className="grid grid-cols-2 gap-4">
<div>
<Label htmlFor="status">Status</Label>
<Select value={status} onValueChange={setStatus}>
<Select value={status} onValueChange={(v) => setStatus(v as "todo" | "in_progress" | "done" | "cancelled")}>
<SelectTrigger id="status"><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="todo">Todo</SelectItem>
@@ -142,7 +162,7 @@ function TaskForm({ task, onClose }: { task?: Task; onClose: () => void }) {
</div>
<div>
<Label htmlFor="priority">Priority</Label>
<Select value={priority} onValueChange={setPriority}>
<Select value={priority} onValueChange={(v) => setPriority(v as "low" | "medium" | "high" | "urgent")}>
<SelectTrigger id="priority"><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="low">Low</SelectItem>
@@ -157,6 +177,7 @@ function TaskForm({ task, onClose }: { task?: Task; onClose: () => void }) {
<Label htmlFor="dueDate">Due Date</Label>
<Input id="dueDate" type="date" value={dueDate} onChange={(e) => setDueDate(e.target.value)} />
</div>
<CustomFieldInputs entityType="tasks" values={customFieldValues} onChange={setCustomFieldValues} />
<div className="flex justify-end gap-2">
<Button type="button" variant="outline" onClick={onClose}>Cancel</Button>
<Button type="submit" disabled={createMutation.isPending || updateMutation.isPending}>
@@ -180,9 +201,11 @@ function TasksPage() {
useRealtime({ enabled: true });
const { data: tasksData, isLoading } = useApiQuery<PaginatedResponse<Task>>(
["tasks", search, statusFilter],
"/tasks?" + new URLSearchParams({ limit: "200", ...(search ? { search } : {}), ...(statusFilter ? { status: statusFilter } : {}) }).toString()
const activeDomainId = useApiDomain();
const { data: tasksData, isLoading, isError, refetch } = useApiQuery<PaginatedResponse<Task>>(
["tasks", activeDomainId, search, statusFilter],
"/tasks?" + new URLSearchParams({ limit: "200", ...(activeDomainId ? { domain: activeDomainId } : {}), ...(search ? { search } : {}), ...(statusFilter ? { status: statusFilter } : {}) }).toString()
);
const tasks = tasksData?.items || [];
@@ -193,6 +216,20 @@ function TasksPage() {
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["tasks"] });
},
onError: () => {
queryClient.invalidateQueries({ queryKey: ["tasks"] });
},
});
const reorderMutation = useMutation({
mutationFn: ({ orderedIds }: { orderedIds: string[] }) =>
api.post("/tasks/reorder", { orderedIds }),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["tasks"] });
},
onError: () => {
queryClient.invalidateQueries({ queryKey: ["tasks"] });
},
});
const deleteMutation = useMutation({
@@ -218,14 +255,78 @@ function TasksPage() {
if (!over) return;
const taskId = active.id as string;
const targetColumn = over.id as string;
const overId = over.id as string;
if (STATUS_COLUMNS.some((c) => c.id === targetColumn)) {
const draggedTask = tasks.find((t) => t.id === taskId);
if (!draggedTask) return;
// Tasks of a column in persisted order
const columnTasks = (status: string) =>
tasks
.filter((t) => t.status === status)
.sort((a, b) => a.order - b.order);
// Decide the target column and insertion index:
// - over a column id => drop at the end of that column (handles empty columns)
// - over a task id => drop at that task's position within its column
let targetColumn: string;
let insertIndex: number;
if (STATUS_COLUMNS.some((c) => c.id === overId)) {
targetColumn = overId;
insertIndex = -1;
} else {
const overTask = tasks.find((t) => t.id === overId);
if (!overTask) return;
targetColumn = overTask.status;
const overIndex = columnTasks(targetColumn).findIndex((t) => t.id === overId);
insertIndex = overIndex === -1 ? -1 : overIndex;
}
// Build the new ordered id list for the target column
const targetIds = columnTasks(targetColumn)
.map((t) => t.id)
.filter((id) => id !== taskId);
if (insertIndex === -1) {
targetIds.push(taskId);
} else {
targetIds.splice(Math.min(insertIndex, targetIds.length), 0, taskId);
}
// No-op when the task is already in that exact spot
const currentIds = columnTasks(targetColumn).map((t) => t.id);
const unchanged =
currentIds.length === targetIds.length &&
currentIds.every((id, i) => id === targetIds[i]);
if (unchanged) return;
// Optimistic local update so the board reorders immediately
const statusChanged = draggedTask.status !== targetColumn;
const orderById = new Map(targetIds.map((id, i) => [id, i]));
queryClient.setQueryData<PaginatedResponse<Task>>(["tasks", activeDomainId, search, statusFilter], (old) => {
if (!old) return old;
return {
...old,
items: old.items.map((t) => {
if (t.id === taskId && statusChanged) {
return { ...t, status: targetColumn as Task["status"], order: orderById.get(t.id) ?? t.order };
}
const order = orderById.get(t.id);
return order !== undefined ? { ...t, order } : t;
}),
};
});
if (statusChanged) {
statusMutation.mutate({ id: taskId, status: targetColumn });
}
reorderMutation.mutate({ orderedIds: targetIds });
};
const openTaskDetail = (task: Task) => {
navigate({ to: "/tasks/$id", params: { id: task.id } });
};
const openTaskPanel = (task: Task) => {
setSelectedTask(task);
setPanelOpen(true);
};
@@ -233,7 +334,10 @@ function TasksPage() {
const columns = useMemo(() => {
return STATUS_COLUMNS.map((col) => ({
...col,
tasks: tasks.filter((t) => t.status === col.id),
color: TASK_STATUS[col.id].dot,
tasks: tasks
.filter((t) => t.status === col.id)
.sort((a, b) => a.order - b.order),
}));
}, [tasks]);
@@ -280,12 +384,14 @@ function TasksPage() {
</div>
{isLoading ? (
<div className="flex items-center justify-center py-12 text-muted-foreground">Loading tasks...</div>
<LoadingState label="Loading tasks..." />
) : isError ? (
<ErrorState message="Failed to load tasks." onRetry={() => refetch()} />
) : view === "board" ? (
<DndContext sensors={sensors} collisionDetection={closestCorners} onDragStart={handleDragStart} onDragEnd={handleDragEnd}>
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4">
{columns.map((col) => (
<div key={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 gap-2">
<div className={cn("w-2 h-2 rounded-full", col.color)} />
@@ -296,14 +402,14 @@ function TasksPage() {
<SortableContext items={col.tasks.map((t) => t.id)} strategy={verticalListSortingStrategy}>
<div className="space-y-2 min-h-[100px]">
{col.tasks.map((task) => (
<SortableTaskCard key={task.id} task={task} onClick={() => openTaskDetail(task)} />
<SortableTaskCard key={task.id} task={task} onClick={() => openTaskDetail(task)} onEdit={() => openTaskPanel(task)} />
))}
{col.tasks.length === 0 && (
<p className="text-xs text-muted-foreground text-center py-4">No tasks</p>
)}
</div>
</SortableContext>
</div>
</ColumnDroppable>
))}
</div>
<DragOverlay>
@@ -325,16 +431,18 @@ function TasksPage() {
<TableBody>
{tasks.length === 0 ? (
<TableRow>
<TableCell colSpan={5} className="text-center text-muted-foreground py-8">No tasks found</TableCell>
<TableCell colSpan={5}>
<EmptyState title="No tasks found" />
</TableCell>
</TableRow>
) : tasks.map((task) => (
<TableRow key={task.id} className="cursor-pointer" onClick={() => openTaskDetail(task)}>
<TableCell className="font-medium">{task.title}</TableCell>
<TableCell>
<Badge variant="secondary" className="text-[10px]">{task.status.replace("_", " ")}</Badge>
<Badge className={cn("text-[10px]", TASK_STATUS[task.status]?.badge)}>{task.status.replace("_", " ")}</Badge>
</TableCell>
<TableCell>
<Badge variant="outline" className={cn("text-[10px]", PRIORITY_COLORS[task.priority])}>{task.priority}</Badge>
<Badge variant="outline" className={cn("text-[10px]", PRIORITY[task.priority]?.badge)}>{task.priority}</Badge>
</TableCell>
<TableCell className="text-sm text-muted-foreground">
{task.dueDate ? new Date(task.dueDate).toLocaleDateString() : "-"}
@@ -345,7 +453,7 @@ function TasksPage() {
<Button variant="ghost" size="icon" className="h-8 w-8"><MoreHorizontal className="h-4 w-4" /></Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem onClick={() => openTaskDetail(task)}>Edit</DropdownMenuItem>
<DropdownMenuItem onClick={() => openTaskPanel(task)}>Edit</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem className="text-destructive" onClick={() => deleteMutation.mutate(task.id)}>Delete</DropdownMenuItem>
</DropdownMenuContent>
+13 -27
View File
@@ -5,23 +5,12 @@ import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Separator } from "@/components/ui/separator";
import { TagManager } from "@/components/entities/tag-manager";
import { CustomFieldsDisplay } from "@/components/custom-fields/custom-fields-display";
import { ArrowLeft, Calendar, Clock, ListTodo } from "lucide-react";
import type { Task } from "@/lib/types";
import { format, parseISO } from "date-fns";
const STATUS_COLORS: Record<string, string> = {
todo: "bg-slate-500",
in_progress: "bg-blue-500",
done: "bg-green-500",
cancelled: "bg-red-500",
};
const PRIORITY_COLORS: Record<string, string> = {
low: "bg-slate-400",
medium: "bg-amber-500",
high: "bg-orange-500",
urgent: "bg-red-500",
};
import { TASK_STATUS, PRIORITY } from "@/lib/status-colors";
function TaskDetail() {
const { id } = useParams({ from: Route.id });
@@ -39,14 +28,19 @@ function TaskDetail() {
<Card>
<CardHeader>
<div className="flex items-center gap-3">
<ListTodo className="h-6 w-6 text-muted-foreground" />
<CardTitle className="text-2xl">{task.title}</CardTitle>
<Badge className={STATUS_COLORS[task.status] || "bg-slate-500"}>{task.status.replace("_", " ")}</Badge>
<Badge variant="outline" className={PRIORITY_COLORS[task.priority]}>
{task.priority}
<Badge className={TASK_STATUS[task.status]?.badge}>{TASK_STATUS[task.status]?.label ?? task.status}</Badge>
<Badge variant="outline" className={PRIORITY[task.priority]?.badge}>
{PRIORITY[task.priority]?.label ?? task.priority}
</Badge>
</div>
</CardHeader>
<CardContent className="space-y-4">
<div className="flex items-center gap-4 text-xs text-muted-foreground">
<span className="flex items-center gap-1"><Clock className="h-3 w-3" /> Created {format(parseISO(task.createdAt), "MMM d, yyyy HH:mm")}</span>
<span className="flex items-center gap-1"><Clock className="h-3 w-3" /> Updated {format(parseISO(task.updatedAt), "MMM d, yyyy HH:mm")}</span>
</div>
{task.description && (
<div>
<h3 className="text-sm font-semibold text-muted-foreground mb-1">Description</h3>
@@ -72,16 +66,8 @@ function TaskDetail() {
<span>Status: {task.status.replace("_", " ")}</span>
</div>
</div>
{task.tags && task.tags.length > 0 && (
<div>
<h3 className="text-sm font-semibold text-muted-foreground mb-2">Tags</h3>
<div className="flex flex-wrap gap-2">
{task.tags.map((t: any) => (
<Badge key={t.id || t.name} variant="secondary">{t.name || t}</Badge>
))}
</div>
</div>
)}
<CustomFieldsDisplay entityType="tasks" values={task.customFields} />
<TagManager entityType="task" entityId={task.id} tags={task.tags || []} />
</CardContent>
</Card>
</div>
+77 -35
View File
@@ -1,16 +1,24 @@
import { createRoute, useNavigate } from "@tanstack/react-router";
import { Route as rootRoute } from "./__root";
import { useState } from "react";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Loader2, Sparkles } from "lucide-react";
function LoginPage() {
const navigate = useNavigate();
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
const [error, setError] = useState("");
const [isSubmitting, setIsSubmitting] = useState(false);
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (isSubmitting) return;
setError("");
setIsSubmitting(true);
try {
const res = await fetch("/api/auth/credentials", {
method: "POST",
@@ -25,46 +33,80 @@ function LoginPage() {
navigate({ to: "/" });
} catch {
setError("Network error");
} finally {
setIsSubmitting(false);
}
};
return (
<div className="flex items-center justify-center min-h-screen">
<form
onSubmit={handleSubmit}
className="w-full max-w-sm p-8 space-y-4 border rounded-lg"
>
<h1 className="text-2xl font-bold text-center">Sign In</h1>
{error && (
<p className="text-sm text-destructive text-center">{error}</p>
)}
<div>
<label className="block text-sm font-medium mb-1">Email</label>
<input
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
className="w-full px-3 py-2 border rounded-md bg-background"
required
/>
<div className="flex min-h-screen items-center justify-center bg-background px-4">
<div className="w-full max-w-sm">
<div className="mb-6 flex flex-col items-center gap-3 text-center">
<span
className="flex h-12 w-12 items-center justify-center rounded-xl shadow-md"
style={{ backgroundColor: "hsl(var(--accent-hsl))" }}
>
<Sparkles className="h-6 w-6 text-white" aria-hidden="true" />
</span>
<div>
<h1 className="text-2xl font-bold tracking-tight">Project E</h1>
<p className="mt-1 text-sm text-muted-foreground">
Sign in to your workspace
</p>
</div>
</div>
<div>
<label className="block text-sm font-medium mb-1">Password</label>
<input
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
className="w-full px-3 py-2 border rounded-md bg-background"
required
/>
</div>
<button
type="submit"
className="w-full py-2 px-4 bg-primary text-primary-foreground rounded-md hover:opacity-90"
>
Sign In
</button>
</form>
<Card className="shadow-md">
<CardHeader>
<CardTitle>Sign in</CardTitle>
<CardDescription>
Enter your credentials to continue
</CardDescription>
</CardHeader>
<CardContent>
{error && (
<div
role="alert"
className="mb-4 rounded-md border border-destructive/30 bg-destructive/10 p-3 text-sm text-destructive"
>
{error}
</div>
)}
<form onSubmit={handleSubmit} className="space-y-4">
<div className="space-y-1.5">
<Label htmlFor="email">Email</Label>
<Input
id="email"
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
placeholder="you@example.com"
autoComplete="email"
autoFocus
required
/>
</div>
<div className="space-y-1.5">
<Label htmlFor="password">Password</Label>
<Input
id="password"
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
autoComplete="current-password"
required
/>
</div>
<Button type="submit" className="w-full" disabled={isSubmitting}>
{isSubmitting && (
<Loader2 className="h-4 w-4 animate-spin" aria-hidden="true" />
)}
{isSubmitting ? "Signing in..." : "Sign in"}
</Button>
</form>
</CardContent>
</Card>
</div>
</div>
);
}
+1
View File
@@ -0,0 +1 @@
/// <reference types="vite/client" />
+3
View File
@@ -6,6 +6,9 @@ export default {
content: ["./index.html", "./src/**/*.{ts,tsx}"],
theme: {
extend: {
fontFamily: {
sans: ["var(--font-sans)"],
},
colors: {
border: "hsl(var(--border))",
input: "hsl(var(--input))",