diff --git a/README.md b/README.md index 4da1426..0b51ca3 100644 --- a/README.md +++ b/README.md @@ -51,6 +51,7 @@ CodePlans sits between your issue tracker and your architecture diagram: from the product architecture to a service's specs, design decisions, active plans, known issues, and delivery history. +- **See the architecture:** an interactive map of assets and dependencies across products — trace what an asset relies on and what relies on it, then click through to its documents. - **Find the detail:** search document bodies and technical identifiers, then narrow by asset, type, status, area, or date. - **Read comfortably:** full Markdown documents with tables, code, task lists, section links, and related content alongside. - **Follow the evidence:** see authors, edit times, spec revisions, and capability receipts without confusing planned work with shipped versions. diff --git a/app/(wiki)/wiki/[slug]/page.tsx b/app/(wiki)/wiki/[slug]/page.tsx index 9edbb50..94212c0 100644 --- a/app/(wiki)/wiki/[slug]/page.tsx +++ b/app/(wiki)/wiki/[slug]/page.tsx @@ -1,8 +1,9 @@ import Link from 'next/link' import { notFound, redirect } from 'next/navigation' import { authAdapter } from '@/lib/auth' -import { getProducts } from '@/lib/db/queries' +import { getAssetInventory, getProducts } from '@/lib/db/queries' import { getWikiProduct } from '@/lib/db/wiki' +import { buildWikiMap } from '@/lib/wiki/map' import { awaitsReview, kindLabels, @@ -18,6 +19,7 @@ import { DocumentList, ActivityList, } from '@/components/wiki/views' +import { ArchitectureMap } from '@/components/wiki/architecture-map' import { ThemeToggle } from '@/components/theme-toggle' import { WikiTime } from '@/components/wiki/time' export const dynamic = 'force-dynamic' @@ -68,17 +70,23 @@ export default async function WikiProduct({ params, searchParams }: Props) { getProducts(user.id), ]) if (!data) notFound() + // The map spans every product the reader can see; the same visibility rules apply. + const map = + view === 'map' && !docKey + ? buildWikiMap(data, await getAssetInventory(user.id)) + : null const asset = data.assets.find((a) => a.id === assetId), doc = data.documents.find((d) => d.key === docKey) if ((assetId && !asset) || (docKey && !doc)) notFound() const listing = - view === 'documents' || + !map && + (view === 'documents' || view === 'review' || !!q || !!kind || !!status || !!since || - !!area + !!area) const filters = { q, asset: assetId, @@ -191,6 +199,12 @@ export default async function WikiProduct({ params, searchParams }: Props) { > Product overview + + Architecture map + / {doc?.title ?? + (map ? 'Architecture map' : undefined) ?? asset?.name ?? (view === 'review' ? 'Awaiting review' @@ -255,6 +270,17 @@ export default async function WikiProduct({ params, searchParams }: Props) { {doc ? ( + ) : map ? ( + <> +

Architecture

+

Architecture map

+

+ Assets and their recorded dependencies, for this product or + across every product you can see. Select an asset to trace its + reach and follow its documents. +

