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
+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;