@leanspec/ui 0.2.5-dev.20251119032109 → 0.2.5-dev.20251119062438

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 (150) hide show
  1. package/.next/standalone/node_modules/.pnpm/tiktoken@1.0.22/node_modules/tiktoken/tiktoken_bg.wasm +0 -0
  2. package/.next/standalone/packages/ui/.next/BUILD_ID +1 -1
  3. package/.next/standalone/packages/ui/.next/app-path-routes-manifest.json +1 -0
  4. package/.next/standalone/packages/ui/.next/build-manifest.json +2 -2
  5. package/.next/standalone/packages/ui/.next/prerender-manifest.json +3 -3
  6. package/.next/standalone/packages/ui/.next/routes-manifest.json +8 -0
  7. package/.next/standalone/packages/ui/.next/server/app/_global-error/page.js +1 -1
  8. package/.next/standalone/packages/ui/.next/server/app/_global-error/page.js.nft.json +1 -1
  9. package/.next/standalone/packages/ui/.next/server/app/_global-error.html +2 -2
  10. package/.next/standalone/packages/ui/.next/server/app/_global-error.rsc +1 -1
  11. package/.next/standalone/packages/ui/.next/server/app/_global-error.segments/__PAGE__.segment.rsc +1 -1
  12. package/.next/standalone/packages/ui/.next/server/app/_global-error.segments/_full.segment.rsc +1 -1
  13. package/.next/standalone/packages/ui/.next/server/app/_global-error.segments/_index.segment.rsc +1 -1
  14. package/.next/standalone/packages/ui/.next/server/app/_global-error.segments/_tree.segment.rsc +1 -1
  15. package/.next/standalone/packages/ui/.next/server/app/_not-found/page.js +2 -2
  16. package/.next/standalone/packages/ui/.next/server/app/_not-found/page.js.nft.json +1 -1
  17. package/.next/standalone/packages/ui/.next/server/app/_not-found/page_client-reference-manifest.js +1 -1
  18. package/.next/standalone/packages/ui/.next/server/app/_not-found.html +2 -2
  19. package/.next/standalone/packages/ui/.next/server/app/_not-found.rsc +16 -16
  20. package/.next/standalone/packages/ui/.next/server/app/_not-found.segments/_full.segment.rsc +16 -16
  21. package/.next/standalone/packages/ui/.next/server/app/_not-found.segments/_index.segment.rsc +10 -10
  22. package/.next/standalone/packages/ui/.next/server/app/_not-found.segments/_not-found/__PAGE__.segment.rsc +3 -3
  23. package/.next/standalone/packages/ui/.next/server/app/_not-found.segments/_not-found.segment.rsc +3 -3
  24. package/.next/standalone/packages/ui/.next/server/app/_not-found.segments/_tree.segment.rsc +5 -5
  25. package/.next/standalone/packages/ui/.next/server/app/api/projects/[id]/specs/route.js +1 -1
  26. package/.next/standalone/packages/ui/.next/server/app/api/projects/[id]/specs/route.js.nft.json +1 -1
  27. package/.next/standalone/packages/ui/.next/server/app/api/projects/route.js +1 -1
  28. package/.next/standalone/packages/ui/.next/server/app/api/projects/route.js.nft.json +1 -1
  29. package/.next/standalone/packages/ui/.next/server/app/api/revalidate/route.js +1 -1
  30. package/.next/standalone/packages/ui/.next/server/app/api/revalidate/route.js.nft.json +1 -1
  31. package/.next/standalone/packages/ui/.next/server/app/api/specs/[id]/dependency-graph/route.js +1 -1
  32. package/.next/standalone/packages/ui/.next/server/app/api/specs/[id]/dependency-graph/route.js.nft.json +1 -1
  33. package/.next/standalone/packages/ui/.next/server/app/api/specs/[id]/route.js +1 -1
  34. package/.next/standalone/packages/ui/.next/server/app/api/specs/[id]/route.js.nft.json +1 -1
  35. package/.next/standalone/packages/ui/.next/server/app/api/specs/[id]/status/route/app-paths-manifest.json +3 -0
  36. package/.next/standalone/packages/ui/.next/server/app/api/specs/[id]/status/route/build-manifest.json +11 -0
  37. package/.next/standalone/packages/ui/.next/server/app/api/specs/[id]/status/route/server-reference-manifest.json +4 -0
  38. package/.next/standalone/packages/ui/.next/server/app/api/specs/[id]/status/route.js +7 -0
  39. package/.next/standalone/packages/ui/.next/server/app/api/specs/[id]/status/route.js.map +5 -0
  40. package/.next/standalone/packages/ui/.next/server/app/api/specs/[id]/status/route.js.nft.json +1 -0
  41. package/.next/standalone/packages/ui/.next/server/app/api/specs/[id]/status/route_client-reference-manifest.js +2 -0
  42. package/.next/standalone/packages/ui/.next/server/app/api/specs/[id]/subspecs/[file]/route.js +1 -1
  43. package/.next/standalone/packages/ui/.next/server/app/api/specs/[id]/subspecs/[file]/route.js.nft.json +1 -1
  44. package/.next/standalone/packages/ui/.next/server/app/api/stats/route.js +1 -1
  45. package/.next/standalone/packages/ui/.next/server/app/api/stats/route.js.nft.json +1 -1
  46. package/.next/standalone/packages/ui/.next/server/app/page.js +2 -2
  47. package/.next/standalone/packages/ui/.next/server/app/page.js.nft.json +1 -1
  48. package/.next/standalone/packages/ui/.next/server/app/page_client-reference-manifest.js +1 -1
  49. package/.next/standalone/packages/ui/.next/server/app/specs/[id]/page.js +2 -2
  50. package/.next/standalone/packages/ui/.next/server/app/specs/[id]/page.js.nft.json +1 -1
  51. package/.next/standalone/packages/ui/.next/server/app/specs/[id]/page_client-reference-manifest.js +1 -1
  52. package/.next/standalone/packages/ui/.next/server/app/specs/page.js +2 -2
  53. package/.next/standalone/packages/ui/.next/server/app/specs/page.js.nft.json +1 -1
  54. package/.next/standalone/packages/ui/.next/server/app/specs/page_client-reference-manifest.js +1 -1
  55. package/.next/standalone/packages/ui/.next/server/app/stats/page.js +2 -2
  56. package/.next/standalone/packages/ui/.next/server/app/stats/page.js.nft.json +1 -1
  57. package/.next/standalone/packages/ui/.next/server/app/stats/page_client-reference-manifest.js +1 -1
  58. package/.next/standalone/packages/ui/.next/server/app-paths-manifest.json +1 -0
  59. package/.next/standalone/packages/ui/.next/server/chunks/730ea_ui__next-internal_server_app_api_specs_[id]_status_route_actions_5d700407.js +3 -0
  60. package/.next/standalone/packages/ui/.next/server/chunks/{[root-of-the-server]__d1d92c7c._.js → [root-of-the-server]__175bef84._.js} +2 -2
  61. package/.next/standalone/packages/ui/.next/server/chunks/[root-of-the-server]__3971eae5._.js +3 -0
  62. package/.next/standalone/packages/ui/.next/server/chunks/[root-of-the-server]__6bca1621._.js +3 -0
  63. package/.next/standalone/packages/ui/.next/server/chunks/[root-of-the-server]__87a3475a._.js +3 -0
  64. package/.next/standalone/packages/ui/.next/server/chunks/[root-of-the-server]__9f0f4c0b._.js +3 -0
  65. package/.next/standalone/packages/ui/.next/server/chunks/[root-of-the-server]__c1c9f5f5._.js +3 -0
  66. package/.next/standalone/packages/ui/.next/server/chunks/{[root-of-the-server]__e09ce4bf._.js → [root-of-the-server]__c6689757._.js} +2 -2
  67. package/.next/standalone/packages/ui/.next/server/chunks/[root-of-the-server]__cd1fb0a2._.js +4 -0
  68. package/.next/standalone/packages/ui/.next/server/chunks/[root-of-the-server]__e2071b2e._.js +3 -0
  69. package/.next/standalone/packages/ui/.next/server/chunks/e0876_tiktoken_3efea2dc._.js +3 -0
  70. package/.next/standalone/packages/ui/.next/server/chunks/ssr/[root-of-the-server]__1d0c2012._.js +1 -1
  71. package/.next/standalone/packages/ui/.next/server/chunks/ssr/{[root-of-the-server]__c65aedd0._.js → [root-of-the-server]__299c81cc._.js} +2 -2
  72. package/.next/standalone/packages/ui/.next/server/chunks/ssr/{[root-of-the-server]__b3633d6e._.js → [root-of-the-server]__41f5b5c0._.js} +2 -2
  73. package/.next/standalone/packages/ui/.next/server/chunks/ssr/{[root-of-the-server]__a9d7fd42._.js → [root-of-the-server]__5ca2e973._.js} +2 -2
  74. package/.next/standalone/packages/ui/.next/server/chunks/ssr/[root-of-the-server]__7f55abaa._.js +1 -1
  75. package/.next/standalone/packages/ui/.next/server/chunks/ssr/[root-of-the-server]__9d8d3fd6._.js +3 -0
  76. package/.next/standalone/packages/ui/.next/server/chunks/ssr/[root-of-the-server]__e79a982a._.js +7 -0
  77. package/.next/standalone/packages/ui/.next/server/chunks/ssr/{[root-of-the-server]__6e89d129._.js → [root-of-the-server]__ff03fc1e._.js} +2 -2
  78. package/.next/standalone/packages/ui/.next/server/chunks/ssr/_000dd317._.js +1 -1
  79. package/.next/standalone/packages/ui/.next/server/chunks/ssr/_6cd9a5e0._.js +3 -0
  80. package/.next/standalone/packages/ui/.next/server/chunks/ssr/_b483c9fe._.js +3 -0
  81. package/.next/standalone/packages/ui/.next/server/chunks/ssr/_b5040d31._.js +5 -0
  82. package/.next/standalone/packages/ui/.next/server/chunks/ssr/_e1889307._.js +3 -0
  83. package/.next/standalone/packages/ui/.next/server/chunks/ssr/packages_ui_src_app_specs_specs-client_tsx_0bb8f8f8._.js +3 -0
  84. package/.next/standalone/packages/ui/.next/server/chunks/ssr/packages_ui_src_components_specs-nav-sidebar_tsx_8237ed13._.js +3 -0
  85. package/.next/standalone/packages/ui/.next/server/pages/404.html +2 -2
  86. package/.next/standalone/packages/ui/.next/server/pages/500.html +2 -2
  87. package/.next/standalone/packages/ui/.next/server/server-reference-manifest.js +1 -1
  88. package/.next/standalone/packages/ui/.next/server/server-reference-manifest.json +1 -1
  89. package/.next/standalone/packages/ui/.next/static/chunks/061e3819fd59154d.js +1 -0
  90. package/.next/standalone/packages/ui/.next/static/chunks/148ab58e68b383da.js +1 -0
  91. package/.next/standalone/packages/ui/.next/static/chunks/5f97d665a4d9dc62.js +3 -0
  92. package/.next/standalone/packages/ui/.next/static/chunks/9b54fc05b02c39e6.css +1 -0
  93. package/.next/standalone/packages/ui/.next/static/chunks/9b7b7024c39c5f99.js +1 -0
  94. package/.next/standalone/packages/ui/.next/static/chunks/{f2151fb88f8fc70a.js → ae50cdf3322e1d02.js} +1 -1
  95. package/.next/standalone/packages/ui/.next/static/chunks/d79bf953f0dfb650.js +1 -0
  96. package/.next/standalone/packages/ui/.next/static/chunks/e50fb8d0b728cd35.js +5 -0
  97. package/.next/standalone/packages/ui/.next/static/chunks/e6e238dbf1d3e740.js +1 -0
  98. package/.next/standalone/packages/ui/package.json +1 -1
  99. package/.next/standalone/packages/ui/src/app/api/specs/[id]/status/route.ts +122 -0
  100. package/.next/standalone/packages/ui/src/app/globals.css +10 -0
  101. package/.next/standalone/packages/ui/src/app/specs/page.tsx +2 -2
  102. package/.next/standalone/packages/ui/src/app/specs/specs-client.tsx +351 -104
  103. package/.next/standalone/packages/ui/src/lib/db/service-queries.ts +23 -0
  104. package/.next/standalone/packages/ui/src/lib/specs/sources/filesystem-source.ts +46 -6
  105. package/.next/standalone/packages/ui/tsconfig.json +2 -1
  106. package/.next/standalone/packages/ui/tsconfig.tsbuildinfo +1 -1
  107. package/.next/standalone/packages/ui/vitest.config.ts +1 -0
  108. package/.next/static/chunks/061e3819fd59154d.js +1 -0
  109. package/.next/static/chunks/148ab58e68b383da.js +1 -0
  110. package/.next/static/chunks/5f97d665a4d9dc62.js +3 -0
  111. package/.next/static/chunks/9b54fc05b02c39e6.css +1 -0
  112. package/.next/static/chunks/9b7b7024c39c5f99.js +1 -0
  113. package/.next/static/chunks/{f2151fb88f8fc70a.js → ae50cdf3322e1d02.js} +1 -1
  114. package/.next/static/chunks/d79bf953f0dfb650.js +1 -0
  115. package/.next/static/chunks/e50fb8d0b728cd35.js +5 -0
  116. package/.next/static/chunks/e6e238dbf1d3e740.js +1 -0
  117. package/package.json +1 -1
  118. package/.next/standalone/packages/ui/.next/server/chunks/[root-of-the-server]__15e8a946._.js +0 -3
  119. package/.next/standalone/packages/ui/.next/server/chunks/[root-of-the-server]__47b8cab4._.js +0 -3
  120. package/.next/standalone/packages/ui/.next/server/chunks/[root-of-the-server]__9fda1bdb._.js +0 -3
  121. package/.next/standalone/packages/ui/.next/server/chunks/[root-of-the-server]__a2c55ffb._.js +0 -3
  122. package/.next/standalone/packages/ui/.next/server/chunks/[root-of-the-server]__d4193584._.js +0 -3
  123. package/.next/standalone/packages/ui/.next/server/chunks/ssr/[root-of-the-server]__68015eee._.js +0 -3
  124. package/.next/standalone/packages/ui/.next/server/chunks/ssr/[root-of-the-server]__8ae28367._.js +0 -7
  125. package/.next/standalone/packages/ui/.next/server/chunks/ssr/_a527620e._.js +0 -3
  126. package/.next/standalone/packages/ui/.next/server/chunks/ssr/_d7e4bd50._.js +0 -5
  127. package/.next/standalone/packages/ui/.next/server/chunks/ssr/_f8b2190a._.js +0 -3
  128. package/.next/standalone/packages/ui/.next/server/chunks/ssr/packages_ui_src_components_ui_input_tsx_190a7701._.js +0 -3
  129. package/.next/standalone/packages/ui/.next/static/chunks/16b2f4261d3a374a.css +0 -1
  130. package/.next/standalone/packages/ui/.next/static/chunks/17ac52d909def83e.js +0 -1
  131. package/.next/standalone/packages/ui/.next/static/chunks/3385e291c49e782d.js +0 -1
  132. package/.next/standalone/packages/ui/.next/static/chunks/5057c3e1e7e13ca3.js +0 -1
  133. package/.next/standalone/packages/ui/.next/static/chunks/88f695d32910a4ac.js +0 -1
  134. package/.next/standalone/packages/ui/.next/static/chunks/dde5d8bddd7fbfcf.js +0 -1
  135. package/.next/standalone/packages/ui/.next/static/chunks/e237e00fd3a84178.js +0 -3
  136. package/.next/standalone/packages/ui/.next/static/chunks/ee4245fe6ba3d6d3.js +0 -5
  137. package/.next/static/chunks/16b2f4261d3a374a.css +0 -1
  138. package/.next/static/chunks/17ac52d909def83e.js +0 -1
  139. package/.next/static/chunks/3385e291c49e782d.js +0 -1
  140. package/.next/static/chunks/5057c3e1e7e13ca3.js +0 -1
  141. package/.next/static/chunks/88f695d32910a4ac.js +0 -1
  142. package/.next/static/chunks/dde5d8bddd7fbfcf.js +0 -1
  143. package/.next/static/chunks/e237e00fd3a84178.js +0 -3
  144. package/.next/static/chunks/ee4245fe6ba3d6d3.js +0 -5
  145. /package/.next/standalone/packages/ui/.next/static/{uj2-H8ytNCFa611gjl5uE → 18365Lfcsz3xqXCT_JLBo}/_buildManifest.js +0 -0
  146. /package/.next/standalone/packages/ui/.next/static/{uj2-H8ytNCFa611gjl5uE → 18365Lfcsz3xqXCT_JLBo}/_clientMiddlewareManifest.json +0 -0
  147. /package/.next/standalone/packages/ui/.next/static/{uj2-H8ytNCFa611gjl5uE → 18365Lfcsz3xqXCT_JLBo}/_ssgManifest.js +0 -0
  148. /package/.next/static/{uj2-H8ytNCFa611gjl5uE → 18365Lfcsz3xqXCT_JLBo}/_buildManifest.js +0 -0
  149. /package/.next/static/{uj2-H8ytNCFa611gjl5uE → 18365Lfcsz3xqXCT_JLBo}/_clientMiddlewareManifest.json +0 -0
  150. /package/.next/static/{uj2-H8ytNCFa611gjl5uE → 18365Lfcsz3xqXCT_JLBo}/_ssgManifest.js +0 -0
