diff --git a/openspec/changes/user-accounts/tasks.md b/openspec/changes/user-accounts/tasks.md index 4907206..e4c6f06 100644 --- a/openspec/changes/user-accounts/tasks.md +++ b/openspec/changes/user-accounts/tasks.md @@ -45,7 +45,7 @@ ## 8. Frontend Routing Restructure - [x] 8.1 `[haiku]` Create `src/app/(public)/layout.tsx` — minimal layout for public pages (shared fonts/theme, no sidebar) -- [ ] 8.2 `[haiku]` Move current `src/app/page.tsx` to `src/app/app/page.tsx` (workspace at `/app`) +- [x] 8.2 `[haiku]` Move current `src/app/page.tsx` to `src/app/app/page.tsx` (workspace at `/app`) - [ ] 8.3 `[sonnet]` Create `src/app/app/layout.tsx` — protected layout with `SessionProvider`, user menu nav bar, sidebar with settings link - [ ] 8.4 `[haiku]` Update any hardcoded `/` links in existing components to `/app` diff --git a/src/app/app/page.tsx b/src/app/app/page.tsx new file mode 100644 index 0000000..c3c9370 --- /dev/null +++ b/src/app/app/page.tsx @@ -0,0 +1,1067 @@ +'use client'; + +import { useState, useRef, useEffect, useCallback } from 'react'; +import Toolbox, { Tool } from '@/components/Toolbox'; +import FileUpload from '@/components/FileUpload'; +import CandleChart, { CandleChartHandle } from '@/components/CandleChart'; +import ChartSelector from '@/components/ChartSelector'; +import PredictionPanel from '@/components/PredictionPanel'; +import SpanAnnotationList from '@/components/SpanAnnotationList'; +import TalibPatternPanel from '@/components/TalibPatternPanel'; +import TrainingPanel from '@/components/TrainingPanel'; +import { ThemeToggle } from '@/components/ThemeToggle'; +import KeyboardShortcutsModal from '@/components/KeyboardShortcutsModal'; +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, + DialogDescription, + DialogFooter, + DialogClose, +} from '@/components/ui/dialog'; +import { useTheme } from 'next-themes'; +import { Settings, Tag, Layers } from 'lucide-react'; +import type { PredictionState, PredictionSpan, PerCandlePrediction, ModelInfoResponse, Disagreement, DisagreementType, PredictionSummary } from '@/types/predictions'; +import type { Candle, SpanAnnotation, SpanLabelType } from '@/types'; +import type { Geometry } from '@/types/annotations'; + +/** + * Calculate overlap between two time ranges + * Returns overlap ratio (0-1) relative to the smaller span + */ +function calculateOverlap( + start1: number, + end1: number, + start2: number, + end2: number +): number { + const overlapStart = Math.max(start1, start2); + const overlapEnd = Math.min(end1, end2); + const overlap = Math.max(0, overlapEnd - overlapStart); + const minLength = Math.min(end1 - start1, end2 - start2); + return minLength > 0 ? overlap / minLength : 0; +} + +/** + * Detect disagreements between human annotations and model predictions + * Returns summary with agreement/disagreement counts and detailed disagreement list + */ +function detectDisagreements( + humanSpans: SpanAnnotation[], + predictionSpans: PredictionSpan[], + overlapThreshold: number = 0.5 +): PredictionSummary { + const disagreements: Disagreement[] = []; + const matchedHumanIds = new Set(); + const matchedPredictionIndices = new Set(); + + + // Find matches and label mismatches + humanSpans.forEach((humanSpan) => { + predictionSpans.forEach((predSpan, predIdx) => { + const overlap = calculateOverlap( + humanSpan.start_time, + humanSpan.end_time, + predSpan.start_time, + predSpan.end_time + ); + + if (overlap >= overlapThreshold) { + matchedHumanIds.add(humanSpan.id); + matchedPredictionIndices.add(predIdx); + + // Check for label mismatch + if (humanSpan.label !== predSpan.label) { + disagreements.push({ + type: 'label_mismatch', + humanSpan: { + id: humanSpan.id, + label: humanSpan.label, + start_time: humanSpan.start_time, + end_time: humanSpan.end_time, + }, + predictionSpan: predSpan, + overlap_ratio: overlap, + }); + } + } + }); + }); + + // Find spans missed by model (human annotation but no prediction) + humanSpans.forEach((humanSpan) => { + if (!matchedHumanIds.has(humanSpan.id)) { + disagreements.push({ + type: 'missed_by_model', + humanSpan: { + id: humanSpan.id, + label: humanSpan.label, + start_time: humanSpan.start_time, + end_time: humanSpan.end_time, + }, + }); + } + }); + + // Find spans missed by human (prediction but no human annotation) + predictionSpans.forEach((predSpan, idx) => { + if (!matchedPredictionIndices.has(idx)) { + disagreements.push({ + type: 'missed_by_human', + predictionSpan: predSpan, + }); + } + }); + + // Calculate agreements (matched spans with same label) + const agreements = matchedHumanIds.size - disagreements.filter(d => d.type === 'label_mismatch').length; + + return { + total_predictions: predictionSpans.length, + total_human_annotations: humanSpans.length, + agreements, + disagreements, + }; +} + +// SpanAnnotation, SpanLabelType are imported from @/types above. +// Chart: kept local because shared Chart.created_at is Date|number but local usage expects number. +interface Chart { + id: number; + name: string; + created_at: number; +} + +// Annotation: kept local because shared Annotation has geometry: Geometry|null but local usage passes geometry: any. +interface Annotation { + id: number; + chart_id: number; + timestamp: number; + label_type: string; + color: string | null; + geometry: Geometry | null; + created_at: number; +} + +export default function Home() { + const { theme, setTheme } = useTheme(); + const [settingsOpen, setSettingsOpen] = useState(false); + const [shortcutsOpen, setShortcutsOpen] = useState(false); + const [activeTool, setActiveTool] = useState(null); + const [selectedColor, setSelectedColor] = useState('#3b82f6'); + const [selectedLabelId, setSelectedLabelId] = useState(null); + const [annotations, setAnnotations] = useState([]); + const [charts, setCharts] = useState([]); + const [activeChartId, setActiveChartId] = useState(null); + const chartRef = useRef(null); + + // Span annotation state + const [spanAnnotations, setSpanAnnotations] = useState([]); + const [selectedSpanId, setSelectedSpanId] = useState(null); + const [spanLabelTypes, setSpanLabelTypes] = useState([]); + + // Prediction state + const [predictionState, setPredictionState] = useState({ + spans: [], + perCandlePredictions: [], + isLoading: false, + error: null, + modelInfo: null, + visible: false, + confidenceThreshold: 0.5, + selectedLabels: new Set(), + autoPredict: false, + cacheKey: null, + }); + + // Prediction cache: Map + const predictionCacheRef = useRef>(new Map()); + + // Ref to avoid stale closure over modelInfo in fetchPredictions/generateCacheKey + const modelInfoRef = useRef(predictionState.modelInfo); + const fetchPredictionsAbortRef = useRef(null); + const fetchBatchAbortRef = useRef(null); + useEffect(() => { + modelInfoRef.current = predictionState.modelInfo; + }, [predictionState.modelInfo]); + + // Model health state + const [isModelOnline, setIsModelOnline] = useState(true); + + // Prediction summary state + const [predictionSummary, setPredictionSummary] = useState(null); + + // Disagreement filter state + const [showOnlyDisagreements, setShowOnlyDisagreements] = useState(false); + const [deleteAllDialogOpen, setDeleteAllDialogOpen] = useState(false); + + // Fetch charts list + const fetchCharts = useCallback(async () => { + try { + const response = await fetch('/api/charts'); + if (!response.ok) throw new Error(`HTTP error! status: ${response.status}`); + const data = await response.json(); + setCharts(data); + return data as Chart[]; + } catch (error) { + console.error('Failed to fetch charts:', error); + return []; + } + }, []); + + // Fetch annotations for active chart + const fetchAnnotations = useCallback(async (chartId: number | null) => { + if (!chartId) { + setAnnotations([]); + return; + } + try { + const response = await fetch(`/api/annotations?chartId=${chartId}`); + if (!response.ok) throw new Error(`HTTP error! status: ${response.status}`); + const data = await response.json(); + setAnnotations(data); + } catch (error) { + console.error('Failed to fetch annotations:', error); + } + }, []); + + // Fetch span annotations for active chart + const fetchSpanAnnotations = useCallback(async (chartId: number | null) => { + if (!chartId) { + setSpanAnnotations([]); + return; + } + try { + const response = await fetch(`/api/span-annotations?chartId=${chartId}`); + if (!response.ok) throw new Error(`HTTP error! status: ${response.status}`); + const data = await response.json(); + setSpanAnnotations(data); + } catch (error) { + console.error('Failed to fetch span annotations:', error); + } + }, []); + + // Fetch span label types + const fetchSpanLabelTypes = useCallback(async () => { + try { + const response = await fetch('/api/span-label-types'); + if (!response.ok) throw new Error(`HTTP error! status: ${response.status}`); + const data = await response.json(); + setSpanLabelTypes(data); + } catch (error) { + console.error('Failed to fetch span label types:', error); + } + }, []); + + // Fetch charts and span label types on mount, auto-select the most recent chart + useEffect(() => { + const init = async () => { + const chartList = await fetchCharts(); + await fetchSpanLabelTypes(); + if (chartList.length > 0) { + setActiveChartId(chartList[0].id); // sorted by created_at desc + } + }; + init(); + }, [fetchCharts, fetchSpanLabelTypes]); + + // When activeChartId changes, refetch data + useEffect(() => { + if (activeChartId !== null) { + chartRef.current?.refreshData(); + fetchAnnotations(activeChartId); + fetchSpanAnnotations(activeChartId); + setSelectedLabelId(null); + setSelectedSpanId(null); + } + }, [activeChartId, fetchAnnotations, fetchSpanAnnotations]); + + const handleExport = () => { + if (activeChartId) { + window.location.href = `/api/export?chartId=${activeChartId}`; + } else { + window.location.href = '/api/export'; + } + }; + + const handleUploadSuccess = (chart: { id: number; name: string }) => { + // Add new chart to list and select it + const newChart: Chart = { + id: chart.id, + name: chart.name, + created_at: Math.floor(Date.now() / 1000), + }; + setCharts((prev) => [newChart, ...prev]); + setActiveChartId(chart.id); + }; + + const handleAnnotationChange = async () => { + await chartRef.current?.refreshData(); + await fetchAnnotations(activeChartId); + }; + + const handleSpanAnnotationsChange = async () => { + await fetchSpanAnnotations(activeChartId); + }; + + const handleSelectedSpanChange = (spanId: number | null) => { + setSelectedSpanId(spanId); + }; + + // Handle prediction click to convert to annotation + const handlePredictionClick = useCallback(async (span: PredictionSpan, disagreementType: string | null) => { + if (!activeChartId) return; + + // Find the span label type that matches the prediction label + const matchingLabelType = spanLabelTypes.find((lt) => lt.name === span.label); + + if (!matchingLabelType) { + console.warn(`No span label type found for prediction label: ${span.label}`); + return; + } + + // Create span annotation from prediction + try { + const response = await fetch('/api/span-annotations', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + chart_id: activeChartId, + start_time: span.start_time, + end_time: span.end_time, + label: span.label, + confidence: 3, // Default confidence for model-confirmed annotations + source: disagreementType === 'label_mismatch' ? 'model_corrected' : 'model_confirmed', + model_prediction: { + label: span.label, + confidence: span.avg_confidence, + }, + }), + }); + + if (response.ok) { + await fetchSpanAnnotations(activeChartId); + // Show a brief notification (you could add a toast notification here) + console.log(`Created span annotation from prediction: ${span.label}`); + } else { + console.error('Failed to create span annotation from prediction'); + } + } catch (error) { + console.error('Error creating span annotation from prediction:', error); + } + }, [activeChartId, spanLabelTypes, fetchSpanAnnotations]); + + // Handle prediction dismiss (save as negative annotation with label "O") + const handlePredictionDismiss = useCallback(async (span: PredictionSpan, disagreementType: string | null) => { + if (!activeChartId) return; + + // Create negative annotation with label "O" + try { + const response = await fetch('/api/span-annotations', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + chart_id: activeChartId, + start_time: span.start_time, + end_time: span.end_time, + label: 'O', // "O" means "not a pattern" + confidence: 5, // High confidence for explicit user correction + source: 'human_correction', + model_prediction: { + label: span.label, + confidence: span.avg_confidence, + }, + }), + }); + + if (response.ok) { + await fetchSpanAnnotations(activeChartId); + console.log(`Dismissed prediction as "not a pattern": ${span.label}`); + } else { + console.error('Failed to save negative annotation'); + } + } catch (error) { + console.error('Error saving negative annotation:', error); + } + }, [activeChartId, fetchSpanAnnotations]); + + 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 handleDeleteAllAnnotations = async () => { + if (!activeChartId) return; + await Promise.all([ + fetch(`/api/span-annotations?chartId=${activeChartId}`, { method: 'DELETE' }), + fetch(`/api/annotations?all=true&chartId=${activeChartId}`, { method: 'DELETE' }), + ]); + await Promise.all([ + fetchSpanAnnotations(activeChartId), + fetchAnnotations(activeChartId), + chartRef.current?.refreshData(), + ]); + setSelectedSpanId(null); + setSelectedLabelId(null); + }; + + const handleLabelDelete = async (id: number) => { + setAnnotations(annotations.filter((a) => a.id !== id)); + if (selectedLabelId === id) { + setSelectedLabelId(null); + } + }; + + const handleLabelSelect = (id: number) => { + setSelectedLabelId(id === -1 ? null : id); + }; + + const handleSelectChart = (chartId: number) => { + setActiveChartId(chartId); + }; + + const handleDeleteChart = async (chartId: number) => { + try { + const response = await fetch(`/api/charts/${chartId}`, { method: 'DELETE' }); + if (response.ok) { + const remaining = charts.filter((c) => c.id !== chartId); + setCharts(remaining); + if (activeChartId === chartId) { + setActiveChartId(remaining.length > 0 ? remaining[0].id : null); + } + } + } catch (error) { + console.error('Failed to delete chart:', error); + } + }; + + // Fetch model info and initialize selected labels + const fetchModelInfo = useCallback(async () => { + try { + const response = await fetch('/api/model/info'); + let data: any = null; + try { + data = await response.json(); + } catch { + data = null; + } + + if (!response.ok) { + const message = data?.error || data?.detail || 'Model info unavailable'; + + // Treat "no model" as online-but-unloaded so the selector still works. + if (response.status === 503 && /no model/i.test(message)) { + setIsModelOnline(true); + setPredictionState((prev) => ({ + ...prev, + modelInfo: null, + selectedLabels: new Set(), + error: null, + })); + return null; + } + + setIsModelOnline(false); + setPredictionState((prev) => ({ + ...prev, + modelInfo: null, + error: message, + })); + return null; + } + + const modelInfo: ModelInfoResponse = data; + setIsModelOnline(true); + setPredictionState((prev) => ({ + ...prev, + modelInfo, + selectedLabels: new Set(modelInfo.labels), + error: null, + })); + return modelInfo; + } catch (error) { + console.error('Failed to fetch model info:', error); + setIsModelOnline(false); + setPredictionState((prev) => ({ + ...prev, + modelInfo: null, + error: error instanceof Error ? error.message : 'Failed to fetch model info', + })); + return null; + } + }, []); + + // Generate cache key from chart, timerange, and model version + const generateCacheKey = useCallback((chartId: number | null, modelVersion?: string | null) => { + if (!chartId) return null; + const version = modelVersion || modelInfoRef.current?.model_version || 'unknown'; + return `${chartId}_${version}`; + }, []); + + // Fetch predictions for visible candles + const fetchPredictions = useCallback(async (candles: Candle[]) => { + if (!activeChartId || candles.length === 0) return; + + const currentModelInfo = modelInfoRef.current; + const cacheKey = generateCacheKey(activeChartId, currentModelInfo?.model_version); + + // Check cache first + if (cacheKey && predictionCacheRef.current.has(cacheKey)) { + const cached = predictionCacheRef.current.get(cacheKey)!; + if (cached.modelVersion === currentModelInfo?.model_version) { + setPredictionState((prev) => ({ + ...prev, + spans: cached.spans, + perCandlePredictions: cached.predictions, + cacheKey, + })); + return; + } + } + + // Abort any in-flight prediction request + fetchPredictionsAbortRef.current?.abort(); + const controller = new AbortController(); + fetchPredictionsAbortRef.current = controller; + + setPredictionState((prev) => ({ ...prev, isLoading: true, error: null })); + + try { + const response = await fetch('/api/predict', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ candles }), + signal: controller.signal, + }); + + if (!response.ok) { + let message = response.statusText || 'Prediction failed'; + try { + const errorBody = await response.json(); + message = errorBody?.error || errorBody?.detail || message; + } catch { + // ignore parse errors + } + throw new Error(`Prediction failed: ${message}`); + } + + const data = await response.json(); + + // Cache the results (bounded: max 100 entries, FIFO eviction) + if (cacheKey) { + const cache = predictionCacheRef.current; + if (cache.size >= 100) { + const firstKey = cache.keys().next().value; + if (firstKey !== undefined) cache.delete(firstKey); + } + cache.set(cacheKey, { + spans: data.spans, + predictions: data.predictions, + modelVersion: data.model_info.model_version, + }); + } + + setPredictionState((prev) => ({ + ...prev, + spans: data.spans, + perCandlePredictions: data.predictions, + isLoading: false, + cacheKey, + })); + } catch (error) { + if (error instanceof Error && error.name === 'AbortError') return; + console.error('Failed to fetch predictions:', error); + setPredictionState((prev) => ({ + ...prev, + isLoading: false, + error: error instanceof Error ? error.message : 'Failed to fetch predictions', + })); + } + }, [activeChartId, generateCacheKey]); + + // Toggle prediction visibility + const togglePredictionVisibility = useCallback(() => { + setPredictionState((prev) => ({ ...prev, visible: !prev.visible })); + }, []); + + // Update confidence threshold + const setConfidenceThreshold = useCallback((threshold: number) => { + setPredictionState((prev) => ({ ...prev, confidenceThreshold: threshold })); + }, []); + + // Toggle label selection + const toggleLabelSelection = useCallback((label: string) => { + setPredictionState((prev) => { + const newSelected = new Set(prev.selectedLabels); + if (newSelected.has(label)) { + newSelected.delete(label); + } else { + newSelected.add(label); + } + return { ...prev, selectedLabels: newSelected }; + }); + }, []); + + // Toggle show only disagreements + const toggleShowOnlyDisagreements = useCallback(() => { + setShowOnlyDisagreements((prev) => !prev); + }, []); + + // Handle on-demand prediction for visible candles + const handleFetchVisiblePredictions = useCallback(() => { + // This will be called by the PredictionPanel + // The actual candles data will be fetched from the chart ref + const candles = chartRef.current?.getVisibleCandles(); + if (candles && candles.length > 0) { + fetchPredictions(candles); + } + }, [fetchPredictions]); + + // Handle model loaded via ModelSelector: refresh model info and clear prediction cache + const handleModelLoaded = useCallback(async () => { + predictionCacheRef.current = new Map(); + setPredictionState((prev) => ({ ...prev, spans: [], perCandlePredictions: [], visible: false })); + await fetchModelInfo(); + }, [fetchModelInfo]); + + // Handle batch prediction for all candles + const handleFetchBatchPredictions = useCallback(async () => { + if (!activeChartId) return; + + // Abort any in-flight batch prediction request + fetchBatchAbortRef.current?.abort(); + const controller = new AbortController(); + fetchBatchAbortRef.current = controller; + + setPredictionState((prev) => ({ ...prev, isLoading: true, error: null })); + + try { + // Fetch chart data to get pair/timeframe info + const chartResponse = await fetch(`/api/charts/${activeChartId}`, { + signal: controller.signal, + }); + if (!chartResponse.ok) { + throw new Error('Failed to fetch chart info'); + } + const chartData = await chartResponse.json(); + + // Fetch candles for the chart + const candlesResponse = await fetch(`/api/candles?chartId=${activeChartId}`, { + signal: controller.signal, + }); + if (!candlesResponse.ok) { + throw new Error('Failed to fetch candles'); + } + const candlesData = await candlesResponse.json(); + + if (candlesData.length === 0) { + throw new Error('No candles found for this chart'); + } + + // Use regular predict endpoint with all candles from the database + const response = await fetch('/api/predict', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + candles: candlesData, + }), + signal: controller.signal, + }); + + if (!response.ok) { + let message = response.statusText || 'Batch prediction failed'; + try { + const errorBody = await response.json(); + message = errorBody?.error || errorBody?.detail || message; + } catch { + // ignore parse errors + } + throw new Error(`Batch prediction failed: ${message}`); + } + + const data = await response.json(); + + const cacheKey = generateCacheKey(activeChartId, data.model_info.model_version); + if (cacheKey) { + const cache = predictionCacheRef.current; + if (cache.size >= 100) { + const firstKey = cache.keys().next().value; + if (firstKey !== undefined) cache.delete(firstKey); + } + cache.set(cacheKey, { + spans: data.spans, + predictions: data.predictions, + modelVersion: data.model_info.model_version, + }); + } + + setPredictionState((prev) => ({ + ...prev, + spans: data.spans, + perCandlePredictions: data.predictions, + isLoading: false, + cacheKey, + })); + } catch (error) { + if (error instanceof Error && error.name === 'AbortError') return; + console.error('Failed to fetch batch predictions:', error); + setPredictionState((prev) => ({ + ...prev, + isLoading: false, + error: error instanceof Error ? error.message : 'Failed to fetch batch predictions', + })); + } + }, [activeChartId, generateCacheKey]); + + // Clear prediction cache when model version changes + useEffect(() => { + if (predictionState.modelInfo) { + const currentVersion = predictionState.modelInfo.model_version; + // Clear cache entries with different model versions + const newCache = new Map(); + for (const [key, value] of predictionCacheRef.current.entries()) { + if (value.modelVersion === currentVersion) { + newCache.set(key, value); + } + } + predictionCacheRef.current = newCache; + } + }, [predictionState.modelInfo?.model_version]); + + // Health polling - check model status every 30 seconds when offline + useEffect(() => { + if (!isModelOnline) { + const interval = setInterval(() => { + fetchModelInfo(); + }, 30000); + return () => clearInterval(interval); + } + }, [isModelOnline, fetchModelInfo]); + + // Initialize model info on mount + useEffect(() => { + fetchModelInfo(); + }, [fetchModelInfo]); + + // Compute prediction summary when predictions or span annotations change + useEffect(() => { + if (predictionState.visible && predictionState.spans.length > 0) { + const summary = detectDisagreements(spanAnnotations, predictionState.spans); + setPredictionSummary(summary); + } else { + setPredictionSummary(null); + } + }, [predictionState.visible, predictionState.spans, spanAnnotations]); + + // Keyboard handler for Delete/Backspace key and tool shortcuts + useEffect(() => { + const handleKeyDown = async (e: KeyboardEvent) => { + // Ignore shortcuts when typing in an input/textarea/select + const tag = (e.target as HTMLElement).tagName; + if (tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT') return; + + // Delete selected marker annotation + if ((e.key === 'Delete' || e.key === 'Backspace') && selectedLabelId !== null) { + try { + const response = await fetch(`/api/annotations/${selectedLabelId}`, { + method: 'DELETE', + }); + if (response.ok) { + setSelectedLabelId(null); + chartRef.current?.refreshData(); + } + } catch (error) { + console.error('Failed to delete label:', error); + } + return; + } + + // Tool shortcuts (only when not in a modal) + switch (e.key.toLowerCase()) { + case 'r': + setActiveTool((prev) => (prev === 'rectangle' ? null : 'rectangle')); + break; + case 's': + setActiveTool((prev) => (prev === 'span' ? null : 'span')); + break; + case 'l': + setActiveTool((prev) => (prev === 'line' ? null : 'line')); + break; + case 'd': + setActiveTool((prev) => (prev === 'delete' ? null : 'delete')); + break; + case 't': + setTheme(theme === 'dark' ? 'light' : 'dark'); + break; + case '?': + setShortcutsOpen((prev) => !prev); + break; + } + }; + + window.addEventListener('keydown', handleKeyDown); + return () => window.removeEventListener('keydown', handleKeyDown); + }, [selectedLabelId, theme, setTheme]); + + const activeChart = charts.find((c) => c.id === activeChartId); + + return ( +
+ setShortcutsOpen(false)} /> + {/* Sidebar */} +
+ {/* Sidebar Header */} +
+
+

