feat: implement section 9 - span annotation sidebar list

This commit is contained in:
Marko Djordjevic 2026-02-14 10:13:23 +01:00
parent 2f05136f20
commit 4089aab77c
4 changed files with 265 additions and 7 deletions

View file

@ -63,13 +63,13 @@
## 9. Span Annotation Sidebar List ## 9. Span Annotation Sidebar List
- [ ] 9.1 Create `SpanAnnotationList.tsx` component: scrollable list of span annotations sorted by start_time desc, showing time range, label with colored badge, delete button - [x] 9.1 Create `SpanAnnotationList.tsx` component: scrollable list of span annotations sorted by start_time desc, showing time range, label with colored badge, delete button
- [ ] 9.2 Add count summary grouped by label type (e.g., "Bull Flag: 3 | Bear Flag: 2") - [x] 9.2 Add count summary grouped by label type (e.g., "Bull Flag: 3 | Bear Flag: 2")
- [ ] 9.3 Implement click-to-select in list: set selectedSpanId, highlight chart rectangle, scroll chart to center on span's time range - [x] 9.3 Implement click-to-select in list: set selectedSpanId, highlight chart rectangle, scroll chart to center on span's time range
- [ ] 9.4 Highlight selected span entry in list (background/border) - [x] 9.4 Highlight selected span entry in list (background/border)
- [ ] 9.5 Wire delete button per list item: DELETE API call, remove primitive, update state - [x] 9.5 Wire delete button per list item: DELETE API call, remove primitive, update state
- [ ] 9.6 Show empty state message when no span annotations exist - [x] 9.6 Show empty state message when no span annotations exist
- [ ] 9.7 Clear and reload span list when active chart changes - [x] 9.7 Clear and reload span list when active chart changes
## 10. Hotkey Label Assignment ## 10. Hotkey Label Assignment

View file

