diff --git a/drizzle/0000_sweet_roland_deschain.sql b/drizzle/0000_sweet_roland_deschain.sql deleted file mode 100644 index 0f50ef2..0000000 --- a/drizzle/0000_sweet_roland_deschain.sql +++ /dev/null @@ -1,18 +0,0 @@ -CREATE TABLE `annotations` ( - `id` integer PRIMARY KEY AUTOINCREMENT NOT NULL, - `timestamp` integer NOT NULL, - `label_type` text NOT NULL, - `geometry` text, - `created_at` integer NOT NULL -); ---> statement-breakpoint -CREATE TABLE `candles` ( - `id` integer PRIMARY KEY AUTOINCREMENT NOT NULL, - `time` integer NOT NULL, - `open` real NOT NULL, - `high` real NOT NULL, - `low` real NOT NULL, - `close` real NOT NULL -); ---> statement-breakpoint -CREATE UNIQUE INDEX `candles_time_unique` ON `candles` (`time`); \ No newline at end of file diff --git a/drizzle/0001_broken_the_fury.sql b/drizzle/0001_broken_the_fury.sql deleted file mode 100644 index ec9e711..0000000 --- a/drizzle/0001_broken_the_fury.sql +++ /dev/null @@ -1 +0,0 @@ -ALTER TABLE `annotations` ADD `color` text DEFAULT '#3b82f6'; \ No newline at end of file diff --git a/drizzle/0001_sticky_shinko_yamashiro.sql b/drizzle/0001_sticky_shinko_yamashiro.sql new file mode 100644 index 0000000..5b071dc --- /dev/null +++ b/drizzle/0001_sticky_shinko_yamashiro.sql @@ -0,0 +1,12 @@ +CREATE TABLE `annotation_types` ( + `id` integer PRIMARY KEY AUTOINCREMENT NOT NULL, + `name` text NOT NULL, + `display_name` text NOT NULL, + `color` text NOT NULL, + `category` text NOT NULL, + `icon` text, + `is_active` integer DEFAULT 1 NOT NULL, + `created_at` integer NOT NULL +); +--> statement-breakpoint +CREATE UNIQUE INDEX `annotation_types_name_unique` ON `annotation_types` (`name`); \ No newline at end of file diff --git a/drizzle/meta/0001_snapshot.json b/drizzle/meta/0001_snapshot.json index 6f2fa40..c042ce0 100644 --- a/drizzle/meta/0001_snapshot.json +++ b/drizzle/meta/0001_snapshot.json @@ -1,9 +1,84 @@ { "version": "6", "dialect": "sqlite", - "id": "9a43200c-01b1-41fc-ac10-8071afa36f6f", - "prevId": "4f92efce-343c-4fa1-a55b-53b8dd7ed42e", + "id": "111e1b91-6d7b-45e4-aeb9-9762725d6905", + "prevId": "9a43200c-01b1-41fc-ac10-8071afa36f6f", "tables": { + "annotation_types": { + "name": "annotation_types", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "display_name": { + "name": "display_name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "color": { + "name": "color", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "category": { + "name": "category", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "icon": { + "name": "icon", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "is_active": { + "name": "is_active", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 1 + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "annotation_types_name_unique": { + "name": "annotation_types_name_unique", + "columns": [ + "name" + ], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, "annotations": { "name": "annotations", "columns": { diff --git a/drizzle/meta/_journal.json b/drizzle/meta/_journal.json index e301432..f2c685b 100644 --- a/drizzle/meta/_journal.json +++ b/drizzle/meta/_journal.json @@ -8,6 +8,13 @@ "when": 1770907611962, "tag": "0000_goofy_captain_midlands", "breakpoints": true + }, + { + "idx": 1, + "version": "6", + "when": 1770915891699, + "tag": "0001_sticky_shinko_yamashiro", + "breakpoints": true } ] } \ No newline at end of file diff --git a/src/app/annotation-types/page.tsx b/src/app/annotation-types/page.tsx new file mode 100644 index 0000000..e27cbd8 --- /dev/null +++ b/src/app/annotation-types/page.tsx @@ -0,0 +1,390 @@ +'use client'; + +import { useEffect, useState } from 'react'; +import { Button } from '@/components/ui/button'; +import { Input } from '@/components/ui/input'; + +type AnnotationType = { + id: number; + name: string; + display_name: string; + color: string; + category: string; + icon: string | null; + is_active: number; + created_at: number; +}; + +export default function AnnotationTypesPage() { + const [types, setTypes] = useState([]); + const [loading, setLoading] = useState(true); + const [editingId, setEditingId] = useState(null); + const [showAddForm, setShowAddForm] = useState(false); + + // Form state + const [formData, setFormData] = useState({ + name: '', + display_name: '', + color: '#3b82f6', + category: 'marker', + icon: '', + }); + + const fetchTypes = async () => { + try { + const res = await fetch('/api/annotation-types'); + if (res.ok) { + const data = await res.json(); + setTypes(data); + } + } catch (error) { + console.error('Error fetching annotation types:', error); + } finally { + setLoading(false); + } + }; + + const seedDefaultTypes = async () => { + try { + const res = await fetch('/api/annotation-types', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ action: 'seed' }), + }); + if (res.ok) { + fetchTypes(); + } + } catch (error) { + console.error('Error seeding types:', error); + } + }; + + useEffect(() => { + fetchTypes(); + }, []); + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + + try { + if (editingId !== null) { + // Update existing type + const res = await fetch(`/api/annotation-types/${editingId}`, { + method: 'PATCH', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(formData), + }); + + if (res.ok) { + setEditingId(null); + resetForm(); + fetchTypes(); + } else { + const error = await res.json(); + alert(error.error || 'Failed to update type'); + } + } else { + // Create new type + const res = await fetch('/api/annotation-types', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(formData), + }); + + if (res.ok) { + setShowAddForm(false); + resetForm(); + fetchTypes(); + } else { + const error = await res.json(); + alert(error.error || 'Failed to create type'); + } + } + } catch (error) { + console.error('Error saving type:', error); + alert('Failed to save type'); + } + }; + + const handleEdit = (type: AnnotationType) => { + setFormData({ + name: type.name, + display_name: type.display_name, + color: type.color, + category: type.category, + icon: type.icon || '', + }); + setEditingId(type.id); + setShowAddForm(true); + }; + + const handleDelete = async (id: number) => { + if (!confirm('Are you sure you want to delete this annotation type?')) { + return; + } + + try { + const res = await fetch(`/api/annotation-types?id=${id}`, { + method: 'DELETE', + }); + + if (res.ok) { + fetchTypes(); + } else { + const error = await res.json(); + alert(error.error || 'Failed to delete type'); + } + } catch (error) { + console.error('Error deleting type:', error); + alert('Failed to delete type'); + } + }; + + const resetForm = () => { + setFormData({ + name: '', + display_name: '', + color: '#3b82f6', + category: 'marker', + icon: '', + }); + setEditingId(null); + }; + + const handleCancel = () => { + setShowAddForm(false); + resetForm(); + }; + + if (loading) { + return ( +
+
+

Loading...

+
+
+ ); + } + + return ( +
+
+
+
+

Annotation Types

+

Manage annotation types for your charts

+
+
+ + {types.length === 0 && ( + + )} + {!showAddForm && ( + + )} +
+
+ + {showAddForm && ( +
+

+ {editingId !== null ? 'Edit Annotation Type' : 'Add New Annotation Type'} +

+
+
+
+ + setFormData({ ...formData, name: e.target.value })} + placeholder="e.g., break_up" + required + disabled={editingId !== null} + /> +

+ Used internally (cannot be changed after creation) +

+
+ +
+ + setFormData({ ...formData, display_name: e.target.value })} + placeholder="e.g., Break Up" + required + /> +
+ +
+ +
+ setFormData({ ...formData, color: e.target.value })} + className="w-20" + required + /> + setFormData({ ...formData, color: e.target.value })} + placeholder="#3b82f6" + /> +
+
+ +
+ + +
+ +
+ + setFormData({ ...formData, icon: e.target.value })} + placeholder="e.g., arrowUp" + /> +
+
+ +
+ + +
+
+
+ )} + +
+ + + + + + + + + + + + + + {types.length === 0 ? ( + + + + ) : ( + types.map((type) => ( + + + + + + + + + + )) + )} + +
+ Name + + Display Name + + Color + + Category + + Icon + + Status + + Actions +
+ No annotation types found. Click "Seed Default Types" to get started. +
+ {type.name} + + {type.display_name} + +
+
+ {type.color} +
+
+ + {type.category} + + + {type.icon || '-'} + + + {type.is_active ? 'Active' : 'Inactive'} + + +
+ + +
+
+
+
+
+ ); +} diff --git a/src/app/api/annotation-types/[id]/route.ts b/src/app/api/annotation-types/[id]/route.ts new file mode 100644 index 0000000..4bf4721 --- /dev/null +++ b/src/app/api/annotation-types/[id]/route.ts @@ -0,0 +1,65 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { db } from '@/lib/db'; +import { annotationTypes } from '@/lib/db/schema'; +import { eq } from 'drizzle-orm'; + +type RouteContext = { + params: Promise<{ id: string }>; +}; + +// PATCH - Update annotation type +export async function PATCH( + request: NextRequest, + context: RouteContext +) { + try { + const { id } = await context.params; + const body = await request.json(); + + const { name, display_name, color, category, icon, is_active } = body; + + const updateData: any = {}; + if (name !== undefined) updateData.name = name; + if (display_name !== undefined) updateData.display_name = display_name; + if (color !== undefined) updateData.color = color; + if (category !== undefined) updateData.category = category; + if (icon !== undefined) updateData.icon = icon; + if (is_active !== undefined) updateData.is_active = is_active; + + if (Object.keys(updateData).length === 0) { + return NextResponse.json( + { error: 'No fields to update' }, + { status: 400 } + ); + } + + const result = await db + .update(annotationTypes) + .set(updateData) + .where(eq(annotationTypes.id, parseInt(id))) + .returning(); + + if (result.length === 0) { + return NextResponse.json( + { error: 'Annotation type not found' }, + { status: 404 } + ); + } + + return NextResponse.json(result[0]); + } catch (error: any) { + console.error('Error updating annotation type:', error); + + if (error.message?.includes('UNIQUE constraint failed')) { + return NextResponse.json( + { error: 'Annotation type with this name already exists' }, + { status: 409 } + ); + } + + return NextResponse.json( + { error: 'Failed to update annotation type' }, + { status: 500 } + ); + } +} diff --git a/src/app/api/annotation-types/route.ts b/src/app/api/annotation-types/route.ts new file mode 100644 index 0000000..1a90966 --- /dev/null +++ b/src/app/api/annotation-types/route.ts @@ -0,0 +1,160 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { db } from '@/lib/db'; +import { annotationTypes, annotations } from '@/lib/db/schema'; +import { eq } from 'drizzle-orm'; + +// GET - List all annotation types +export async function GET() { + try { + const types = await db.select().from(annotationTypes); + return NextResponse.json(types); + } catch (error) { + console.error('Error fetching annotation types:', error); + return NextResponse.json( + { error: 'Failed to fetch annotation types' }, + { status: 500 } + ); + } +} + +// POST - Create new annotation type or seed defaults +export async function POST(request: NextRequest) { + try { + const body = await request.json(); + + // Special case: seed default types + if (body.action === 'seed') { + const existing = await db.select().from(annotationTypes); + + if (existing.length > 0) { + return NextResponse.json({ message: 'Types already seeded' }); + } + + const now = Math.floor(Date.now() / 1000); + const defaultTypes = [ + { + name: 'break_up', + display_name: 'Break Up', + color: '#10b981', + category: 'marker', + icon: 'arrowUp', + is_active: 1, + created_at: now, + }, + { + name: 'break_down', + display_name: 'Break Down', + color: '#ef4444', + category: 'marker', + icon: 'arrowDown', + is_active: 1, + created_at: now, + }, + { + name: 'line', + display_name: 'Line', + color: '#3b82f6', + category: 'line', + icon: 'line', + is_active: 1, + created_at: now, + }, + ]; + + await db.insert(annotationTypes).values(defaultTypes); + return NextResponse.json({ message: 'Default types seeded successfully' }); + } + + // Regular create + const { name, display_name, color, category, icon } = body; + + if (!name || !display_name || !color || !category) { + return NextResponse.json( + { error: 'name, display_name, color, and category are required' }, + { status: 400 } + ); + } + + const result = await db + .insert(annotationTypes) + .values({ + name, + display_name, + color, + category, + icon: icon || null, + is_active: 1, + created_at: Math.floor(Date.now() / 1000), + }) + .returning(); + + return NextResponse.json(result[0], { status: 201 }); + } catch (error: any) { + console.error('Error creating annotation type:', error); + + if (error.message?.includes('UNIQUE constraint failed')) { + return NextResponse.json( + { error: 'Annotation type with this name already exists' }, + { status: 409 } + ); + } + + return NextResponse.json( + { error: 'Failed to create annotation type' }, + { status: 500 } + ); + } +} + +// DELETE - Delete annotation type (only if no annotations use it) +export async function DELETE(request: NextRequest) { + try { + const { searchParams } = request.nextUrl; + const id = searchParams.get('id'); + + if (!id) { + return NextResponse.json( + { error: 'id parameter is required' }, + { status: 400 } + ); + } + + // Check if the type exists + const type = await db + .select() + .from(annotationTypes) + .where(eq(annotationTypes.id, parseInt(id))) + .limit(1); + + if (type.length === 0) { + return NextResponse.json( + { error: 'Annotation type not found' }, + { status: 404 } + ); + } + + // Check if any annotations use this type + const existingAnnotations = await db + .select() + .from(annotations) + .where(eq(annotations.label_type, type[0].name)) + .limit(1); + + if (existingAnnotations.length > 0) { + return NextResponse.json( + { error: 'Cannot delete annotation type: annotations exist with this type' }, + { status: 409 } + ); + } + + await db.delete(annotationTypes).where(eq(annotationTypes.id, parseInt(id))); + + return NextResponse.json({ message: 'Annotation type deleted successfully' }); + } catch (error) { + console.error('Error deleting annotation type:', error); + return NextResponse.json( + { error: 'Failed to delete annotation type' }, + { status: 500 } + ); + } +} diff --git a/src/app/page.tsx b/src/app/page.tsx index 2aa9ad6..34638fb 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -93,6 +93,12 @@ export default function Home() {