Candle Annotator

+

Chart annotation tool

+
+
+ +
+
+ + {/* Chart Selector */} +
+ +
+ + {/* File Upload */} +
+ +
+ + {/* Toolbox */} +
+ +
+ + {/* Scrollable middle: Annotations + TA-Lib + Training + Predictions */} +
+ {/* Annotations List */} +
+ +
+ + {/* TA-Lib Pattern Panel */} +
+ fetchSpanAnnotations(activeChartId)} + getCandles={() => chartRef.current?.getVisibleCandles()} + /> +
+ + {/* Training Panel */} +
+ +
+ + {/* Predictions */} +
+ +
+
+ + {/* Fixed bottom actions */} + {/* Delete all annotations */} +
+ +
+ + {/* Confirmation dialog for delete-all annotations */} + + + + Delete All Annotations + + This will permanently delete all span and label annotations for the current chart. + This action cannot be undone. + + + + + + + + + + + + {/* Export */} +
+ +
+ + {/* Settings */} +
+ + {settingsOpen && ( + <> + {/* backdrop */} +
setSettingsOpen(false)} /> + {/* menu */} + + + )} +
+
+ + {/* Main chart area */} +
+ {/* Chart top bar */} +
+
+ + {activeChart?.name || 'No chart'} + +
+
+ R Rect + S Span + L Line + D Del + T Theme +
+
+ + {/* Chart */} +
+ {/* Loading overlay for predictions */} + {predictionState.isLoading && ( +
+
+
+
+ Loading predictions... +
+
+
+ )} + +
+
+
+ ); +} diff --git a/src/app/page.tsx b/src/app/page.tsx index c3c9370..b681560 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -1,1067 +1,15 @@ 'use client'; -import { useState, useRef, useEffect, useCallback } from 'react'; -import Toolbox, { Tool } from '@/components/Toolbox'; -import FileUpload from '@/components/FileUpload'; -import CandleChart, { CandleChartHandle } from '@/components/CandleChart'; -import ChartSelector from '@/components/ChartSelector'; -import PredictionPanel from '@/components/PredictionPanel'; -import SpanAnnotationList from '@/components/SpanAnnotationList'; -import TalibPatternPanel from '@/components/TalibPatternPanel'; -import TrainingPanel from '@/components/TrainingPanel'; -import { ThemeToggle } from '@/components/ThemeToggle'; -import KeyboardShortcutsModal from '@/components/KeyboardShortcutsModal'; -import { - Dialog, - DialogContent, - DialogHeader, - DialogTitle, - DialogDescription, - DialogFooter, - DialogClose, -} from '@/components/ui/dialog'; -import { useTheme } from 'next-themes'; -import { Settings, Tag, Layers } from 'lucide-react'; -import type { PredictionState, PredictionSpan, PerCandlePrediction, ModelInfoResponse, Disagreement, DisagreementType, PredictionSummary } from '@/types/predictions'; -import type { Candle, SpanAnnotation, SpanLabelType } from '@/types'; -import type { Geometry } from '@/types/annotations'; +import { useEffect } from 'react'; +import { useRouter } from 'next/navigation'; -/** - * Calculate overlap between two time ranges - * Returns overlap ratio (0-1) relative to the smaller span - */ -function calculateOverlap( - start1: number, - end1: number, - start2: number, - end2: number -): number { - const overlapStart = Math.max(start1, start2); - const overlapEnd = Math.min(end1, end2); - const overlap = Math.max(0, overlapEnd - overlapStart); - const minLength = Math.min(end1 - start1, end2 - start2); - return minLength > 0 ? overlap / minLength : 0; -} - -/** - * Detect disagreements between human annotations and model predictions - * Returns summary with agreement/disagreement counts and detailed disagreement list - */ -function detectDisagreements( - humanSpans: SpanAnnotation[], - predictionSpans: PredictionSpan[], - overlapThreshold: number = 0.5 -): PredictionSummary { - const disagreements: Disagreement[] = []; - const matchedHumanIds = new Set(); - const matchedPredictionIndices = new Set(); - - - // Find matches and label mismatches - humanSpans.forEach((humanSpan) => { - predictionSpans.forEach((predSpan, predIdx) => { - const overlap = calculateOverlap( - humanSpan.start_time, - humanSpan.end_time, - predSpan.start_time, - predSpan.end_time - ); - - if (overlap >= overlapThreshold) { - matchedHumanIds.add(humanSpan.id); - matchedPredictionIndices.add(predIdx); - - // Check for label mismatch - if (humanSpan.label !== predSpan.label) { - disagreements.push({ - type: 'label_mismatch', - humanSpan: { - id: humanSpan.id, - label: humanSpan.label, - start_time: humanSpan.start_time, - end_time: humanSpan.end_time, - }, - predictionSpan: predSpan, - overlap_ratio: overlap, - }); - } - } - }); - }); - - // Find spans missed by model (human annotation but no prediction) - humanSpans.forEach((humanSpan) => { - if (!matchedHumanIds.has(humanSpan.id)) { - disagreements.push({ - type: 'missed_by_model', - humanSpan: { - id: humanSpan.id, - label: humanSpan.label, - start_time: humanSpan.start_time, - end_time: humanSpan.end_time, - }, - }); - } - }); - - // Find spans missed by human (prediction but no human annotation) - predictionSpans.forEach((predSpan, idx) => { - if (!matchedPredictionIndices.has(idx)) { - disagreements.push({ - type: 'missed_by_human', - predictionSpan: predSpan, - }); - } - }); - - // Calculate agreements (matched spans with same label) - const agreements = matchedHumanIds.size - disagreements.filter(d => d.type === 'label_mismatch').length; - - return { - total_predictions: predictionSpans.length, - total_human_annotations: humanSpans.length, - agreements, - disagreements, - }; -} - -// SpanAnnotation, SpanLabelType are imported from @/types above. -// Chart: kept local because shared Chart.created_at is Date|number but local usage expects number. -interface Chart { - id: number; - name: string; - created_at: number; -} - -// Annotation: kept local because shared Annotation has geometry: Geometry|null but local usage passes geometry: any. -interface Annotation { - id: number; - chart_id: number; - timestamp: number; - label_type: string; - color: string | null; - geometry: Geometry | null; - created_at: number; -} - -export default function Home() { - const { theme, setTheme } = useTheme(); - const [settingsOpen, setSettingsOpen] = useState(false); - const [shortcutsOpen, setShortcutsOpen] = useState(false); - const [activeTool, setActiveTool] = useState(null); - const [selectedColor, setSelectedColor] = useState('#3b82f6'); - const [selectedLabelId, setSelectedLabelId] = useState(null); - const [annotations, setAnnotations] = useState([]); - const [charts, setCharts] = useState([]); - const [activeChartId, setActiveChartId] = useState(null); - const chartRef = useRef(null); - - // Span annotation state - const [spanAnnotations, setSpanAnnotations] = useState([]); - const [selectedSpanId, setSelectedSpanId] = useState(null); - const [spanLabelTypes, setSpanLabelTypes] = useState([]); - - // Prediction state - const [predictionState, setPredictionState] = useState({ - spans: [], - perCandlePredictions: [], - isLoading: false, - error: null, - modelInfo: null, - visible: false, - confidenceThreshold: 0.5, - selectedLabels: new Set(), - autoPredict: false, - cacheKey: null, - }); - - // Prediction cache: Map - const predictionCacheRef = useRef>(new Map()); - - // Ref to avoid stale closure over modelInfo in fetchPredictions/generateCacheKey - const modelInfoRef = useRef(predictionState.modelInfo); - const fetchPredictionsAbortRef = useRef(null); - const fetchBatchAbortRef = useRef(null); - useEffect(() => { - modelInfoRef.current = predictionState.modelInfo; - }, [predictionState.modelInfo]); - - // Model health state - const [isModelOnline, setIsModelOnline] = useState(true); - - // Prediction summary state - const [predictionSummary, setPredictionSummary] = useState(null); - - // Disagreement filter state - const [showOnlyDisagreements, setShowOnlyDisagreements] = useState(false); - const [deleteAllDialogOpen, setDeleteAllDialogOpen] = useState(false); - - // Fetch charts list - const fetchCharts = useCallback(async () => { - try { - const response = await fetch('/api/charts'); - if (!response.ok) throw new Error(`HTTP error! status: ${response.status}`); - const data = await response.json(); - setCharts(data); - return data as Chart[]; - } catch (error) { - console.error('Failed to fetch charts:', error); - return []; - } - }, []); - - // Fetch annotations for active chart - const fetchAnnotations = useCallback(async (chartId: number | null) => { - if (!chartId) { - setAnnotations([]); - return; - } - try { - const response = await fetch(`/api/annotations?chartId=${chartId}`); - if (!response.ok) throw new Error(`HTTP error! status: ${response.status}`); - const data = await response.json(); - setAnnotations(data); - } catch (error) { - console.error('Failed to fetch annotations:', error); - } - }, []); - - // Fetch span annotations for active chart - const fetchSpanAnnotations = useCallback(async (chartId: number | null) => { - if (!chartId) { - setSpanAnnotations([]); - return; - } - try { - const response = await fetch(`/api/span-annotations?chartId=${chartId}`); - if (!response.ok) throw new Error(`HTTP error! status: ${response.status}`); - const data = await response.json(); - setSpanAnnotations(data); - } catch (error) { - console.error('Failed to fetch span annotations:', error); - } - }, []); - - // Fetch span label types - const fetchSpanLabelTypes = useCallback(async () => { - try { - const response = await fetch('/api/span-label-types'); - if (!response.ok) throw new Error(`HTTP error! status: ${response.status}`); - const data = await response.json(); - setSpanLabelTypes(data); - } catch (error) { - console.error('Failed to fetch span label types:', error); - } - }, []); - - // Fetch charts and span label types on mount, auto-select the most recent chart - useEffect(() => { - const init = async () => { - const chartList = await fetchCharts(); - await fetchSpanLabelTypes(); - if (chartList.length > 0) { - setActiveChartId(chartList[0].id); // sorted by created_at desc - } - }; - init(); - }, [fetchCharts, fetchSpanLabelTypes]); - - // When activeChartId changes, refetch data - useEffect(() => { - if (activeChartId !== null) { - chartRef.current?.refreshData(); - fetchAnnotations(activeChartId); - fetchSpanAnnotations(activeChartId); - setSelectedLabelId(null); - setSelectedSpanId(null); - } - }, [activeChartId, fetchAnnotations, fetchSpanAnnotations]); - - const handleExport = () => { - if (activeChartId) { - window.location.href = `/api/export?chartId=${activeChartId}`; - } else { - window.location.href = '/api/export'; - } - }; - - const handleUploadSuccess = (chart: { id: number; name: string }) => { - // Add new chart to list and select it - const newChart: Chart = { - id: chart.id, - name: chart.name, - created_at: Math.floor(Date.now() / 1000), - }; - setCharts((prev) => [newChart, ...prev]); - setActiveChartId(chart.id); - }; - - const handleAnnotationChange = async () => { - await chartRef.current?.refreshData(); - await fetchAnnotations(activeChartId); - }; - - const handleSpanAnnotationsChange = async () => { - await fetchSpanAnnotations(activeChartId); - }; - - const handleSelectedSpanChange = (spanId: number | null) => { - setSelectedSpanId(spanId); - }; - - // Handle prediction click to convert to annotation - const handlePredictionClick = useCallback(async (span: PredictionSpan, disagreementType: string | null) => { - if (!activeChartId) return; - - // Find the span label type that matches the prediction label - const matchingLabelType = spanLabelTypes.find((lt) => lt.name === span.label); - - if (!matchingLabelType) { - console.warn(`No span label type found for prediction label: ${span.label}`); - return; - } - - // Create span annotation from prediction - try { - const response = await fetch('/api/span-annotations', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - chart_id: activeChartId, - start_time: span.start_time, - end_time: span.end_time, - label: span.label, - confidence: 3, // Default confidence for model-confirmed annotations - source: disagreementType === 'label_mismatch' ? 'model_corrected' : 'model_confirmed', - model_prediction: { - label: span.label, - confidence: span.avg_confidence, - }, - }), - }); - - if (response.ok) { - await fetchSpanAnnotations(activeChartId); - // Show a brief notification (you could add a toast notification here) - console.log(`Created span annotation from prediction: ${span.label}`); - } else { - console.error('Failed to create span annotation from prediction'); - } - } catch (error) { - console.error('Error creating span annotation from prediction:', error); - } - }, [activeChartId, spanLabelTypes, fetchSpanAnnotations]); - - // Handle prediction dismiss (save as negative annotation with label "O") - const handlePredictionDismiss = useCallback(async (span: PredictionSpan, disagreementType: string | null) => { - if (!activeChartId) return; - - // Create negative annotation with label "O" - try { - const response = await fetch('/api/span-annotations', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - chart_id: activeChartId, - start_time: span.start_time, - end_time: span.end_time, - label: 'O', // "O" means "not a pattern" - confidence: 5, // High confidence for explicit user correction - source: 'human_correction', - model_prediction: { - label: span.label, - confidence: span.avg_confidence, - }, - }), - }); - - if (response.ok) { - await fetchSpanAnnotations(activeChartId); - console.log(`Dismissed prediction as "not a pattern": ${span.label}`); - } else { - console.error('Failed to save negative annotation'); - } - } catch (error) { - console.error('Error saving negative annotation:', error); - } - }, [activeChartId, fetchSpanAnnotations]); - - 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 handleDeleteAllAnnotations = async () => { - if (!activeChartId) return; - await Promise.all([ - fetch(`/api/span-annotations?chartId=${activeChartId}`, { method: 'DELETE' }), - fetch(`/api/annotations?all=true&chartId=${activeChartId}`, { method: 'DELETE' }), - ]); - await Promise.all([ - fetchSpanAnnotations(activeChartId), - fetchAnnotations(activeChartId), - chartRef.current?.refreshData(), - ]); - setSelectedSpanId(null); - setSelectedLabelId(null); - }; - - const handleLabelDelete = async (id: number) => { - setAnnotations(annotations.filter((a) => a.id !== id)); - if (selectedLabelId === id) { - setSelectedLabelId(null); - } - }; - - const handleLabelSelect = (id: number) => { - setSelectedLabelId(id === -1 ? null : id); - }; - - const handleSelectChart = (chartId: number) => { - setActiveChartId(chartId); - }; - - const handleDeleteChart = async (chartId: number) => { - try { - const response = await fetch(`/api/charts/${chartId}`, { method: 'DELETE' }); - if (response.ok) { - const remaining = charts.filter((c) => c.id !== chartId); - setCharts(remaining); - if (activeChartId === chartId) { - setActiveChartId(remaining.length > 0 ? remaining[0].id : null); - } - } - } catch (error) { - console.error('Failed to delete chart:', error); - } - }; - - // Fetch model info and initialize selected labels - const fetchModelInfo = useCallback(async () => { - try { - const response = await fetch('/api/model/info'); - let data: any = null; - try { - data = await response.json(); - } catch { - data = null; - } - - if (!response.ok) { - const message = data?.error || data?.detail || 'Model info unavailable'; - - // Treat "no model" as online-but-unloaded so the selector still works. - if (response.status === 503 && /no model/i.test(message)) { - setIsModelOnline(true); - setPredictionState((prev) => ({ - ...prev, - modelInfo: null, - selectedLabels: new Set(), - error: null, - })); - return null; - } - - setIsModelOnline(false); - setPredictionState((prev) => ({ - ...prev, - modelInfo: null, - error: message, - })); - return null; - } - - const modelInfo: ModelInfoResponse = data; - setIsModelOnline(true); - setPredictionState((prev) => ({ - ...prev, - modelInfo, - selectedLabels: new Set(modelInfo.labels), - error: null, - })); - return modelInfo; - } catch (error) { - console.error('Failed to fetch model info:', error); - setIsModelOnline(false); - setPredictionState((prev) => ({ - ...prev, - modelInfo: null, - error: error instanceof Error ? error.message : 'Failed to fetch model info', - })); - return null; - } - }, []); - - // Generate cache key from chart, timerange, and model version - const generateCacheKey = useCallback((chartId: number | null, modelVersion?: string | null) => { - if (!chartId) return null; - const version = modelVersion || modelInfoRef.current?.model_version || 'unknown'; - return `${chartId}_${version}`; - }, []); - - // Fetch predictions for visible candles - const fetchPredictions = useCallback(async (candles: Candle[]) => { - if (!activeChartId || candles.length === 0) return; - - const currentModelInfo = modelInfoRef.current; - const cacheKey = generateCacheKey(activeChartId, currentModelInfo?.model_version); - - // Check cache first - if (cacheKey && predictionCacheRef.current.has(cacheKey)) { - const cached = predictionCacheRef.current.get(cacheKey)!; - if (cached.modelVersion === currentModelInfo?.model_version) { - setPredictionState((prev) => ({ - ...prev, - spans: cached.spans, - perCandlePredictions: cached.predictions, - cacheKey, - })); - return; - } - } - - // Abort any in-flight prediction request - fetchPredictionsAbortRef.current?.abort(); - const controller = new AbortController(); - fetchPredictionsAbortRef.current = controller; - - setPredictionState((prev) => ({ ...prev, isLoading: true, error: null })); - - try { - const response = await fetch('/api/predict', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ candles }), - signal: controller.signal, - }); - - if (!response.ok) { - let message = response.statusText || 'Prediction failed'; - try { - const errorBody = await response.json(); - message = errorBody?.error || errorBody?.detail || message; - } catch { - // ignore parse errors - } - throw new Error(`Prediction failed: ${message}`); - } - - const data = await response.json(); - - // Cache the results (bounded: max 100 entries, FIFO eviction) - if (cacheKey) { - const cache = predictionCacheRef.current; - if (cache.size >= 100) { - const firstKey = cache.keys().next().value; - if (firstKey !== undefined) cache.delete(firstKey); - } - cache.set(cacheKey, { - spans: data.spans, - predictions: data.predictions, - modelVersion: data.model_info.model_version, - }); - } - - setPredictionState((prev) => ({ - ...prev, - spans: data.spans, - perCandlePredictions: data.predictions, - isLoading: false, - cacheKey, - })); - } catch (error) { - if (error instanceof Error && error.name === 'AbortError') return; - console.error('Failed to fetch predictions:', error); - setPredictionState((prev) => ({ - ...prev, - isLoading: false, - error: error instanceof Error ? error.message : 'Failed to fetch predictions', - })); - } - }, [activeChartId, generateCacheKey]); - - // Toggle prediction visibility - const togglePredictionVisibility = useCallback(() => { - setPredictionState((prev) => ({ ...prev, visible: !prev.visible })); - }, []); - - // Update confidence threshold - const setConfidenceThreshold = useCallback((threshold: number) => { - setPredictionState((prev) => ({ ...prev, confidenceThreshold: threshold })); - }, []); - - // Toggle label selection - const toggleLabelSelection = useCallback((label: string) => { - setPredictionState((prev) => { - const newSelected = new Set(prev.selectedLabels); - if (newSelected.has(label)) { - newSelected.delete(label); - } else { - newSelected.add(label); - } - return { ...prev, selectedLabels: newSelected }; - }); - }, []); - - // Toggle show only disagreements - const toggleShowOnlyDisagreements = useCallback(() => { - setShowOnlyDisagreements((prev) => !prev); - }, []); - - // Handle on-demand prediction for visible candles - const handleFetchVisiblePredictions = useCallback(() => { - // This will be called by the PredictionPanel - // The actual candles data will be fetched from the chart ref - const candles = chartRef.current?.getVisibleCandles(); - if (candles && candles.length > 0) { - fetchPredictions(candles); - } - }, [fetchPredictions]); - - // Handle model loaded via ModelSelector: refresh model info and clear prediction cache - const handleModelLoaded = useCallback(async () => { - predictionCacheRef.current = new Map(); - setPredictionState((prev) => ({ ...prev, spans: [], perCandlePredictions: [], visible: false })); - await fetchModelInfo(); - }, [fetchModelInfo]); - - // Handle batch prediction for all candles - const handleFetchBatchPredictions = useCallback(async () => { - if (!activeChartId) return; - - // Abort any in-flight batch prediction request - fetchBatchAbortRef.current?.abort(); - const controller = new AbortController(); - fetchBatchAbortRef.current = controller; - - setPredictionState((prev) => ({ ...prev, isLoading: true, error: null })); - - try { - // Fetch chart data to get pair/timeframe info - const chartResponse = await fetch(`/api/charts/${activeChartId}`, { - signal: controller.signal, - }); - if (!chartResponse.ok) { - throw new Error('Failed to fetch chart info'); - } - const chartData = await chartResponse.json(); - - // Fetch candles for the chart - const candlesResponse = await fetch(`/api/candles?chartId=${activeChartId}`, { - signal: controller.signal, - }); - if (!candlesResponse.ok) { - throw new Error('Failed to fetch candles'); - } - const candlesData = await candlesResponse.json(); - - if (candlesData.length === 0) { - throw new Error('No candles found for this chart'); - } - - // Use regular predict endpoint with all candles from the database - const response = await fetch('/api/predict', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - candles: candlesData, - }), - signal: controller.signal, - }); - - if (!response.ok) { - let message = response.statusText || 'Batch prediction failed'; - try { - const errorBody = await response.json(); - message = errorBody?.error || errorBody?.detail || message; - } catch { - // ignore parse errors - } - throw new Error(`Batch prediction failed: ${message}`); - } - - const data = await response.json(); - - const cacheKey = generateCacheKey(activeChartId, data.model_info.model_version); - if (cacheKey) { - const cache = predictionCacheRef.current; - if (cache.size >= 100) { - const firstKey = cache.keys().next().value; - if (firstKey !== undefined) cache.delete(firstKey); - } - cache.set(cacheKey, { - spans: data.spans, - predictions: data.predictions, - modelVersion: data.model_info.model_version, - }); - } - - setPredictionState((prev) => ({ - ...prev, - spans: data.spans, - perCandlePredictions: data.predictions, - isLoading: false, - cacheKey, - })); - } catch (error) { - if (error instanceof Error && error.name === 'AbortError') return; - console.error('Failed to fetch batch predictions:', error); - setPredictionState((prev) => ({ - ...prev, - isLoading: false, - error: error instanceof Error ? error.message : 'Failed to fetch batch predictions', - })); - } - }, [activeChartId, generateCacheKey]); - - // Clear prediction cache when model version changes - useEffect(() => { - if (predictionState.modelInfo) { - const currentVersion = predictionState.modelInfo.model_version; - // Clear cache entries with different model versions - const newCache = new Map(); - for (const [key, value] of predictionCacheRef.current.entries()) { - if (value.modelVersion === currentVersion) { - newCache.set(key, value); - } - } - predictionCacheRef.current = newCache; - } - }, [predictionState.modelInfo?.model_version]); - - // Health polling - check model status every 30 seconds when offline - useEffect(() => { - if (!isModelOnline) { - const interval = setInterval(() => { - fetchModelInfo(); - }, 30000); - return () => clearInterval(interval); - } - }, [isModelOnline, fetchModelInfo]); - - // Initialize model info on mount - useEffect(() => { - fetchModelInfo(); - }, [fetchModelInfo]); - - // Compute prediction summary when predictions or span annotations change - useEffect(() => { - if (predictionState.visible && predictionState.spans.length > 0) { - const summary = detectDisagreements(spanAnnotations, predictionState.spans); - setPredictionSummary(summary); - } else { - setPredictionSummary(null); - } - }, [predictionState.visible, predictionState.spans, spanAnnotations]); - - // Keyboard handler for Delete/Backspace key and tool shortcuts - useEffect(() => { - const handleKeyDown = async (e: KeyboardEvent) => { - // Ignore shortcuts when typing in an input/textarea/select - const tag = (e.target as HTMLElement).tagName; - if (tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT') return; - - // Delete selected marker annotation - if ((e.key === 'Delete' || e.key === 'Backspace') && selectedLabelId !== null) { - try { - const response = await fetch(`/api/annotations/${selectedLabelId}`, { - method: 'DELETE', - }); - if (response.ok) { - setSelectedLabelId(null); - chartRef.current?.refreshData(); - } - } catch (error) { - console.error('Failed to delete label:', error); - } - return; - } - - // Tool shortcuts (only when not in a modal) - switch (e.key.toLowerCase()) { - case 'r': - setActiveTool((prev) => (prev === 'rectangle' ? null : 'rectangle')); - break; - case 's': - setActiveTool((prev) => (prev === 'span' ? null : 'span')); - break; - case 'l': - setActiveTool((prev) => (prev === 'line' ? null : 'line')); - break; - case 'd': - setActiveTool((prev) => (prev === 'delete' ? null : 'delete')); - break; - case 't': - setTheme(theme === 'dark' ? 'light' : 'dark'); - break; - case '?': - setShortcutsOpen((prev) => !prev); - break; - } - }; - - window.addEventListener('keydown', handleKeyDown); - return () => window.removeEventListener('keydown', handleKeyDown); - }, [selectedLabelId, theme, setTheme]); - - const activeChart = charts.find((c) => c.id === activeChartId); - - return ( -
- setShortcutsOpen(false)} /> - {/* Sidebar */} -
- {/* Sidebar Header */} -
-
-