@ -169,6 +169,22 @@ export default function Home() {
setSelectedSpanId(spanId); setSelectedSpanId(spanId);
}; };
const handleDeleteSpan = async (spanId: number) => {
try {
const response = await fetch(`/api/span-annotations/${spanId}`, {
method: 'DELETE',
});
if (response.ok) {
await fetchSpanAnnotations(activeChartId);
if (selectedSpanId === spanId) {
setSelectedSpanId(null);
}
}
} catch (error) {
console.error('Failed to delete span annotation:', error);
}
};
const handleLabelDelete = async (id: number) => { const handleLabelDelete = async (id: number) => {
setAnnotations(annotations.filter((a) => a.id !== id)); setAnnotations(annotations.filter((a) => a.id !== id));
if (selectedLabelId === id) { if (selectedLabelId === id) {
@ -258,6 +274,11 @@ export default function Home() {
onLabelSelect={handleLabelSelect} onLabelSelect={handleLabelSelect}
onLabelDelete={handleLabelDelete} onLabelDelete={handleLabelDelete}
activeChartId={activeChartId} activeChartId={activeChartId}
spanAnnotations={spanAnnotations}
spanLabelTypes={spanLabelTypes}
selectedSpanId={selectedSpanId}
onSelectSpan={handleSelectedSpanChange}
onDeleteSpan={handleDeleteSpan}
/> />
</div> </div>
</aside> </aside>

View file

@ -0,0 +1,190 @@
'use client';
import { useState } from 'react';
import { Trash2, ChevronDown, ChevronUp } from 'lucide-react';
import { Button } from '@/components/ui/button';
interface SpanAnnotation {
id: number;
chart_id: number;
start_time: number;
end_time: number;
label: string;
confidence: number | null;
outcome: string | null;
notes: string | null;
sub_spans: any;
color: string;
created_at: number;
}
interface SpanLabelType {
id: number;
name: string;
display_name: string;
color: string;
hotkey: string | null;
is_active: number;
sort_order: number;
created_at: number;
}
interface SpanAnnotationListProps {
spanAnnotations: SpanAnnotation[];
spanLabelTypes: SpanLabelType[];
selectedSpanId: number | null;
onSelectSpan: (spanId: number) => void;
onDeleteSpan: (spanId: number) => void;
}
export default function SpanAnnotationList({
spanAnnotations,
spanLabelTypes,
selectedSpanId,
onSelectSpan,
onDeleteSpan,
}: SpanAnnotationListProps) {
const [expanded, setExpanded] = useState(true);
// Format timestamp to readable date/time
const formatTime = (timestamp: number) => {
return new Date(timestamp * 1000).toLocaleString('en-US', {
month: 'short',
day: 'numeric',
hour: '2-digit',
minute: '2-digit',
});
};
// Get display name and color for label
const getLabelInfo = (labelName: string) => {
const labelType = spanLabelTypes.find((t) => t.name === labelName);
return {
displayName: labelType?.display_name || labelName.replace(/_/g, ' ').toUpperCase(),
color: labelType?.color || '#2196F3',
};
};
// Calculate count per label type
const labelCounts = spanAnnotations.reduce((acc, span) => {
acc[span.label] = (acc[span.label] || 0) + 1;
return acc;
}, {} as Record<string, number>);
// Sort by start_time descending (most recent first)
const sortedSpans = [...spanAnnotations].sort((a, b) => b.start_time - a.start_time);
return (
<div className="flex flex-col">
{/* Header with collapse toggle */}
<button
onClick={() => setExpanded(!expanded)}
className="w-full flex items-center justify-between px-2 py-2 hover:bg-secondary/50 rounded"
>
<span className="text-sm font-semibold text-foreground">
Span Annotations ({spanAnnotations.length})
</span>
{expanded ? (
<ChevronUp className="w-4 h-4" />
) : (
<ChevronDown className="w-4 h-4" />
)}
</button>
{expanded && (
<>
{/* Count summary */}
{spanAnnotations.length > 0 && (
<div className="text-xs text-muted-foreground px-2 mb-2">
{Object.entries(labelCounts).map(([labelName, count], idx) => {
const { displayName } = getLabelInfo(labelName);
return (
<span key={labelName}>
{idx > 0 && ' | '}
{displayName}: {count}
</span>
);
})}
</div>
)}
{/* Empty state */}
{spanAnnotations.length === 0 ? (
<div className="text-xs text-muted-foreground px-2 py-4 text-center">
No span annotations yet. Use the Span tool to select candle ranges.
</div>
) : (
/* Span list */
<div className="flex flex-col gap-1 max-h-64 overflow-y-auto">
{sortedSpans.map((span) => {
const { displayName, color } = getLabelInfo(span.label);
const isSelected = span.id === selectedSpanId;
return (
<div
key={span.id}
className={`
flex items-start gap-2 p-2 rounded cursor-pointer
transition-colors
${
isSelected
? 'bg-primary/10 border border-primary'
: 'hover:bg-secondary/50 border border-transparent'
}
`}
onClick={() => onSelectSpan(span.id)}
>
<div className="flex-1 min-w-0">
{/* Time range */}
<div className="text-xs text-muted-foreground">
{formatTime(span.start_time)} {formatTime(span.end_time)}
</div>
{/* Label badge */}
<div className="mt-1">
<span
className="px-2 py-0.5 rounded text-xs font-medium text-white"
style={{ backgroundColor: color }}
>
{displayName}
</span>
</div>
{/* Additional info */}
{(span.confidence || span.outcome) && (
<div className="mt-1 text-xs text-muted-foreground flex gap-2">
{span.confidence && <span>Confidence: {span.confidence}</span>}
{span.outcome && <span>Outcome: {span.outcome}</span>}
</div>
)}
{/* Notes preview */}
{span.notes && (
<div className="mt-1 text-xs text-muted-foreground truncate">
{span.notes}
</div>
)}
</div>
{/* Delete button */}
<Button
variant="ghost"
size="sm"
className="h-6 w-6 p-0 hover:bg-destructive/20 hover:text-destructive"
onClick={(e) => {
e.stopPropagation();
onDeleteSpan(span.id);
}}
>
<Trash2 className="w-3 h-3" />
</Button>
</div>
);
})}
</div>
)}
</>
)}
</div>
);
}

View file

@ -5,6 +5,7 @@ import { ArrowUpCircle, ArrowDownCircle, TrendingUp, Trash2, Download, ChevronDo
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input'; import { Input } from '@/components/ui/input';
import { ThemeToggle } from '@/components/ThemeToggle'; import { ThemeToggle } from '@/components/ThemeToggle';
import SpanAnnotationList from '@/components/SpanAnnotationList';
export type Tool = string | 'delete' | null; export type Tool = string | 'delete' | null;
@ -26,6 +27,31 @@ interface Annotation {
created_at: number; created_at: number;
} }
interface SpanAnnotation {
id: number;
chart_id: number;
start_time: number;
end_time: number;
label: string;
confidence: number | null;
outcome: string | null;
notes: string | null;
sub_spans: any;
color: string;
created_at: number;
}
interface SpanLabelType {
id: number;
name: string;
display_name: string;
color: string;
hotkey: string | null;
is_active: number;
sort_order: number;
created_at: number;
}
interface ToolboxProps { interface ToolboxProps {
activeTool: Tool; activeTool: Tool;
onToolChange: (tool: Tool) => void; onToolChange: (tool: Tool) => void;
@ -37,6 +63,11 @@ interface ToolboxProps {
onLabelSelect?: (id: number) => void; onLabelSelect?: (id: number) => void;
onLabelDelete?: (id: number) => void; onLabelDelete?: (id: number) => void;
activeChartId?: number | null; activeChartId?: number | null;
spanAnnotations?: SpanAnnotation[];
spanLabelTypes?: SpanLabelType[];
selectedSpanId?: number | null;
onSelectSpan?: (spanId: number) => void;
onDeleteSpan?: (spanId: number) => void;
} }
export default function Toolbox({ export default function Toolbox({
@ -50,6 +81,11 @@ export default function Toolbox({
onLabelSelect, onLabelSelect,
onLabelDelete, onLabelDelete,
activeChartId, activeChartId,
spanAnnotations = [],
spanLabelTypes = [],
selectedSpanId = null,
onSelectSpan,
onDeleteSpan,
}: ToolboxProps) { }: ToolboxProps) {
const [labelsExpanded, setLabelsExpanded] = useState(true); const [labelsExpanded, setLabelsExpanded] = useState(true);
const [searchText, setSearchText] = useState(''); const [searchText, setSearchText] = useState('');
@ -332,6 +368,17 @@ export default function Toolbox({
)} )}
</div> </div>
{/* Span Annotations List */}
<div className="pt-4">
<SpanAnnotationList
spanAnnotations={spanAnnotations}
spanLabelTypes={spanLabelTypes}
selectedSpanId={selectedSpanId}
onSelectSpan={onSelectSpan || (() => {})}
onDeleteSpan={onDeleteSpan || (() => {})}
/>
</div>
{/* Export button */} {/* Export button */}
<div className="mt-auto pt-4 border-t border-border"> <div className="mt-auto pt-4 border-t border-border">
<Button <Button