Skip to content

Commit 954c700

Browse files
committed
feat: implement sub-spec detection and enhance error handling in SpecDetailPage; update API to support sub-specs in responses
1 parent a736ca0 commit 954c700

File tree

7 files changed

+327
-28
lines changed

7 files changed

+327
-28
lines changed

packages/ui-vite/src/components/spec-detail/SubSpecTabs.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -98,7 +98,7 @@ export function SubSpecTabs({ mainContent, subSpecs = [] }: SubSpecTabsProps) {
9898
key={value}
9999
onClick={() => setActiveTab(value)}
100100
className={cn(
101-
'flex items-center gap-2 px-4 py-2 text-sm border-b-2 -mb-px transition-colors',
101+
'flex items-center gap-2 px-4 py-2 text-sm border-b-2 -mb-px transition-colors rounded-sm focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary focus-visible:ring-offset-2',
102102
activeTab === value
103103
? 'border-primary text-foreground'
104104
: 'border-transparent text-muted-foreground hover:text-foreground'
@@ -128,7 +128,7 @@ export function SubSpecTabs({ mainContent, subSpecs = [] }: SubSpecTabsProps) {
128128
key={subSpec.file}
129129
variant="ghost"
130130
onClick={() => setActiveTab(subSpec.name.toLowerCase())}
131-
className="justify-start gap-2 h-auto px-2 py-2"
131+
className="justify-start gap-2 h-auto px-2 py-2 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary focus-visible:ring-offset-2"
132132
>
133133
{Icon && <Icon className={cn('h-4 w-4', subSpec.color)} />}
134134
<span className="text-sm font-medium">{subSpec.name}</span>

packages/ui-vite/src/lib/api.ts

Lines changed: 62 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ import type {
1919
RustSpec,
2020
RustSpecDetail,
2121
RustStats,
22+
SubSpecItem,
2223
} from '../types/api';
2324

2425
const API_BASE = import.meta.env.VITE_API_URL || 'http://localhost:3333';
@@ -28,7 +29,7 @@ export type SpecDetail = NextJsSpecDetail;
2829
export type Stats = NextJsStats;
2930
export type Project = ProjectType;
3031

31-
class APIError extends Error {
32+
export class APIError extends Error {
3233
status: number;
3334

3435
constructor(status: number, message: string) {
@@ -48,8 +49,23 @@ async function fetchAPI<T>(endpoint: string, options?: RequestInit): Promise<T>
4849
});
4950

5051
if (!response.ok) {
51-
const error = await response.text();
52-
throw new APIError(response.status, error || response.statusText);
52+
const raw = await response.text();
53+
let message = raw || response.statusText;
54+
55+
try {
56+
const parsed = JSON.parse(raw);
57+
if (typeof parsed.message === 'string') {
58+
message = parsed.message;
59+
} else if (typeof parsed.error === 'string') {
60+
message = parsed.error;
61+
} else if (typeof parsed.detail === 'string') {
62+
message = parsed.detail;
63+
}
64+
} catch {
65+
// Fall back to raw message
66+
}
67+
68+
throw new APIError(response.status, message || response.statusText);
5369
}
5470

5571
if (response.status === 204) {
@@ -68,8 +84,9 @@ async function fetchAPI<T>(endpoint: string, options?: RequestInit): Promise<T>
6884
}
6985
}
7086

71-
function toDateOrNull(value?: string): Date | null {
87+
function toDateOrNull(value?: string | Date | null): Date | null {
7288
if (!value) return null;
89+
if (value instanceof Date) return Number.isNaN(value.getTime()) ? null : value;
7390
const date = new Date(value);
7491
return Number.isNaN(date.getTime()) ? null : date;
7592
}
@@ -91,6 +108,10 @@ export function calculateCompletionRate(byStatus: Record<string, number>): numbe
91108
}
92109

93110
export function adaptSpec(rustSpec: RustSpec): NextJsSpec {
111+
const created = rustSpec.created_at ?? rustSpec.createdAt ?? rustSpec.created;
112+
const updated = rustSpec.updated_at ?? rustSpec.updatedAt ?? rustSpec.updated;
113+
const completed = rustSpec.completed_at ?? rustSpec.completedAt;
114+
94115
return {
95116
id: rustSpec.name,
96117
name: rustSpec.name,
@@ -100,18 +121,49 @@ export function adaptSpec(rustSpec: RustSpec): NextJsSpec {
100121
status: rustSpec.status ?? null,
101122
priority: rustSpec.priority ?? null,
102123
tags: rustSpec.tags ?? null,
103-
createdAt: toDateOrNull(rustSpec.created),
104-
updatedAt: toDateOrNull(rustSpec.updated),
124+
assignee: rustSpec.assignee ?? null,
125+
createdAt: toDateOrNull(created),
126+
updatedAt: toDateOrNull(updated),
127+
completedAt: toDateOrNull(completed),
128+
filePath: rustSpec.file_path ?? rustSpec.filePath,
129+
relationships: rustSpec.relationships,
105130
};
106131
}
107132

108133
export function adaptSpecDetail(rustSpec: RustSpecDetail): NextJsSpecDetail {
134+
const content = rustSpec.contentMd ?? rustSpec.content_md ?? rustSpec.content ?? '';
135+
const dependsOn = rustSpec.depends_on ?? rustSpec.dependsOn ?? [];
136+
const requiredBy = rustSpec.required_by ?? rustSpec.requiredBy ?? [];
137+
const metadata = rustSpec.metadata ?? {};
138+
139+
const rawSubSpecs = rustSpec.sub_specs ?? rustSpec.subSpecs ?? (metadata.sub_specs as unknown);
140+
const subSpecs: SubSpecItem[] = Array.isArray(rawSubSpecs)
141+
? rawSubSpecs.flatMap((entry) => {
142+
if (!entry || typeof entry !== 'object') return [];
143+
const candidate = entry as Partial<SubSpecItem> & Record<string, unknown>;
144+
const name = typeof candidate.name === 'string' ? candidate.name : undefined;
145+
const subContent = typeof candidate.content === 'string' ? candidate.content : undefined;
146+
if (!name || !subContent) return [];
147+
148+
const file = typeof candidate.file === 'string' ? candidate.file : name;
149+
const iconName = typeof candidate.iconName === 'string'
150+
? candidate.iconName
151+
: typeof candidate.icon_name === 'string'
152+
? (candidate.icon_name as string)
153+
: undefined;
154+
const color = typeof candidate.color === 'string' ? candidate.color : undefined;
155+
156+
return [{ name, file, iconName, color, content: subContent } satisfies SubSpecItem];
157+
})
158+
: [];
159+
109160
return {
110161
...adaptSpec(rustSpec),
111-
content: rustSpec.content ?? '',
112-
metadata: rustSpec.metadata ?? {},
113-
dependsOn: rustSpec.depends_on ?? [],
114-
requiredBy: rustSpec.required_by ?? [],
162+
content,
163+
metadata,
164+
dependsOn,
165+
requiredBy,
166+
subSpecs: subSpecs.length > 0 ? subSpecs : undefined,
115167
};
116168
}
117169

packages/ui-vite/src/pages/SpecDetailPage.tsx

Lines changed: 40 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ import { useCallback, useEffect, useMemo, useState } from 'react';
22
import { Link, useParams } from 'react-router-dom';
33
import { AlertTriangle, ArrowLeft, RefreshCcw } from 'lucide-react';
44
import { Button, Card, CardContent } from '@leanspec/ui-components';
5-
import { api, type SpecDetail } from '../lib/api';
5+
import { APIError, api, type SpecDetail } from '../lib/api';
66
import { StatusBadge } from '../components/StatusBadge';
77
import { PriorityBadge } from '../components/PriorityBadge';
88
import { SubSpecTabs, type SubSpec } from '../components/spec-detail/SubSpecTabs';
@@ -17,6 +17,27 @@ export function SpecDetailPage() {
1717
const [loading, setLoading] = useState(true);
1818
const [error, setError] = useState<string | null>(null);
1919

20+
const describeError = useCallback((err: unknown) => {
21+
if (err instanceof APIError) {
22+
switch (err.status) {
23+
case 404:
24+
return 'Spec not found (404). Check the spec ID or sync the project.';
25+
case 400:
26+
return 'Bad request when loading spec. Verify the spec path and try again.';
27+
case 500:
28+
return 'Server error while loading spec. Restart the Rust server or check logs.';
29+
default:
30+
return `Failed to load spec (${err.status}). ${err.message || 'Please retry.'}`;
31+
}
32+
}
33+
34+
if (err instanceof Error && err.message.includes('Failed to fetch')) {
35+
return 'Network error: unable to reach the backend. Ensure VITE_API_URL is reachable and the server is running.';
36+
}
37+
38+
return err instanceof Error ? err.message : 'Unexpected error while loading spec.';
39+
}, []);
40+
2041
const loadSpec = useCallback(async () => {
2142
if (!specName) return;
2243
setLoading(true);
@@ -25,19 +46,18 @@ export function SpecDetailPage() {
2546
setSpec(data);
2647
setError(null);
2748
} catch (err) {
28-
const message = err instanceof Error ? err.message : 'Failed to load spec';
29-
setError(message);
49+
setError(describeError(err));
3050
} finally {
3151
setLoading(false);
3252
}
33-
}, [specName]);
53+
}, [describeError, specName]);
3454

3555
useEffect(() => {
3656
void loadSpec();
3757
}, [loadSpec]);
3858

3959
const subSpecs: SubSpec[] = useMemo(() => {
40-
const raw = spec?.metadata?.sub_specs as unknown;
60+
const raw = (spec?.subSpecs as unknown) ?? (spec?.metadata?.sub_specs as unknown);
4161
if (!Array.isArray(raw)) return [];
4262
return raw
4363
.map((entry) => {
@@ -49,7 +69,11 @@ export function SpecDetailPage() {
4969
name,
5070
content,
5171
file: typeof (entry as Record<string, unknown>).file === 'string' ? (entry as Record<string, unknown>).file as string : name,
52-
iconName: typeof (entry as Record<string, unknown>).iconName === 'string' ? (entry as Record<string, unknown>).iconName as string : undefined,
72+
iconName: typeof (entry as Record<string, unknown>).iconName === 'string'
73+
? (entry as Record<string, unknown>).iconName as string
74+
: typeof (entry as Record<string, unknown>).icon_name === 'string'
75+
? (entry as Record<string, unknown>).icon_name as string
76+
: undefined,
5377
color: typeof (entry as Record<string, unknown>).color === 'string' ? (entry as Record<string, unknown>).color as string : undefined,
5478
} satisfies SubSpec;
5579
})
@@ -82,6 +106,16 @@ export function SpecDetailPage() {
82106
<RefreshCcw className="h-4 w-4" />
83107
Retry
84108
</Button>
109+
<a
110+
href="https://github.com/codervisor/lean-spec/issues"
111+
target="_blank"
112+
rel="noreferrer"
113+
className="inline-flex"
114+
>
115+
<Button variant="ghost" size="sm" className="gap-2">
116+
Report issue
117+
</Button>
118+
</a>
85119
</>
86120
)}
87121
/>

packages/ui-vite/src/types/api.ts

Lines changed: 37 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,27 +1,55 @@
11
export type SpecStatus = 'planned' | 'in-progress' | 'complete' | 'archived';
22
export type SpecPriority = 'low' | 'medium' | 'high' | 'critical';
33

4+
export interface SubSpecItem {
5+
name: string;
6+
file: string;
7+
iconName?: string;
8+
color?: string;
9+
content: string;
10+
}
11+
412
export interface RustSpec {
513
name: string;
614
title: string;
715
status: SpecStatus;
816
priority?: SpecPriority;
917
tags?: string[];
18+
assignee?: string | null;
1019
created?: string;
20+
created_at?: string;
21+
createdAt?: string;
1122
updated?: string;
23+
updated_at?: string;
24+
updatedAt?: string;
25+
completed_at?: string;
26+
completedAt?: string;
1227
depends_on?: string[];
28+
dependsOn?: string[];
1329
required_by?: string[];
30+
requiredBy?: string[];
31+
file_path?: string;
32+
filePath?: string;
33+
relationships?: {
34+
depends_on: string[];
35+
required_by?: string[];
36+
};
1437
}
1538

1639
export interface RustSpecDetail extends RustSpec {
17-
content: string;
40+
content?: string;
41+
content_md?: string;
42+
contentMd?: string;
1843
metadata?: {
1944
created_at?: string;
2045
updated_at?: string;
2146
assignee?: string;
2247
github_url?: string;
48+
sub_specs?: SubSpecItem[];
2349
[key: string]: unknown;
2450
};
51+
sub_specs?: SubSpecItem[];
52+
subSpecs?: SubSpecItem[];
2553
}
2654

2755
export interface RustStats {
@@ -40,8 +68,15 @@ export interface NextJsSpec {
4068
status: SpecStatus | null;
4169
priority: SpecPriority | null;
4270
tags: string[] | null;
71+
assignee?: string | null;
4372
createdAt: Date | null;
4473
updatedAt: Date | null;
74+
completedAt?: Date | null;
75+
filePath?: string;
76+
relationships?: {
77+
depends_on: string[];
78+
required_by?: string[];
79+
};
4580
}
4681

4782
export interface NextJsSpecDetail extends NextJsSpec {
@@ -55,6 +90,7 @@ export interface NextJsSpecDetail extends NextJsSpec {
5590
};
5691
dependsOn?: string[];
5792
requiredBy?: string[];
93+
subSpecs?: SubSpecItem[];
5894
}
5995

6096
export interface NextJsStats {

0 commit comments

Comments
 (0)