+ + ) : listing ? ( <>

@@ -518,10 +544,13 @@ export default async function WikiProduct({ params, searchParams }: Props) {

Explore the architecture

- - Select an asset to read its story - + + Open the architecture map → +
+

+ Select an asset to read its story +

{data.assets.map((a) => ( span { + color: var(--wiki-muted); + margin-right: 4px; +} +.wiki-shell .wiki-map-segment button { + padding: 5px 11px; + border-radius: 999px; + background: transparent; + color: var(--wiki-muted); + font-size: 11px; + text-transform: capitalize; +} +.wiki-shell .wiki-map-segment button[aria-pressed='true'] { + border-color: color-mix(in oklch, var(--accent) 50%, transparent); + background: color-mix(in oklch, var(--accent) 14%, transparent); + color: var(--foreground); +} +.wiki-map-body { + display: grid; + grid-template-columns: minmax(0, 1fr) 330px; + gap: 18px; + align-items: start; +} +.wiki-map-canvas { + position: relative; + overflow: auto; + max-height: calc(100vh - 230px); + min-height: 240px; + border: 1px solid var(--border); + border-radius: 10px; + background-color: var(--card); + background-image: radial-gradient( + color-mix(in oklch, var(--muted-foreground) 22%, transparent) 1px, + transparent 1px + ); + background-size: 18px 18px; + scrollbar-width: thin; +} +.wiki-map-stage { + position: relative; + transform-origin: 0 0; +} +.wiki-map-band { + position: absolute; + pointer-events: none; + padding: 8px 12px; + border: 1px dashed color-mix(in oklch, var(--muted-foreground) 35%, transparent); + border-radius: 12px; + background: color-mix(in oklch, var(--muted) 45%, transparent); + font-size: 11px; + font-weight: 600; + color: var(--wiki-muted); +} +.wiki-map-band[data-current] { + border-color: color-mix(in oklch, var(--accent) 45%, transparent); +} +.wiki-map-zoom { + margin-left: auto; +} +.wiki-shell .wiki-map-segment button:disabled { + opacity: 0.4; + cursor: default; +} +.wiki-map-edges { + position: absolute; + inset: 0; + pointer-events: none; +} +.wiki-map-edges path[data-type] { + fill: none; + stroke: color-mix(in oklch, var(--muted-foreground) 55%, transparent); + stroke-width: 1.3; + transition: + stroke 0.15s, + opacity 0.15s; +} +.wiki-map-edges path[data-type='integrates_with'] { + stroke-dasharray: 6 4; +} +.wiki-map-edges path[data-type='aggregates'] { + stroke-dasharray: 2 4; +} +.wiki-map-edges path[data-state='active'] { + stroke: var(--accent); + stroke-width: 2.2; +} +.wiki-map-edges path[data-state='dim'] { + opacity: 0.18; +} +.wiki-map-edges marker .idle { + fill: color-mix(in oklch, var(--muted-foreground) 70%, transparent); +} +.wiki-map-edges marker .active { + fill: var(--accent); +} +.wiki-map-column { + position: absolute; + font-size: 10px; + font-weight: 600; + letter-spacing: 0.09em; + text-transform: uppercase; + color: var(--wiki-muted); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} +.wiki-map-column small { + margin-left: 4px; + font-size: 10px; +} +.wiki-shell .wiki-map-node { + --tone: var(--muted-foreground); + position: absolute; + display: flex; + flex-direction: column; + justify-content: center; + gap: 4px; + padding: 0 12px; + border: 1px solid var(--border); + border-left: 3px solid var(--tone); + border-radius: 8px; + background: var(--background); + color: var(--foreground); + text-align: left; + transition: + opacity 0.15s, + box-shadow 0.15s, + border-color 0.15s; +} +.wiki-map-node[data-tone='ok'] { + --tone: var(--success); +} +.wiki-map-node[data-tone='warn'] { + --tone: var(--warning); +} +.wiki-map-node[data-tone='bad'] { + --tone: var(--destructive); +} +.wiki-map-node[data-tone='active'] { + --tone: var(--accent); +} +.wiki-map-node[data-tone='quiet'] { + --tone: color-mix(in oklch, var(--muted-foreground) 40%, transparent); +} +.wiki-shell .wiki-map-node:hover { + border-color: color-mix(in oklch, var(--accent) 60%, var(--border)); + border-left-color: var(--tone); + box-shadow: 0 4px 14px #0002; +} +.wiki-shell .wiki-map-node[data-state='focus'] { + border-color: var(--accent); + border-left-color: var(--tone); + box-shadow: 0 0 0 3px color-mix(in oklch, var(--accent) 30%, transparent); +} +.wiki-map-node[data-state='match'] { + box-shadow: 0 0 0 2px color-mix(in oklch, var(--accent) 45%, transparent); +} +.wiki-map-node[data-state='dim'] { + opacity: 0.32; +} +.wiki-map-node-title { + display: flex; + align-items: center; + gap: 7px; + min-width: 0; + font-size: 13px; +} +.wiki-map-node-title svg { + flex-shrink: 0; + color: var(--wiki-muted); +} +.wiki-map-node-title strong { + font-weight: 550; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} +.wiki-map-node-title code { + margin-left: auto; + flex-shrink: 0; + font-size: 10px; + border: 1px solid var(--border); + border-radius: 4px; + padding: 0 4px; + color: var(--accent); +} +.wiki-map-node-detail { + font-size: 11px; + color: var(--wiki-muted); + text-transform: capitalize; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} +.wiki-map-depth { + position: absolute; + top: -9px; + right: 8px; + font-size: 10px; + font-weight: 600; + padding: 1px 6px; + border-radius: 999px; + background: var(--accent); + color: var(--accent-foreground); +} +.wiki-map-panel { + position: sticky; + top: 90px; + max-height: calc(100vh - 110px); + overflow-y: auto; + padding: 20px; + border: 1px solid var(--border); + border-radius: 10px; + background: var(--card); + font-size: 13px; + line-height: 1.7; + scrollbar-width: thin; +} +.wiki-map-panel .wiki-eyebrow { + margin-top: 0; + text-transform: uppercase; +} +.wiki-map-panel h2 { + margin: 0 0 12px; + overflow-wrap: anywhere; +} +.wiki-map-panel h3 { + font-size: 13px; + margin: 22px 0 8px; +} +.wiki-map-panel h4 { + font-size: 11px; + font-weight: 600; + color: var(--wiki-muted); + text-transform: uppercase; + letter-spacing: 0.06em; + margin: 14px 0 4px; +} +.wiki-map-panel .wiki-badges { + margin-bottom: 12px; + gap: 8px 12px; + text-transform: capitalize; +} +.wiki-map-panel > p { + color: var(--wiki-muted); + overflow-wrap: anywhere; +} +.wiki-map-panel .wiki-map-segment { + flex-wrap: wrap; +} +.wiki-map-actions { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 10px 16px; + margin: 14px 0 4px; + font-size: 12px; +} +.wiki-shell .wiki-map-actions button { + margin-left: auto; + padding: 4px 10px; + font-size: 11px; +} +.wiki-map-list { + list-style: none; + padding: 0; + margin: 6px 0; +} +.wiki-map-list li { + padding: 7px 0; + border-bottom: 1px solid var(--border); +} +.wiki-map-list li > a, +.wiki-shell .wiki-map-list li > button { + display: block; + padding: 0; + border: 0; + background: none; + color: var(--foreground); + font-weight: 500; + text-align: left; + overflow-wrap: anywhere; +} +.wiki-shell .wiki-map-list li > button:hover { + color: var(--accent); +} +.wiki-map-list li small { + display: block; +} +.wiki-map-list li p { + margin: 2px 0 0; + font-size: 11px; + color: var(--wiki-muted); +} +.wiki-shell .wiki-map-link { + display: inline-block; + padding: 0; + border: 0; + background: none; + color: var(--foreground); + font-size: 12px; + text-decoration: underline; + text-underline-offset: 3px; +} +.wiki-context .wiki-map-link { + margin-top: 8px; +} +.wiki-map-legend { + list-style: none; + padding: 0; + margin: 6px 0; + display: flex; + flex-wrap: wrap; + gap: 6px 16px; + font-size: 11px; + color: var(--wiki-muted); +} +.wiki-map-legend li { + display: flex; + align-items: center; + gap: 6px; +} +.wiki-map-legend li::before { + content: ''; + width: 22px; + border-top: 1.5px solid var(--muted-foreground); +} +.wiki-map-legend [data-type='integrates_with']::before { + border-top-style: dashed; +} +.wiki-map-legend [data-type='aggregates']::before { + border-top-style: dotted; +} +@media (max-width: 1150px) { + .wiki-map-body { + grid-template-columns: minmax(0, 1fr); + } + .wiki-map-panel { + position: static; + max-height: none; + } + .wiki-map-canvas { + max-height: 70vh; + } +} +@media print { + .wiki-map-toolbar, + .wiki-map-panel { + display: none; + } + .wiki-map-canvas { + max-height: none; + overflow: visible; + } +} diff --git a/components/wiki/architecture-map.tsx b/components/wiki/architecture-map.tsx new file mode 100644 index 0000000..169ce4b --- /dev/null +++ b/components/wiki/architecture-map.tsx @@ -0,0 +1,762 @@ +'use client' +import { useEffect, useMemo, useRef, useState } from 'react' +import Link from 'next/link' +import { useRouter, useSearchParams } from 'next/navigation' +import { Box, Database, Globe, Library, Server } from 'lucide-react' +import { kindLabels, wikiHref, type WikiKind } from '@/lib/wiki/model' +import { + MAP, + edgePath, + layoutMap, + reachEdge, + reachFrom, + type MapGroup, + type MapLens, + type MapReach, + type MapScope, + type WikiMapData, + type WikiMapEdge, + type WikiMapNode, +} from '@/lib/wiki/map' + +const typeIcons: Record = { + app: Box, + service: Server, + library: Library, + datastore: Database, + platform: Globe, +} +const edgeLabels: Record = { + depends_on: 'depends on', + integrates_with: 'integrates with', + aggregates: 'aggregates', +} +const reachCaptions: Record = { + direct: 'Assets one connection away, in either direction.', + upstream: + 'Everything this asset relies on, following dependencies all the way down.', + downstream: + 'Everything that relies on this asset — who to notify before changing it.', +} + +function tone(n: WikiMapNode, lens: MapLens) { + if (lens === 'activity') return n.activePlans > 0 ? 'active' : 'quiet' + if (lens === 'debt') + return n.debtScore >= 50 ? 'bad' : n.debtScore >= 25 ? 'warn' : 'ok' + return n.health === 'critical' + ? 'bad' + : n.health === 'warning' + ? 'warn' + : n.health === 'healthy' + ? 'ok' + : 'quiet' +} +function detail(n: WikiMapNode, lens: MapLens) { + if (lens === 'activity') + return n.activePlans + ? `${n.activePlans} active plan${n.activePlans === 1 ? '' : 's'}` + : 'No active plans' + if (lens === 'debt') return `Debt score ${n.debtScore}` + return n.health +} +const plural = (n: number, word: string, many = `${word}s`) => + `${n} ${n === 1 ? word : many}` +const MIN_ZOOM = 0.5 +const ZOOM_STEP = 0.15 + +function Segmented({ + label, + value, + options, + onChange, +}: { + label: string + value: T + options: [T, string][] + onChange: (v: T) => void +}) { + return ( +
+ {label} + {options.map(([v, text]) => ( + + ))} +
+ ) +} + +export function ArchitectureMap({ map }: { map: WikiMapData }) { + const router = useRouter() + // Read from the live URL, not server props: Back restores a cached payload + // that predates the URL updates made while exploring. + const params = useSearchParams() + const pick = (key: string, options: readonly T[]) => + options.find((o) => o === params.get(key)) + const nodeById = useMemo( + () => new Map(map.nodes.map((n) => [n.id, n])), + [map.nodes], + ) + const initialFocus = nodeById.get(params.get('focus') ?? '') + const [focus, setFocus] = useState(initialFocus?.id ?? null) + const [reach, setReach] = useState( + () => pick('reach', ['upstream', 'downstream'] as const) ?? 'direct', + ) + const [scope, setScope] = useState(() => + (initialFocus && initialFocus.productId !== map.product.id) || + params.get('scope') === 'all' + ? 'all' + : 'product', + ) + const [groupChoice, setGroupChoice] = useState(() => + pick('group', ['product', 'layer'] as const), + ) + const [lens, setLens] = useState( + () => pick('lens', ['activity', 'debt'] as const) ?? 'health', + ) + const [query, setQuery] = useState('') + const [hover, setHover] = useState(null) + const [zoom, setZoom] = useState('fit') + const [canvasWidth, setCanvasWidth] = useState(null) + const searchRef = useRef(null) + const canvasRef = useRef(null) + const nodeRefs = useRef(new Map()) + const group: MapGroup = + scope === 'all' ? (groupChoice ?? 'product') : 'layer' + + const visible = useMemo( + () => + scope === 'all' + ? map.nodes + : map.nodes.filter((n) => n.productId === map.product.id), + [map, scope], + ) + const visibleIds = useMemo( + () => new Set(visible.map((n) => n.id)), + [visible], + ) + const visibleEdges = useMemo( + () => + map.edges.filter( + (e) => visibleIds.has(e.source) && visibleIds.has(e.target), + ), + [map.edges, visibleIds], + ) + const layout = useMemo( + () => layoutMap(visible, visibleEdges, group, map.product.id), + [visible, visibleEdges, group, map.product.id], + ) + const depth = useMemo( + () => (focus ? reachFrom(focus, map.edges, reach) : null), + [focus, map.edges, reach], + ) + const hoverSet = useMemo( + () => (!focus && hover ? reachFrom(hover, map.edges, 'direct') : null), + [focus, hover, map.edges], + ) + const highlight = depth ?? hoverSet + const fit = canvasWidth + ? Math.min(1, Math.max(MIN_ZOOM, (canvasWidth - 2) / layout.width)) + : 1 + const scale = zoom === 'fit' ? fit : zoom + const q = query.trim().toLocaleLowerCase() + const matches = useMemo( + () => + q + ? visible.filter((n) => + `${n.name} ${n.type} ${n.layer} ${n.productName}` + .toLocaleLowerCase() + .includes(q), + ) + : [], + [visible, q], + ) + const matchIds = new Set(matches.map((n) => n.id)) + + useEffect(() => { + window.history.replaceState( + null, + '', + wikiHref(map.product.slug, { + view: 'map', + focus: focus ?? undefined, + reach: focus && reach !== 'direct' ? reach : undefined, + scope: scope === 'all' ? 'all' : undefined, + group: scope === 'all' ? groupChoice : undefined, + lens: lens === 'health' ? undefined : lens, + }), + ) + }, [map.product.slug, focus, reach, scope, groupChoice, lens]) + + useEffect(() => { + const onKey = (e: KeyboardEvent) => { + if (e.key !== 'Escape') return + if (document.activeElement === searchRef.current && query) setQuery('') + else setFocus(null) + } + window.addEventListener('keydown', onKey) + return () => window.removeEventListener('keydown', onKey) + }, [query]) + + useEffect(() => { + const el = canvasRef.current + if (!el) return + const observer = new ResizeObserver(([entry]) => + setCanvasWidth(entry.contentRect.width), + ) + observer.observe(el) + return () => observer.disconnect() + }, []) + + useEffect(() => { + if (focus) + nodeRefs.current + .get(focus) + ?.scrollIntoView({ block: 'nearest', inline: 'nearest' }) + }, [focus, layout, scale]) + + const select = (id: string) => { + const n = nodeById.get(id) + if (!n) return + if (n.productId !== map.product.id) setScope('all') + setFocus(id) + } + const assetHref = (n: WikiMapNode) => + wikiHref(n.productSlug, { asset: n.id }) + const selected = focus ? nodeById.get(focus) : undefined + const outside = (ids: Iterable) => + [...ids].filter((id) => !visibleIds.has(id)).length + const crossProduct = map.edges.filter( + (e) => visibleIds.has(e.source) !== visibleIds.has(e.target), + ).length + + const reached = depth + ? [...depth.entries()] + .filter(([id, d]) => d > 0 && nodeById.has(id)) + .sort( + (a, b) => + a[1] - b[1] || + nodeById.get(a[0])!.name.localeCompare(nodeById.get(b[0])!.name), + ) + : [] + const connectionRow = (e: WikiMapEdge, otherId: string) => { + const other = nodeById.get(otherId) + if (!other) return null + return ( +
  • + + + {edgeLabels[e.type]} + {other.productId !== selected?.productId + ? ` · ${other.productName}` + : ''} + + {e.description &&

    {e.description}

    } +
  • + ) + } + + return ( +
    +
    +
    { + e.preventDefault() + if (matches[0]) select(matches[0].id) + }} + > + setQuery(e.target.value)} + /> + {q && ( + + {matches.length + ? `${plural(matches.length, 'match', 'matches')} · Enter selects` + : 'No matches'} + + )} +
    + { + setScope(v) + if ( + v === 'product' && + selected && + selected.productId !== map.product.id + ) + setFocus(null) + }} + /> + {scope === 'all' && ( + + )} + +
    + Zoom + + + +
    +
    +
    +
    { + if (e.target === e.currentTarget) setFocus(null) + }} + > + {visible.length ? ( +
    +
    { + if (e.target === e.currentTarget) setFocus(null) + }} + > + {layout.bands + .filter((b) => b.label) + .map((b) => ( +
    + {b.label} +
    + ))} + + + {['idle', 'active'].map((s) => ( + + + + ))} + + {visibleEdges.map((e) => { + const s = layout.positions.get(e.source), + t = layout.positions.get(e.target) + if (!s || !t) return null + const on = focus + ? reachEdge(e, focus, reach, depth!) + : hover + ? e.source === hover || e.target === hover + : false + const state = on ? 'active' : highlight ? 'dim' : 'idle' + return ( + + + {`${nodeById.get(e.source)?.name} ${edgeLabels[e.type]} ${nodeById.get(e.target)?.name}${e.description ? ` — ${e.description}` : ''}`} + + + ) + })} + + {layout.columns.map((c) => ( +
    + {c.key} {c.count} +
    + ))} + {visible.map((n) => { + const p = layout.positions.get(n.id) + if (!p) return null + const Icon = typeIcons[n.type] ?? Box + const d = depth?.get(n.id) + const dim = + (highlight && !highlight.has(n.id)) || + (!!q && !matchIds.has(n.id)) + return ( + + ) + })} +
    +
    + ) : ( +

    + No assets recorded for this product yet. +

    + )} +
    + +
    +
    + ) +} diff --git a/components/wiki/views.tsx b/components/wiki/views.tsx index bf133a5..73dbd86 100644 --- a/components/wiki/views.tsx +++ b/components/wiki/views.tsx @@ -366,6 +366,12 @@ export function AssetReader({ ) : (

    No dependencies recorded.

    )} + + View on architecture map → +
    ) diff --git a/docs/app-spec.md b/docs/app-spec.md index b22816c..668cb92 100644 --- a/docs/app-spec.md +++ b/docs/app-spec.md @@ -330,7 +330,9 @@ readers cover specs, plans, work items, notes, capabilities, and releases. Search includes scoped ranking, excerpts, and pagination; filters cover asset, kind, status, area, date, and review/archive state. Layer navigation, dependency links, GFM section anchors, source-relative links, delivery receipts, and grouped -history complete the reading experience. See [the wiki guide](guides/product-wiki.md) +history complete the reading experience. `?view=map` is an interactive +architecture map across the reader's visible products, with upstream/downstream +tracing and URL-kept state. See [the wiki guide](guides/product-wiki.md) for metadata semantics, URL shapes, implementation limits, and upgrade steps. #### Auth Routes (`/(auth)`) diff --git a/docs/guides/product-wiki.md b/docs/guides/product-wiki.md index b463115..49c17d5 100644 --- a/docs/guides/product-wiki.md +++ b/docs/guides/product-wiki.md @@ -38,6 +38,32 @@ reader explains **Why this appears here**, retaining the association paths and plan relationship types. Dependencies provide navigation to related assets; they do not automatically import those assets' document collections. +## Explore the architecture map + +**Architecture map** in the wiki navigation (also linked from the product +overview and each asset page) draws assets as cards in layer columns, with their +recorded dependencies as arrows pointing at what is depended on: solid for +*depends on*, dashed for *integrates with*, dotted for *aggregates*. Switch +**Show** to **All products** to see every product you can access as stacked +bands over the same layer columns, so cross-product dependencies are visible; +**Layers only** drops the bands and labels each card with its product. + +Select an asset to open its panel: health, shipped version, owners, active plans, +and open items, followed by its current specs, plans, issues, and decisions. A +**Trace** control follows **Direct** connections, **Upstream** (everything the +asset relies on, transitively), or **Downstream** (everything that relies on it, +with owners — who to notify before a change). Hop counts appear on the map. +**Color** recolors cards by recorded health, active plans, or tech debt; **Find** +highlights matches and Enter selects the first. The map fits the reading width +by default; zoom changes it, and Escape clears the search or the selection. + +The selection, trace, scope, grouping, and color are kept in the URL, for +example `/wiki/my-product?view=map&focus=&reach=downstream&scope=all`, +so a view can be shared and survives Back and reload. Assets in other products +link to their own product's wiki for documents. The map is derived on each +request from the same records and visibility rules as the rest of CodePlans; +it stores no layout, and archived assets are not drawn. + ## Read and follow documents Specs, plan descriptions, work items, asset notes, design decisions, diff --git a/lib/wiki/map.ts b/lib/wiki/map.ts new file mode 100644 index 0000000..6965c31 --- /dev/null +++ b/lib/wiki/map.ts @@ -0,0 +1,346 @@ +import type { AssetInventory } from '@/lib/db/queries' +import { LAYER_TAXONOMY } from '@/lib/types' +import { + inactiveStatuses, + plainText, + wikiKinds, + type WikiData, + type WikiKind, +} from './model' + +export type MapReach = 'direct' | 'upstream' | 'downstream' +export type MapLens = 'health' | 'activity' | 'debt' +export type MapGroup = 'layer' | 'product' +export type MapScope = 'product' | 'all' +export type WikiMapDocument = { + key: string + kind: WikiKind + title: string + status: string +} +export type WikiMapNode = { + id: string + name: string + type: string + layer: string + productId: string + productName: string + productSlug: string + health: string + version?: string + owners: string[] + openItems: number + activePlans: number + capabilities: number + debtScore: number + /** Description excerpt; empty for assets outside the wiki's product. */ + summary: string + /** Current, capped per kind; empty for assets outside the wiki's product. */ + documents: WikiMapDocument[] + documentCounts: Partial> +} +export type WikiMapEdge = { + id: string + source: string + target: string + type: 'depends_on' | 'integrates_with' | 'aggregates' + description: string | null +} +export type WikiMapData = { + product: { id: string; name: string; slug: string } + nodes: WikiMapNode[] + edges: WikiMapEdge[] +} + +const DOCS_PER_KIND = 4 + +/** Map nodes and edges for every asset the reader can see. Only the wiki's own + * product contributes document titles and excerpts; bodies never leave the server. + */ +export function buildWikiMap( + data: WikiData, + inventory: AssetInventory, +): WikiMapData { + return { + product: { + id: data.product.id, + name: data.product.name, + slug: data.product.slug, + }, + nodes: inventory.assets.map((a) => { + const own = a.productId === data.product.id + const docs = own + ? data.documents.filter( + (d) => + d.kind !== 'asset' && + !inactiveStatuses.has(d.status) && + d.associations.some((x) => x.assetId === a.id), + ) + : [] + const documentCounts: Partial> = {} + for (const d of docs) + documentCounts[d.kind] = (documentCounts[d.kind] ?? 0) + 1 + return { + id: a.id, + name: a.name, + type: a.type, + layer: a.effectiveLayer, + productId: a.productId, + productName: a.productName, + productSlug: a.productSlug, + health: a.health, + version: a.currentVersion, + owners: a.owners.map((o) => o.name), + openItems: a.openItemCount, + activePlans: a.activePlanCount, + capabilities: a.capabilityCount, + debtScore: a.effectiveDebtScore, + summary: own + ? plainText( + data.documents.find((d) => d.key === `asset:${a.id}`)?.body ?? + '', + ).slice(0, 240) + : '', + documents: wikiKinds.flatMap((k) => + docs + .filter((d) => d.kind === k) + .slice(0, DOCS_PER_KIND) + .map((d) => ({ + key: d.key, + kind: d.kind, + title: d.title, + status: d.status, + })), + ), + documentCounts, + } + }), + edges: inventory.edges.map((e) => ({ + id: e.id, + source: e.sourceAssetId, + target: e.targetAssetId, + type: e.dependencyType, + description: e.description, + })), + } +} + +/** + * Nodes reachable from `focus`, with their shortest distance. Upstream follows + * what the asset depends on; downstream follows who depends on it (blast + * radius). Direct is both directions, one hop. + */ +export function reachFrom( + focus: string, + edges: Pick[], + reach: MapReach, +): Map { + const depth = new Map([[focus, 0]]) + if (reach === 'direct') { + for (const e of edges) { + if (e.source === focus && !depth.has(e.target)) depth.set(e.target, 1) + if (e.target === focus && !depth.has(e.source)) depth.set(e.source, 1) + } + return depth + } + const queue = [focus] + while (queue.length) { + const id = queue.shift()! + for (const e of edges) { + const [from, to] = + reach === 'upstream' ? [e.source, e.target] : [e.target, e.source] + if (from === id && !depth.has(to)) { + depth.set(to, depth.get(id)! + 1) + queue.push(to) + } + } + } + return depth +} + +/** Edges that make up a reach: touching the focus for direct, otherwise any + * edge between two reached nodes that runs in the traversal direction. + */ +export function reachEdge( + e: Pick, + focus: string, + reach: MapReach, + depth: Map, +) { + if (reach === 'direct') return e.source === focus || e.target === focus + const [from, to] = + reach === 'upstream' ? [e.source, e.target] : [e.target, e.source] + return depth.has(from) && depth.has(to) && depth.get(to)! > 0 +} + +export const MAP = { + pad: 20, + header: 36, + colW: 210, + colGap: 76, + nodeH: 60, + nodeGap: 18, + /** Product band label strip and the space between bands. */ + bandHeader: 34, + bandGap: 22, + /** Same-column arcs bow out past the last column. */ + trail: 90, +} + +export type MapLayout = { + columns: { key: string; x: number; count: number }[] + /** One band per product when grouped by product; otherwise a single unlabelled band. */ + bands: { key: string; label: string; y: number; height: number }[] + positions: Map + width: number + height: number +} + +/** + * Deterministic grid: one column per layer (taxonomy order, unknown layers + * last) and, when grouped by product, one horizontal band per product (the + * wiki's product first). Within each cell, two barycenter sweeps order assets + * by their neighbours' rows so connected assets sit near each other. Nothing + * is stored; the same records always produce the same map. + */ +export function layoutMap( + nodes: Pick< + WikiMapNode, + 'id' | 'name' | 'layer' | 'productId' | 'productName' + >[], + edges: Pick[], + group: MapGroup, + currentProductId?: string, +): MapLayout { + const sorted = [...nodes].sort( + (a, b) => a.name.localeCompare(b.name) || a.id.localeCompare(b.id), + ) + const taxonomy = LAYER_TAXONOMY as readonly string[] + const layers = [...new Set(sorted.map((n) => n.layer))].sort( + (a, b) => + (taxonomy.includes(a) ? taxonomy.indexOf(a) : 99) - + (taxonomy.includes(b) ? taxonomy.indexOf(b) : 99) || + a.localeCompare(b), + ) + const productName = new Map(sorted.map((n) => [n.productId, n.productName])) + const bandKeys = + group === 'product' + ? [...productName.keys()].sort( + (a, b) => + Number(b === currentProductId) - Number(a === currentProductId) || + productName.get(a)!.localeCompare(productName.get(b)!) || + a.localeCompare(b), + ) + : [''] + const bandOf = (n: (typeof nodes)[number]) => + group === 'product' ? n.productId : '' + const cells = new Map() + const cellKey = (band: string, layer: string) => `${band}\u0000${layer}` + for (const n of sorted) { + const k = cellKey(bandOf(n), n.layer) + cells.set(k, [...(cells.get(k) ?? []), n]) + } + const bandRows = bandKeys.map((b) => + Math.max(1, ...layers.map((l) => cells.get(cellKey(b, l))?.length ?? 0)), + ) + const bandOffset = bandKeys.map((_, i) => + bandRows.slice(0, i).reduce((s, r) => s + r, 0), + ) + const neighbors = new Map() + for (const e of edges) { + neighbors.set(e.source, [...(neighbors.get(e.source) ?? []), e.target]) + neighbors.set(e.target, [...(neighbors.get(e.target) ?? []), e.source]) + } + const row = new Map() + const place = () => + bandKeys.forEach((b, bi) => + layers.forEach((l) => + cells + .get(cellKey(b, l)) + ?.forEach((n, i) => row.set(n.id, bandOffset[bi] + i)), + ), + ) + place() + for (let sweep = 0; sweep < 2; sweep++) + for (const l of layers) + for (const b of bandKeys) { + const cell = cells.get(cellKey(b, l)) + if (!cell) continue + const bary = (id: string) => { + const ns = (neighbors.get(id) ?? []).filter((n) => row.has(n)) + return ns.length + ? ns.reduce((s, n) => s + row.get(n)!, 0) / ns.length + : row.get(id)! + } + const scores = new Map(cell.map((n) => [n.id, bary(n.id)])) + cell.sort( + (x, y) => + scores.get(x.id)! - scores.get(y.id)! || + x.name.localeCompare(y.name) || + x.id.localeCompare(y.id), + ) + place() + } + const labelled = group === 'product' + const rowH = MAP.nodeH + MAP.nodeGap + let y = MAP.pad + MAP.header + const bands = bandKeys.map((key, i) => { + const height = + (labelled ? MAP.bandHeader : 0) + bandRows[i] * rowH - MAP.nodeGap + const band = { + key, + label: labelled ? productName.get(key)! : '', + y, + height, + } + y += height + (labelled ? MAP.bandGap : MAP.nodeGap) + return band + }) + const positions = new Map() + const columns = layers.map((key, c) => { + const x = MAP.pad + c * (MAP.colW + MAP.colGap) + let count = 0 + bandKeys.forEach((b, bi) => + cells.get(cellKey(b, key))?.forEach((n, r) => { + count++ + positions.set(n.id, { + x, + y: bands[bi].y + (labelled ? MAP.bandHeader : 0) + r * rowH, + }) + }), + ) + return { key, x, count } + }) + return { + columns, + bands, + positions, + width: + MAP.pad * 2 + + layers.length * MAP.colW + + Math.max(0, layers.length - 1) * MAP.colGap + + MAP.trail, + height: nodes.length + ? y - (labelled ? MAP.bandGap : MAP.nodeGap) + MAP.pad + : MAP.pad * 2 + MAP.header, + } +} + +/** Cubic edge between node sides; same-column edges arc out to the right. */ +export function edgePath( + s: { x: number; y: number }, + t: { x: number; y: number }, +) { + const sy = s.y + MAP.nodeH / 2, + ty = t.y + MAP.nodeH / 2 + if (s.x === t.x) { + const x = s.x + MAP.colW, + bulge = Math.min(80, 36 + Math.abs(ty - sy) * 0.08) + return `M ${x} ${sy} C ${x + bulge} ${sy}, ${x + bulge} ${ty}, ${x} ${ty}` + } + const ltr = s.x < t.x, + sx = ltr ? s.x + MAP.colW : s.x, + tx = ltr ? t.x : t.x + MAP.colW, + mid = (tx - sx) / 2 + return `M ${sx} ${sy} C ${sx + mid} ${sy}, ${tx - mid} ${ty}, ${tx} ${ty}` +} diff --git a/tests/lib/wiki/map.test.ts b/tests/lib/wiki/map.test.ts new file mode 100644 index 0000000..614fa77 --- /dev/null +++ b/tests/lib/wiki/map.test.ts @@ -0,0 +1,203 @@ +import { describe, it, expect, beforeAll, beforeEach, afterEach } from 'vitest' +import { runMigrations, seedFixtures, clearTables, F } from '@/tests/helpers/db' +import { db } from '@/lib/db' +import { assets, assetDependencies, products } from '@/lib/db/schema.sqlite' +import { getWikiProduct } from '@/lib/db/wiki' +import { getAssetInventory } from '@/lib/db/queries' +import { createSpec, linkSpec } from '@/lib/db/specs' +import { + MAP, + buildWikiMap, + layoutMap, + reachEdge, + reachFrom, +} from '@/lib/wiki/map' +const d = db as any + +// web → api → db, worker → db, and a cycle api ↔ cache. +const edges = [ + { source: 'web', target: 'api' }, + { source: 'api', target: 'db' }, + { source: 'worker', target: 'db' }, + { source: 'api', target: 'cache' }, + { source: 'cache', target: 'api' }, +] +const node = (id: string, layer: string, productId = 'p1') => ({ + id, + name: id, + layer, + productId, + productName: productId.toUpperCase(), +}) + +describe('map reach', () => { + it('follows dependencies upstream and dependents downstream with shortest depth, through cycles', () => { + expect(Object.fromEntries(reachFrom('web', edges, 'upstream'))).toEqual({ + web: 0, + api: 1, + db: 2, + cache: 2, + }) + expect(Object.fromEntries(reachFrom('db', edges, 'downstream'))).toEqual( + { db: 0, api: 1, worker: 1, web: 2, cache: 2 }, + ) + expect(Object.fromEntries(reachFrom('api', edges, 'direct'))).toEqual({ + api: 0, + web: 1, + db: 1, + cache: 1, + }) + expect([...reachFrom('worker', edges, 'downstream').keys()]).toEqual([ + 'worker', + ]) + }) + it('highlights only edges that run along the traced direction', () => { + const up = reachFrom('web', edges, 'upstream') + const on = edges.filter((e) => reachEdge(e, 'web', 'upstream', up)) + expect(on).toContainEqual({ source: 'api', target: 'db' }) + expect(on).not.toContainEqual({ source: 'worker', target: 'db' }) + expect( + edges.filter((e) => + reachEdge(e, 'db', 'direct', reachFrom('db', edges, 'direct')), + ), + ).toHaveLength(2) + }) +}) + +describe('map layout', () => { + const nodes = [ + node('web', 'frontend'), + node('db', 'data'), + node('api', 'backend'), + node('worker', 'backend'), + node('cache', 'data'), + node('ops', 'tooling'), + ] + it('orders layer columns by taxonomy with unknown layers last, independent of input order', () => { + const a = layoutMap(nodes, edges, 'layer') + const b = layoutMap([...nodes].reverse(), [...edges].reverse(), 'layer') + expect(a.columns.map((c) => c.key)).toEqual([ + 'frontend', + 'backend', + 'data', + 'tooling', + ]) + expect([...a.positions.entries()].sort()).toEqual( + [...b.positions.entries()].sort(), + ) + expect(a.width).toBe( + MAP.pad * 2 + 4 * MAP.colW + 3 * MAP.colGap + MAP.trail, + ) + }) + it('keeps connected assets near each other and places the wiki product first', () => { + const layout = layoutMap(nodes, edges, 'layer') + // api's neighbours are cache and db; worker only reaches db. + expect(layout.positions.get('api')!.y).toBeLessThan( + layout.positions.get('worker')!.y, + ) + expect(layout.bands).toHaveLength(1) + expect(layout.bands[0].label).toBe('') + }) + it('stacks one non-overlapping band per product over shared layer columns, wiki product first', () => { + const byProduct = layoutMap( + [ + node('a', 'backend', 'zeta'), + node('a2', 'backend', 'zeta'), + node('b', 'backend', 'alpha'), + node('c', 'frontend', 'alpha'), + ], + [{ source: 'c', target: 'a' }], + 'product', + 'zeta', + ) + expect(byProduct.bands.map((b) => b.label)).toEqual(['ZETA', 'ALPHA']) + expect(byProduct.columns.map((c) => c.key)).toEqual([ + 'frontend', + 'backend', + ]) + const [zeta, alpha] = byProduct.bands + expect(alpha.y).toBeGreaterThanOrEqual(zeta.y + zeta.height) + for (const id of ['a', 'a2']) + expect(byProduct.positions.get(id)!.y).toBeLessThan(alpha.y) + expect(byProduct.positions.get('b')!.y).toBe( + byProduct.positions.get('c')!.y, + ) + expect(byProduct.height).toBeGreaterThan(alpha.y + alpha.height) + }) +}) + +describe('wiki map data', () => { + beforeAll(runMigrations) + beforeEach(seedFixtures) + afterEach(clearTables) + it('maps every visible product while keeping documents to the wiki product and bodies off the client', async () => { + await d.insert(products).values({ + id: 'product-billing', + name: 'Billing', + slug: 'billing', + description: '', + tags: [], + organizationId: F.org, + creatorId: F.alice, + }) + await d.insert(assets).values({ + id: 'asset-billing', + productId: 'product-billing', + name: 'Billing Service', + type: 'service', + description: 'Charges customers', + tags: [], + health: 'critical', + status: 'active', + metadata: {}, + }) + await d.insert(assetDependencies).values([ + { + id: 'edge-api-db', + sourceAssetId: F.assetApi, + targetAssetId: F.assetDb, + dependencyType: 'depends_on', + }, + { + id: 'edge-billing-api', + sourceAssetId: 'asset-billing', + targetAssetId: F.assetApi, + dependencyType: 'integrates_with', + description: 'Usage export', + }, + ]) + const spec = await createSpec( + { + productId: F.productShared, + title: 'API contract', + body: 'SECRET_BODY_TEXT', + specType: 'api', + }, + F.alice, + ) + await linkSpec(spec.id, 'asset', F.assetApi, undefined, F.alice) + const data = (await getWikiProduct('shared-product', F.alice))! + const map = buildWikiMap(data, await getAssetInventory(F.alice)) + expect(map.nodes.map((n) => n.name).sort()).toEqual([ + 'API Service', + 'Billing Service', + 'Database', + ]) + expect(map.edges).toHaveLength(2) + const api = map.nodes.find((n) => n.id === F.assetApi)! + expect(api.summary).toBe('Main API') + expect(api.activePlans).toBe(1) + expect(api.documents.map((x) => x.key)).toContain(`spec:${spec.id}`) + expect(api.documentCounts.spec).toBe(1) + const billing = map.nodes.find((n) => n.id === 'asset-billing')! + expect(billing).toMatchObject({ + productSlug: 'billing', + summary: '', + documents: [], + }) + expect(JSON.stringify(map)).not.toContain('SECRET_BODY_TEXT') + const carol = (await getWikiProduct('carol-product', F.carol))! + const carolMap = buildWikiMap(carol, await getAssetInventory(F.carol)) + expect(carolMap.nodes.some((n) => n.id === 'asset-billing')).toBe(false) + }) +})