@leanspec/ui 0.2.13 → 0.2.15-dev.21022397862

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.
Files changed (149) hide show
  1. package/bin/leanspec-ui.js +191 -0
  2. package/dist/assets/_baseUniq-CRqreL7N.js +1 -0
  3. package/dist/assets/arc-DMhx9AJT.js +1 -0
  4. package/dist/assets/architectureDiagram-VXUJARFQ-DM0L0YzO.js +36 -0
  5. package/dist/assets/blockDiagram-VD42YOAC-DHQXDHsD.js +122 -0
  6. package/dist/assets/c4Diagram-YG6GDRKO-0L7o2gpH.js +10 -0
  7. package/dist/assets/channel-2tOl0nAZ.js +1 -0
  8. package/dist/assets/chunk-4BX2VUAB-CwFT-Uaj.js +1 -0
  9. package/dist/assets/chunk-55IACEB6-CjvuUHHG.js +1 -0
  10. package/dist/assets/chunk-B4BG7PRW-BRJBysMK.js +165 -0
  11. package/dist/assets/chunk-DI55MBZ5-BnNEeoaA.js +220 -0
  12. package/dist/assets/chunk-FMBD7UC4-BK2l30pm.js +15 -0
  13. package/dist/assets/chunk-QN33PNHL-BN_cZkCU.js +1 -0
  14. package/dist/assets/chunk-QZHKN3VN-Brc3Yrub.js +1 -0
  15. package/dist/assets/chunk-TZMSLE5B-D2zzpLfO.js +1 -0
  16. package/dist/assets/classDiagram-2ON5EDUG-BB9CSNmS.js +1 -0
  17. package/dist/assets/classDiagram-v2-WZHVMYZB-BB9CSNmS.js +1 -0
  18. package/dist/assets/clone-BjxVFtyI.js +1 -0
  19. package/dist/assets/core-DV6XEvTN.js +1 -0
  20. package/dist/assets/cose-bilkent-S5V4N54A-CLJgM3XR.js +1 -0
  21. package/dist/assets/cytoscape.esm-5J0xJHOV.js +321 -0
  22. package/dist/assets/dagre-6UL2VRFP-_IFvBJKJ.js +4 -0
  23. package/dist/assets/diagram-PSM6KHXK--83HIYSQ.js +24 -0
  24. package/dist/assets/diagram-QEK2KX5R-6jAWnCnZ.js +43 -0
  25. package/dist/assets/diagram-S2PKOQOG-D5pwHvjZ.js +24 -0
  26. package/dist/assets/erDiagram-Q2GNP2WA-B4FV3mTd.js +60 -0
  27. package/dist/assets/flowDiagram-NV44I4VS-mtD2kF4M.js +162 -0
  28. package/dist/assets/ganttDiagram-JELNMOA3-BKALgqTK.js +267 -0
  29. package/dist/assets/gitGraphDiagram-NY62KEGX-Bd7r0pAf.js +65 -0
  30. package/dist/assets/graph-B2rEI7cK.js +1 -0
  31. package/dist/assets/index-Bekv_o1t.css +1 -0
  32. package/dist/assets/index-DSRxU-E5.js +389 -0
  33. package/dist/assets/infoDiagram-WHAUD3N6--nJOBKqh.js +2 -0
  34. package/dist/assets/journeyDiagram-XKPGCS4Q-BzGutKN3.js +139 -0
  35. package/dist/assets/kanban-definition-3W4ZIXB7-DyQO17vq.js +89 -0
  36. package/dist/assets/katex-XbL3y5x-.js +261 -0
  37. package/dist/assets/layout-iCSHU015.js +1 -0
  38. package/dist/assets/min-BK_AIJdo.js +1 -0
  39. package/dist/assets/mindmap-definition-VGOIOE7T-BZMj_6zo.js +68 -0
  40. package/dist/assets/pieDiagram-ADFJNKIX-CkAGsq9p.js +30 -0
  41. package/dist/assets/quadrantDiagram-AYHSOK5B-CWa93px1.js +7 -0
  42. package/dist/assets/requirementDiagram-UZGBJVZJ-CufFVR8c.js +64 -0
  43. package/dist/assets/sankeyDiagram-TZEHDZUN-BEPgVgU4.js +10 -0
  44. package/dist/assets/sequenceDiagram-WL72ISMW-BkdBWhel.js +145 -0
  45. package/dist/assets/stateDiagram-FKZM4ZOC-D5T73yx0.js +1 -0
  46. package/dist/assets/stateDiagram-v2-4FDKWEC3-9hJWG2n6.js +1 -0
  47. package/dist/assets/timeline-definition-IT6M3QCI-CX7kTdU2.js +61 -0
  48. package/dist/assets/treemap-KMMF4GRG-ftWCQ9lJ.js +128 -0
  49. package/dist/assets/xychartDiagram-PRI3JC2R-Ngrels4n.js +7 -0
  50. package/{index.html → dist/index.html} +2 -1
  51. package/package.json +13 -3
  52. package/eslint.config.js +0 -23
  53. package/postcss.config.js +0 -6
  54. package/src/App.css +0 -42
  55. package/src/App.tsx +0 -17
  56. package/src/assets/react.svg +0 -1
  57. package/src/components/LanguageSwitcher.tsx +0 -67
  58. package/src/components/Layout.tsx +0 -88
  59. package/src/components/MainSidebar.tsx +0 -163
  60. package/src/components/MermaidDiagram.tsx +0 -85
  61. package/src/components/MinimalLayout.tsx +0 -51
  62. package/src/components/Navigation.tsx +0 -254
  63. package/src/components/PriorityBadge.tsx +0 -59
  64. package/src/components/ProjectSwitcher.tsx +0 -222
  65. package/src/components/QuickSearch.tsx +0 -225
  66. package/src/components/RootRedirect.tsx +0 -40
  67. package/src/components/SpecDetailLayout.context.ts +0 -10
  68. package/src/components/SpecDetailLayout.tsx +0 -14
  69. package/src/components/SpecsNavSidebar.tsx +0 -615
  70. package/src/components/StatusBadge.tsx +0 -59
  71. package/src/components/ThemeToggle.tsx +0 -25
  72. package/src/components/Tooltip.tsx +0 -29
  73. package/src/components/context/ContextClient.tsx +0 -471
  74. package/src/components/context/ContextFileDetail.tsx +0 -163
  75. package/src/components/dashboard/ActivityItem.tsx +0 -36
  76. package/src/components/dashboard/DashboardClient.tsx +0 -218
  77. package/src/components/dashboard/SpecListItem.tsx +0 -58
  78. package/src/components/dashboard/StatCard.tsx +0 -52
  79. package/src/components/dependencies/SpecNode.tsx +0 -128
  80. package/src/components/dependencies/SpecSidebar.tsx +0 -256
  81. package/src/components/dependencies/constants.ts +0 -25
  82. package/src/components/dependencies/types.ts +0 -38
  83. package/src/components/dependencies/utils.ts +0 -261
  84. package/src/components/metadata-editors/PriorityEditor.tsx +0 -89
  85. package/src/components/metadata-editors/StatusEditor.tsx +0 -85
  86. package/src/components/metadata-editors/TagsEditor.tsx +0 -207
  87. package/src/components/projects/CreateProjectDialog.tsx +0 -162
  88. package/src/components/projects/DirectoryPicker.tsx +0 -182
  89. package/src/components/shared/BackToTop.tsx +0 -39
  90. package/src/components/shared/ColorPicker.tsx +0 -68
  91. package/src/components/shared/EmptyState.tsx +0 -35
  92. package/src/components/shared/ErrorBoundary.tsx +0 -79
  93. package/src/components/shared/PageHeader.tsx +0 -23
  94. package/src/components/shared/PageTransition.tsx +0 -40
  95. package/src/components/shared/ProjectAvatar.tsx +0 -107
  96. package/src/components/shared/Skeletons.tsx +0 -184
  97. package/src/components/spec-detail/EditableMetadata.tsx +0 -129
  98. package/src/components/spec-detail/MarkdownRenderer.tsx +0 -47
  99. package/src/components/spec-detail/TableOfContents.tsx +0 -150
  100. package/src/components/specs/BoardView.tsx +0 -204
  101. package/src/components/specs/ListView.tsx +0 -62
  102. package/src/components/specs/SpecsFilters.tsx +0 -190
  103. package/src/contexts/KeyboardShortcutsContext.tsx +0 -95
  104. package/src/contexts/LayoutContext.tsx +0 -45
  105. package/src/contexts/ProjectContext.tsx +0 -163
  106. package/src/contexts/ThemeContext.tsx +0 -90
  107. package/src/contexts/index.ts +0 -7
  108. package/src/hooks/useKeyboardShortcuts.ts +0 -87
  109. package/src/index.css +0 -624
  110. package/src/lib/api.ts +0 -72
  111. package/src/lib/backend-adapter.ts +0 -382
  112. package/src/lib/date-utils.ts +0 -122
  113. package/src/lib/i18n.test.ts +0 -57
  114. package/src/lib/i18n.ts +0 -51
  115. package/src/lib/markdown-utils.ts +0 -38
  116. package/src/lib/sub-spec-utils.ts +0 -166
  117. package/src/lib/utils.ts +0 -6
  118. package/src/locales/en/common.json +0 -660
  119. package/src/locales/en/errors.json +0 -20
  120. package/src/locales/en/help.json +0 -8
  121. package/src/locales/zh-CN/common.json +0 -660
  122. package/src/locales/zh-CN/errors.json +0 -20
  123. package/src/locales/zh-CN/help.json +0 -8
  124. package/src/main.tsx +0 -12
  125. package/src/pages/ContextPage.tsx +0 -111
  126. package/src/pages/DashboardPage.tsx +0 -97
  127. package/src/pages/DependenciesPage.tsx +0 -881
  128. package/src/pages/ProjectsPage.tsx +0 -432
  129. package/src/pages/SpecDetailPage.tsx +0 -592
  130. package/src/pages/SpecsPage.tsx +0 -319
  131. package/src/pages/StatsPage.tsx +0 -307
  132. package/src/router/projectRoutes.tsx +0 -36
  133. package/src/router.tsx +0 -33
  134. package/src/test/setup.ts +0 -39
  135. package/src/types/api.ts +0 -185
  136. package/tailwind.config.ts +0 -57
  137. package/tsconfig.app.json +0 -29
  138. package/tsconfig.json +0 -7
  139. package/tsconfig.node.json +0 -26
  140. package/tsconfig.tsbuildinfo +0 -1
  141. package/vite.config.ts +0 -27
  142. package/vitest.config.ts +0 -18
  143. /package/{public → dist}/favicon.ico +0 -0
  144. /package/{public → dist}/github-mark-white.svg +0 -0
  145. /package/{public → dist}/github-mark.svg +0 -0
  146. /package/{public → dist}/logo-dark-bg.svg +0 -0
  147. /package/{public → dist}/logo-with-bg.svg +0 -0
  148. /package/{public → dist}/logo.svg +0 -0
  149. /package/{public → dist}/vite.svg +0 -0
