diff --git a/apps/api/src/knowledge/dto/graph-query.dto.ts b/apps/api/src/knowledge/dto/graph-query.dto.ts new file mode 100644 index 0000000..9a01824 --- /dev/null +++ b/apps/api/src/knowledge/dto/graph-query.dto.ts @@ -0,0 +1,14 @@ +import { IsOptional, IsInt, Min, Max } from "class-validator"; +import { Type } from "class-transformer"; + +/** + * Query parameters for entry-centered graph view + */ +export class GraphQueryDto { + @IsOptional() + @Type(() => Number) + @IsInt() + @Min(1) + @Max(5) + depth?: number = 1; +} diff --git a/apps/api/src/knowledge/dto/index.ts b/apps/api/src/knowledge/dto/index.ts index 1fe3c76..90b0dfd 100644 --- a/apps/api/src/knowledge/dto/index.ts +++ b/apps/api/src/knowledge/dto/index.ts @@ -8,3 +8,4 @@ export { TagSearchDto, RecentEntriesDto, } from "./search-query.dto"; +export { GraphQueryDto } from "./graph-query.dto"; diff --git a/apps/api/src/knowledge/entities/graph.entity.ts b/apps/api/src/knowledge/entities/graph.entity.ts new file mode 100644 index 0000000..0c438d7 --- /dev/null +++ b/apps/api/src/knowledge/entities/graph.entity.ts @@ -0,0 +1,40 @@ +/** + * Represents a node in the knowledge graph + */ +export interface GraphNode { + id: string; + slug: string; + title: string; + summary: string | null; + tags: Array<{ + id: string; + name: string; + slug: string; + color: string | null; + }>; + depth: number; +} + +/** + * Represents an edge/link in the knowledge graph + */ +export interface GraphEdge { + id: string; + sourceId: string; + targetId: string; + linkText: string; +} + +/** + * Entry-centered graph response + */ +export interface EntryGraphResponse { + centerNode: GraphNode; + nodes: GraphNode[]; + edges: GraphEdge[]; + stats: { + totalNodes: number; + totalEdges: number; + maxDepth: number; + }; +} diff --git a/apps/api/src/knowledge/entities/stats.entity.ts b/apps/api/src/knowledge/entities/stats.entity.ts new file mode 100644 index 0000000..5533e95 --- /dev/null +++ b/apps/api/src/knowledge/entities/stats.entity.ts @@ -0,0 +1,35 @@ +/** + * Knowledge base statistics + */ +export interface KnowledgeStats { + overview: { + totalEntries: number; + totalTags: number; + totalLinks: number; + publishedEntries: number; + draftEntries: number; + archivedEntries: number; + }; + mostConnected: Array<{ + id: string; + slug: string; + title: string; + incomingLinks: number; + outgoingLinks: number; + totalConnections: number; + }>; + recentActivity: Array<{ + id: string; + slug: string; + title: string; + updatedAt: Date; + status: string; + }>; + tagDistribution: Array<{ + id: string; + name: string; + slug: string; + color: string | null; + entryCount: number; + }>; +} diff --git a/apps/api/src/knowledge/knowledge.controller.ts b/apps/api/src/knowledge/knowledge.controller.ts index 78ebfb4..405c1c0 100644 --- a/apps/api/src/knowledge/knowledge.controller.ts +++ b/apps/api/src/knowledge/knowledge.controller.ts @@ -10,12 +10,12 @@ import { UseGuards, } from "@nestjs/common"; import { KnowledgeService } from "./knowledge.service"; -import { CreateEntryDto, UpdateEntryDto, EntryQueryDto } from "./dto"; +import { CreateEntryDto, UpdateEntryDto, EntryQueryDto, GraphQueryDto } from "./dto"; import { AuthGuard } from "../auth/guards/auth.guard"; import { WorkspaceGuard, PermissionGuard } from "../common/guards"; import { Workspace, Permission, RequirePermission } from "../common/decorators"; import { CurrentUser } from "../auth/decorators/current-user.decorator"; -import { LinkSyncService } from "./services/link-sync.service"; +import { LinkSyncService, GraphService } from "./services"; /** * Controller for knowledge entry endpoints @@ -27,7 +27,8 @@ import { LinkSyncService } from "./services/link-sync.service"; export class KnowledgeController { constructor( private readonly knowledgeService: KnowledgeService, - private readonly linkSync: LinkSyncService + private readonly linkSync: LinkSyncService, + private readonly graphService: GraphService ) {} /** @@ -132,4 +133,31 @@ export class KnowledgeController { count: backlinks.length, }; } + + /** + * GET /api/knowledge/entries/:slug/graph + * Get entry-centered graph view + * Returns the entry and connected nodes with specified depth + * Requires: Any workspace member + */ + @Get(":slug/graph") + @RequirePermission(Permission.WORKSPACE_ANY) + async getEntryGraph( + @Workspace() workspaceId: string, + @Param("slug") slug: string, + @Query() query: GraphQueryDto + ) { + // Find the entry to get its ID + const entry = await this.knowledgeService.findOne(workspaceId, slug); + + // Get graph + const graph = await this.graphService.getEntryGraph( + workspaceId, + entry.id, + query.depth || 1 + ); + + return graph; + } + } diff --git a/apps/api/src/knowledge/knowledge.module.ts b/apps/api/src/knowledge/knowledge.module.ts index a22a58c..92cfa69 100644 --- a/apps/api/src/knowledge/knowledge.module.ts +++ b/apps/api/src/knowledge/knowledge.module.ts @@ -4,13 +4,26 @@ import { AuthModule } from "../auth/auth.module"; import { KnowledgeService } from "./knowledge.service"; import { KnowledgeController } from "./knowledge.controller"; import { SearchController } from "./search.controller"; -import { LinkResolutionService } from "./services/link-resolution.service"; -import { SearchService } from "./services/search.service"; +import { KnowledgeStatsController } from "./stats.controller"; +import { + LinkResolutionService, + SearchService, + LinkSyncService, + GraphService, + StatsService, +} from "./services"; @Module({ imports: [PrismaModule, AuthModule], - controllers: [KnowledgeController, SearchController], - providers: [KnowledgeService, LinkResolutionService, SearchService], + controllers: [KnowledgeController, SearchController, KnowledgeStatsController], + providers: [ + KnowledgeService, + LinkResolutionService, + SearchService, + LinkSyncService, + GraphService, + StatsService, + ], exports: [KnowledgeService, LinkResolutionService, SearchService], }) export class KnowledgeModule {} diff --git a/apps/api/src/knowledge/services/graph.service.spec.ts b/apps/api/src/knowledge/services/graph.service.spec.ts new file mode 100644 index 0000000..383edb8 --- /dev/null +++ b/apps/api/src/knowledge/services/graph.service.spec.ts @@ -0,0 +1,126 @@ +import { Test, TestingModule } from "@nestjs/testing"; +import { NotFoundException } from "@nestjs/common"; +import { GraphService } from "./graph.service"; +import { PrismaService } from "../../prisma/prisma.service"; + +describe("GraphService", () => { + let service: GraphService; + let prisma: PrismaService; + + const mockEntry = { + id: "entry-1", + workspaceId: "workspace-1", + slug: "test-entry", + title: "Test Entry", + content: "Test content", + contentHtml: "

Test content

", + summary: "Test summary", + status: "PUBLISHED", + visibility: "WORKSPACE", + createdAt: new Date(), + updatedAt: new Date(), + createdBy: "user-1", + updatedBy: "user-1", + tags: [], + outgoingLinks: [], + incomingLinks: [], + }; + + const mockPrismaService = { + knowledgeEntry: { + findUnique: jest.fn(), + }, + }; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + providers: [ + GraphService, + { + provide: PrismaService, + useValue: mockPrismaService, + }, + ], + }).compile(); + + service = module.get(GraphService); + prisma = module.get(PrismaService); + + jest.clearAllMocks(); + }); + + it("should be defined", () => { + expect(service).toBeDefined(); + }); + + describe("getEntryGraph", () => { + it("should throw NotFoundException if entry does not exist", async () => { + mockPrismaService.knowledgeEntry.findUnique.mockResolvedValue(null); + + await expect( + service.getEntryGraph("workspace-1", "non-existent", 1) + ).rejects.toThrow(NotFoundException); + }); + + it("should throw NotFoundException if entry belongs to different workspace", async () => { + mockPrismaService.knowledgeEntry.findUnique.mockResolvedValue({ + ...mockEntry, + workspaceId: "different-workspace", + }); + + await expect( + service.getEntryGraph("workspace-1", "entry-1", 1) + ).rejects.toThrow(NotFoundException); + }); + + it("should return graph with center node when depth is 0", async () => { + mockPrismaService.knowledgeEntry.findUnique.mockResolvedValue(mockEntry); + + const result = await service.getEntryGraph("workspace-1", "entry-1", 0); + + expect(result.centerNode.id).toBe("entry-1"); + expect(result.nodes).toHaveLength(1); + expect(result.edges).toHaveLength(0); + expect(result.stats.totalNodes).toBe(1); + expect(result.stats.totalEdges).toBe(0); + }); + + it("should build graph with connected nodes at depth 1", async () => { + const linkedEntry = { + id: "entry-2", + slug: "linked-entry", + title: "Linked Entry", + summary: null, + tags: [], + }; + + mockPrismaService.knowledgeEntry.findUnique + .mockResolvedValueOnce({ + ...mockEntry, + outgoingLinks: [ + { + id: "link-1", + sourceId: "entry-1", + targetId: "entry-2", + linkText: "link to entry 2", + target: linkedEntry, + }, + ], + incomingLinks: [], + }) + .mockResolvedValueOnce({ + ...linkedEntry, + tags: [], + outgoingLinks: [], + incomingLinks: [], + }); + + const result = await service.getEntryGraph("workspace-1", "entry-1", 1); + + expect(result.nodes).toHaveLength(2); + expect(result.edges).toHaveLength(1); + expect(result.stats.totalNodes).toBe(2); + expect(result.stats.totalEdges).toBe(1); + }); + }); +}); diff --git a/apps/api/src/knowledge/services/graph.service.ts b/apps/api/src/knowledge/services/graph.service.ts new file mode 100644 index 0000000..50b59c4 --- /dev/null +++ b/apps/api/src/knowledge/services/graph.service.ts @@ -0,0 +1,164 @@ +import { Injectable, NotFoundException } from "@nestjs/common"; +import { PrismaService } from "../../prisma/prisma.service"; +import type { EntryGraphResponse, GraphNode, GraphEdge } from "../entities/graph.entity"; + +/** + * Service for knowledge graph operations + */ +@Injectable() +export class GraphService { + constructor(private readonly prisma: PrismaService) {} + + /** + * Get entry-centered graph view + * Returns the entry and all connected nodes up to specified depth + */ + async getEntryGraph( + workspaceId: string, + entryId: string, + maxDepth: number = 1 + ): Promise { + // Verify entry exists + const centerEntry = await this.prisma.knowledgeEntry.findUnique({ + where: { id: entryId }, + include: { + tags: { + include: { + tag: true, + }, + }, + }, + }); + + if (!centerEntry || centerEntry.workspaceId !== workspaceId) { + throw new NotFoundException("Entry not found"); + } + + // Build graph using BFS + const visitedNodes = new Set(); + const nodes: GraphNode[] = []; + const edges: GraphEdge[] = []; + const nodeDepths = new Map(); + + // Queue: [entryId, depth] + const queue: Array<[string, number]> = [[entryId, 0]]; + visitedNodes.add(entryId); + nodeDepths.set(entryId, 0); + + while (queue.length > 0) { + const [currentId, depth] = queue.shift()!; + + // Fetch current entry with related data + const currentEntry = await this.prisma.knowledgeEntry.findUnique({ + where: { id: currentId }, + include: { + tags: { + include: { + tag: true, + }, + }, + outgoingLinks: { + include: { + target: { + select: { + id: true, + slug: true, + title: true, + summary: true, + }, + }, + }, + }, + incomingLinks: { + include: { + source: { + select: { + id: true, + slug: true, + title: true, + summary: true, + }, + }, + }, + }, + }, + }); + + if (!currentEntry) continue; + + // Add current node + const graphNode: GraphNode = { + id: currentEntry.id, + slug: currentEntry.slug, + title: currentEntry.title, + summary: currentEntry.summary, + tags: currentEntry.tags.map((et) => ({ + id: et.tag.id, + name: et.tag.name, + slug: et.tag.slug, + color: et.tag.color, + })), + depth, + }; + nodes.push(graphNode); + + // Continue BFS if not at max depth + if (depth < maxDepth) { + // Process outgoing links + for (const link of currentEntry.outgoingLinks) { + // Add edge + edges.push({ + id: link.id, + sourceId: link.sourceId, + targetId: link.targetId, + linkText: link.linkText, + }); + + // Add target to queue if not visited + if (!visitedNodes.has(link.targetId)) { + visitedNodes.add(link.targetId); + nodeDepths.set(link.targetId, depth + 1); + queue.push([link.targetId, depth + 1]); + } + } + + // Process incoming links + for (const link of currentEntry.incomingLinks) { + // Add edge + const edgeExists = edges.some( + (e) => e.sourceId === link.sourceId && e.targetId === link.targetId + ); + if (!edgeExists) { + edges.push({ + id: link.id, + sourceId: link.sourceId, + targetId: link.targetId, + linkText: link.linkText, + }); + } + + // Add source to queue if not visited + if (!visitedNodes.has(link.sourceId)) { + visitedNodes.add(link.sourceId); + nodeDepths.set(link.sourceId, depth + 1); + queue.push([link.sourceId, depth + 1]); + } + } + } + } + + // Find center node + const centerNode = nodes.find((n) => n.id === entryId)!; + + return { + centerNode, + nodes, + edges, + stats: { + totalNodes: nodes.length, + totalEdges: edges.length, + maxDepth, + }, + }; + } +} diff --git a/apps/api/src/knowledge/services/index.ts b/apps/api/src/knowledge/services/index.ts index ed6384e..fcbde1a 100644 --- a/apps/api/src/knowledge/services/index.ts +++ b/apps/api/src/knowledge/services/index.ts @@ -4,3 +4,7 @@ export type { ResolvedLink, Backlink, } from "./link-resolution.service"; +export { LinkSyncService } from "./link-sync.service"; +export { SearchService } from "./search.service"; +export { GraphService } from "./graph.service"; +export { StatsService } from "./stats.service"; diff --git a/apps/api/src/knowledge/services/stats.service.spec.ts b/apps/api/src/knowledge/services/stats.service.spec.ts new file mode 100644 index 0000000..22e7a8d --- /dev/null +++ b/apps/api/src/knowledge/services/stats.service.spec.ts @@ -0,0 +1,122 @@ +import { Test, TestingModule } from "@nestjs/testing"; +import { StatsService } from "./stats.service"; +import { PrismaService } from "../../prisma/prisma.service"; +import { EntryStatus } from "@prisma/client"; + +describe("StatsService", () => { + let service: StatsService; + let prisma: PrismaService; + + const mockPrismaService = { + knowledgeEntry: { + count: jest.fn(), + findMany: jest.fn(), + }, + knowledgeTag: { + count: jest.fn(), + findMany: jest.fn(), + }, + knowledgeLink: { + count: jest.fn(), + }, + }; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + providers: [ + StatsService, + { + provide: PrismaService, + useValue: mockPrismaService, + }, + ], + }).compile(); + + service = module.get(StatsService); + prisma = module.get(PrismaService); + + jest.clearAllMocks(); + }); + + it("should be defined", () => { + expect(service).toBeDefined(); + }); + + describe("getStats", () => { + it("should return knowledge base statistics", async () => { + // Mock all the parallel queries + mockPrismaService.knowledgeEntry.count + .mockResolvedValueOnce(10) // total entries + .mockResolvedValueOnce(5) // published + .mockResolvedValueOnce(3) // drafts + .mockResolvedValueOnce(2); // archived + + mockPrismaService.knowledgeTag.count.mockResolvedValue(7); + mockPrismaService.knowledgeLink.count.mockResolvedValue(15); + + mockPrismaService.knowledgeEntry.findMany + .mockResolvedValueOnce([ + // most connected + { + id: "entry-1", + slug: "test-entry", + title: "Test Entry", + _count: { incomingLinks: 5, outgoingLinks: 3 }, + }, + ]) + .mockResolvedValueOnce([ + // recent activity + { + id: "entry-2", + slug: "recent-entry", + title: "Recent Entry", + updatedAt: new Date(), + status: EntryStatus.PUBLISHED, + }, + ]); + + mockPrismaService.knowledgeTag.findMany.mockResolvedValue([ + { + id: "tag-1", + name: "Test Tag", + slug: "test-tag", + color: "#FF0000", + _count: { entries: 3 }, + }, + ]); + + const result = await service.getStats("workspace-1"); + + expect(result.overview.totalEntries).toBe(10); + expect(result.overview.totalTags).toBe(7); + expect(result.overview.totalLinks).toBe(15); + expect(result.overview.publishedEntries).toBe(5); + expect(result.overview.draftEntries).toBe(3); + expect(result.overview.archivedEntries).toBe(2); + + expect(result.mostConnected).toHaveLength(1); + expect(result.mostConnected[0].totalConnections).toBe(8); + + expect(result.recentActivity).toHaveLength(1); + expect(result.tagDistribution).toHaveLength(1); + }); + + it("should handle empty knowledge base", async () => { + // Mock all counts as 0 + mockPrismaService.knowledgeEntry.count.mockResolvedValue(0); + mockPrismaService.knowledgeTag.count.mockResolvedValue(0); + mockPrismaService.knowledgeLink.count.mockResolvedValue(0); + mockPrismaService.knowledgeEntry.findMany.mockResolvedValue([]); + mockPrismaService.knowledgeTag.findMany.mockResolvedValue([]); + + const result = await service.getStats("workspace-1"); + + expect(result.overview.totalEntries).toBe(0); + expect(result.overview.totalTags).toBe(0); + expect(result.overview.totalLinks).toBe(0); + expect(result.mostConnected).toHaveLength(0); + expect(result.recentActivity).toHaveLength(0); + expect(result.tagDistribution).toHaveLength(0); + }); + }); +}); diff --git a/apps/api/src/knowledge/services/stats.service.ts b/apps/api/src/knowledge/services/stats.service.ts new file mode 100644 index 0000000..1453dbe --- /dev/null +++ b/apps/api/src/knowledge/services/stats.service.ts @@ -0,0 +1,169 @@ +import { Injectable } from "@nestjs/common"; +import { EntryStatus } from "@prisma/client"; +import { PrismaService } from "../../prisma/prisma.service"; +import type { KnowledgeStats } from "../entities/stats.entity"; + +/** + * Service for knowledge base statistics + */ +@Injectable() +export class StatsService { + constructor(private readonly prisma: PrismaService) {} + + /** + * Get comprehensive knowledge base statistics + */ + async getStats(workspaceId: string): Promise { + // Run queries in parallel for better performance + const [ + totalEntries, + totalTags, + totalLinks, + publishedEntries, + draftEntries, + archivedEntries, + entriesWithLinkCounts, + recentEntries, + tagsWithCounts, + ] = await Promise.all([ + // Total entries + this.prisma.knowledgeEntry.count({ + where: { workspaceId }, + }), + + // Total tags + this.prisma.knowledgeTag.count({ + where: { workspaceId }, + }), + + // Total links + this.prisma.knowledgeLink.count({ + where: { + source: { workspaceId }, + }, + }), + + // Published entries + this.prisma.knowledgeEntry.count({ + where: { + workspaceId, + status: EntryStatus.PUBLISHED, + }, + }), + + // Draft entries + this.prisma.knowledgeEntry.count({ + where: { + workspaceId, + status: EntryStatus.DRAFT, + }, + }), + + // Archived entries + this.prisma.knowledgeEntry.count({ + where: { + workspaceId, + status: EntryStatus.ARCHIVED, + }, + }), + + // Most connected entries + this.prisma.knowledgeEntry.findMany({ + where: { workspaceId }, + include: { + _count: { + select: { + incomingLinks: true, + outgoingLinks: true, + }, + }, + }, + orderBy: { + incomingLinks: { + _count: "desc", + }, + }, + take: 10, + }), + + // Recent activity + this.prisma.knowledgeEntry.findMany({ + where: { workspaceId }, + orderBy: { + updatedAt: "desc", + }, + take: 10, + select: { + id: true, + slug: true, + title: true, + updatedAt: true, + status: true, + }, + }), + + // Tag distribution + this.prisma.knowledgeTag.findMany({ + where: { workspaceId }, + include: { + _count: { + select: { + entries: true, + }, + }, + }, + orderBy: { + entries: { + _count: "desc", + }, + }, + }), + ]); + + // Transform most connected entries + const mostConnected = entriesWithLinkCounts.map((entry) => { + const incomingLinks = entry._count.incomingLinks; + const outgoingLinks = entry._count.outgoingLinks; + return { + id: entry.id, + slug: entry.slug, + title: entry.title, + incomingLinks, + outgoingLinks, + totalConnections: incomingLinks + outgoingLinks, + }; + }); + + // Sort by total connections + mostConnected.sort((a, b) => b.totalConnections - a.totalConnections); + + // Transform tag distribution + const tagDistribution = tagsWithCounts.map((tag) => ({ + id: tag.id, + name: tag.name, + slug: tag.slug, + color: tag.color, + entryCount: tag._count.entries, + })); + + return { + overview: { + totalEntries, + totalTags, + totalLinks, + publishedEntries, + draftEntries, + archivedEntries, + }, + mostConnected, + recentActivity: recentEntries.map((entry) => ({ + id: entry.id, + slug: entry.slug, + title: entry.title, + updatedAt: entry.updatedAt, + status: entry.status, + })), + tagDistribution, + }; + } +} diff --git a/apps/api/src/knowledge/stats.controller.ts b/apps/api/src/knowledge/stats.controller.ts new file mode 100644 index 0000000..8f5f701 --- /dev/null +++ b/apps/api/src/knowledge/stats.controller.ts @@ -0,0 +1,25 @@ +import { Controller, Get, UseGuards } from "@nestjs/common"; +import { AuthGuard } from "../auth/guards/auth.guard"; +import { WorkspaceGuard, PermissionGuard } from "../common/guards"; +import { Workspace, RequirePermission, Permission } from "../common/decorators"; +import { StatsService } from "./services"; + +/** + * Controller for knowledge statistics endpoints + */ +@Controller("knowledge/stats") +@UseGuards(AuthGuard, WorkspaceGuard, PermissionGuard) +export class KnowledgeStatsController { + constructor(private readonly statsService: StatsService) {} + + /** + * GET /api/knowledge/stats + * Get knowledge base statistics + * Requires: Any workspace member + */ + @Get() + @RequirePermission(Permission.WORKSPACE_ANY) + async getStats(@Workspace() workspaceId: string) { + return this.statsService.getStats(workspaceId); + } +} diff --git a/apps/web/src/app/(authenticated)/knowledge/[slug]/page.tsx b/apps/web/src/app/(authenticated)/knowledge/[slug]/page.tsx index 9df8e78..e52f800 100644 --- a/apps/web/src/app/(authenticated)/knowledge/[slug]/page.tsx +++ b/apps/web/src/app/(authenticated)/knowledge/[slug]/page.tsx @@ -7,6 +7,7 @@ import { EntryStatus, Visibility } from "@mosaic/shared"; import { EntryViewer } from "@/components/knowledge/EntryViewer"; import { EntryEditor } from "@/components/knowledge/EntryEditor"; import { EntryMetadata } from "@/components/knowledge/EntryMetadata"; +import { EntryGraphViewer } from "@/components/knowledge/EntryGraphViewer"; import { fetchEntry, updateEntry, deleteEntry, fetchTags } from "@/lib/api/knowledge"; /** @@ -20,6 +21,7 @@ export default function EntryPage() { const [entry, setEntry] = useState(null); const [isEditing, setIsEditing] = useState(false); + const [showGraph, setShowGraph] = useState(false); const [isLoading, setIsLoading] = useState(true); const [isSaving, setIsSaving] = useState(false); const [error, setError] = useState(null); @@ -268,10 +270,42 @@ export default function EntryPage() { )} + {/* View Tabs */} + {!isEditing && ( +
+
+ + +
+
+ )} + {/* Content */}
{isEditing ? ( + ) : showGraph ? ( +
+ +
) : ( )} diff --git a/apps/web/src/app/(authenticated)/knowledge/stats/page.tsx b/apps/web/src/app/(authenticated)/knowledge/stats/page.tsx new file mode 100644 index 0000000..2de8af3 --- /dev/null +++ b/apps/web/src/app/(authenticated)/knowledge/stats/page.tsx @@ -0,0 +1,5 @@ +import { StatsDashboard } from "@/components/knowledge"; + +export default function KnowledgeStatsPage() { + return ; +} diff --git a/apps/web/src/components/knowledge/EntryGraphViewer.tsx b/apps/web/src/components/knowledge/EntryGraphViewer.tsx new file mode 100644 index 0000000..915278a --- /dev/null +++ b/apps/web/src/components/knowledge/EntryGraphViewer.tsx @@ -0,0 +1,293 @@ +"use client"; + +import { useEffect, useState, useCallback } from "react"; +import { fetchEntryGraph } from "@/lib/api/knowledge"; +import Link from "next/link"; + +interface GraphNode { + id: string; + slug: string; + title: string; + summary: string | null; + tags: Array<{ + id: string; + name: string; + slug: string; + color: string | null; + }>; + depth: number; +} + +interface GraphEdge { + id: string; + sourceId: string; + targetId: string; + linkText: string; +} + +interface EntryGraphResponse { + centerNode: GraphNode; + nodes: GraphNode[]; + edges: GraphEdge[]; + stats: { + totalNodes: number; + totalEdges: number; + maxDepth: number; + }; +} + +interface EntryGraphViewerProps { + slug: string; + initialDepth?: number; +} + +export function EntryGraphViewer({ slug, initialDepth = 1 }: EntryGraphViewerProps) { + const [graphData, setGraphData] = useState(null); + const [depth, setDepth] = useState(initialDepth); + const [isLoading, setIsLoading] = useState(true); + const [error, setError] = useState(null); + const [selectedNode, setSelectedNode] = useState(null); + + const loadGraph = useCallback(async () => { + try { + setIsLoading(true); + setError(null); + const data = await fetchEntryGraph(slug, depth); + setGraphData(data as EntryGraphResponse); + } catch (err) { + setError(err instanceof Error ? err.message : "Failed to load graph"); + } finally { + setIsLoading(false); + } + }, [slug, depth]); + + useEffect(() => { + void loadGraph(); + }, [loadGraph]); + + const handleDepthChange = (newDepth: number) => { + setDepth(newDepth); + }; + + if (isLoading) { + return ( +
+
+
+ ); + } + + if (error || !graphData) { + return ( +
+
Error loading graph
+
{error}
+
+ ); + } + + const { centerNode, nodes, edges, stats } = graphData; + + // Group nodes by depth for better visualization + const nodesByDepth = nodes.reduce((acc, node) => { + const d = node.depth; + if (!acc[d]) acc[d] = []; + acc[d].push(node); + return acc; + }, {} as Record); + + return ( +
+ {/* Toolbar */} +
+
+

+ Graph View +

+
+ {stats.totalNodes} nodes • {stats.totalEdges} connections +
+
+ +
+ +
+ {[1, 2, 3].map((d) => ( + + ))} +
+
+
+ + {/* Graph Visualization - Simple List View */} +
+
+ {/* Center Node */} +
+
+ setSelectedNode(centerNode)} + isSelected={selectedNode?.id === centerNode.id} + /> +
+
+ + {/* Nodes by Depth */} + {Object.entries(nodesByDepth) + .filter(([d]) => d !== "0") + .sort(([a], [b]) => Number(a) - Number(b)) + .map(([depthLevel, depthNodes]) => ( +
+

+ Depth {depthLevel} ({depthNodes.length} {depthNodes.length === 1 ? "node" : "nodes"}) +

+
+ {depthNodes.map((node) => ( + setSelectedNode(node)} + isSelected={selectedNode?.id === node.id} + connections={getNodeConnections(node.id, edges)} + /> + ))} +
+
+ ))} +
+
+ + {/* Selected Node Details */} + {selectedNode && ( +
+
+
+

+ {selectedNode.title} +

+ {selectedNode.summary && ( +

+ {selectedNode.summary} +

+ )} + {selectedNode.tags.length > 0 && ( +
+ {selectedNode.tags.map((tag) => ( + + {tag.name} + + ))} +
+ )} +
+ + View Full Entry → + +
+
+ +
+
+ )} +
+ ); +} + +interface NodeCardProps { + node: GraphNode; + isCenter?: boolean; + onClick?: () => void; + isSelected?: boolean; + connections?: { incoming: number; outgoing: number }; +} + +function NodeCard({ node, isCenter, onClick, isSelected, connections }: NodeCardProps) { + return ( +
+
+
+

+ {node.title} +

+ {node.summary && ( +

+ {node.summary} +

+ )} + {node.tags.length > 0 && ( +
+ {node.tags.slice(0, 3).map((tag) => ( + + {tag.name} + + ))} + {node.tags.length > 3 && ( + + +{node.tags.length - 3} + + )} +
+ )} + {connections && ( +
+ {connections.incoming} incoming • {connections.outgoing} outgoing +
+ )} +
+
+
+ ); +} + +function getNodeConnections(nodeId: string, edges: GraphEdge[]) { + const incoming = edges.filter((e) => e.targetId === nodeId).length; + const outgoing = edges.filter((e) => e.sourceId === nodeId).length; + return { incoming, outgoing }; +} diff --git a/apps/web/src/components/knowledge/StatsDashboard.tsx b/apps/web/src/components/knowledge/StatsDashboard.tsx new file mode 100644 index 0000000..12a0f64 --- /dev/null +++ b/apps/web/src/components/knowledge/StatsDashboard.tsx @@ -0,0 +1,249 @@ +"use client"; + +import { useEffect, useState } from "react"; +import { fetchKnowledgeStats } from "@/lib/api/knowledge"; +import Link from "next/link"; + +interface KnowledgeStats { + overview: { + totalEntries: number; + totalTags: number; + totalLinks: number; + publishedEntries: number; + draftEntries: number; + archivedEntries: number; + }; + mostConnected: Array<{ + id: string; + slug: string; + title: string; + incomingLinks: number; + outgoingLinks: number; + totalConnections: number; + }>; + recentActivity: Array<{ + id: string; + slug: string; + title: string; + updatedAt: string; + status: string; + }>; + tagDistribution: Array<{ + id: string; + name: string; + slug: string; + color: string | null; + entryCount: number; + }>; +} + +export function StatsDashboard() { + const [stats, setStats] = useState(null); + const [isLoading, setIsLoading] = useState(true); + const [error, setError] = useState(null); + + useEffect(() => { + async function loadStats() { + try { + setIsLoading(true); + const data = await fetchKnowledgeStats(); + setStats(data as KnowledgeStats); + } catch (err) { + setError(err instanceof Error ? err.message : "Failed to load statistics"); + } finally { + setIsLoading(false); + } + } + + void loadStats(); + }, []); + + if (isLoading) { + return ( +
+
+
+ ); + } + + if (error || !stats) { + return ( +
+ Error loading statistics: {error} +
+ ); + } + + const { overview, mostConnected, recentActivity, tagDistribution } = stats; + + return ( +
+
+

+ Knowledge Base Statistics +

+ + Back to Entries + +
+ + {/* Overview Cards */} +
+ + + +
+ + {/* Two Column Layout */} +
+ {/* Most Connected Entries */} +
+

+ Most Connected Entries +

+
+ {mostConnected.slice(0, 10).map((entry) => ( +
+
+ + {entry.title} + +
+ {entry.incomingLinks} incoming • {entry.outgoingLinks} outgoing +
+
+
+ + {entry.totalConnections} + +
+
+ ))} + {mostConnected.length === 0 && ( +

+ No connections yet +

+ )} +
+
+ + {/* Recent Activity */} +
+

+ Recent Activity +

+
+ {recentActivity.slice(0, 10).map((entry) => ( +
+
+ + {entry.title} + +
+ {new Date(entry.updatedAt).toLocaleDateString()} •{" "} + {entry.status.toLowerCase()} +
+
+
+ ))} + {recentActivity.length === 0 && ( +

+ No recent activity +

+ )} +
+
+
+ + {/* Tag Distribution */} +
+

+ Tag Distribution +

+
+ {tagDistribution.slice(0, 12).map((tag) => ( +
+
+ {tag.color && ( +
+ )} + + {tag.name} + +
+ + {tag.entryCount} + +
+ ))} + {tagDistribution.length === 0 && ( +

+ No tags yet +

+ )} +
+
+
+ ); +} + +function StatsCard({ + title, + value, + subtitle, + icon, +}: { + title: string; + value: number; + subtitle: string; + icon: string; +}) { + return ( +
+
+
+

{title}

+

{value}

+

{subtitle}

+
+
{icon}
+
+
+ ); +} diff --git a/apps/web/src/components/knowledge/index.ts b/apps/web/src/components/knowledge/index.ts index 99a6056..6719d71 100644 --- a/apps/web/src/components/knowledge/index.ts +++ b/apps/web/src/components/knowledge/index.ts @@ -1,7 +1,8 @@ -/** - * Knowledge module components - */ - -export { EntryViewer } from "./EntryViewer"; +export { EntryCard } from "./EntryCard"; export { EntryEditor } from "./EntryEditor"; +export { EntryFilters } from "./EntryFilters"; +export { EntryList } from "./EntryList"; export { EntryMetadata } from "./EntryMetadata"; +export { EntryViewer } from "./EntryViewer"; +export { StatsDashboard } from "./StatsDashboard"; +export { EntryGraphViewer } from "./EntryGraphViewer"; diff --git a/apps/web/src/lib/api/knowledge.ts b/apps/web/src/lib/api/knowledge.ts index 09eed50..5a429bb 100644 --- a/apps/web/src/lib/api/knowledge.ts +++ b/apps/web/src/lib/api/knowledge.ts @@ -128,6 +128,22 @@ export async function fetchTags(): Promise { return response.data; } +/** + * Fetch entry-centered graph view + */ +export async function fetchEntryGraph(slug: string, depth: number = 1) { + const params = new URLSearchParams(); + params.append("depth", depth.toString()); + return apiGet(`/api/knowledge/entries/${slug}/graph?${params.toString()}`); +} + +/** + * Fetch knowledge base statistics + */ +export async function fetchKnowledgeStats() { + return apiGet("/api/knowledge/stats"); +} + /** * Mock entries for development (until backend endpoints are ready) */