@leanspec/ui 0.2.3 → 0.2.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/standalone/packages/web/.next/BUILD_ID +1 -1
- package/dist/standalone/packages/web/.next/build-manifest.json +2 -2
- package/dist/standalone/packages/web/.next/prerender-manifest.json +3 -3
- package/dist/standalone/packages/web/.next/server/app/_global-error.html +2 -2
- package/dist/standalone/packages/web/.next/server/app/_global-error.rsc +1 -1
- package/dist/standalone/packages/web/.next/server/app/_global-error.segments/__PAGE__.segment.rsc +1 -1
- package/dist/standalone/packages/web/.next/server/app/_global-error.segments/_full.segment.rsc +1 -1
- package/dist/standalone/packages/web/.next/server/app/_global-error.segments/_index.segment.rsc +1 -1
- package/dist/standalone/packages/web/.next/server/app/_global-error.segments/_tree.segment.rsc +1 -1
- package/dist/standalone/packages/web/.next/server/app/_not-found/page.js.nft.json +1 -1
- package/dist/standalone/packages/web/.next/server/app/_not-found.html +2 -2
- package/dist/standalone/packages/web/.next/server/app/_not-found.rsc +2 -2
- package/dist/standalone/packages/web/.next/server/app/_not-found.segments/_full.segment.rsc +2 -2
- package/dist/standalone/packages/web/.next/server/app/_not-found.segments/_index.segment.rsc +1 -1
- package/dist/standalone/packages/web/.next/server/app/_not-found.segments/_not-found/__PAGE__.segment.rsc +1 -1
- package/dist/standalone/packages/web/.next/server/app/_not-found.segments/_not-found.segment.rsc +1 -1
- package/dist/standalone/packages/web/.next/server/app/_not-found.segments/_tree.segment.rsc +1 -1
- package/dist/standalone/packages/web/.next/server/app/api/projects/[id]/specs/route.js.nft.json +1 -1
- package/dist/standalone/packages/web/.next/server/app/api/projects/route.js.nft.json +1 -1
- package/dist/standalone/packages/web/.next/server/app/api/revalidate/route.js.nft.json +1 -1
- package/dist/standalone/packages/web/.next/server/app/api/specs/[id]/dependency-graph/route.js.nft.json +1 -1
- package/dist/standalone/packages/web/.next/server/app/api/specs/[id]/route.js.nft.json +1 -1
- package/dist/standalone/packages/web/.next/server/app/api/specs/[id]/subspecs/[file]/route.js.nft.json +1 -1
- package/dist/standalone/packages/web/.next/server/app/api/stats/route.js.nft.json +1 -1
- package/dist/standalone/packages/web/.next/server/app/board/page.js.nft.json +1 -1
- package/dist/standalone/packages/web/.next/server/app/board.html +1 -1
- package/dist/standalone/packages/web/.next/server/app/board.rsc +2 -2
- package/dist/standalone/packages/web/.next/server/app/board.segments/_full.segment.rsc +2 -2
- package/dist/standalone/packages/web/.next/server/app/board.segments/_index.segment.rsc +1 -1
- package/dist/standalone/packages/web/.next/server/app/board.segments/_tree.segment.rsc +1 -1
- package/dist/standalone/packages/web/.next/server/app/board.segments/board/__PAGE__.segment.rsc +1 -1
- package/dist/standalone/packages/web/.next/server/app/board.segments/board.segment.rsc +1 -1
- package/dist/standalone/packages/web/.next/server/app/page.js.nft.json +1 -1
- package/dist/standalone/packages/web/.next/server/app/specs/[id]/page.js.nft.json +1 -1
- package/dist/standalone/packages/web/.next/server/app/specs/[id]/page_client-reference-manifest.js +1 -1
- package/dist/standalone/packages/web/.next/server/app/specs/page.js.nft.json +1 -1
- package/dist/standalone/packages/web/.next/server/app/stats/page.js.nft.json +1 -1
- package/dist/standalone/packages/web/.next/server/chunks/[root-of-the-server]__2e0f9179._.js +1 -1
- package/dist/standalone/packages/web/.next/server/chunks/[root-of-the-server]__577d6d08._.js +1 -1
- package/dist/standalone/packages/web/.next/server/chunks/[root-of-the-server]__e54bc4b8._.js +1 -1
- package/dist/standalone/packages/web/.next/server/chunks/[root-of-the-server]__f8978f3e._.js +1 -1
- package/dist/standalone/packages/web/.next/server/chunks/ssr/[root-of-the-server]__be46bb7c._.js +1 -1
- package/dist/standalone/packages/web/.next/server/chunks/ssr/_7dedc302._.js +1 -1
- package/dist/standalone/packages/web/.next/server/chunks/ssr/_ad71cd8c._.js +1 -1
- package/dist/standalone/packages/web/.next/server/chunks/ssr/_c5a5c652._.js +1 -1
- package/dist/standalone/packages/web/.next/server/pages/404.html +2 -2
- package/dist/standalone/packages/web/.next/server/pages/500.html +2 -2
- package/dist/standalone/packages/web/.next/server/server-reference-manifest.js +1 -1
- package/dist/standalone/packages/web/.next/server/server-reference-manifest.json +1 -1
- package/dist/{static/chunks/0de258404bcae76f.js → standalone/packages/web/.next/static/chunks/8864b47e107cbe63.js} +1 -1
- package/dist/{static/chunks/09ff02250dd56621.js → standalone/packages/web/.next/static/chunks/a2889ecda42c83e7.js} +1 -1
- package/dist/standalone/packages/web/.next/static/chunks/c22619397bb8368e.js +1 -0
- package/dist/standalone/packages/web/components.json +20 -0
- package/dist/standalone/packages/web/drizzle/0000_reflective_thena.sql +59 -0
- package/dist/standalone/packages/web/drizzle/0001_fresh_carmella_unuscione.sql +1 -0
- package/dist/standalone/packages/web/drizzle/meta/0000_snapshot.json +427 -0
- package/dist/standalone/packages/web/drizzle/meta/0001_snapshot.json +436 -0
- package/dist/standalone/packages/web/drizzle/meta/_journal.json +20 -0
- package/dist/standalone/packages/web/drizzle.config.ts +10 -0
- package/dist/standalone/packages/web/eslint.config.mjs +18 -0
- package/dist/standalone/packages/web/next.config.ts +7 -0
- package/dist/standalone/packages/web/package.json +1 -1
- package/dist/standalone/packages/web/postcss.config.mjs +8 -0
- package/dist/standalone/packages/web/src/app/api/projects/[id]/specs/route.ts +23 -0
- package/dist/standalone/packages/web/src/app/api/projects/route.ts +19 -0
- package/dist/standalone/packages/web/src/app/api/revalidate/route.ts +63 -0
- package/dist/standalone/packages/web/src/app/api/specs/[id]/dependency-graph/route.test.ts +51 -0
- package/dist/standalone/packages/web/src/app/api/specs/[id]/dependency-graph/route.ts +171 -0
- package/dist/standalone/packages/web/src/app/api/specs/[id]/route.ts +36 -0
- package/dist/standalone/packages/web/src/app/api/specs/[id]/subspecs/[file]/route.ts +46 -0
- package/dist/standalone/packages/web/src/app/api/stats/route.ts +19 -0
- package/dist/standalone/packages/web/src/app/board/board-client.tsx +162 -0
- package/dist/standalone/packages/web/src/app/board/loading.tsx +43 -0
- package/dist/standalone/packages/web/src/app/board/page.tsx +18 -0
- package/dist/standalone/packages/web/src/app/dashboard-client.tsx +364 -0
- package/dist/standalone/packages/web/src/app/error.tsx +43 -0
- package/dist/standalone/packages/web/src/app/globals.css +531 -0
- package/dist/standalone/packages/web/src/app/home-client.tsx +277 -0
- package/dist/standalone/packages/web/src/app/layout.tsx +70 -0
- package/dist/standalone/packages/web/src/app/loading.tsx +87 -0
- package/dist/standalone/packages/web/src/app/not-found.tsx +27 -0
- package/dist/standalone/packages/web/src/app/page.tsx +18 -0
- package/dist/standalone/packages/web/src/app/specs/[id]/loading.tsx +5 -0
- package/dist/standalone/packages/web/src/app/specs/[id]/page.tsx +43 -0
- package/dist/standalone/packages/web/src/app/specs/page.tsx +18 -0
- package/dist/standalone/packages/web/src/app/specs/specs-client.tsx +425 -0
- package/dist/standalone/packages/web/src/app/stats/page.tsx +18 -0
- package/dist/standalone/packages/web/src/app/stats/stats-client.tsx +283 -0
- package/dist/standalone/packages/web/src/components/back-to-top.tsx +46 -0
- package/dist/standalone/packages/web/src/components/empty-state.tsx +52 -0
- package/dist/standalone/packages/web/src/components/main-sidebar.tsx +175 -0
- package/dist/standalone/packages/web/src/components/markdown-link.test.ts +96 -0
- package/dist/standalone/packages/web/src/components/markdown-link.tsx +95 -0
- package/dist/standalone/packages/web/src/components/navigation.tsx +210 -0
- package/dist/standalone/packages/web/src/components/priority-badge.tsx +53 -0
- package/dist/standalone/packages/web/src/components/quick-search.tsx +180 -0
- package/dist/standalone/packages/web/src/components/skeletons.tsx +119 -0
- package/dist/standalone/packages/web/src/components/spec-dependency-graph.tsx +369 -0
- package/dist/standalone/packages/web/src/components/spec-detail-client.tsx +372 -0
- package/dist/standalone/packages/web/src/components/spec-detail-loading-shell.tsx +42 -0
- package/dist/standalone/packages/web/src/components/spec-detail-wrapper.tsx +70 -0
- package/dist/standalone/packages/web/src/components/spec-metadata.tsx +136 -0
- package/dist/standalone/packages/web/src/components/spec-sidebar.tsx +127 -0
- package/dist/standalone/packages/web/src/components/spec-timeline.tsx +186 -0
- package/dist/standalone/packages/web/src/components/specs-nav-sidebar.tsx +561 -0
- package/dist/standalone/packages/web/src/components/status-badge.tsx +53 -0
- package/dist/standalone/packages/web/src/components/sub-spec-tabs.tsx +143 -0
- package/dist/standalone/packages/web/src/components/table-of-contents.tsx +130 -0
- package/dist/standalone/packages/web/src/components/theme-provider.tsx +11 -0
- package/dist/standalone/packages/web/src/components/theme-toggle.tsx +37 -0
- package/dist/standalone/packages/web/src/components/ui/avatar.tsx +50 -0
- package/dist/standalone/packages/web/src/components/ui/badge.tsx +36 -0
- package/dist/standalone/packages/web/src/components/ui/breadcrumb.tsx +110 -0
- package/dist/standalone/packages/web/src/components/ui/button.tsx +57 -0
- package/dist/standalone/packages/web/src/components/ui/card.tsx +76 -0
- package/dist/standalone/packages/web/src/components/ui/command.tsx +153 -0
- package/dist/standalone/packages/web/src/components/ui/dialog.tsx +122 -0
- package/dist/standalone/packages/web/src/components/ui/input.tsx +24 -0
- package/dist/standalone/packages/web/src/components/ui/select.tsx +159 -0
- package/dist/standalone/packages/web/src/components/ui/separator.tsx +31 -0
- package/dist/standalone/packages/web/src/components/ui/skeleton.tsx +15 -0
- package/dist/standalone/packages/web/src/components/ui/tabs.tsx +55 -0
- package/dist/standalone/packages/web/src/components/ui/toast.tsx +30 -0
- package/dist/standalone/packages/web/src/components/ui/tooltip.tsx +32 -0
- package/dist/standalone/packages/web/src/lib/date-utils.ts +76 -0
- package/dist/standalone/packages/web/src/lib/db/index.ts +42 -0
- package/dist/standalone/packages/web/src/lib/db/migrate.ts +18 -0
- package/dist/standalone/packages/web/src/lib/db/queries.ts +114 -0
- package/dist/standalone/packages/web/src/lib/db/schema.ts +123 -0
- package/dist/standalone/packages/web/src/lib/db/seed.ts +156 -0
- package/dist/standalone/packages/web/src/lib/db/service-queries.ts +216 -0
- package/dist/standalone/packages/web/src/lib/dependency-graph.ts +105 -0
- package/dist/standalone/packages/web/src/lib/specs/service.ts +120 -0
- package/dist/standalone/packages/web/src/lib/specs/sources/database-source.ts +94 -0
- package/dist/standalone/packages/web/src/lib/specs/sources/filesystem-source.ts +249 -0
- package/dist/standalone/packages/web/src/lib/specs/types.ts +55 -0
- package/dist/standalone/packages/web/src/lib/stores/specs-sidebar-store.ts +152 -0
- package/dist/standalone/packages/web/src/lib/sub-specs.ts +171 -0
- package/dist/standalone/packages/web/src/lib/utils.ts +17 -0
- package/dist/standalone/packages/web/src/types/specs.ts +18 -0
- package/dist/standalone/packages/web/tailwind.config.ts +58 -0
- package/dist/standalone/packages/web/tsconfig.json +34 -0
- package/dist/standalone/packages/web/vitest.config.ts +14 -0
- package/dist/standalone/specs/100-release-process-typecheck-failure/README.md +266 -0
- package/dist/standalone/specs/101-sidebar-scroll-position-drift/README.md +100 -0
- package/package.json +5 -3
- package/dist/BUILD_ID +0 -1
- package/dist/static/chunks/a3e649fcddd3d715.js +0 -1
- /package/dist/{static/Z_BxkB0KJViCNbULN1S5p → standalone/packages/web/.next/static/DDHAmoL_2Poji-whvFjry}/_buildManifest.js +0 -0
- /package/dist/{static/Z_BxkB0KJViCNbULN1S5p → standalone/packages/web/.next/static/DDHAmoL_2Poji-whvFjry}/_clientMiddlewareManifest.json +0 -0
- /package/dist/{static/Z_BxkB0KJViCNbULN1S5p → standalone/packages/web/.next/static/DDHAmoL_2Poji-whvFjry}/_ssgManifest.js +0 -0
- /package/dist/{static → standalone/packages/web/.next/static}/chunks/0c19c69aa7625475.js +0 -0
- /package/dist/{static → standalone/packages/web/.next/static}/chunks/116800b03245a1e5.js +0 -0
- /package/dist/{static → standalone/packages/web/.next/static}/chunks/19e80edf527aef5c.js +0 -0
- /package/dist/{static → standalone/packages/web/.next/static}/chunks/2ece90370908f56c.js +0 -0
- /package/dist/{static → standalone/packages/web/.next/static}/chunks/36fd2dddb486f6bc.js +0 -0
- /package/dist/{static → standalone/packages/web/.next/static}/chunks/5c2072ad938de8ed.js +0 -0
- /package/dist/{static → standalone/packages/web/.next/static}/chunks/6577fe797a336bab.js +0 -0
- /package/dist/{static → standalone/packages/web/.next/static}/chunks/6a05a93ec8fa7b83.js +0 -0
- /package/dist/{static → standalone/packages/web/.next/static}/chunks/7f732ea69e643219.js +0 -0
- /package/dist/{static → standalone/packages/web/.next/static}/chunks/a02c1f50ff00204f.js +0 -0
- /package/dist/{static → standalone/packages/web/.next/static}/chunks/a45464b9776dd88e.js +0 -0
- /package/dist/{static → standalone/packages/web/.next/static}/chunks/a6dad97d9634a72d.js +0 -0
- /package/dist/{static → standalone/packages/web/.next/static}/chunks/ae04dcd433be6dab.js +0 -0
- /package/dist/{static → standalone/packages/web/.next/static}/chunks/b20313408e970968.css +0 -0
- /package/dist/{static → standalone/packages/web/.next/static}/chunks/c46095e1a421d93f.js +0 -0
- /package/dist/{static → standalone/packages/web/.next/static}/chunks/c48dd4c72d7c5ef4.js +0 -0
- /package/dist/{static → standalone/packages/web/.next/static}/chunks/c557ac675be79771.js +0 -0
- /package/dist/{static → standalone/packages/web/.next/static}/chunks/dca0c854c59234cd.js +0 -0
- /package/dist/{static → standalone/packages/web/.next/static}/chunks/df1731c03abf1aee.css +0 -0
- /package/dist/{static → standalone/packages/web/.next/static}/chunks/dfd41488ad062cd5.js +0 -0
- /package/dist/{static → standalone/packages/web/.next/static}/chunks/ebd89051637b9a47.js +0 -0
- /package/dist/{static → standalone/packages/web/.next/static}/chunks/f3ec9fd77a8618b1.js +0 -0
- /package/dist/{static → standalone/packages/web/.next/static}/chunks/turbopack-7450632b40b2e378.js +0 -0
- /package/dist/{public → standalone/packages/web/public}/f864aa7e7061c0600e35cf3d879b27cf.txt +0 -0
- /package/dist/{public → standalone/packages/web/public}/favicon.ico +0 -0
- /package/dist/{public → standalone/packages/web/public}/file.svg +0 -0
- /package/dist/{public → standalone/packages/web/public}/github-mark-white.svg +0 -0
- /package/dist/{public → standalone/packages/web/public}/github-mark.svg +0 -0
- /package/dist/{public → standalone/packages/web/public}/globe.svg +0 -0
- /package/dist/{public → standalone/packages/web/public}/icon.svg +0 -0
- /package/dist/{public → standalone/packages/web/public}/logo-dark-bg.svg +0 -0
- /package/dist/{public → standalone/packages/web/public}/logo-with-bg.svg +0 -0
- /package/dist/{public → standalone/packages/web/public}/logo.svg +0 -0
- /package/dist/{public → standalone/packages/web/public}/next.svg +0 -0
- /package/dist/{public → standalone/packages/web/public}/vercel.svg +0 -0
- /package/dist/{public → standalone/packages/web/public}/window.svg +0 -0
|
@@ -0,0 +1,283 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/components/ui/card';
|
|
4
|
+
import { FileText, CheckCircle2, PlayCircle, Clock } from 'lucide-react';
|
|
5
|
+
import { PieChart, Pie, Cell, BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer } from 'recharts';
|
|
6
|
+
|
|
7
|
+
interface Stats {
|
|
8
|
+
totalSpecs: number;
|
|
9
|
+
completionRate: number;
|
|
10
|
+
specsByStatus: { status: string; count: number }[];
|
|
11
|
+
specsByPriority?: { priority: string; count: number }[];
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
interface Spec {
|
|
15
|
+
id: string;
|
|
16
|
+
specNumber: number | null;
|
|
17
|
+
status: string | null;
|
|
18
|
+
priority: string | null;
|
|
19
|
+
tags: string[] | null;
|
|
20
|
+
createdAt: Date | null;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
interface StatsClientProps {
|
|
24
|
+
stats: Stats;
|
|
25
|
+
specs: Spec[];
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const STATUS_COLORS = {
|
|
29
|
+
planned: '#3B82F6',
|
|
30
|
+
'in-progress': '#F59E0B',
|
|
31
|
+
complete: '#10B981',
|
|
32
|
+
archived: '#6B7280',
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
const PRIORITY_COLORS = {
|
|
36
|
+
critical: '#EF4444',
|
|
37
|
+
high: '#F59E0B',
|
|
38
|
+
medium: '#3B82F6',
|
|
39
|
+
low: '#6B7280',
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
export function StatsClient({ stats, specs }: StatsClientProps) {
|
|
43
|
+
// Calculate priority breakdown
|
|
44
|
+
const priorityBreakdown = specs.reduce((acc, spec) => {
|
|
45
|
+
const priority = spec.priority || 'unknown';
|
|
46
|
+
acc[priority] = (acc[priority] || 0) + 1;
|
|
47
|
+
return acc;
|
|
48
|
+
}, {} as Record<string, number>);
|
|
49
|
+
|
|
50
|
+
const priorityData = Object.entries(priorityBreakdown).map(([priority, count]) => ({
|
|
51
|
+
name: priority.charAt(0).toUpperCase() + priority.slice(1),
|
|
52
|
+
value: count,
|
|
53
|
+
color: PRIORITY_COLORS[priority as keyof typeof PRIORITY_COLORS] || '#6B7280',
|
|
54
|
+
}));
|
|
55
|
+
|
|
56
|
+
// Calculate status data for charts
|
|
57
|
+
const statusData = stats.specsByStatus.map(item => ({
|
|
58
|
+
name: item.status.split('-').map(w => w.charAt(0).toUpperCase() + w.slice(1)).join(' '),
|
|
59
|
+
value: item.count,
|
|
60
|
+
color: STATUS_COLORS[item.status as keyof typeof STATUS_COLORS] || '#6B7280',
|
|
61
|
+
}));
|
|
62
|
+
|
|
63
|
+
// Calculate tag frequency
|
|
64
|
+
const tagFrequency = specs.reduce((acc, spec) => {
|
|
65
|
+
(spec.tags || []).forEach(tag => {
|
|
66
|
+
acc[tag] = (acc[tag] || 0) + 1;
|
|
67
|
+
});
|
|
68
|
+
return acc;
|
|
69
|
+
}, {} as Record<string, number>);
|
|
70
|
+
|
|
71
|
+
const topTags = Object.entries(tagFrequency)
|
|
72
|
+
.sort(([, a], [, b]) => b - a)
|
|
73
|
+
.slice(0, 10)
|
|
74
|
+
.map(([tag, count]) => ({ tag, count }));
|
|
75
|
+
|
|
76
|
+
// Calculate monthly trends (last 6 months)
|
|
77
|
+
const monthlyTrends = specs
|
|
78
|
+
.filter(spec => spec.createdAt)
|
|
79
|
+
.reduce((acc, spec) => {
|
|
80
|
+
const month = new Date(spec.createdAt!).toLocaleDateString('en-US', { year: 'numeric', month: 'short' });
|
|
81
|
+
acc[month] = (acc[month] || 0) + 1;
|
|
82
|
+
return acc;
|
|
83
|
+
}, {} as Record<string, number>);
|
|
84
|
+
|
|
85
|
+
const trendData = Object.entries(monthlyTrends)
|
|
86
|
+
.slice(-6)
|
|
87
|
+
.map(([month, count]) => ({ month, count }));
|
|
88
|
+
|
|
89
|
+
return (
|
|
90
|
+
<div className="min-h-screen bg-background">
|
|
91
|
+
<div className="container mx-auto px-4 py-8">
|
|
92
|
+
<div className="mb-8">
|
|
93
|
+
<h1 className="text-4xl font-bold tracking-tight">Project Statistics</h1>
|
|
94
|
+
<p className="text-muted-foreground mt-2">Comprehensive metrics and insights</p>
|
|
95
|
+
</div>
|
|
96
|
+
|
|
97
|
+
{/* Summary Cards */}
|
|
98
|
+
<div className="grid grid-cols-1 md:grid-cols-4 gap-4 mb-8">
|
|
99
|
+
{/* Total Specs */}
|
|
100
|
+
<Card className="relative overflow-hidden">
|
|
101
|
+
<div className="absolute inset-0 bg-gradient-to-br from-blue-500/10 to-transparent" />
|
|
102
|
+
<CardHeader className="relative pb-3">
|
|
103
|
+
<div className="flex items-center justify-between">
|
|
104
|
+
<CardTitle className="text-sm font-medium text-muted-foreground">Total Specs</CardTitle>
|
|
105
|
+
<FileText className="h-5 w-5 text-blue-600" />
|
|
106
|
+
</div>
|
|
107
|
+
</CardHeader>
|
|
108
|
+
<CardContent className="relative">
|
|
109
|
+
<div className="text-3xl font-bold">{stats.totalSpecs}</div>
|
|
110
|
+
<p className="text-xs text-muted-foreground mt-1">All specifications</p>
|
|
111
|
+
</CardContent>
|
|
112
|
+
</Card>
|
|
113
|
+
|
|
114
|
+
{/* Completed */}
|
|
115
|
+
<Card className="relative overflow-hidden">
|
|
116
|
+
<div className="absolute inset-0 bg-gradient-to-br from-green-500/10 to-transparent" />
|
|
117
|
+
<CardHeader className="relative pb-3">
|
|
118
|
+
<div className="flex items-center justify-between">
|
|
119
|
+
<CardTitle className="text-sm font-medium text-muted-foreground">Completed</CardTitle>
|
|
120
|
+
<CheckCircle2 className="h-5 w-5 text-green-600" />
|
|
121
|
+
</div>
|
|
122
|
+
</CardHeader>
|
|
123
|
+
<CardContent className="relative">
|
|
124
|
+
<div className="text-3xl font-bold">
|
|
125
|
+
{stats.specsByStatus.find(s => s.status === 'complete')?.count || 0}
|
|
126
|
+
</div>
|
|
127
|
+
<p className="text-xs text-muted-foreground mt-1">
|
|
128
|
+
<span className="text-green-600 font-medium">{stats.completionRate}%</span> completion rate
|
|
129
|
+
</p>
|
|
130
|
+
</CardContent>
|
|
131
|
+
</Card>
|
|
132
|
+
|
|
133
|
+
{/* In Progress */}
|
|
134
|
+
<Card className="relative overflow-hidden">
|
|
135
|
+
<div className="absolute inset-0 bg-gradient-to-br from-orange-500/10 to-transparent" />
|
|
136
|
+
<CardHeader className="relative pb-3">
|
|
137
|
+
<div className="flex items-center justify-between">
|
|
138
|
+
<CardTitle className="text-sm font-medium text-muted-foreground">In Progress</CardTitle>
|
|
139
|
+
<PlayCircle className="h-5 w-5 text-orange-600" />
|
|
140
|
+
</div>
|
|
141
|
+
</CardHeader>
|
|
142
|
+
<CardContent className="relative">
|
|
143
|
+
<div className="text-3xl font-bold">
|
|
144
|
+
{stats.specsByStatus.find(s => s.status === 'in-progress')?.count || 0}
|
|
145
|
+
</div>
|
|
146
|
+
<p className="text-xs text-muted-foreground mt-1">Active work</p>
|
|
147
|
+
</CardContent>
|
|
148
|
+
</Card>
|
|
149
|
+
|
|
150
|
+
{/* Planned */}
|
|
151
|
+
<Card className="relative overflow-hidden">
|
|
152
|
+
<div className="absolute inset-0 bg-gradient-to-br from-blue-500/10 to-transparent" />
|
|
153
|
+
<CardHeader className="relative pb-3">
|
|
154
|
+
<div className="flex items-center justify-between">
|
|
155
|
+
<CardTitle className="text-sm font-medium text-muted-foreground">Planned</CardTitle>
|
|
156
|
+
<Clock className="h-5 w-5 text-blue-600" />
|
|
157
|
+
</div>
|
|
158
|
+
</CardHeader>
|
|
159
|
+
<CardContent className="relative">
|
|
160
|
+
<div className="text-3xl font-bold">
|
|
161
|
+
{stats.specsByStatus.find(s => s.status === 'planned')?.count || 0}
|
|
162
|
+
</div>
|
|
163
|
+
<p className="text-xs text-muted-foreground mt-1">Upcoming work</p>
|
|
164
|
+
</CardContent>
|
|
165
|
+
</Card>
|
|
166
|
+
</div>
|
|
167
|
+
|
|
168
|
+
{/* Charts Section */}
|
|
169
|
+
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6 mb-8">
|
|
170
|
+
{/* Status Distribution */}
|
|
171
|
+
<Card>
|
|
172
|
+
<CardHeader>
|
|
173
|
+
<CardTitle>Status Distribution</CardTitle>
|
|
174
|
+
<CardDescription>Breakdown of specs by status</CardDescription>
|
|
175
|
+
</CardHeader>
|
|
176
|
+
<CardContent>
|
|
177
|
+
<ResponsiveContainer width="100%" height={300}>
|
|
178
|
+
<PieChart>
|
|
179
|
+
<Pie
|
|
180
|
+
data={statusData}
|
|
181
|
+
cx="50%"
|
|
182
|
+
cy="50%"
|
|
183
|
+
labelLine={false}
|
|
184
|
+
label={({ name, value }) => `${name}: ${value}`}
|
|
185
|
+
outerRadius={80}
|
|
186
|
+
fill="#8884d8"
|
|
187
|
+
dataKey="value"
|
|
188
|
+
>
|
|
189
|
+
{statusData.map((entry, index) => (
|
|
190
|
+
<Cell key={`cell-${index}`} fill={entry.color} />
|
|
191
|
+
))}
|
|
192
|
+
</Pie>
|
|
193
|
+
<Tooltip />
|
|
194
|
+
</PieChart>
|
|
195
|
+
</ResponsiveContainer>
|
|
196
|
+
</CardContent>
|
|
197
|
+
</Card>
|
|
198
|
+
|
|
199
|
+
{/* Priority Distribution */}
|
|
200
|
+
<Card>
|
|
201
|
+
<CardHeader>
|
|
202
|
+
<CardTitle>Priority Distribution</CardTitle>
|
|
203
|
+
<CardDescription>Breakdown of specs by priority</CardDescription>
|
|
204
|
+
</CardHeader>
|
|
205
|
+
<CardContent>
|
|
206
|
+
<ResponsiveContainer width="100%" height={300}>
|
|
207
|
+
<PieChart>
|
|
208
|
+
<Pie
|
|
209
|
+
data={priorityData}
|
|
210
|
+
cx="50%"
|
|
211
|
+
cy="50%"
|
|
212
|
+
labelLine={false}
|
|
213
|
+
label={({ name, value }) => `${name}: ${value}`}
|
|
214
|
+
outerRadius={80}
|
|
215
|
+
fill="#8884d8"
|
|
216
|
+
dataKey="value"
|
|
217
|
+
>
|
|
218
|
+
{priorityData.map((entry, index) => (
|
|
219
|
+
<Cell key={`cell-${index}`} fill={entry.color} />
|
|
220
|
+
))}
|
|
221
|
+
</Pie>
|
|
222
|
+
<Tooltip />
|
|
223
|
+
</PieChart>
|
|
224
|
+
</ResponsiveContainer>
|
|
225
|
+
</CardContent>
|
|
226
|
+
</Card>
|
|
227
|
+
</div>
|
|
228
|
+
|
|
229
|
+
{/* Trends and Tags */}
|
|
230
|
+
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
|
231
|
+
{/* Monthly Trends */}
|
|
232
|
+
{trendData.length > 0 && (
|
|
233
|
+
<Card>
|
|
234
|
+
<CardHeader>
|
|
235
|
+
<CardTitle>Creation Trend</CardTitle>
|
|
236
|
+
<CardDescription>Specs created over time</CardDescription>
|
|
237
|
+
</CardHeader>
|
|
238
|
+
<CardContent>
|
|
239
|
+
<ResponsiveContainer width="100%" height={300}>
|
|
240
|
+
<BarChart data={trendData}>
|
|
241
|
+
<CartesianGrid strokeDasharray="3 3" />
|
|
242
|
+
<XAxis dataKey="month" />
|
|
243
|
+
<YAxis />
|
|
244
|
+
<Tooltip />
|
|
245
|
+
<Bar dataKey="count" fill="#3B82F6" />
|
|
246
|
+
</BarChart>
|
|
247
|
+
</ResponsiveContainer>
|
|
248
|
+
</CardContent>
|
|
249
|
+
</Card>
|
|
250
|
+
)}
|
|
251
|
+
|
|
252
|
+
{/* Top Tags */}
|
|
253
|
+
{topTags.length > 0 && (
|
|
254
|
+
<Card>
|
|
255
|
+
<CardHeader>
|
|
256
|
+
<CardTitle>Top Tags</CardTitle>
|
|
257
|
+
<CardDescription>Most frequently used tags</CardDescription>
|
|
258
|
+
</CardHeader>
|
|
259
|
+
<CardContent>
|
|
260
|
+
<div className="space-y-3">
|
|
261
|
+
{topTags.map(({ tag, count }) => (
|
|
262
|
+
<div key={tag} className="flex items-center justify-between">
|
|
263
|
+
<span className="text-sm font-medium">{tag}</span>
|
|
264
|
+
<div className="flex items-center gap-2">
|
|
265
|
+
<div className="w-32 h-2 bg-muted rounded-full overflow-hidden">
|
|
266
|
+
<div
|
|
267
|
+
className="h-full bg-primary"
|
|
268
|
+
style={{ width: `${(count / topTags[0].count) * 100}%` }}
|
|
269
|
+
/>
|
|
270
|
+
</div>
|
|
271
|
+
<span className="text-sm text-muted-foreground w-8 text-right">{count}</span>
|
|
272
|
+
</div>
|
|
273
|
+
</div>
|
|
274
|
+
))}
|
|
275
|
+
</div>
|
|
276
|
+
</CardContent>
|
|
277
|
+
</Card>
|
|
278
|
+
)}
|
|
279
|
+
</div>
|
|
280
|
+
</div>
|
|
281
|
+
</div>
|
|
282
|
+
);
|
|
283
|
+
}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import * as React from 'react';
|
|
4
|
+
import { Button } from '@/components/ui/button';
|
|
5
|
+
import { ArrowUp } from 'lucide-react';
|
|
6
|
+
|
|
7
|
+
export function BackToTop() {
|
|
8
|
+
const [isVisible, setIsVisible] = React.useState(false);
|
|
9
|
+
|
|
10
|
+
React.useEffect(() => {
|
|
11
|
+
const toggleVisibility = () => {
|
|
12
|
+
// Show button when page is scrolled down 300px
|
|
13
|
+
if (window.pageYOffset > 300) {
|
|
14
|
+
setIsVisible(true);
|
|
15
|
+
} else {
|
|
16
|
+
setIsVisible(false);
|
|
17
|
+
}
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
window.addEventListener('scroll', toggleVisibility);
|
|
21
|
+
|
|
22
|
+
return () => {
|
|
23
|
+
window.removeEventListener('scroll', toggleVisibility);
|
|
24
|
+
};
|
|
25
|
+
}, []);
|
|
26
|
+
|
|
27
|
+
const scrollToTop = () => {
|
|
28
|
+
window.scrollTo({
|
|
29
|
+
top: 0,
|
|
30
|
+
behavior: 'smooth',
|
|
31
|
+
});
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
if (!isVisible) return null;
|
|
35
|
+
|
|
36
|
+
return (
|
|
37
|
+
<Button
|
|
38
|
+
onClick={scrollToTop}
|
|
39
|
+
size="icon"
|
|
40
|
+
className="fixed bottom-6 right-6 h-12 w-12 rounded-full shadow-lg z-40 hover:scale-110 transition-transform"
|
|
41
|
+
aria-label="Back to top"
|
|
42
|
+
>
|
|
43
|
+
<ArrowUp className="h-5 w-5" />
|
|
44
|
+
</Button>
|
|
45
|
+
);
|
|
46
|
+
}
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import { LucideIcon } from "lucide-react"
|
|
2
|
+
import { Button } from "@/components/ui/button"
|
|
3
|
+
import { cn } from "@/lib/utils"
|
|
4
|
+
|
|
5
|
+
interface EmptyStateProps {
|
|
6
|
+
icon: LucideIcon
|
|
7
|
+
title: string
|
|
8
|
+
description: string
|
|
9
|
+
action?: {
|
|
10
|
+
label: string
|
|
11
|
+
onClick?: () => void
|
|
12
|
+
href?: string
|
|
13
|
+
}
|
|
14
|
+
className?: string
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export function EmptyState({
|
|
18
|
+
icon: Icon,
|
|
19
|
+
title,
|
|
20
|
+
description,
|
|
21
|
+
action,
|
|
22
|
+
className,
|
|
23
|
+
}: EmptyStateProps) {
|
|
24
|
+
return (
|
|
25
|
+
<div
|
|
26
|
+
className={cn(
|
|
27
|
+
"flex flex-col items-center justify-center py-12 px-4 text-center",
|
|
28
|
+
className
|
|
29
|
+
)}
|
|
30
|
+
>
|
|
31
|
+
<div className="rounded-full bg-muted p-6 mb-4">
|
|
32
|
+
<Icon className="h-12 w-12 text-muted-foreground" />
|
|
33
|
+
</div>
|
|
34
|
+
<h3 className="text-lg font-semibold mb-2">{title}</h3>
|
|
35
|
+
<p className="text-sm text-muted-foreground max-w-md mb-6">
|
|
36
|
+
{description}
|
|
37
|
+
</p>
|
|
38
|
+
{action && (
|
|
39
|
+
<Button
|
|
40
|
+
onClick={action.onClick}
|
|
41
|
+
{...(action.href ? { asChild: true } : {})}
|
|
42
|
+
>
|
|
43
|
+
{action.href ? (
|
|
44
|
+
<a href={action.href}>{action.label}</a>
|
|
45
|
+
) : (
|
|
46
|
+
action.label
|
|
47
|
+
)}
|
|
48
|
+
</Button>
|
|
49
|
+
)}
|
|
50
|
+
</div>
|
|
51
|
+
)
|
|
52
|
+
}
|
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import Link from 'next/link';
|
|
4
|
+
import { usePathname } from 'next/navigation';
|
|
5
|
+
import { Home, FileText, BarChart3, ChevronLeft, ChevronRight, X } from 'lucide-react';
|
|
6
|
+
import { Button } from '@/components/ui/button';
|
|
7
|
+
import { cn } from '@/lib/utils';
|
|
8
|
+
import * as React from 'react';
|
|
9
|
+
|
|
10
|
+
interface SidebarLinkProps {
|
|
11
|
+
href: string;
|
|
12
|
+
icon: React.ComponentType<{ className?: string }>;
|
|
13
|
+
children: React.ReactNode;
|
|
14
|
+
description?: string;
|
|
15
|
+
currentPath: string;
|
|
16
|
+
isCollapsed?: boolean;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function SidebarLink({ href, icon: Icon, children, description, currentPath, isCollapsed }: SidebarLinkProps) {
|
|
20
|
+
const isActive = currentPath === href || (href !== '/' && currentPath.startsWith(href));
|
|
21
|
+
|
|
22
|
+
return (
|
|
23
|
+
<Link
|
|
24
|
+
href={href}
|
|
25
|
+
className={cn(
|
|
26
|
+
"flex items-center gap-3 rounded-lg px-3 py-2 transition-colors",
|
|
27
|
+
"hover:bg-accent hover:text-accent-foreground",
|
|
28
|
+
isActive && "bg-accent text-accent-foreground font-medium",
|
|
29
|
+
isCollapsed && "justify-center px-2"
|
|
30
|
+
)}
|
|
31
|
+
>
|
|
32
|
+
<Icon className={cn("h-5 w-5 shrink-0", isActive && "text-primary")} />
|
|
33
|
+
{!isCollapsed && (
|
|
34
|
+
<div className="flex flex-col">
|
|
35
|
+
<span className="text-sm">{children}</span>
|
|
36
|
+
{description && (
|
|
37
|
+
<span className="text-xs text-muted-foreground">{description}</span>
|
|
38
|
+
)}
|
|
39
|
+
</div>
|
|
40
|
+
)}
|
|
41
|
+
</Link>
|
|
42
|
+
);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export function MainSidebar() {
|
|
46
|
+
const pathname = usePathname();
|
|
47
|
+
const [isCollapsed, setIsCollapsed] = React.useState(() => {
|
|
48
|
+
if (typeof window !== 'undefined') {
|
|
49
|
+
const saved = localStorage.getItem('main-sidebar-collapsed');
|
|
50
|
+
return saved === 'true';
|
|
51
|
+
}
|
|
52
|
+
return false;
|
|
53
|
+
});
|
|
54
|
+
const [mobileOpen, setMobileOpen] = React.useState(false);
|
|
55
|
+
const [mounted, setMounted] = React.useState(false);
|
|
56
|
+
|
|
57
|
+
React.useEffect(() => {
|
|
58
|
+
setMounted(true);
|
|
59
|
+
}, []);
|
|
60
|
+
|
|
61
|
+
// Update CSS variable for other components to use and persist to localStorage
|
|
62
|
+
React.useEffect(() => {
|
|
63
|
+
document.documentElement.style.setProperty(
|
|
64
|
+
'--main-sidebar-width',
|
|
65
|
+
isCollapsed ? '60px' : '240px'
|
|
66
|
+
);
|
|
67
|
+
localStorage.setItem('main-sidebar-collapsed', String(isCollapsed));
|
|
68
|
+
}, [isCollapsed]);
|
|
69
|
+
|
|
70
|
+
// Close mobile menu on route change
|
|
71
|
+
React.useEffect(() => {
|
|
72
|
+
setMobileOpen(false);
|
|
73
|
+
}, [pathname]);
|
|
74
|
+
|
|
75
|
+
// Expose function for mobile toggle
|
|
76
|
+
React.useEffect(() => {
|
|
77
|
+
if (typeof window === 'undefined') {
|
|
78
|
+
return;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
window.toggleMainSidebar = () => setMobileOpen(prev => !prev);
|
|
82
|
+
return () => {
|
|
83
|
+
window.toggleMainSidebar = undefined;
|
|
84
|
+
};
|
|
85
|
+
}, []);
|
|
86
|
+
|
|
87
|
+
return (
|
|
88
|
+
<>
|
|
89
|
+
{/* Mobile overlay backdrop */}
|
|
90
|
+
{mobileOpen && (
|
|
91
|
+
<div
|
|
92
|
+
className="fixed inset-0 bg-black/50 z-40 lg:hidden"
|
|
93
|
+
onClick={() => setMobileOpen(false)}
|
|
94
|
+
/>
|
|
95
|
+
)}
|
|
96
|
+
|
|
97
|
+
{/* Sidebar */}
|
|
98
|
+
<aside
|
|
99
|
+
className={cn(
|
|
100
|
+
"sticky top-14 h-[calc(100vh-3.5rem)] border-r border-border bg-background transition-all duration-300 flex-shrink-0",
|
|
101
|
+
// Desktop behavior
|
|
102
|
+
"hidden lg:flex",
|
|
103
|
+
mounted && isCollapsed ? "lg:w-[60px]" : "lg:w-[240px]",
|
|
104
|
+
// Mobile behavior - show as overlay when open
|
|
105
|
+
mobileOpen && "fixed left-0 top-14 z-50 flex w-[280px]"
|
|
106
|
+
)}
|
|
107
|
+
>
|
|
108
|
+
<div className="flex flex-col h-full w-full">
|
|
109
|
+
{/* Mobile close button */}
|
|
110
|
+
<div className="lg:hidden flex justify-end p-2 border-b border-border">
|
|
111
|
+
<Button
|
|
112
|
+
variant="ghost"
|
|
113
|
+
size="sm"
|
|
114
|
+
onClick={() => setMobileOpen(false)}
|
|
115
|
+
className="h-8 w-8 p-0"
|
|
116
|
+
>
|
|
117
|
+
<X className="h-4 w-4" />
|
|
118
|
+
<span className="sr-only">Close menu</span>
|
|
119
|
+
</Button>
|
|
120
|
+
</div>
|
|
121
|
+
|
|
122
|
+
{/* Navigation */}
|
|
123
|
+
{mounted && <nav className="flex-1 px-2 py-4 space-y-1">
|
|
124
|
+
<SidebarLink
|
|
125
|
+
href="/"
|
|
126
|
+
icon={Home}
|
|
127
|
+
currentPath={pathname}
|
|
128
|
+
description={(!isCollapsed || mobileOpen) ? "Dashboard" : undefined}
|
|
129
|
+
isCollapsed={isCollapsed && !mobileOpen}
|
|
130
|
+
>
|
|
131
|
+
Home
|
|
132
|
+
</SidebarLink>
|
|
133
|
+
<SidebarLink
|
|
134
|
+
href="/specs"
|
|
135
|
+
icon={FileText}
|
|
136
|
+
currentPath={pathname}
|
|
137
|
+
description={(!isCollapsed || mobileOpen) ? "All Specifications" : undefined}
|
|
138
|
+
isCollapsed={isCollapsed && !mobileOpen}
|
|
139
|
+
>
|
|
140
|
+
Specs
|
|
141
|
+
</SidebarLink>
|
|
142
|
+
<SidebarLink
|
|
143
|
+
href="/stats"
|
|
144
|
+
icon={BarChart3}
|
|
145
|
+
currentPath={pathname}
|
|
146
|
+
description={(!isCollapsed || mobileOpen) ? "Analytics" : undefined}
|
|
147
|
+
isCollapsed={isCollapsed && !mobileOpen}
|
|
148
|
+
>
|
|
149
|
+
Stats
|
|
150
|
+
</SidebarLink>
|
|
151
|
+
</nav>}
|
|
152
|
+
|
|
153
|
+
{/* Collapse Toggle (desktop only) */}
|
|
154
|
+
<div className="hidden lg:block p-2 border-t border-border">
|
|
155
|
+
<Button
|
|
156
|
+
variant="ghost"
|
|
157
|
+
size="sm"
|
|
158
|
+
onClick={() => setIsCollapsed(!isCollapsed)}
|
|
159
|
+
className={cn("w-full", mounted && isCollapsed && "px-2")}
|
|
160
|
+
>
|
|
161
|
+
{mounted && isCollapsed ? (
|
|
162
|
+
<ChevronRight className="h-4 w-4" />
|
|
163
|
+
) : (
|
|
164
|
+
<>
|
|
165
|
+
<ChevronLeft className="h-4 w-4 mr-2" />
|
|
166
|
+
<span className="text-xs">Collapse</span>
|
|
167
|
+
</>
|
|
168
|
+
)}
|
|
169
|
+
</Button>
|
|
170
|
+
</div>
|
|
171
|
+
</div>
|
|
172
|
+
</aside>
|
|
173
|
+
</>
|
|
174
|
+
);
|
|
175
|
+
}
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tests for MarkdownLink component
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import { describe, it, expect } from 'vitest';
|
|
6
|
+
|
|
7
|
+
// Extract the transformation function for testing
|
|
8
|
+
function transformSpecLink(href: string, currentSpecNumber?: number): string {
|
|
9
|
+
// Don't transform anchor links or external URLs
|
|
10
|
+
if (href.startsWith('#') || href.startsWith('http://') || href.startsWith('https://')) {
|
|
11
|
+
return href;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
// Match same-directory sub-spec links: ./FILE.md
|
|
15
|
+
const sameDirectoryPattern = /^\.\/([^/]+\.md)$/;
|
|
16
|
+
const sameDirectoryMatch = href.match(sameDirectoryPattern);
|
|
17
|
+
|
|
18
|
+
if (sameDirectoryMatch && currentSpecNumber) {
|
|
19
|
+
const subSpecFile = sameDirectoryMatch[1];
|
|
20
|
+
if (subSpecFile !== 'README.md') {
|
|
21
|
+
return `/specs/${currentSpecNumber}?subspec=${subSpecFile}`;
|
|
22
|
+
} else {
|
|
23
|
+
return `/specs/${currentSpecNumber}`;
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
// Match internal spec links: ../NNN-spec-name/ or ../NNN-spec-name/FILE.md
|
|
28
|
+
const specLinkPattern = /^\.\.\/(\d+)-[^/]+\/?([^/]+\.md)?$/;
|
|
29
|
+
const match = href.match(specLinkPattern);
|
|
30
|
+
|
|
31
|
+
if (match) {
|
|
32
|
+
// Convert to number to remove leading zeros (018 -> 18)
|
|
33
|
+
const specNumber = parseInt(match[1], 10);
|
|
34
|
+
const subSpecFile = match[2];
|
|
35
|
+
|
|
36
|
+
if (subSpecFile && subSpecFile !== 'README.md') {
|
|
37
|
+
// Sub-spec link (e.g., DESIGN.md, IMPLEMENTATION.md)
|
|
38
|
+
return `/specs/${specNumber}?subspec=${subSpecFile}`;
|
|
39
|
+
} else {
|
|
40
|
+
// Main spec link (README.md or just the directory)
|
|
41
|
+
return `/specs/${specNumber}`;
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// If no pattern matches, return original href
|
|
46
|
+
return href;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
describe('transformSpecLink', () => {
|
|
50
|
+
it('transforms spec directory links to /specs/NNN', () => {
|
|
51
|
+
expect(transformSpecLink('../048-spec-complexity-analysis/')).toBe('/specs/48');
|
|
52
|
+
expect(transformSpecLink('../066-context-economy-thresholds-refinement/')).toBe('/specs/66');
|
|
53
|
+
expect(transformSpecLink('../018-spec-validation/')).toBe('/specs/18');
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
it('transforms spec README.md links to /specs/NNN', () => {
|
|
57
|
+
expect(transformSpecLink('../048-spec-complexity-analysis/README.md')).toBe('/specs/48');
|
|
58
|
+
expect(transformSpecLink('../018-spec-validation/README.md')).toBe('/specs/18');
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
it('transforms sub-spec links to /specs/NNN?subspec=FILE.md', () => {
|
|
62
|
+
expect(transformSpecLink('../048-spec-complexity-analysis/DESIGN.md')).toBe('/specs/48?subspec=DESIGN.md');
|
|
63
|
+
expect(transformSpecLink('../066-context-economy-thresholds-refinement/IMPLEMENTATION.md')).toBe('/specs/66?subspec=IMPLEMENTATION.md');
|
|
64
|
+
expect(transformSpecLink('../018-spec-validation/CONFIGURATION-EXAMPLES.md')).toBe('/specs/18?subspec=CONFIGURATION-EXAMPLES.md');
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
it('transforms same-directory sub-spec links when currentSpecNumber is provided', () => {
|
|
68
|
+
expect(transformSpecLink('./DESIGN.md', 48)).toBe('/specs/48?subspec=DESIGN.md');
|
|
69
|
+
expect(transformSpecLink('./IMPLEMENTATION.md', 66)).toBe('/specs/66?subspec=IMPLEMENTATION.md');
|
|
70
|
+
expect(transformSpecLink('./CONFIGURATION-EXAMPLES.md', 18)).toBe('/specs/18?subspec=CONFIGURATION-EXAMPLES.md');
|
|
71
|
+
expect(transformSpecLink('./VALIDATION-RULES.md', 18)).toBe('/specs/18?subspec=VALIDATION-RULES.md');
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
it('leaves same-directory links unchanged when currentSpecNumber is not provided', () => {
|
|
75
|
+
expect(transformSpecLink('./DESIGN.md')).toBe('./DESIGN.md');
|
|
76
|
+
expect(transformSpecLink('./README.md')).toBe('./README.md');
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
it('transforms same-directory README.md to main spec', () => {
|
|
80
|
+
expect(transformSpecLink('./README.md', 18)).toBe('/specs/18');
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
it('leaves anchor links unchanged', () => {
|
|
84
|
+
expect(transformSpecLink('#heading')).toBe('#heading');
|
|
85
|
+
expect(transformSpecLink('#section-name')).toBe('#section-name');
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
it('leaves external URLs unchanged', () => {
|
|
89
|
+
expect(transformSpecLink('https://example.com')).toBe('https://example.com');
|
|
90
|
+
expect(transformSpecLink('http://example.com')).toBe('http://example.com');
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
it('leaves non-matching relative paths unchanged', () => {
|
|
94
|
+
expect(transformSpecLink('../other/path')).toBe('../other/path');
|
|
95
|
+
});
|
|
96
|
+
});
|