@@ -1,319 +0,0 @@
1
- import { useState, useEffect, useMemo, useCallback, useRef } from 'react';
2
- import { LayoutGrid, List, AlertCircle, FileQuestion, FilterX, RefreshCcw } from 'lucide-react';
3
- import { Button, Card, CardContent } from '@leanspec/ui-components';
4
- import { useParams, useSearchParams } from 'react-router-dom';
5
- import { api } from '../lib/api';
6
- import type { Spec } from '../types/api';
7
- import { BoardView } from '../components/specs/BoardView';
8
- import { ListView } from '../components/specs/ListView';
9
- import { SpecsFilters } from '../components/specs/SpecsFilters';
10
- import { cn } from '../lib/utils';
11
- import { SpecListSkeleton } from '../components/shared/Skeletons';
12
- import { PageHeader } from '../components/shared/PageHeader';
13
- import { EmptyState } from '../components/shared/EmptyState';
14
- import { useProject } from '../contexts';
15
- import { useTranslation } from 'react-i18next';
16
-
17
- type ViewMode = 'list' | 'board';
18
- type SpecStatus = 'planned' | 'in-progress' | 'complete' | 'archived';
19
- type SortOption = 'id-desc' | 'id-asc' | 'updated-desc' | 'title-asc';
20
-
21
- export function SpecsPage() {
22
- const [specs, setSpecs] = useState<Spec[]>([]);
23
- const { projectId } = useParams<{ projectId: string }>();
24
- const basePath = projectId ? `/projects/${projectId}` : '/projects/default';
25
- const { currentProject, loading: projectLoading } = useProject();
26
- const projectReady = !projectId || currentProject?.id === projectId;
27
- const { t } = useTranslation('common');
28
- const [loading, setLoading] = useState(true);
29
- const [error, setError] = useState<string | null>(null);
30
-
31
- // Filters
32
- const [searchQuery, setSearchQuery] = useState('');
33
- const [statusFilter, setStatusFilter] = useState<string>('all');
34
- const [priorityFilter, setPriorityFilter] = useState<string>('all');
35
- const [tagFilter, setTagFilter] = useState<string>('all');
36
- const [sortBy, setSortBy] = useState<SortOption>('id-desc');
37
-
38
- const [searchParams] = useSearchParams();
39
- const initializedFromQuery = useRef(false);
40
-
41
- // View State
42
- const [viewMode, setViewMode] = useState<ViewMode>(() => {
43
- const saved = localStorage.getItem('specs-view-mode');
44
- return (saved === 'board' || saved === 'list') ? saved : 'list';
45
- });
46
-
47
- const loadSpecs = useCallback(async () => {
48
- if (!projectReady || projectLoading) return;
49
- try {
50
- setLoading(true);
51
- const data = await api.getSpecs();
52
- setSpecs(data);
53
- setError(null);
54
- } catch (err: unknown) {
55
- console.error('Failed to load specs', err);
56
- setError(t('specsPage.state.errorDescription'));
57
- } finally {
58
- setLoading(false);
59
- }
60
- }, [projectLoading, projectReady, t]);
61
-
62
- useEffect(() => {
63
- void loadSpecs();
64
- }, [loadSpecs]);
65
-
66
- useEffect(() => {
67
- if (initializedFromQuery.current) return;
68
- const initialTag = searchParams.get('tag');
69
- const initialQuery = searchParams.get('q');
70
- if (initialTag) setTagFilter(initialTag);
71
- if (initialQuery) setSearchQuery(initialQuery);
72
- initializedFromQuery.current = true;
73
- }, [searchParams]);
74
-
75
- useEffect(() => {
76
- localStorage.setItem('specs-view-mode', viewMode);
77
- }, [viewMode]);
78
-
79
- const handleStatusChange = useCallback(async (spec: Spec, newStatus: SpecStatus) => {
80
- // Optimistic update
81
- setSpecs(prev => prev.map(s =>
82
- s.specName === spec.specName ? { ...s, status: newStatus } : s
83
- ));
84
-
85
- try {
86
- await api.updateSpec(spec.specName, { status: newStatus });
87
- } catch (err) {
88
- // Revert on error
89
- setSpecs(prev => prev.map(s =>
90
- s.specName === spec.specName ? { ...s, status: spec.status } : s
91
- ));
92
- console.error('Failed to update status:', err);
93
- }
94
- }, []);
95
-
96
- // Get unique values for filters
97
- const uniqueStatuses = useMemo(() => {
98
- const statuses = specs.map((s) => s.status).filter((s): s is SpecStatus => Boolean(s));
99
- const uniqueSet = Array.from(new Set(statuses));
100
- // Sort by defined order: planned -> in-progress -> complete -> archived
101
- const statusOrder: Record<SpecStatus, number> = {
102
- 'planned': 1,
103
- 'in-progress': 2,
104
- 'complete': 3,
105
- 'archived': 4,
106
- };
107
- return uniqueSet.sort((a, b) => statusOrder[a] - statusOrder[b]);
108
- }, [specs]);
109
- const uniquePriorities = useMemo(() => {
110
- const uniqueSet = Array.from(new Set(specs.map(s => s.priority).filter(Boolean) as string[]));
111
- // Sort by defined order: critical -> high -> medium -> low
112
- const priorityOrder: Record<string, number> = {
113
- 'critical': 1,
114
- 'high': 2,
115
- 'medium': 3,
116
- 'low': 4,
117
- };
118
- return uniqueSet.sort((a, b) => (priorityOrder[a] || 999) - (priorityOrder[b] || 999));
119
- }, [specs]);
120
- const uniqueTags = useMemo(() => {
121
- const uniqueSet = Array.from(new Set(specs.flatMap(s => s.tags || [])));
122
- // Sort alphabetically ascending
123
- return uniqueSet.sort((a, b) => a.toLowerCase().localeCompare(b.toLowerCase()));
124
- }, [specs]);
125
-
126
- const handleClearFilters = useCallback(() => {
127
- setSearchQuery('');
128
- setStatusFilter('all');
129
- setPriorityFilter('all');
130
- setTagFilter('all');
131
- }, []);
132
-
133
- // Filter specs based on search and filters
134
- const filteredSpecs = useMemo(() => {
135
- const filtered = specs.filter(spec => {
136
- if (searchQuery) {
137
- const query = searchQuery.toLowerCase();
138
- const matchesSearch =
139
- spec.specName.toLowerCase().includes(query) ||
140
- (spec.title ? spec.title.toLowerCase().includes(query) : false) ||
141
- spec.tags?.some((tag: string) => tag.toLowerCase().includes(query));
142
- if (!matchesSearch) return false;
143
- }
144
-
145
- if (statusFilter !== 'all' && spec.status !== statusFilter) {
146
- return false;
147
- }
148
-
149
- if (priorityFilter !== 'all' && spec.priority !== priorityFilter) {
150
- return false;
151
- }
152
-
153
- if (tagFilter !== 'all' && !spec.tags?.includes(tagFilter)) {
154
- return false;
155
- }
156
-
157
- return true;
158
- });
159
-
160
- const sorted = [...filtered];
161
- switch (sortBy) {
162
- case 'id-asc':
163
- sorted.sort((a, b) => (a.specNumber || 0) - (b.specNumber || 0));
164
- break;
165
- case 'updated-desc':
166
- sorted.sort((a, b) => {
167
- if (!a.updatedAt) return 1;
168
- if (!b.updatedAt) return -1;
169
- const aTime = new Date(a.updatedAt).getTime();
170
- const bTime = new Date(b.updatedAt).getTime();
171
- return bTime - aTime;
172
- });
173
- break;
174
- case 'title-asc':
175
- sorted.sort((a, b) => {
176
- const titleA = (a.title || a.specName).toLowerCase();
177
- const titleB = (b.title || b.specName).toLowerCase();
178
- return titleA.localeCompare(titleB);
179
- });
180
- break;
181
- case 'id-desc':
182
- default:
183
- sorted.sort((a, b) => (b.specNumber || 0) - (a.specNumber || 0));
184
- break;
185
- }
186
-
187
- return sorted;
188
- }, [priorityFilter, searchQuery, sortBy, specs, statusFilter, tagFilter]);
189
-
190
- if (loading) {
191
- return (
192
- <div className="p-4 sm:p-6">
193
- <SpecListSkeleton />
194
- </div>
195
- );
196
- }
197
-
198
- if (error) {
199
- return (
200
- <div className="p-4 sm:p-6">
201
- <Card>
202
- <CardContent className="py-10 text-center space-y-3">
203
- <div className="flex justify-center">
204
- <AlertCircle className="h-6 w-6 text-destructive" />
205
- </div>
206
- <div className="text-lg font-semibold">{t('specsPage.state.errorTitle')}</div>
207
- <p className="text-sm text-muted-foreground">{error || t('specsPage.state.errorDescription')}</p>
208
- <Button variant="secondary" size="sm" onClick={loadSpecs} className="mt-2">
209
- {t('actions.retry')}
210
- </Button>
211
- </CardContent>
212
- </Card>
213
- </div>
214
- );
215
- }
216
-
217
- return (
218
- <div className="h-[calc(100vh-3.5rem)] flex flex-col gap-4 p-4 sm:p-6 max-w-7xl mx-auto w-full">
219
- <div className="flex flex-col gap-4 sticky top-14 bg-background mt-0 py-2 z-10">
220
- <PageHeader
221
- title={t('specsPage.title')}
222
- description={t('specsPage.description')}
223
- actions={(
224
- <div className="flex items-center gap-1 bg-secondary/50 p-1 rounded-lg border">
225
- <Button
226
- variant={viewMode === 'list' ? 'secondary' : 'ghost'}
227
- size="sm"
228
- onClick={() => setViewMode('list')}
229
- className={cn(
230
- "h-8",
231
- viewMode === 'list' && "bg-background shadow-sm"
232
- )}
233
- title={t('specsPage.views.listTooltip')}
234
- >
235
- <List className="w-4 h-4 mr-1.5" />
236
- {t('specsPage.views.list')}
237
- </Button>
238
- <Button
239
- variant={viewMode === 'board' ? 'secondary' : 'ghost'}
240
- size="sm"
241
- onClick={() => setViewMode('board')}
242
- className={cn(
243
- "h-8",
244
- viewMode === 'board' && "bg-background shadow-sm"
245
- )}
246
- title={t('specsPage.views.boardTooltip')}
247
- >
248
- <LayoutGrid className="w-4 h-4 mr-1.5" />
249
- {t('specsPage.views.board')}
250
- </Button>
251
- </div>
252
- )}
253
- />
254
-
255
- <p className="text-sm text-muted-foreground">{t('specsPage.count', { count: filteredSpecs.length })}</p>
256
-
257
- <SpecsFilters
258
- searchQuery={searchQuery}
259
- onSearchChange={setSearchQuery}
260
- statusFilter={statusFilter}
261
- onStatusFilterChange={setStatusFilter}
262
- priorityFilter={priorityFilter}
263
- onPriorityFilterChange={setPriorityFilter}
264
- tagFilter={tagFilter}
265
- onTagFilterChange={setTagFilter}
266
- sortBy={sortBy}
267
- onSortByChange={(value) => setSortBy(value as SortOption)}
268
- uniqueStatuses={uniqueStatuses}
269
- uniquePriorities={uniquePriorities}
270
- uniqueTags={uniqueTags}
271
- onClearFilters={handleClearFilters}
272
- totalSpecs={specs.length}
273
- filteredCount={filteredSpecs.length}
274
- />
275
- </div>
276
-
277
- <div className="flex-1 min-h-0">
278
- {specs.length === 0 ? (
279
- <EmptyState
280
- icon={FileQuestion}
281
- title={t('specsPage.state.noSpecsTitle')}
282
- description={t('specsPage.state.noSpecsDescription')}
283
- actions={(
284
- <Button variant="secondary" size="sm" onClick={loadSpecs}>
285
- <RefreshCcw className="h-4 w-4 mr-2" />
286
- {t('specsPage.buttons.refreshList')}
287
- </Button>
288
- )}
289
- />
290
- ) : filteredSpecs.length === 0 ? (
291
- <EmptyState
292
- icon={FilterX}
293
- title={t('specsPage.state.noFiltersTitle')}
294
- description={t('specsPage.state.noFiltersDescription')}
295
- actions={(
296
- <div className="flex gap-2 flex-wrap justify-center">
297
- <Button variant="outline" size="sm" onClick={handleClearFilters}>
298
- {t('specsNavSidebar.clearFilters')}
299
- </Button>
300
- <Button variant="secondary" size="sm" onClick={loadSpecs}>
301
- <RefreshCcw className="h-4 w-4 mr-2" />
302
- {t('specsPage.buttons.reloadData')}
303
- </Button>
304
- </div>
305
- )}
306
- />
307
- ) : viewMode === 'list' ? (
308
- <ListView specs={filteredSpecs} basePath={basePath} />
309
- ) : (
310
- <BoardView
311
- specs={filteredSpecs}
312
- onStatusChange={handleStatusChange}
313
- basePath={basePath}
314
- />
315
- )}
316
- </div>
317
- </div>
318
- );
319
- }
@@ -1,307 +0,0 @@
1
- import { useState, useEffect, useCallback, useMemo } from 'react';
2
- import {
3
- BarChart,
4
- Bar,
5
- PieChart,
6
- Pie,
7
- Cell,
8
- XAxis,
9
- YAxis,
10
- CartesianGrid,
11
- Tooltip,
12
- ResponsiveContainer,
13
- Legend
14
- } from 'recharts';
15
- import { AlertCircle, FileText, Clock, PlayCircle, CheckCircle2, TrendingUp } from 'lucide-react';
16
- import { Button, Card, CardContent, CardHeader, CardTitle } from '@leanspec/ui-components';
17
- import { Link } from 'react-router-dom';
18
- import { StatCard } from '../components/dashboard/StatCard';
19
- import { api } from '../lib/api';
20
- import type { Stats, Spec } from '../types/api';
21
- import { StatsSkeleton } from '../components/shared/Skeletons';
22
- import { PageHeader } from '../components/shared/PageHeader';
23
- import { useTranslation } from 'react-i18next';
24
- import { useProject } from '../contexts';
25
-
26
- const STATUS_COLORS = {
27
- planned: '#3B82F6',
28
- 'in-progress': '#F59E0B',
29
- complete: '#10B981',
30
- archived: '#6B7280',
31
- };
32
-
33
- const PRIORITY_COLORS = {
34
- critical: '#EF4444',
35
- high: '#F59E0B',
36
- medium: '#3B82F6',
37
- low: '#6B7280',
38
- };
39
-
40
- export function StatsPage() {
41
- const [stats, setStats] = useState<Stats | null>(null);
42
- const [specs, setSpecs] = useState<Spec[]>([]);
43
- const [loading, setLoading] = useState(true);
44
- const [error, setError] = useState<string | null>(null);
45
- const { t, i18n } = useTranslation('common');
46
- const { currentProject, loading: projectLoading } = useProject();
47
-
48
- const loadStats = useCallback(async () => {
49
- if (projectLoading || !currentProject) return;
50
- try {
51
- setLoading(true);
52
- const [statsData, specsData] = await Promise.all([api.getStats(), api.getSpecs()]);
53
- setStats(statsData);
54
- setSpecs(Array.isArray(specsData) ? specsData : []);
55
- setError(null);
56
- } catch (err) {
57
- console.error('Failed to load statistics', err);
58
- setError(t('statsPage.state.errorDescription'));
59
- } finally {
60
- setLoading(false);
61
- }
62
- }, [currentProject, projectLoading, t]);
63
-
64
- useEffect(() => {
65
- void loadStats();
66
- }, [loadStats]);
67
-
68
- // Prepare data for charts - must be before any conditional returns
69
- const statusCounts = useMemo(() => stats?.specsByStatus.reduce<Record<string, number>>((acc: Record<string, number>, entry: { status: string; count: number }) => {
70
- acc[entry.status] = entry.count;
71
- return acc;
72
- }, {}) || {}, [stats?.specsByStatus]);
73
-
74
- const statusData = useMemo(() => stats?.specsByStatus.map(({ status, count }: { status: string; count: number }) => ({
75
- name: t(`status.${status}`, { defaultValue: status }),
76
- value: count,
77
- fill: STATUS_COLORS[status as keyof typeof STATUS_COLORS] || '#6B7280',
78
- })) || [], [stats?.specsByStatus, t]);
79
-
80
- const priorityData = useMemo(() => (stats?.specsByPriority || []).map(({ priority, count }: { priority: string; count: number }) => ({
81
- name: t(`priority.${priority}`, { defaultValue: priority }),
82
- value: count,
83
- fill: PRIORITY_COLORS[priority as keyof typeof PRIORITY_COLORS] || '#6B7280',
84
- })), [stats?.specsByPriority, t]);
85
-
86
- const topTags = useMemo(() => {
87
- const tagFrequency = specs.reduce<Record<string, number>>((acc, spec) => {
88
- (spec.tags || []).forEach((tag: string) => {
89
- acc[tag] = (acc[tag] || 0) + 1;
90
- });
91
- return acc;
92
- }, {});
93
-
94
- return Object.entries(tagFrequency)
95
- .sort(([, a], [, b]) => b - a)
96
- .slice(0, 10)
97
- .map(([tag, count]) => ({ tag, count }));
98
- }, [specs]);
99
-
100
- const trendData = useMemo(() => {
101
- const monthFormatter = new Intl.DateTimeFormat(i18n.language, { year: 'numeric', month: 'short' });
102
- const monthly = specs
103
- .filter((spec) => spec.createdAt)
104
- .reduce<Record<string, number>>((acc, spec) => {
105
- try {
106
- const date = typeof spec.createdAt === 'string'
107
- ? new Date(spec.createdAt)
108
- : spec.createdAt;
109
-
110
- if (date instanceof Date && !isNaN(date.getTime())) {
111
- const month = monthFormatter.format(date);
112
- acc[month] = (acc[month] || 0) + 1;
113
- }
114
- } catch {
115
- // Skip invalid dates
116
- }
117
- return acc;
118
- }, {});
119
-
120
- return Object.entries(monthly)
121
- .slice(-6)
122
- .map(([month, count]) => ({ month, count }));
123
- }, [i18n.language, specs]);
124
-
125
- const completionRate = stats?.completionRate.toFixed(1) || '0.0';
126
-
127
- if (projectLoading || loading) {
128
- return <StatsSkeleton />;
129
- }
130
-
131
- if (!currentProject) {
132
- return (
133
- <Card>
134
- <CardContent className="py-10 text-center space-y-3">
135
- <div className="text-lg font-semibold">{t('statsPage.state.noProjectTitle', { defaultValue: 'No project selected' })}</div>
136
- <p className="text-sm text-muted-foreground">
137
- {t('statsPage.state.noProjectDescription', { defaultValue: 'Select or create a project to view statistics.' })}
138
- </p>
139
- <Link to="/projects" className="inline-flex">
140
- <Button variant="secondary" size="sm">{t('projectsPage.title', { defaultValue: 'Projects' })}</Button>
141
- </Link>
142
- </CardContent>
143
- </Card>
144
- );
145
- }
146
-
147
- if (error || !stats) {
148
- return (
149
- <Card>
150
- <CardContent className="py-10 text-center space-y-3">
151
- <div className="flex justify-center">
152
- <AlertCircle className="h-6 w-6 text-destructive" />
153
- </div>
154
- <div className="text-lg font-semibold">{t('statsPage.state.errorTitle')}</div>
155
- <p className="text-sm text-muted-foreground">{error || t('statsPage.state.unknownError')}</p>
156
- <Button variant="secondary" size="sm" onClick={loadStats} className="mt-2">
157
- {t('actions.retry')}
158
- </Button>
159
- </CardContent>
160
- </Card>
161
- );
162
- }
163
-
164
- return (
165
- <div className="space-y-6 p-6">
166
- <PageHeader
167
- title={t('statsPage.title')}
168
- description={t('statsPage.description')}
169
- />
170
-
171
- {/* Summary Cards */}
172
- <div className="grid grid-cols-2 lg:grid-cols-4 gap-3 sm:gap-4">
173
- <StatCard
174
- title={t('statsPage.cards.total.title')}
175
- value={stats.totalSpecs}
176
- icon={FileText}
177
- iconColor="text-blue-600"
178
- gradientFrom="from-blue-500/10"
179
- />
180
- <StatCard
181
- title={t('statsPage.cards.planned.title')}
182
- value={statusCounts.planned || 0}
183
- icon={Clock}
184
- iconColor="text-purple-600"
185
- gradientFrom="from-purple-500/10"
186
- />
187
- <StatCard
188
- title={t('statsPage.cards.inProgress.title')}
189
- value={statusCounts['in-progress'] || 0}
190
- icon={PlayCircle}
191
- iconColor="text-orange-600"
192
- gradientFrom="from-orange-500/10"
193
- />
194
- <StatCard
195
- title={t('statsPage.cards.completed.title')}
196
- value={statusCounts.complete || 0}
197
- icon={CheckCircle2}
198
- iconColor="text-green-600"
199
- gradientFrom="from-green-500/10"
200
- subtext={
201
- <span className="flex items-center gap-1">
202
- <TrendingUp className="h-3 w-3" />
203
- {completionRate}% {t('statsPage.cards.completed.subtitle')}
204
- </span>
205
- }
206
- />
207
- </div>
208
-
209
- {/* Charts */}
210
- <div className="grid gap-6 md:grid-cols-2">
211
- {/* Status Distribution - Pie Chart */}
212
- <Card>
213
- <CardHeader>
214
- <CardTitle>{t('statsPage.charts.status.title')}</CardTitle>
215
- </CardHeader>
216
- <CardContent>
217
- <ResponsiveContainer width="100%" height={300}>
218
- <PieChart>
219
- <Pie
220
- data={statusData}
221
- cx="50%"
222
- cy="50%"
223
- labelLine={false}
224
- label={({ name, value }) => t('statsPage.charts.label', { name, value })}
225
- outerRadius={80}
226
- dataKey="value"
227
- >
228
- {statusData.map((entry: { name: string; value: number; fill: string }, index: number) => (
229
- <Cell key={`cell-${index}`} fill={entry.fill} />
230
- ))}
231
- </Pie>
232
- <Tooltip />
233
- <Legend />
234
- </PieChart>
235
- </ResponsiveContainer>
236
- </CardContent>
237
- </Card>
238
-
239
- {/* Priority Distribution - Bar Chart */}
240
- <Card>
241
- <CardHeader>
242
- <CardTitle>{t('statsPage.charts.priority.title')}</CardTitle>
243
- </CardHeader>
244
- <CardContent>
245
- <ResponsiveContainer width="100%" height={300}>
246
- <BarChart data={priorityData}>
247
- <CartesianGrid strokeDasharray="3 3" />
248
- <XAxis dataKey="name" />
249
- <YAxis />
250
- <Tooltip />
251
- <Legend />
252
- <Bar dataKey="value" fill="#3B82F6" />
253
- </BarChart>
254
- </ResponsiveContainer>
255
- </CardContent>
256
- </Card>
257
- </div>
258
-
259
- <div className="grid gap-6 md:grid-cols-2">
260
- {trendData.length > 0 && (
261
- <Card>
262
- <CardHeader>
263
- <CardTitle>{t('statsPage.charts.creation.title')}</CardTitle>
264
- </CardHeader>
265
- <CardContent>
266
- <ResponsiveContainer width="100%" height={300}>
267
- <BarChart data={trendData}>
268
- <CartesianGrid strokeDasharray="3 3" />
269
- <XAxis dataKey="month" />
270
- <YAxis allowDecimals={false} />
271
- <Tooltip />
272
- <Bar dataKey="count" fill="#3B82F6" />
273
- </BarChart>
274
- </ResponsiveContainer>
275
- </CardContent>
276
- </Card>
277
- )}
278
-
279
- {topTags.length > 0 && (
280
- <Card>
281
- <CardHeader>
282
- <CardTitle>{t('statsPage.charts.topTags.title')}</CardTitle>
283
- </CardHeader>
284
- <CardContent>
285
- <div className="space-y-3">
286
- {topTags.map(({ tag, count }) => (
287
- <div key={tag} className="flex items-center justify-between gap-3">
288
- <span className="text-sm font-medium truncate">{tag}</span>
289
- <div className="flex items-center gap-2 w-40">
290
- <div className="w-full h-2 bg-muted rounded-full overflow-hidden">
291
- <div
292
- className="h-full bg-primary"
293
- style={{ width: `${(count / topTags[0].count) * 100}%` }}
294
- />
295
- </div>
296
- <span className="text-xs text-muted-foreground w-6 text-right">{count}</span>
297
- </div>
298
- </div>
299
- ))}
300
- </div>
301
- </CardContent>
302
- </Card>
303
- )}
304
- </div>
305
- </div>
306
- );
307
- }
@@ -1,36 +0,0 @@
1
- import type { RouteObject } from 'react-router-dom';
2
-
3
- import { SpecDetailLayout } from '../components/SpecDetailLayout';
4
- import { ContextPage } from '../pages/ContextPage';
5
- import { DashboardPage } from '../pages/DashboardPage';
6
- import { DependenciesPage } from '../pages/DependenciesPage';
7
- import { SpecDetailPage } from '../pages/SpecDetailPage';
8
- import { SpecsPage } from '../pages/SpecsPage';
9
- import { StatsPage } from '../pages/StatsPage';
10
-
11
- /**
12
- * Shared project-scoped route definitions.
13
- *
14
- * Both the web app (@leanspec/ui) and desktop app (@leanspec/desktop)
15
- * compose these under their own top-level layouts and router types
16
- * (browser vs hash).
17
- */
18
- export function createProjectRoutes(): RouteObject[] {
19
- return [
20
- { index: true, element: <DashboardPage /> },
21
- {
22
- path: 'specs',
23
- children: [
24
- { index: true, element: <SpecsPage /> },
25
- {
26
- element: <SpecDetailLayout />,
27
- children: [{ path: ':specName', element: <SpecDetailPage /> }],
28
- },
29
- ],
30
- },
31
- { path: 'stats', element: <StatsPage /> },
32
- { path: 'dependencies', element: <DependenciesPage /> },
33
- { path: 'dependencies/:specName', element: <DependenciesPage /> },
34
- { path: 'context', element: <ContextPage /> },
35
- ];
36
- }
package/src/router.tsx DELETED
@@ -1,33 +0,0 @@
1
- import { createBrowserRouter } from 'react-router-dom';
2
- import { Layout } from './components/Layout';
3
- import { ProjectsPage } from './pages/ProjectsPage';
4
- import { RootRedirect } from './components/RootRedirect';
5
- import { createProjectRoutes } from './router/projectRoutes';
6
-
7
- /**
8
- * Router configuration for @leanspec/ui (Vite SPA).
9
- *
10
- * Layout hierarchy:
11
- * - Layout: Navigation + MainSidebar (for all project routes including projects list)
12
- *
13
- * This nested layout approach ensures:
14
- * 1. Navigation bar is always present across all pages
15
- * 2. MainSidebar is always visible for consistent navigation
16
- * 3. SpecDetailLayout provides SpecsNavSidebar + outlet context
17
- */
18
- export const router = createBrowserRouter([
19
- {
20
- path: '/',
21
- element: <RootRedirect />,
22
- },
23
- {
24
- path: '/projects',
25
- element: <Layout />,
26
- children: [{ index: true, element: <ProjectsPage /> }],
27
- },
28
- {
29
- path: '/projects/:projectId',
30
- element: <Layout />,
31
- children: createProjectRoutes(),
32
- },
33
- ]);