Candle Annotator

Chart annotation tool

+ + Manage Annotation Types +
diff --git a/src/components/CandleChart.tsx b/src/components/CandleChart.tsx index eff9579..d4e402f 100644 --- a/src/components/CandleChart.tsx +++ b/src/components/CandleChart.tsx @@ -25,6 +25,16 @@ interface Annotation { created_at: number; } +type AnnotationType = { + id: number; + name: string; + display_name: string; + color: string; + category: string; + icon: string | null; + is_active: number; +}; + interface CandleChartProps { activeTool: string | null; onAnnotationChange?: () => void; @@ -44,6 +54,7 @@ const CandleChart = forwardRef( const seriesRef = useRef | null>(null); const [candles, setCandles] = useState([]); const [annotations, setAnnotations] = useState([]); + const [annotationTypes, setAnnotationTypes] = useState([]); const [isEmpty, setIsEmpty] = useState(true); // Fetch candles from API @@ -73,11 +84,25 @@ const CandleChart = forwardRef( } }; + // Fetch annotation types from API + const fetchAnnotationTypes = async () => { + try { + const response = await fetch('/api/annotation-types'); + const data = await response.json(); + setAnnotationTypes(data.filter((t: AnnotationType) => t.is_active === 1)); + return data; + } catch (error) { + console.error('Failed to fetch annotation types:', error); + return []; + } + }; + // Expose refresh method to parent useImperativeHandle(ref, () => ({ refreshData: async () => { await fetchCandles(); await fetchAnnotations(); + await fetchAnnotationTypes(); }, })); @@ -154,30 +179,43 @@ const CandleChart = forwardRef( // Update markers from annotations useEffect(() => { - if (!seriesRef.current) return; + if (!seriesRef.current || annotationTypes.length === 0) return; - const markerAnnotations = annotations.filter( - (a) => a.label_type === 'break_up' || a.label_type === 'break_down' + // Get marker type names + const markerTypeNames = annotationTypes + .filter((t) => t.category === 'marker') + .map((t) => t.name); + + const markerAnnotations = annotations.filter((a) => + markerTypeNames.includes(a.label_type) ); const markers = markerAnnotations .map((annotation) => { + const annotationType = annotationTypes.find((t) => t.name === annotation.label_type); + if (!annotationType) return null; + const isSelected = annotation.id === selectedLabelId; + + // Determine marker shape and position based on icon + const isUpArrow = annotationType.icon === 'arrowUp'; + return { time: annotation.timestamp as Time, - position: annotation.label_type === 'break_up' ? ('belowBar' as const) : ('aboveBar' as const), + position: isUpArrow ? ('belowBar' as const) : ('aboveBar' as const), color: isSelected - ? (annotation.label_type === 'break_up' ? '#059669' : '#dc2626') - : (annotation.label_type === 'break_up' ? '#10b981' : '#ef4444'), - shape: annotation.label_type === 'break_up' ? ('arrowUp' as const) : ('arrowDown' as const), - text: annotation.label_type === 'break_up' ? 'Break Up' : 'Break Down', + ? annotationType.color + 'CC' // Add slight transparency when selected + : annotationType.color, + shape: isUpArrow ? ('arrowUp' as const) : ('arrowDown' as const), + text: annotationType.display_name, size: isSelected ? 2 : 1, }; }) - .sort((a, b) => (a.time as number) - (b.time as number)); + .filter((m) => m !== null) + .sort((a, b) => (a!.time as number) - (b!.time as number)); - seriesRef.current.setMarkers(markers); - }, [annotations, selectedLabelId]); + seriesRef.current.setMarkers(markers as any); + }, [annotations, selectedLabelId, annotationTypes]); // Handle chart clicks for annotation useEffect(() => { @@ -194,8 +232,12 @@ const CandleChart = forwardRef( if (time === null || price === null) return; - // For break_up and break_down, snap to nearest candle - if (activeTool === 'break_up' || activeTool === 'break_down') { + // Check if activeTool is a marker type + const markerType = annotationTypes.find( + (t) => t.category === 'marker' && t.name === activeTool + ); + + if (markerType) { const timestamp = typeof time === 'string' ? Date.parse(time) / 1000 : (time as number); // Find nearest candle @@ -228,12 +270,15 @@ const CandleChart = forwardRef( // For delete tool, find and delete marker at clicked position if (activeTool === 'delete') { const timestamp = typeof time === 'string' ? Date.parse(time) / 1000 : (time as number); + const markerTypeNames = annotationTypes + .filter((t) => t.category === 'marker') + .map((t) => t.name); // Find annotation at this timestamp (within tolerance) const tolerance = 60; // 60 seconds tolerance const annotation = annotations.find( (a) => - (a.label_type === 'break_up' || a.label_type === 'break_down') && + markerTypeNames.includes(a.label_type) && Math.abs(a.timestamp - timestamp) < tolerance ); @@ -254,14 +299,21 @@ const CandleChart = forwardRef( } // Select/deselect label markers by clicking them - if (!activeTool || activeTool === 'break_up' || activeTool === 'break_down') { + const isMarkerTool = annotationTypes.find( + (t) => t.category === 'marker' && t.name === activeTool + ); + + if (!activeTool || isMarkerTool) { const timestamp = typeof time === 'string' ? Date.parse(time) / 1000 : (time as number); + const markerTypeNames = annotationTypes + .filter((t) => t.category === 'marker') + .map((t) => t.name); // Find annotation at this timestamp (within tolerance) const tolerance = 60; // 60 seconds tolerance const annotation = annotations.find( (a) => - (a.label_type === 'break_up' || a.label_type === 'break_down') && + markerTypeNames.includes(a.label_type) && Math.abs(a.timestamp - timestamp) < tolerance ); @@ -276,12 +328,13 @@ const CandleChart = forwardRef( return () => { chartRef.current?.unsubscribeClick(handleClick); }; - }, [activeTool, candles, annotations, onAnnotationChange]); + }, [activeTool, candles, annotations, annotationTypes, onAnnotationChange]); // Fetch data on mount useEffect(() => { fetchCandles(); fetchAnnotations(); + fetchAnnotationTypes(); }, []); if (isEmpty) { diff --git a/src/components/Toolbox.tsx b/src/components/Toolbox.tsx index fd91926..a15c801 100644 --- a/src/components/Toolbox.tsx +++ b/src/components/Toolbox.tsx @@ -1,11 +1,21 @@ 'use client'; -import { useState } from 'react'; +import { useState, useEffect } from 'react'; import { ArrowUpCircle, ArrowDownCircle, TrendingUp, Trash2, Download, ChevronDown, ChevronUp } from 'lucide-react'; import { Button } from '@/components/ui/button'; import { Input } from '@/components/ui/input'; -export type Tool = 'break_up' | 'break_down' | 'line' | 'delete' | null; +export type Tool = string | 'delete' | null; + +type AnnotationType = { + id: number; + name: string; + display_name: string; + color: string; + category: string; + icon: string | null; + is_active: number; +}; interface Annotation { id: number; @@ -40,7 +50,24 @@ export default function Toolbox({ }: ToolboxProps) { const [labelsExpanded, setLabelsExpanded] = useState(true); const [searchText, setSearchText] = useState(''); - const [filterType, setFilterType] = useState<'all' | 'break_up' | 'break_down'>('all'); + const [filterType, setFilterType] = useState('all'); + const [annotationTypes, setAnnotationTypes] = useState([]); + + // Fetch annotation types on mount + useEffect(() => { + const fetchTypes = async () => { + try { + const res = await fetch('/api/annotation-types'); + if (res.ok) { + const data = await res.json(); + setAnnotationTypes(data.filter((t: AnnotationType) => t.is_active === 1)); + } + } catch (error) { + console.error('Failed to fetch annotation types:', error); + } + }; + fetchTypes(); + }, []); const handleToolClick = (tool: Tool) => { // Toggle: if clicking the active tool, deactivate it @@ -51,9 +78,13 @@ export default function Toolbox({ } }; + // Get marker types (exclude line types) + const markerTypes = annotationTypes.filter((t) => t.category === 'marker'); + const markerTypeNames = markerTypes.map((t) => t.name); + // Filter and sort annotations const labelAnnotations = annotations - .filter((a) => a.label_type === 'break_up' || a.label_type === 'break_down') + .filter((a) => markerTypeNames.includes(a.label_type)) .filter((a) => (filterType === 'all' ? true : a.label_type === filterType)) .sort((a, b) => b.timestamp - a.timestamp); @@ -68,8 +99,11 @@ export default function Toolbox({ return formattedTime.toLowerCase().includes(searchText.toLowerCase()); }); - const breakUpCount = labelAnnotations.filter((a) => a.label_type === 'break_up').length; - const breakDownCount = labelAnnotations.filter((a) => a.label_type === 'break_down').length; + // Count annotations per type + const typeCounts = markerTypes.reduce((acc, type) => { + acc[type.name] = labelAnnotations.filter((a) => a.label_type === type.name).length; + return acc; + }, {} as Record); const handleLabelDelete = async (id: number, e: React.MouseEvent) => { e.stopPropagation(); @@ -85,37 +119,51 @@ export default function Toolbox({ } }; + const getIconComponent = (iconName: string | null) => { + switch (iconName) { + case 'arrowUp': + return ; + case 'arrowDown': + return ; + case 'line': + return ; + default: + return ; + } + }; + return (

Annotation Tools

- + {/* Marker type buttons */} + {markerTypes.map((type) => ( + + ))} - - - + {/* Line type buttons */} + {annotationTypes + .filter((t) => t.category === 'line') + .map((type) => ( + + ))} {/* Color picker */}
@@ -179,7 +227,12 @@ export default function Toolbox({
{/* Count display */}
- Break Up: {breakUpCount} | Break Down: {breakDownCount} + {markerTypes.map((type, idx) => ( + + {idx > 0 && ' | '} + {type.display_name}: {typeCounts[type.name] || 0} + + ))}
{/* Search input */} @@ -193,12 +246,15 @@ export default function Toolbox({ {/* Filter dropdown */} {/* Labels list */} @@ -218,6 +274,7 @@ export default function Toolbox({ minute: '2-digit', }); const isSelected = annotation.id === selectedLabelId; + const annotationType = annotationTypes.find((t) => t.name === annotation.label_type); return (
{formattedTime}
- {annotation.label_type === 'break_up' ? 'Break Up' : 'Break Down'} + {annotationType?.display_name || annotation.label_type}
diff --git a/src/lib/db/schema.ts b/src/lib/db/schema.ts index 4f6605d..d5bf59e 100644 --- a/src/lib/db/schema.ts +++ b/src/lib/db/schema.ts @@ -9,6 +9,17 @@ export const candles = sqliteTable('candles', { close: real('close').notNull(), }); +export const annotationTypes = sqliteTable('annotation_types', { + id: integer('id').primaryKey({ autoIncrement: true }), + name: text('name').notNull().unique(), // internal name (e.g., 'break_up') + display_name: text('display_name').notNull(), // display name (e.g., 'Break Up') + color: text('color').notNull(), // hex color code + category: text('category').notNull(), // 'marker' or 'line' + icon: text('icon'), // icon name or symbol + is_active: integer('is_active').notNull().default(1), // 1 = active, 0 = inactive + created_at: integer('created_at').notNull(), +}); + export const annotations = sqliteTable('annotations', { id: integer('id').primaryKey({ autoIncrement: true }), timestamp: integer('timestamp').notNull(), diff --git a/src/lib/db/seed-annotation-types.ts b/src/lib/db/seed-annotation-types.ts new file mode 100644 index 0000000..0291404 --- /dev/null +++ b/src/lib/db/seed-annotation-types.ts @@ -0,0 +1,46 @@ +import { db } from './index'; +import { annotationTypes } from './schema'; + +export async function seedAnnotationTypes() { + const now = Math.floor(Date.now() / 1000); + + const defaultTypes = [ + { + name: 'break_up', + display_name: 'Break Up', + color: '#10b981', + category: 'marker', + icon: 'arrowUp', + is_active: 1, + created_at: now, + }, + { + name: 'break_down', + display_name: 'Break Down', + color: '#ef4444', + category: 'marker', + icon: 'arrowDown', + is_active: 1, + created_at: now, + }, + { + name: 'line', + display_name: 'Line', + color: '#3b82f6', + category: 'line', + icon: 'line', + is_active: 1, + created_at: now, + }, + ]; + + // Check if types already exist + const existing = await db.select().from(annotationTypes); + + if (existing.length === 0) { + await db.insert(annotationTypes).values(defaultTypes); + console.log('Seeded default annotation types'); + } else { + console.log('Annotation types already exist, skipping seed'); + } +}