@@ -1,6 +1,7 @@
1
1
  'use client';
2
2
 
3
- import { useState, useMemo, useEffect, useRef } from 'react';
3
+ import { useState, useMemo, useEffect, useRef, useCallback } from 'react';
4
+ import type { DragEvent } from 'react';
4
5
  import Link from 'next/link';
5
6
  import { useSearchParams, useRouter } from 'next/navigation';
6
7
  import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
@@ -19,22 +20,75 @@ import {
19
20
  CheckCircle2,
20
21
  PlayCircle,
21
22
  Clock,
23
+ Archive,
22
24
  LayoutGrid,
23
- List as ListIcon
25
+ List as ListIcon,
26
+ FileText,
27
+ GitBranch
24
28
  } from 'lucide-react';
25
29
  import { StatusBadge } from '@/components/status-badge';
26
30
  import { PriorityBadge } from '@/components/priority-badge';
27
31
  import { cn } from '@/lib/utils';
32
+ import { formatRelativeTime } from '@/lib/date-utils';
33
+ import { toast } from '@/components/ui/toast';
34
+
35
+ type SpecStatus = 'planned' | 'in-progress' | 'complete' | 'archived';
36
+
37
+ const STATUS_CONFIG: Record<SpecStatus, {
38
+ icon: typeof Clock;
39
+ title: string;
40
+ colorClass: string;
41
+ bgClass: string;
42
+ borderClass: string;
43
+ }> = {
44
+ 'planned': {
45
+ icon: Clock,
46
+ title: 'Planned',
47
+ colorClass: 'text-blue-600 dark:text-blue-400',
48
+ bgClass: 'bg-blue-50 dark:bg-blue-900/20',
49
+ borderClass: 'border-blue-200 dark:border-blue-800'
50
+ },
51
+ 'in-progress': {
52
+ icon: PlayCircle,
53
+ title: 'In Progress',
54
+ colorClass: 'text-orange-600 dark:text-orange-400',
55
+ bgClass: 'bg-orange-50 dark:bg-orange-900/20',
56
+ borderClass: 'border-orange-200 dark:border-orange-800'
57
+ },
58
+ 'complete': {
59
+ icon: CheckCircle2,
60
+ title: 'Complete',
61
+ colorClass: 'text-green-600 dark:text-green-400',
62
+ bgClass: 'bg-green-50 dark:bg-green-900/20',
63
+ borderClass: 'border-green-200 dark:border-green-800'
64
+ },
65
+ 'archived': {
66
+ icon: Archive,
67
+ title: 'Archived',
68
+ colorClass: 'text-gray-600 dark:text-gray-400',
69
+ bgClass: 'bg-gray-50 dark:bg-gray-900/20',
70
+ borderClass: 'border-gray-200 dark:border-gray-800'
71
+ }
72
+ };
73
+
74
+ const BOARD_STATUSES: SpecStatus[] = ['planned', 'in-progress', 'complete', 'archived'];
75
+
76
+ interface SpecRelationships {
77
+ dependsOn: string[];
78
+ related: string[];
79
+ }
28
80
 
