From 4089aab77ca6ccab03f91ffbf9e03b66a5be507d Mon Sep 17 00:00:00 2001 From: Marko Djordjevic Date: Sat, 14 Feb 2026 10:13:23 +0100 Subject: [PATCH] feat: implement section 9 - span annotation sidebar list --- openspec/changes/span-annotation/tasks.md | 14 +- src/app/page.tsx | 21 +++ src/components/SpanAnnotationList.tsx | 190 ++++++++++++++++++++++ src/components/Toolbox.tsx | 47 ++++++ 4 files changed, 265 insertions(+), 7 deletions(-) create mode 100644 src/components/SpanAnnotationList.tsx diff --git a/openspec/changes/span-annotation/tasks.md b/openspec/changes/span-annotation/tasks.md index a4fa00e..291784e 100644 --- a/openspec/changes/span-annotation/tasks.md +++ b/openspec/changes/span-annotation/tasks.md @@ -63,13 +63,13 @@ ## 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 -- [ ] 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 -- [ ] 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 -- [ ] 9.6 Show empty state message when no span annotations exist -- [ ] 9.7 Clear and reload span list when active chart changes +- [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 +- [x] 9.2 Add count summary grouped by label type (e.g., "Bull Flag: 3 | Bear Flag: 2") +- [x] 9.3 Implement click-to-select in list: set selectedSpanId, highlight chart rectangle, scroll chart to center on span's time range +- [x] 9.4 Highlight selected span entry in list (background/border) +- [x] 9.5 Wire delete button per list item: DELETE API call, remove primitive, update state +- [x] 9.6 Show empty state message when no span annotations exist +- [x] 9.7 Clear and reload span list when active chart changes ## 10. Hotkey Label Assignment diff --git a/src/app/page.tsx b/src/app/page.tsx index a2c286e..05de44c 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -169,6 +169,22 @@ export default function Home() { 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) => { setAnnotations(annotations.filter((a) => a.id !== id)); if (selectedLabelId === id) { @@ -258,6 +274,11 @@ export default function Home() { onLabelSelect={handleLabelSelect} onLabelDelete={handleLabelDelete} activeChartId={activeChartId} + spanAnnotations={spanAnnotations} + spanLabelTypes={spanLabelTypes} + selectedSpanId={selectedSpanId} + onSelectSpan={handleSelectedSpanChange} + onDeleteSpan={handleDeleteSpan} /> diff --git a/src/components/SpanAnnotationList.tsx b/src/components/SpanAnnotationList.tsx new file mode 100644 index 0000000..fde3c1d --- /dev/null +++ b/src/components/SpanAnnotationList.tsx @@ -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); + + // Sort by start_time descending (most recent first) + const sortedSpans = [...spanAnnotations].sort((a, b) => b.start_time - a.start_time); + + return ( +
+ {/* Header with collapse toggle */} + + + {expanded && ( + <> + {/* Count summary */} + {spanAnnotations.length > 0 && ( +
+ {Object.entries(labelCounts).map(([labelName, count], idx) => { + const { displayName } = getLabelInfo(labelName); + return ( + + {idx > 0 && ' | '} + {displayName}: {count} + + ); + })} +
+ )} + + {/* Empty state */} + {spanAnnotations.length === 0 ? ( +
+ No span annotations yet. Use the Span tool to select candle ranges. +
+ ) : ( + /* Span list */ +
+ {sortedSpans.map((span) => { + const { displayName, color } = getLabelInfo(span.label); + const isSelected = span.id === selectedSpanId; + + return ( +
onSelectSpan(span.id)} + > +
+ {/* Time range */} +
+ {formatTime(span.start_time)} → {formatTime(span.end_time)} +
+ + {/* Label badge */} +
+ + {displayName} + +
+ + {/* Additional info */} + {(span.confidence || span.outcome) && ( +
+ {span.confidence && Confidence: {span.confidence}} + {span.outcome && Outcome: {span.outcome}} +
+ )} + + {/* Notes preview */} + {span.notes && ( +
+ {span.notes} +
+ )} +
+ + {/* Delete button */} + +
+ ); + })} +
+ )} + + )} +
+ ); +} diff --git a/src/components/Toolbox.tsx b/src/components/Toolbox.tsx index 04b4e49..713fe79 100644 --- a/src/components/Toolbox.tsx +++ b/src/components/Toolbox.tsx @@ -5,6 +5,7 @@ import { ArrowUpCircle, ArrowDownCircle, TrendingUp, Trash2, Download, ChevronDo import { Button } from '@/components/ui/button'; import { Input } from '@/components/ui/input'; import { ThemeToggle } from '@/components/ThemeToggle'; +import SpanAnnotationList from '@/components/SpanAnnotationList'; export type Tool = string | 'delete' | null; @@ -26,6 +27,31 @@ interface Annotation { 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 { activeTool: Tool; onToolChange: (tool: Tool) => void; @@ -37,6 +63,11 @@ interface ToolboxProps { onLabelSelect?: (id: number) => void; onLabelDelete?: (id: number) => void; activeChartId?: number | null; + spanAnnotations?: SpanAnnotation[]; + spanLabelTypes?: SpanLabelType[]; + selectedSpanId?: number | null; + onSelectSpan?: (spanId: number) => void; + onDeleteSpan?: (spanId: number) => void; } export default function Toolbox({ @@ -50,6 +81,11 @@ export default function Toolbox({ onLabelSelect, onLabelDelete, activeChartId, + spanAnnotations = [], + spanLabelTypes = [], + selectedSpanId = null, + onSelectSpan, + onDeleteSpan, }: ToolboxProps) { const [labelsExpanded, setLabelsExpanded] = useState(true); const [searchText, setSearchText] = useState(''); @@ -332,6 +368,17 @@ export default function Toolbox({ )} + {/* Span Annotations List */} +
+ {})} + onDeleteSpan={onDeleteSpan || (() => {})} + /> +
+ {/* Export button */}