Candle Annotator

-

Chart annotation tool

-
-
- -
-
- - {/* Chart Selector */} -
- -
- - {/* File Upload */} -
- -
- - {/* Toolbox */} -
- -
- - {/* Scrollable middle: Annotations + TA-Lib + Training + Predictions */} -
- {/* Annotations List */} -
- -
- - {/* TA-Lib Pattern Panel */} -
- fetchSpanAnnotations(activeChartId)} - getCandles={() => chartRef.current?.getVisibleCandles()} - /> -
- - {/* Training Panel */} -
- -
- - {/* Predictions */} -
- -
-
- - {/* Fixed bottom actions */} - {/* Delete all annotations */} -
- -
- - {/* Confirmation dialog for delete-all annotations */} - - - - Delete All Annotations - - This will permanently delete all span and label annotations for the current chart. - This action cannot be undone. - - - - - - - - - - - - {/* Export */} -
- -
- - {/* Settings */} -
- - {settingsOpen && ( - <> - {/* backdrop */} -
setSettingsOpen(false)} /> - {/* menu */} - - - )} -
-
- - {/* Main chart area */} -
- {/* Chart top bar */} -
-
- - {activeChart?.name || 'No chart'} - -
-
- R Rect - S Span - L Line - D Del - T Theme -
-
- - {/* Chart */} -
- {/* Loading overlay for predictions */} - {predictionState.isLoading && ( -
-
-
-
- Loading predictions... -
-
-
- )} - -
-
-
- ); +export default function RootPage() { + const router = useRouter(); + + useEffect(() => { + // Redirect to login page + router.push('/login'); + }, [router]); + + return null; }