29
81
  interface Spec {
30
82
  id: string;
31
83
  specNumber: number | null;
32
84
  specName: string;
33
85
  title: string | null;
34
- status: string | null;
86
+ status: SpecStatus | null;
35
87
  priority: string | null;
36
88
  tags: string[] | null;
37
89
  updatedAt: Date | null;
90
+ subSpecsCount?: number;
91
+ relationships?: SpecRelationships;
38
92
  }
39
93
 
40
94
  interface Stats {
@@ -55,10 +109,13 @@ export function SpecsClient({ initialSpecs }: SpecsClientProps) {
55
109
  const searchParams = useSearchParams();
56
110
  const router = useRouter();
57
111
 
112
+ const [specs, setSpecs] = useState<Spec[]>(initialSpecs);
113
+ const [pendingSpecIds, setPendingSpecIds] = useState<Record<string, boolean>>({});
58
114
  const [searchQuery, setSearchQuery] = useState('');
59
- const [statusFilter, setStatusFilter] = useState<string>('all');
115
+ const [statusFilter, setStatusFilter] = useState<'all' | SpecStatus>('all');
60
116
  const [priorityFilter, setPriorityFilter] = useState<string>('all');
61
117
  const [sortBy, setSortBy] = useState<SortBy>('id-desc');
118
+ const [showArchivedBoard, setShowArchivedBoard] = useState(false); // Start collapsed
62
119
  const [viewMode, setViewMode] = useState<ViewMode>(() => {
63
120
  // Initialize from URL or localStorage
64
121
  const urlView = searchParams.get('view');
@@ -73,6 +130,55 @@ export function SpecsClient({ initialSpecs }: SpecsClientProps) {
73
130
 
74
131
  const isFirstRender = useRef(true);
75
132
 
133
+ useEffect(() => {
134
+ setSpecs(initialSpecs);
135
+ }, [initialSpecs]);
136
+
137
+ const handleStatusChange = useCallback(async (spec: Spec, nextStatus: SpecStatus) => {
138
+ if (spec.status === nextStatus) {
139
+ return;
140
+ }
141
+
142
+ const previousStatus = spec.status;
143
+ setPendingSpecIds((prev) => ({ ...prev, [spec.id]: true }));
144
+ setSpecs((prev) => prev.map(item => item.id === spec.id ? { ...item, status: nextStatus } : item));
145
+
146
+ try {
147
+ const response = await fetch(`/api/specs/${encodeURIComponent(spec.specName)}/status`, {
148
+ method: 'PATCH',
149
+ headers: {
150
+ 'Content-Type': 'application/json',
151
+ },
152
+ body: JSON.stringify({ status: nextStatus }),
153
+ });
154
+
155
+ if (!response.ok) {
156
+ const message = await response.text();
157
+ throw new Error(message || 'Failed to update spec status');
158
+ }
159
+
160
+ const displayName = spec.specNumber ? `#${spec.specNumber}` : spec.specName;
161
+ toast.success(`Moved ${displayName} to ${STATUS_CONFIG[nextStatus].title}`);
162
+ } catch (error) {
163
+ console.error('Failed to update spec status', error);
164
+ setSpecs((prev) => prev.map(item => item.id === spec.id ? { ...item, status: previousStatus } : item));
165
+ toast.error('Unable to update status. Please try again.');
166
+ } finally {
167
+ setPendingSpecIds((prev) => {
168
+ const next = { ...prev };
169
+ delete next[spec.id];
170
+ return next;
171
+ });
172
+ }
173
+ }, []);
174
+
175
+ // Auto-show archived column when filtering by archived status in board view
176
+ useEffect(() => {
177
+ if (statusFilter === 'archived' && viewMode === 'board') {
178
+ setShowArchivedBoard(true);
179
+ }
180
+ }, [statusFilter, viewMode]);
181
+
76
182
  // Update URL when view mode changes (skip on initial mount)
77
183
  useEffect(() => {
78
184
  if (isFirstRender.current) {
@@ -97,35 +203,39 @@ export function SpecsClient({ initialSpecs }: SpecsClientProps) {
97
203
  }, [viewMode, router]);
98
204
 
99
205
  const filteredAndSortedSpecs = useMemo(() => {
100
- const specs = initialSpecs.filter(spec => {
206
+ const filtered = specs.filter(spec => {
101
207
  const matchesSearch = !searchQuery ||
102
208
  spec.title?.toLowerCase().includes(searchQuery.toLowerCase()) ||
103
209
  spec.specName.toLowerCase().includes(searchQuery.toLowerCase()) ||
104
210
  spec.tags?.some(tag => tag.toLowerCase().includes(searchQuery.toLowerCase()));
105
211
 
106
- const matchesStatus = statusFilter === 'all' || spec.status === statusFilter;
212
+ const matchesStatus = statusFilter === 'all'
213
+ ? (viewMode === 'list' ? spec.status !== 'archived' : true)
214
+ : spec.status === statusFilter;
107
215
  const matchesPriority = priorityFilter === 'all' || spec.priority === priorityFilter;
108
216
 
109
217
  return matchesSearch && matchesStatus && matchesPriority;
110
218
  });
111
219
 
220
+ const sorted = [...filtered];
221
+
112
222
  // Sort
113
223
  switch (sortBy) {
114
224
  case 'id-desc':
115
- specs.sort((a, b) => (b.specNumber || 0) - (a.specNumber || 0));
225
+ sorted.sort((a, b) => (b.specNumber || 0) - (a.specNumber || 0));
116
226
  break;
117
227
  case 'id-asc':
118
- specs.sort((a, b) => (a.specNumber || 0) - (b.specNumber || 0));
228
+ sorted.sort((a, b) => (a.specNumber || 0) - (b.specNumber || 0));
119
229
  break;
120
230
  case 'updated-desc':
121
- specs.sort((a, b) => {
231
+ sorted.sort((a, b) => {
122
232
  if (!a.updatedAt) return 1;
123
233
  if (!b.updatedAt) return -1;
124
234
  return b.updatedAt.getTime() - a.updatedAt.getTime();
125
235
  });
126
236
  break;
127
237
  case 'title-asc':
128
- specs.sort((a, b) => {
238
+ sorted.sort((a, b) => {
129
239
  const titleA = (a.title || a.specName).toLowerCase();
130
240
  const titleB = (b.title || b.specName).toLowerCase();
131
241
  return titleA.localeCompare(titleB);
@@ -133,8 +243,8 @@ export function SpecsClient({ initialSpecs }: SpecsClientProps) {
133
243
  break;
134
244
  }
135
245
 
136
- return specs;
137
- }, [initialSpecs, searchQuery, statusFilter, priorityFilter, sortBy]);
246
+ return sorted;
247
+ }, [specs, searchQuery, statusFilter, priorityFilter, sortBy, viewMode]);
138
248
 
139
249
  return (
140
250
  <div className="min-h-screen bg-background p-8">
@@ -161,7 +271,7 @@ export function SpecsClient({ initialSpecs }: SpecsClientProps) {
161
271
  </div>
162
272
 
163
273
  {/* Status Filter */}
164
- <Select value={statusFilter} onValueChange={setStatusFilter}>
274
+ <Select value={statusFilter} onValueChange={(value) => setStatusFilter(value as SpecStatus | 'all')}>
165
275
  <SelectTrigger className="w-full sm:w-[180px]">
166
276
  <SelectValue placeholder="Status" />
167
277
  </SelectTrigger>
@@ -189,54 +299,62 @@ export function SpecsClient({ initialSpecs }: SpecsClientProps) {
189
299
  </Select>
190
300
  </div>
191
301
 
192
- <div className="flex flex-col sm:flex-row justify-between gap-4">
193
- {/* Sort Controls */}
194
- <Select value={sortBy} onValueChange={(v) => setSortBy(v as SortBy)}>
195
- <SelectTrigger className="w-full sm:w-[220px]">
196
- <SelectValue placeholder="Sort by" />
197
- </SelectTrigger>
198
- <SelectContent>
199
- <SelectItem value="id-desc">Newest First (ID ↓)</SelectItem>
200
- <SelectItem value="id-asc">Oldest First (ID )</SelectItem>
201
- <SelectItem value="updated-desc">Recently Updated</SelectItem>
202
- <SelectItem value="title-asc">Title (A-Z)</SelectItem>
203
- </SelectContent>
204
- </Select>
302
+ <div className="flex flex-col sm:flex-row items-start sm:items-center justify-between gap-4">
303
+ <div className="flex flex-col sm:flex-row items-start sm:items-center gap-4 flex-1">
304
+ {/* Sort Controls */}
305
+ <Select value={sortBy} onValueChange={(v) => setSortBy(v as SortBy)}>
306
+ <SelectTrigger className="w-full sm:w-[220px]">
307
+ <SelectValue placeholder="Sort by" />
308
+ </SelectTrigger>
309
+ <SelectContent>
310
+ <SelectItem value="id-desc">Newest First (ID )</SelectItem>
311
+ <SelectItem value="id-asc">Oldest First (ID ↑)</SelectItem>
312
+ <SelectItem value="updated-desc">Recently Updated</SelectItem>
313
+ <SelectItem value="title-asc">Title (A-Z)</SelectItem>
314
+ </SelectContent>
315
+ </Select>
205
316
 
206
- {/* View Mode Switcher */}
207
- <div className="flex gap-2">
208
- <Button
209
- variant={viewMode === 'list' ? 'default' : 'outline'}
210
- size="sm"
211
- onClick={() => setViewMode('list')}
212
- className="flex items-center gap-2"
213
- >
214
- <ListIcon className="h-4 w-4" />
215
- List
216
- </Button>
217
- <Button
218
- variant={viewMode === 'board' ? 'default' : 'outline'}
219
- size="sm"
220
- onClick={() => setViewMode('board')}
221
- className="flex items-center gap-2"
222
- >
223
- <LayoutGrid className="h-4 w-4" />
224
- Board
225
- </Button>
317
+ {/* View Mode Switcher */}
318
+ <div className="flex gap-2">
319
+ <Button
320
+ variant={viewMode === 'list' ? 'default' : 'outline'}
321
+ size="sm"
322
+ onClick={() => setViewMode('list')}
323
+ className="flex items-center gap-2"
324
+ >
325
+ <ListIcon className="h-4 w-4" />
326
+ List
327
+ </Button>
328
+ <Button
329
+ variant={viewMode === 'board' ? 'default' : 'outline'}
330
+ size="sm"
331
+ onClick={() => setViewMode('board')}
332
+ className="flex items-center gap-2"
333
+ >
334
+ <LayoutGrid className="h-4 w-4" />
335
+ Board
336
+ </Button>
337
+ </div>
338
+
339
+ {/* Results count */}
340
+ <div className="text-sm text-muted-foreground">
341
+ Showing {filteredAndSortedSpecs.length} of {specs.length} specs
342
+ </div>
226
343
  </div>
227
344
  </div>
228
345
  </div>
229
346
 
230
- {/* Results count */}
231
- <div className="text-sm text-muted-foreground mb-4">
232
- Showing {filteredAndSortedSpecs.length} of {initialSpecs.length} specs
233
- </div>
234
-
235
347
  {/* Content based on view mode */}
236
348
  {viewMode === 'list' ? (
237
349
  <ListView specs={filteredAndSortedSpecs} />
238
350
  ) : (
239
- <BoardView specs={filteredAndSortedSpecs} />
351
+ <BoardView
352
+ specs={filteredAndSortedSpecs}
353
+ onStatusChange={handleStatusChange}
354
+ pendingSpecIds={pendingSpecIds}
355
+ showArchived={showArchivedBoard}
356
+ onToggleArchived={() => setShowArchivedBoard(!showArchivedBoard)}
357
+ />
240
358
  )}
241
359
  </div>
242
360
  </div>
@@ -254,6 +372,8 @@ function ListView({ specs }: { specs: Spec[] }) {
254
372
  'low': 'border-l-gray-400'
255
373
  };
256
374
  const borderColor = priorityColors[spec.priority as keyof typeof priorityColors] || 'border-l-gray-300';
375
+ const hasDependencies = spec.relationships && (spec.relationships.dependsOn.length > 0 || spec.relationships.related.length > 0);
376
+ const hasSubSpecs = !!(spec.subSpecsCount && spec.subSpecsCount > 0);
257
377
 
258
378
  return (
259
379
  <Card
@@ -274,6 +394,9 @@ function ListView({ specs }: { specs: Spec[] }) {
274
394
  {spec.title || spec.specName}
275
395
  </CardTitle>
276
396
  </Link>
397
+ {spec.title && spec.title !== spec.specName && (
398
+ <p className="text-sm text-muted-foreground mt-1">{spec.specName}</p>
399
+ )}
277
400
  </div>
278
401
  <div className="flex gap-2 shrink-0">
279
402
  {spec.status && <StatusBadge status={spec.status} />}
@@ -281,15 +404,47 @@ function ListView({ specs }: { specs: Spec[] }) {
281
404
  </div>
282
405
  </div>
283
406
  </CardHeader>
284
- {spec.tags && spec.tags.length > 0 && (
285
- <CardContent>
286
- <div className="flex flex-wrap gap-2">
287
- {spec.tags.map(tag => (
288
- <Badge key={tag} variant="secondary">
289
- {tag}
290
- </Badge>
291
- ))}
292
- </div>
407
+ {/* Only render CardContent if there's metadata or tags to show */}
408
+ {((spec.updatedAt || hasSubSpecs || hasDependencies || (spec.tags && spec.tags.length > 0))) && (
409
+ <CardContent className="space-y-3">
410
+ {/* Metadata row */}
411
+ {(spec.updatedAt || hasSubSpecs || hasDependencies) && (
412
+ <div className="flex items-center gap-4 text-sm text-muted-foreground flex-wrap">
413
+ {spec.updatedAt && (
414
+ <div className="flex items-center gap-1.5">
415
+ <Clock className="h-3.5 w-3.5" />
416
+ <span>Updated {formatRelativeTime(spec.updatedAt)}</span>
417
+ </div>
418
+ )}
419
+ {hasSubSpecs && (
420
+ <div className="flex items-center gap-1.5">
421
+ <FileText className="h-3.5 w-3.5" />
422
+ <span>+{spec.subSpecsCount} files</span>
423
+ </div>
424
+ )}
425
+ {hasDependencies && (
426
+ <div className="flex items-center gap-1.5">
427
+ <GitBranch className="h-3.5 w-3.5" />
428
+ <span>
429
+ {spec.relationships!.dependsOn.length > 0 && `${spec.relationships!.dependsOn.length} deps`}
430
+ {spec.relationships!.dependsOn.length > 0 && spec.relationships!.related.length > 0 && ', '}
431
+ {spec.relationships!.related.length > 0 && `${spec.relationships!.related.length} related`}
432
+ </span>
433
+ </div>
434
+ )}
435
+ </div>
436
+ )}
437
+
438
+ {/* Tags */}
439
+ {spec.tags && spec.tags.length > 0 && (
440
+ <div className="flex flex-wrap gap-2">
441
+ {spec.tags.map(tag => (
442
+ <Badge key={tag} variant="secondary" className="text-xs">
443
+ {tag}
444
+ </Badge>
445
+ ))}
446
+ </div>
447
+ )}
293
448
  </CardContent>
294
449
  )}
295
450
  </Card>
@@ -299,63 +454,128 @@ function ListView({ specs }: { specs: Spec[] }) {
299
454
  );
300
455
  }
301
456
 
302
- function BoardView({ specs }: { specs: Spec[] }) {
457
+ interface BoardViewProps {
458
+ specs: Spec[];
459
+ onStatusChange: (spec: Spec, status: SpecStatus) => void;
460
+ pendingSpecIds: Record<string, boolean>;
461
+ showArchived: boolean;
462
+ onToggleArchived: () => void;
463
+ }
464
+
465
+ function BoardView({ specs, onStatusChange, pendingSpecIds, showArchived, onToggleArchived }: BoardViewProps) {
466
+ const [draggingId, setDraggingId] = useState<string | null>(null);
467
+ const [activeDropZone, setActiveDropZone] = useState<SpecStatus | null>(null);
468
+
303
469
  const columns = useMemo(() => {
304
- const statusConfig = {
305
- 'planned': {
306
- icon: Clock,
307
- title: 'Planned',
308
- colorClass: 'text-blue-600 dark:text-blue-400',
309
- bgClass: 'bg-blue-50 dark:bg-blue-900/20',
310
- borderClass: 'border-blue-200 dark:border-blue-800'
311
- },
312
- 'in-progress': {
313
- icon: PlayCircle,
314
- title: 'In Progress',
315
- colorClass: 'text-orange-600 dark:text-orange-400',
316
- bgClass: 'bg-orange-50 dark:bg-orange-900/20',
317
- borderClass: 'border-orange-200 dark:border-orange-800'
318
- },
319
- 'complete': {
320
- icon: CheckCircle2,
321
- title: 'Complete',
322
- colorClass: 'text-green-600 dark:text-green-400',
323
- bgClass: 'bg-green-50 dark:bg-green-900/20',
324
- borderClass: 'border-green-200 dark:border-green-800'
325
- }
326
- } as const;
327
-
328
- const statuses = ['planned', 'in-progress', 'complete'] as const;
329
-
330
- return statuses.map(status => ({
470
+ // Always show all columns, including archived (it will be rendered as collapsed bar when showArchived=false)
471
+ return BOARD_STATUSES.map(status => ({
331
472
  status,
332
- config: statusConfig[status],
473
+ config: STATUS_CONFIG[status],
333
474
  specs: specs.filter(spec => spec.status === status),
334
475
  }));
335
476
  }, [specs]);
336
477
 
478
+ const specLookup = useMemo(() => {
479
+ const map = new Map<string, Spec>();
480
+ specs.forEach(spec => map.set(spec.id, spec));
481
+ return map;
482
+ }, [specs]);
483
+
484
+ const handleDragStart = useCallback((specId: string, event: DragEvent<HTMLDivElement>) => {
485
+ event.dataTransfer.setData('text/plain', specId);
486
+ event.dataTransfer.effectAllowed = 'move';
487
+ setDraggingId(specId);
488
+ }, []);
489
+
490
+ const handleDragEnd = useCallback(() => {
491
+ setDraggingId(null);
492
+ setActiveDropZone(null);
493
+ }, []);
494
+
495
+ const handleDragOver = useCallback((status: SpecStatus, event: DragEvent<HTMLDivElement>) => {
496
+ if (!draggingId) return;
497
+ event.preventDefault();
498
+ event.dataTransfer.dropEffect = 'move';
499
+ setActiveDropZone(status);
500
+ }, [draggingId]);
501
+
502
+ const handleDragLeave = useCallback((status: SpecStatus, event: DragEvent<HTMLDivElement>) => {
503
+ if (!draggingId) return;
504
+ const related = event.relatedTarget as Node | null;
505
+ if (!related || !event.currentTarget.contains(related)) {
506
+ setActiveDropZone((current) => (current === status ? null : current));
507
+ }
508
+ }, [draggingId]);
509
+
510
+ const handleDrop = useCallback((status: SpecStatus, event: DragEvent<HTMLDivElement>) => {
511
+ event.preventDefault();
512
+ const draggedId = event.dataTransfer.getData('text/plain') || draggingId;
513
+ if (!draggedId) {
514
+ handleDragEnd();
515
+ return;
516
+ }
517
+
518
+ const spec = specLookup.get(draggedId);
519
+ if (spec && spec.status !== status) {
520
+ onStatusChange(spec, status);
521
+ }
522
+
523
+ handleDragEnd();
524
+ }, [draggingId, handleDragEnd, onStatusChange, specLookup]);
525
+
337
526
  return (
338
- <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
527
+ <div className="flex gap-6">
339
528
  {columns.map(column => {
340
529
  const Icon = column.config.icon;
530
+ const isArchivedColumn = column.status === 'archived';
531
+
341
532
  return (
342
- <div key={column.status} className="flex flex-col">
533
+ <div key={column.status} className={cn(
534
+ "flex flex-col",
535
+ isArchivedColumn && !showArchived && "w-20 flex-shrink-0"
536
+ )}>
343
537
  <div className={cn(
344
- "sticky top-14 z-40 mb-4 p-3 rounded-lg border-2 bg-background",
538
+ 'sticky top-14 z-40 mb-4 rounded-lg border-2 bg-background transition-all',
345
539
  column.config.bgClass,
346
- column.config.borderClass
347
- )}>
540
+ column.config.borderClass,
541
+ isArchivedColumn ? 'cursor-pointer hover:opacity-80' : '',
542
+ isArchivedColumn && !showArchived ? 'py-6 px-2' : 'p-3'
543
+ )}
544
+ onClick={isArchivedColumn ? onToggleArchived : undefined}
545
+ >
348
546
  <h2 className={cn(
349
- "text-lg font-semibold flex items-center gap-2",
350
- column.config.colorClass
547
+ 'text-lg font-semibold flex items-center gap-2',
548
+ column.config.colorClass,
549
+ isArchivedColumn && !showArchived && 'flex-col text-sm gap-3'
351
550
  )}>
352
551
  <Icon className="h-5 w-5" />
353
- {column.config.title}
354
- <Badge variant="outline" className="ml-auto">{column.specs.length}</Badge>
552
+ {isArchivedColumn && !showArchived ? (
553
+ <>
554
+ <span className="vertical-text text-sm whitespace-nowrap">
555
+ {column.config.title}
556
+ </span>
557
+ <Badge variant="outline" className="text-xs">{column.specs.length}</Badge>
558
+ </>
559
+ ) : (
560
+ <>
561
+ {column.config.title}
562
+ <Badge variant="outline" className="ml-auto">{column.specs.length}</Badge>
563
+ </>
564
+ )}
355
565
  </h2>
356
566
  </div>
357
567
 
358
- <div className="space-y-3 flex-1">
568
+ {(!isArchivedColumn || showArchived) && (
569
+ <div
570
+ className={cn(
571
+ 'space-y-3 flex-1 rounded-xl border border-transparent p-1 transition-colors overflow-y-auto max-h-[calc(100vh-250px)]',
572
+ draggingId && 'border-dashed border-muted-foreground/40',
573
+ draggingId && activeDropZone === column.status && 'bg-muted/40 border-primary/50'
574
+ )}
575
+ onDragOver={(event) => handleDragOver(column.status, event)}
576
+ onDragLeave={(event) => handleDragLeave(column.status, event)}
577
+ onDrop={(event) => handleDrop(column.status, event)}
578
+ >
359
579
  {column.specs.map(spec => {
360
580
  const priorityColors = {
361
581
  'critical': 'border-l-red-500',
@@ -364,16 +584,33 @@ function BoardView({ specs }: { specs: Spec[] }) {
364
584
  'low': 'border-l-gray-400'
365
585
  };
366
586
  const borderColor = priorityColors[spec.priority as keyof typeof priorityColors] || 'border-l-gray-300';
587
+ const isUpdating = Boolean(pendingSpecIds[spec.id]);
367
588
 
368
589
  return (
369
- <Card
370
- key={spec.id}
590
+ <Card
591
+ key={spec.id}
592
+ draggable={!isUpdating}
593
+ onDragStart={(event) => {
594
+ if (isUpdating) {
595
+ event.preventDefault();
596
+ return;
597
+ }
598
+ handleDragStart(spec.id, event);
599
+ }}
600
+ onDragEnd={handleDragEnd}
601
+ aria-disabled={isUpdating}
371
602
  className={cn(
372
- "hover:shadow-lg transition-all duration-150 hover:scale-[1.02] border-l-4 cursor-pointer",
373
- borderColor
603
+ 'relative hover:shadow-lg transition-all duration-150 hover:scale-[1.02] border-l-4 cursor-pointer',
604
+ borderColor,
605
+ isUpdating && 'opacity-60 cursor-wait'
374
606
  )}
375
607
  onClick={() => window.location.href = `/specs/${spec.specNumber || spec.id}`}
376
608
  >
609
+ {isUpdating && (
610
+ <div className="absolute inset-0 rounded-lg bg-background/80 flex items-center justify-center text-xs font-medium">
611
+ Updating...
612
+ </div>
613
+ )}
377
614
  <CardHeader className="pb-3">
378
615
  <Link href={`/specs/${spec.specNumber || spec.id}`}>
379
616
  <CardTitle className="text-sm font-medium hover:text-primary transition-colors">
@@ -408,7 +645,17 @@ function BoardView({ specs }: { specs: Spec[] }) {
408
645
  </Card>
409
646
  );
410
647
  })}
648
+
649
+ {column.specs.length === 0 && (
650
+ <Card className="border-dashed border-gray-300 dark:border-gray-700 bg-transparent">
651
+ <CardContent className="py-8 text-center">
652
+ <Icon className={cn('mx-auto h-8 w-8 mb-2', column.config.colorClass, 'opacity-50')} />
653
+ <p className="text-sm text-muted-foreground">Drop here to move specs</p>
654
+ </CardContent>
655
+ </Card>
656
+ )}
411
657
  </div>
658
+ )}
412
659
  </div>
413
660
  );
414
661
  })}
@@ -136,6 +136,29 @@ export async function getSpecsWithSubSpecCount(projectId?: string): Promise<(Par
136
136
  });
137
137
  }
138
138
 
139
+ /**
140
+ * Get all specs with sub-spec count and relationships (for comprehensive list view)
141
+ */
142
+ export async function getSpecsWithMetadata(projectId?: string): Promise<(ParsedSpec & { subSpecsCount: number; relationships: SpecRelationships })[]> {
143
+ const specs = await specsService.getAllSpecs(projectId);
144
+
145
+ // Only count sub-specs and relationships for filesystem mode
146
+ if (projectId) {
147
+ return specs.map(spec => ({
148
+ ...parseSpecTags(spec),
149
+ subSpecsCount: 0,
150
+ relationships: { dependsOn: [], related: [] }
151
+ }));
152
+ }
153
+
154
+ return specs.map(spec => {
155
+ const specDirPath = buildSpecDirPath(spec.filePath);
156
+ const subSpecsCount = countSubSpecs(specDirPath);
157
+ const relationships = getFilesystemRelationships(specDirPath);
158
+ return { ...parseSpecTags(spec), subSpecsCount, relationships };
159
+ });
160
+ }
161
+
139
162
  /**
140
163
  * Get a spec by ID (number or UUID)
141
164
  */