From 142d80fb9c53b19cd6430d16f2c88f84fc3336fc Mon Sep 17 00:00:00 2001 From: Owais Jamil Date: Fri, 2 Jan 2026 16:07:06 -0600 Subject: [PATCH] feat: add notes graph visualization and sidebar filtering for notes management --- web/package.json | 14 +- web/pnpm-lock.yaml | 151 +++++++++++++++ web/src/components/notes/NotesGraph.tsx | 179 ++++++++++++++++++ web/src/components/notes/NotesSidebar.tsx | 127 +++++++++++++ .../notes/tests/NotesGraph.test.tsx | 81 ++++++++ .../notes/tests/NotesSidebar.test.tsx | 114 +++++++++++ web/src/pages/Notes.tsx | 38 +++- 7 files changed, 692 insertions(+), 12 deletions(-) create mode 100644 web/src/components/notes/NotesGraph.tsx create mode 100644 web/src/components/notes/NotesSidebar.tsx create mode 100644 web/src/components/notes/tests/NotesGraph.test.tsx create mode 100644 web/src/components/notes/tests/NotesSidebar.test.tsx diff --git a/web/package.json b/web/package.json index 8fe0f0f..a18850a 100644 --- a/web/package.json +++ b/web/package.json @@ -28,6 +28,10 @@ "@textcomplete/core": "^0.1.0", "@textcomplete/textarea": "^0.1.0", "clsx": "^2.1.1", + "d3-drag": "^3.0.0", + "d3-force": "^3.0.0", + "d3-selection": "^3.0.0", + "d3-zoom": "^3.0.0", "motion": "^12.23.26", "rehype-external-links": "^3.0.0", "rehype-sanitize": "^6.0.0", @@ -51,6 +55,10 @@ "@tailwindcss/forms": "^0.5.11", "@testing-library/jest-dom": "^6.9.1", "@testing-library/user-event": "^14.6.1", + "@types/d3-drag": "^3.0.7", + "@types/d3-force": "^3.0.10", + "@types/d3-selection": "^3.0.11", + "@types/d3-zoom": "^3.0.8", "@types/node": "^24.10.1", "@typescript-eslint/parser": "^8.50.1", "eslint": "^9.39.2", @@ -65,9 +73,5 @@ "vite-plugin-solid": "^2.11.10", "vitest": "^4.0.16" }, - "pnpm": { - "overrides": { - "vite": "npm:rolldown-vite@7.2.5" - } - } + "pnpm": { "overrides": { "vite": "npm:rolldown-vite@7.2.5" } } } diff --git a/web/pnpm-lock.yaml b/web/pnpm-lock.yaml index 7129af2..aaaf1f4 100644 --- a/web/pnpm-lock.yaml +++ b/web/pnpm-lock.yaml @@ -56,6 +56,18 @@ importers: clsx: specifier: ^2.1.1 version: 2.1.1 + d3-drag: + specifier: ^3.0.0 + version: 3.0.0 + d3-force: + specifier: ^3.0.0 + version: 3.0.0 + d3-selection: + specifier: ^3.0.0 + version: 3.0.0 + d3-zoom: + specifier: ^3.0.0 + version: 3.0.0 motion: specifier: ^12.23.26 version: 12.23.26 @@ -120,6 +132,18 @@ importers: '@testing-library/user-event': specifier: ^14.6.1 version: 14.6.1(@testing-library/dom@10.4.1) + '@types/d3-drag': + specifier: ^3.0.7 + version: 3.0.7 + '@types/d3-force': + specifier: ^3.0.10 + version: 3.0.10 + '@types/d3-selection': + specifier: ^3.0.11 + version: 3.0.11 + '@types/d3-zoom': + specifier: ^3.0.8 + version: 3.0.8 '@types/node': specifier: ^24.10.1 version: 24.10.4 @@ -988,6 +1012,24 @@ packages: '@types/chai@5.2.3': resolution: {integrity: sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==} + '@types/d3-color@3.1.3': + resolution: {integrity: sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==} + + '@types/d3-drag@3.0.7': + resolution: {integrity: sha512-HE3jVKlzU9AaMazNufooRJ5ZpWmLIoc90A37WU2JMmeq28w1FQqCZswHZ3xR+SuxYftzHq6WU6KJHvqxKzTxxQ==} + + '@types/d3-force@3.0.10': + resolution: {integrity: sha512-ZYeSaCF3p73RdOKcjj+swRlZfnYpK1EbaDiYICEEp5Q6sUiqFaFQ9qgoshp5CzIyyb/yD09kD9o2zEltCexlgw==} + + '@types/d3-interpolate@3.0.4': + resolution: {integrity: sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA==} + + '@types/d3-selection@3.0.11': + resolution: {integrity: sha512-bhAXu23DJWsrI45xafYpkQ4NtcKMwWnAC/vKrd2l+nxMFuvOT3XMYTIj2opv8vq8AO5Yh7Qac/nSeP/3zjTK0w==} + + '@types/d3-zoom@3.0.8': + resolution: {integrity: sha512-iqMC4/YlFCSlO8+2Ii1GGGliCAY4XdeG748w5vQUbevlbDu0zSjH/+jojorQVBK/se0j6DUFNPBGSqD3YWYnDw==} + '@types/debug@4.1.12': resolution: {integrity: sha512-vIChWdVG3LG1SMxEvI/AK+FWJthlrqlTu7fbrlywTkkaONwk/UAGaULXRlf8vkzFBLVm0zkMdCquhL5aOjhXPQ==} @@ -1282,6 +1324,52 @@ packages: csstype@3.2.3: resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==} + d3-color@3.1.0: + resolution: {integrity: sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==} + engines: {node: '>=12'} + + d3-dispatch@3.0.1: + resolution: {integrity: sha512-rzUyPU/S7rwUflMyLc1ETDeBj0NRuHKKAcvukozwhshr6g6c5d8zh4c2gQjY2bZ0dXeGLWc1PF174P2tVvKhfg==} + engines: {node: '>=12'} + + d3-drag@3.0.0: + resolution: {integrity: sha512-pWbUJLdETVA8lQNJecMxoXfH6x+mO2UQo8rSmZ+QqxcbyA3hfeprFgIT//HW2nlHChWeIIMwS2Fq+gEARkhTkg==} + engines: {node: '>=12'} + + d3-ease@3.0.1: + resolution: {integrity: sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==} + engines: {node: '>=12'} + + d3-force@3.0.0: + resolution: {integrity: sha512-zxV/SsA+U4yte8051P4ECydjD/S+qeYtnaIyAs9tgHCqfguma/aAQDjo85A9Z6EKhBirHRJHXIgJUlffT4wdLg==} + engines: {node: '>=12'} + + d3-interpolate@3.0.1: + resolution: {integrity: sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==} + engines: {node: '>=12'} + + d3-quadtree@3.0.1: + resolution: {integrity: sha512-04xDrxQTDTCFwP5H6hRhsRcb9xxv2RzkcsygFzmkSIOJy3PeRJP7sNk3VRIbKXcog561P9oU0/rVH6vDROAgUw==} + engines: {node: '>=12'} + + d3-selection@3.0.0: + resolution: {integrity: sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ==} + engines: {node: '>=12'} + + d3-timer@3.0.1: + resolution: {integrity: sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==} + engines: {node: '>=12'} + + d3-transition@3.0.1: + resolution: {integrity: sha512-ApKvfjsSR6tg06xrL434C0WydLr7JewBB3V+/39RMHsaXTOG0zmt/OAXeng5M5LBm0ojmxJrpomQVZ1aPvBL4w==} + engines: {node: '>=12'} + peerDependencies: + d3-selection: 2 - 3 + + d3-zoom@3.0.0: + resolution: {integrity: sha512-b8AmV3kfQaqWAuacbPuNbL6vahnOJflOhexLzMMNLga62+/nh0JzvJ0aO/5a5MVgUFGS7Hu1P9P03o3fJkDCyw==} + engines: {node: '>=12'} + data-urls@6.0.0: resolution: {integrity: sha512-BnBS08aLUM+DKamupXs3w2tJJoqU+AkaE/+6vQxi/G/DPmIZFJJp9Dkb1kM03AZx8ADehDUZgsNxju3mPXZYIA==} engines: {node: '>=20'} @@ -3146,6 +3234,25 @@ snapshots: '@types/deep-eql': 4.0.2 assertion-error: 2.0.1 + '@types/d3-color@3.1.3': {} + + '@types/d3-drag@3.0.7': + dependencies: + '@types/d3-selection': 3.0.11 + + '@types/d3-force@3.0.10': {} + + '@types/d3-interpolate@3.0.4': + dependencies: + '@types/d3-color': 3.1.3 + + '@types/d3-selection@3.0.11': {} + + '@types/d3-zoom@3.0.8': + dependencies: + '@types/d3-interpolate': 3.0.4 + '@types/d3-selection': 3.0.11 + '@types/debug@4.1.12': dependencies: '@types/ms': 2.1.0 @@ -3456,6 +3563,50 @@ snapshots: csstype@3.2.3: {} + d3-color@3.1.0: {} + + d3-dispatch@3.0.1: {} + + d3-drag@3.0.0: + dependencies: + d3-dispatch: 3.0.1 + d3-selection: 3.0.0 + + d3-ease@3.0.1: {} + + d3-force@3.0.0: + dependencies: + d3-dispatch: 3.0.1 + d3-quadtree: 3.0.1 + d3-timer: 3.0.1 + + d3-interpolate@3.0.1: + dependencies: + d3-color: 3.1.0 + + d3-quadtree@3.0.1: {} + + d3-selection@3.0.0: {} + + d3-timer@3.0.1: {} + + d3-transition@3.0.1(d3-selection@3.0.0): + dependencies: + d3-color: 3.1.0 + d3-dispatch: 3.0.1 + d3-ease: 3.0.1 + d3-interpolate: 3.0.1 + d3-selection: 3.0.0 + d3-timer: 3.0.1 + + d3-zoom@3.0.0: + dependencies: + d3-dispatch: 3.0.1 + d3-drag: 3.0.0 + d3-interpolate: 3.0.1 + d3-selection: 3.0.0 + d3-transition: 3.0.1(d3-selection@3.0.0) + data-urls@6.0.0: dependencies: whatwg-mimetype: 4.0.0 diff --git a/web/src/components/notes/NotesGraph.tsx b/web/src/components/notes/NotesGraph.tsx new file mode 100644 index 0000000..a8c490f --- /dev/null +++ b/web/src/components/notes/NotesGraph.tsx @@ -0,0 +1,179 @@ +import type { Note } from "$lib/model"; +import { extractWikilinkTitles, resolveWikilink } from "$lib/wikilink"; +import { useNavigate } from "@solidjs/router"; +import { drag } from "d3-drag"; +import type { Simulation, SimulationLinkDatum, SimulationNodeDatum } from "d3-force"; +import { forceCenter, forceCollide, forceLink, forceManyBody, forceSimulation } from "d3-force"; +import { select } from "d3-selection"; +import { zoom, type ZoomBehavior, zoomIdentity } from "d3-zoom"; +import type { Component } from "solid-js"; +import { createEffect, createMemo, onCleanup, onMount } from "solid-js"; + +export type GraphNode = SimulationNodeDatum & { id: string; title: string; tags: string[]; linkCount: number }; + +export type GraphLink = SimulationLinkDatum & { source: string | GraphNode; target: string | GraphNode }; + +type NotesGraphProps = { + notes: Note[]; + currentNoteId?: string; + onNodeClick?: (noteId: string) => void; + width?: number; + height?: number; +}; + +const NODE_RADIUS = 8; +const LINK_DISTANCE = 100; +const CHARGE_STRENGTH = -300; + +export const NotesGraph: Component = (props) => { + const navigate = useNavigate(); + let containerRef: HTMLDivElement | undefined; + let simulation: Simulation | undefined; + let zoomBehavior: ZoomBehavior | undefined; + + const width = () => props.width ?? 800; + const height = () => props.height ?? 600; + + const graphData = createMemo(() => { + const notes = props.notes; + const nodeMap = new Map(); + const links: GraphLink[] = []; + + for (const note of notes) { + nodeMap.set(note.id, { id: note.id, title: note.title, tags: note.tags, linkCount: 0 }); + } + + for (const note of notes) { + const titles = extractWikilinkTitles(note.body); + for (const title of titles) { + const targetNote = resolveWikilink(title, notes); + if (targetNote && targetNote.id !== note.id) { + const sourceNode = nodeMap.get(note.id); + const targetNode = nodeMap.get(targetNote.id); + if (sourceNode && targetNode) { + sourceNode.linkCount++; + targetNode.linkCount++; + links.push({ source: note.id, target: targetNote.id }); + } + } + } + } + + return { nodes: Array.from(nodeMap.values()), links }; + }); + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const handleNodeClick = (_: any, d: GraphNode) => { + if (props.onNodeClick) { + props.onNodeClick(d.id); + } else { + navigate(`/notes/${d.id}`); + } + }; + + const computeColor = (d: GraphNode) => + d.id === props.currentNoteId + ? "fill-blue-500" + : d.linkCount > 0 + ? "fill-emerald-500" + : "fill-slate-400 dark:fill-slate-500"; + const initializeGraph = () => { + if (!containerRef) return; + + const svg = select(containerRef).select("svg").attr("viewBox", `0 0 ${width()} ${height()}`); + const g = svg.select(".graph-container"); + const data = graphData(); + + zoomBehavior = zoom().scaleExtent([0.1, 4]).on("zoom", (event) => { + g.attr("transform", event.transform); + }); + + svg.call(zoomBehavior); + svg.call(zoomBehavior.transform, zoomIdentity.translate(width() / 2, height() / 2)); + + simulation = forceSimulation(data.nodes).force( + "link", + forceLink(data.links).id((d) => d.id).distance(LINK_DISTANCE), + ).force("charge", forceManyBody().strength(CHARGE_STRENGTH)).force("center", forceCenter(0, 0)).force( + "collision", + forceCollide().radius(NODE_RADIUS * 2), + ); + + const linkSelection = g.select(".links").selectAll("line").data( + data.links, + (d) => `${(d.source as GraphNode).id ?? d.source}-${(d.target as GraphNode).id ?? d.target}`, + ).join("line").attr("class", "stroke-slate-600 dark:stroke-slate-500").attr("stroke-opacity", 0.6).attr( + "stroke-width", + 1.5, + ); + + const nodeSelection = g.select(".nodes").selectAll(".node").data( + data.nodes, + (d) => d.id, + ).join("g").attr("class", "node cursor-pointer").on("click", handleNodeClick); + + nodeSelection.selectAll("circle").data((d) => [d]).join("circle").attr( + "r", + (d) => NODE_RADIUS + Math.min(d.linkCount, 5), + ).attr("class", computeColor).attr("stroke", "var(--color-slate-900)").attr("stroke-width", 2); + + nodeSelection.selectAll("text").data((d) => [d]).join("text").text((d) => + d.title.length > 20 ? d.title.slice(0, 20) + "..." : d.title + ).attr("x", 14).attr("y", 4).attr( + "class", + "text-xs fill-slate-300 dark:fill-slate-400 pointer-events-none select-none", + ); + + const dragBehavior = drag().on("start", (event, d) => { + if (!event.active) simulation?.alphaTarget(0.3).restart(); + d.fx = d.x; + d.fy = d.y; + }).on("drag", (event, d) => { + d.fx = event.x; + d.fy = event.y; + }).on("end", (event, d) => { + if (!event.active) simulation?.alphaTarget(0); + d.fx = null; + d.fy = null; + }); + + nodeSelection.call(dragBehavior); + + simulation.on("tick", () => { + linkSelection.attr("x1", (d) => (d.source as GraphNode).x ?? 0).attr("y1", (d) => (d.source as GraphNode).y ?? 0) + .attr("x2", (d) => (d.target as GraphNode).x ?? 0).attr("y2", (d) => (d.target as GraphNode).y ?? 0); + + nodeSelection.attr("transform", (d) => `translate(${d.x ?? 0}, ${d.y ?? 0})`); + }); + }; + + onMount(() => { + initializeGraph(); + }); + + createEffect(() => { + graphData(); + if (simulation) { + simulation.stop(); + } + initializeGraph(); + }); + + onCleanup(() => { + simulation?.stop(); + }); + + return ( +
+ + + + + + +
+ ); +}; diff --git a/web/src/components/notes/NotesSidebar.tsx b/web/src/components/notes/NotesSidebar.tsx new file mode 100644 index 0000000..4fe69cb --- /dev/null +++ b/web/src/components/notes/NotesSidebar.tsx @@ -0,0 +1,127 @@ +import type { Note } from "$lib/model"; +import { extractWikilinkTitles } from "$lib/wikilink"; +import { A } from "@solidjs/router"; +import type { Component } from "solid-js"; +import { createMemo, For, Show } from "solid-js"; + +type FilterType = "all" | "linked" | "orphaned"; + +type NotesSidebarProps = { + notes: Note[]; + selectedTag?: string; + selectedFilter?: FilterType; + onTagSelect?: (tag: string | undefined) => void; + onFilterSelect?: (filter: FilterType) => void; +}; + +type TagCount = { tag: string; count: number }; + +export const NotesSidebar: Component = (props) => { + const tagCounts = createMemo(() => { + const counts = new Map(); + props.notes.forEach((note) => { + note.tags.forEach((tag) => { + counts.set(tag, (counts.get(tag) ?? 0) + 1); + }); + }); + return Array.from(counts.entries()).map(([tag, count]) => ({ tag, count })).sort((a, b) => b.count - a.count); + }); + + const recentNotes = createMemo(() => { + return [...props.notes].sort((a, b) => { + const dateA = a.updated_at ?? a.created_at ?? ""; + const dateB = b.updated_at ?? b.created_at ?? ""; + return dateB.localeCompare(dateA); + }).slice(0, 5); + }); + + const linkedCount = createMemo(() => { + return props.notes.filter((note) => extractWikilinkTitles(note.body).length > 0).length; + }); + + const orphanedCount = createMemo(() => { + return props.notes.filter((note) => extractWikilinkTitles(note.body).length === 0).length; + }); + + const filterButtonClass = (filter: FilterType) => { + const isActive = props.selectedFilter === filter; + return `px-3 py-1.5 text-sm rounded-md transition-colors ${ + isActive ? "bg-blue-500/20 text-blue-400 font-medium" : "text-slate-400 hover:text-white hover:bg-slate-800" + }`; + }; + + return ( + + ); +}; diff --git a/web/src/components/notes/tests/NotesGraph.test.tsx b/web/src/components/notes/tests/NotesGraph.test.tsx new file mode 100644 index 0000000..6bb1ba5 --- /dev/null +++ b/web/src/components/notes/tests/NotesGraph.test.tsx @@ -0,0 +1,81 @@ +import type { Note } from "$lib/model"; +import { cleanup, render, screen } from "@solidjs/testing-library"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { NotesGraph } from "../NotesGraph"; + +vi.mock("@solidjs/router", () => ({ useNavigate: () => vi.fn() })); + +const mockNotes: Note[] = [{ + id: "note-1", + owner_did: "did:plc:test", + title: "First Note", + body: "Content with [[Second Note]] link", + tags: ["test"], + visibility: { type: "Private" }, + created_at: "2026-01-01T00:00:00Z", + updated_at: "2026-01-01T00:00:00Z", +}, { + id: "note-2", + owner_did: "did:plc:test", + title: "Second Note", + body: "This links to [[First Note]]", + tags: ["test", "linked"], + visibility: { type: "Private" }, + created_at: "2026-01-01T00:00:00Z", + updated_at: "2026-01-01T00:00:00Z", +}, { + id: "note-3", + owner_did: "did:plc:test", + title: "Orphan Note", + body: "No wikilinks here", + tags: [], + visibility: { type: "Private" }, + created_at: "2026-01-01T00:00:00Z", + updated_at: "2026-01-01T00:00:00Z", +}]; + +describe("NotesGraph", () => { + afterEach(cleanup); + + it("renders the graph container", () => { + render(() => ); + expect(screen.getByTestId("notes-graph")).toBeInTheDocument(); + }); + + it("renders SVG element", () => { + render(() => ); + const container = screen.getByTestId("notes-graph"); + const svg = container.querySelector("svg"); + expect(svg).toBeInTheDocument(); + }); + + it("creates nodes group", () => { + render(() => ); + const container = screen.getByTestId("notes-graph"); + const nodesGroup = container.querySelector(".nodes"); + expect(nodesGroup).toBeInTheDocument(); + }); + + it("creates links group", () => { + render(() => ); + const container = screen.getByTestId("notes-graph"); + const linksGroup = container.querySelector(".links"); + expect(linksGroup).toBeInTheDocument(); + }); + + it("renders with custom dimensions", () => { + render(() => ); + const container = screen.getByTestId("notes-graph"); + expect(container).toBeInTheDocument(); + }); + + it("handles empty notes array", () => { + render(() => ); + expect(screen.getByTestId("notes-graph")).toBeInTheDocument(); + }); + + it("highlights current note when provided", () => { + render(() => ); + expect(screen.getByTestId("notes-graph")).toBeInTheDocument(); + }); +}); diff --git a/web/src/components/notes/tests/NotesSidebar.test.tsx b/web/src/components/notes/tests/NotesSidebar.test.tsx new file mode 100644 index 0000000..a5e5fc4 --- /dev/null +++ b/web/src/components/notes/tests/NotesSidebar.test.tsx @@ -0,0 +1,114 @@ +import type { Note } from "$lib/model"; +import { cleanup, fireEvent, render, screen } from "@solidjs/testing-library"; +import type { JSX } from "solid-js"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { NotesSidebar } from "../NotesSidebar"; + +vi.mock( + "@solidjs/router", + () => ({ + A: (props: { href: string; class: string; children: JSX.Element; "data-testid"?: string }) => ( + {props.children} + ), + }), +); + +const mockNotes: Note[] = [{ + id: "note-1", + owner_did: "did:plc:test", + title: "First Note", + body: "Content with [[Second Note]] link", + tags: ["rust", "learning"], + visibility: { type: "Private" }, + created_at: "2026-01-01T00:00:00Z", + updated_at: "2026-01-03T00:00:00Z", +}, { + id: "note-2", + owner_did: "did:plc:test", + title: "Second Note", + body: "This links to [[First Note]]", + tags: ["rust", "advanced"], + visibility: { type: "Private" }, + created_at: "2026-01-01T00:00:00Z", + updated_at: "2026-01-02T00:00:00Z", +}, { + id: "note-3", + owner_did: "did:plc:test", + title: "Orphan Note", + body: "No wikilinks here", + tags: ["learning"], + visibility: { type: "Private" }, + created_at: "2026-01-01T00:00:00Z", + updated_at: "2026-01-01T00:00:00Z", +}]; + +describe("NotesSidebar", () => { + afterEach(cleanup); + + it("renders the sidebar", () => { + render(() => ); + expect(screen.getByTestId("notes-sidebar")).toBeInTheDocument(); + }); + + it("displays filter buttons with counts", () => { + render(() => ); + expect(screen.getByTestId("filter-all")).toHaveTextContent("All (3)"); + expect(screen.getByTestId("filter-linked")).toHaveTextContent("Linked (2)"); + expect(screen.getByTestId("filter-orphaned")).toHaveTextContent("Orphaned (1)"); + }); + + it("displays unique tags with correct counts", () => { + render(() => ); + + expect(screen.getByTestId("tag-rust")).toHaveTextContent("#rust"); + expect(screen.getByTestId("tag-rust")).toHaveTextContent("2"); + + expect(screen.getByTestId("tag-learning")).toHaveTextContent("#learning"); + expect(screen.getByTestId("tag-learning")).toHaveTextContent("2"); + + expect(screen.getByTestId("tag-advanced")).toHaveTextContent("#advanced"); + expect(screen.getByTestId("tag-advanced")).toHaveTextContent("1"); + }); + + it("displays recent notes sorted by update date", () => { + render(() => ); + + const recentLinks = screen.getAllByTestId(/^recent-/); + expect(recentLinks[0]).toHaveAttribute("href", "/notes/note-1"); + expect(recentLinks[1]).toHaveAttribute("href", "/notes/note-2"); + expect(recentLinks[2]).toHaveAttribute("href", "/notes/note-3"); + }); + + it("calls onFilterSelect when filter clicked", async () => { + const onFilterSelect = vi.fn(); + render(() => ); + + await fireEvent.click(screen.getByTestId("filter-linked")); + expect(onFilterSelect).toHaveBeenCalledWith("linked"); + + await fireEvent.click(screen.getByTestId("filter-orphaned")); + expect(onFilterSelect).toHaveBeenCalledWith("orphaned"); + }); + + it("calls onTagSelect when tag clicked", async () => { + const onTagSelect = vi.fn(); + render(() => ); + + await fireEvent.click(screen.getByTestId("tag-rust")); + expect(onTagSelect).toHaveBeenCalledWith("rust"); + }); + + it("toggles tag selection off when same tag clicked", async () => { + const onTagSelect = vi.fn(); + render(() => ); + + await fireEvent.click(screen.getByTestId("tag-rust")); + expect(onTagSelect).toHaveBeenCalledWith(undefined); + }); + + it("handles empty notes array", () => { + render(() => ); + expect(screen.getByTestId("notes-sidebar")).toBeInTheDocument(); + expect(screen.getByTestId("filter-all")).toHaveTextContent("All (0)"); + }); +}); diff --git a/web/src/pages/Notes.tsx b/web/src/pages/Notes.tsx index 5876632..fe14fb6 100644 --- a/web/src/pages/Notes.tsx +++ b/web/src/pages/Notes.tsx @@ -1,4 +1,5 @@ import { NoteCard } from "$components/NoteCard"; +import { NotesGraph } from "$components/notes/NotesGraph"; import { Button } from "$components/ui/Button"; import { EmptyState } from "$components/ui/EmptyState"; import { api } from "$lib/api"; @@ -13,7 +14,7 @@ const fetchNotes = async (): Promise => { return res.json(); }; -type ViewMode = "grid" | "list"; +type ViewMode = "grid" | "list" | "graph"; const Notes: Component = () => { const location = useLocation(); @@ -104,6 +105,23 @@ const Notes: Component = () => { + @@ -141,12 +159,18 @@ const Notes: Component = () => { } /> }> -
- {(note) => } -
+ + {(note) => } + + }> + + -- 2.51.2