diff --git a/.dockerignore b/.dockerignore
new file mode 100644
index 0000000..b54df39
--- /dev/null
+++ b/.dockerignore
@@ -0,0 +1,9 @@
+node_modules
+.next
+.git
+data/
+*.md
+.env*
+*.log
+coverage/
+.DS_Store
diff --git a/.env.example b/.env.example
new file mode 100644
index 0000000..15b45a5
--- /dev/null
+++ b/.env.example
@@ -0,0 +1,3 @@
+NODE_ENV=production
+PORT=3000
+DATABASE_PATH=/app/data/candles.db
diff --git a/Dockerfile b/Dockerfile
new file mode 100644
index 0000000..97919e8
--- /dev/null
+++ b/Dockerfile
@@ -0,0 +1,37 @@
+# Build stage
+FROM node:18-alpine AS builder
+
+WORKDIR /app
+
+COPY package*.json ./
+
+RUN npm ci
+
+COPY . .
+
+RUN npm run build
+
+# Production stage
+FROM node:18-alpine
+
+WORKDIR /app
+
+RUN addgroup -g 1001 -S nodejs && adduser -S nextjs -u 1001
+
+COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
+
+COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
+
+COPY --from=builder --chown=nextjs:nodejs /app/public ./public
+
+RUN mkdir -p /app/data && chown nextjs:nodejs /app/data
+
+ENV NODE_ENV=production PORT=3000 HOSTNAME=0.0.0.0
+
+USER nextjs
+
+EXPOSE 3000
+
+HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 CMD wget --no-verbose --tries=1 --spider http://localhost:3000/api/health || exit 1
+
+CMD ["node", "server.js"]
diff --git a/docker-compose.yml b/docker-compose.yml
new file mode 100644
index 0000000..871a61e
--- /dev/null
+++ b/docker-compose.yml
@@ -0,0 +1,15 @@
+version: '3.8'
+
+services:
+ candle-annotator:
+ build: .
+ ports:
+ - "3000:3000"
+ volumes:
+ - candle-data:/app/data
+ environment:
+ - NODE_ENV=production
+ restart: unless-stopped
+
+volumes:
+ candle-data:
diff --git a/next-env.d.ts b/next-env.d.ts
index c4b7818..9edff1c 100644
--- a/next-env.d.ts
+++ b/next-env.d.ts
@@ -1,6 +1,6 @@
///
///
-import "./.next/dev/types/routes.d.ts";
+import "./.next/types/routes.d.ts";
// NOTE: This file should not be edited
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
diff --git a/next.config.js b/next.config.js
index 767719f..5cd8cc3 100644
--- a/next.config.js
+++ b/next.config.js
@@ -1,4 +1,6 @@
/** @type {import('next').NextConfig} */
-const nextConfig = {}
+const nextConfig = {
+ output: 'standalone',
+}
module.exports = nextConfig
diff --git a/src/app/api/annotations/route.ts b/src/app/api/annotations/route.ts
index bc1316d..6dcfeff 100644
--- a/src/app/api/annotations/route.ts
+++ b/src/app/api/annotations/route.ts
@@ -1,6 +1,7 @@
import { NextRequest, NextResponse } from 'next/server';
import { db } from '@/lib/db';
import { annotations } from '@/lib/db/schema';
+import { eq, inArray } from 'drizzle-orm';
// GET all annotations
export async function GET() {
@@ -66,3 +67,42 @@ export async function POST(request: NextRequest) {
);
}
}
+
+// DELETE annotations with bulk operations
+export async function DELETE(request: NextRequest) {
+ try {
+ const { searchParams } = request.nextUrl;
+ const type = searchParams.get('type');
+ const all = searchParams.get('all');
+
+ let result;
+
+ if (all === 'true') {
+ // Delete all annotations
+ result = await db.delete(annotations).returning();
+ } else if (type) {
+ // Delete by type(s)
+ const types = type.split(',').map((t) => t.trim());
+ result = await db
+ .delete(annotations)
+ .where(inArray(annotations.label_type, types))
+ .returning();
+ } else {
+ // No filter specified
+ return NextResponse.json(
+ { error: 'Specify type or all parameter for bulk delete' },
+ { status: 400 }
+ );
+ }
+
+ return NextResponse.json({
+ success: true,
+ deleted: result.length,
+ });
+ } catch (error: any) {
+ return NextResponse.json(
+ { error: error.message || 'Failed to delete annotations' },
+ { status: 500 }
+ );
+ }
+}
diff --git a/src/app/api/health/route.ts b/src/app/api/health/route.ts
new file mode 100644
index 0000000..ee90023
--- /dev/null
+++ b/src/app/api/health/route.ts
@@ -0,0 +1,41 @@
+import { NextRequest, NextResponse } from 'next/server';
+import { db } from '@/lib/db';
+
+export async function GET(request: NextRequest) {
+ try {
+ const { searchParams } = request.nextUrl;
+ const checkDb = searchParams.get('check');
+
+ const response: any = {
+ status: 'ok',
+ timestamp: Date.now(),
+ };
+
+ if (checkDb === 'db') {
+ try {
+ // Test database connectivity with a simple query
+ await db.execute('SELECT 1');
+ response.database = 'ok';
+ } catch (dbError: any) {
+ return NextResponse.json(
+ {
+ status: 'error',
+ database: 'failed',
+ message: dbError.message || 'Database check failed',
+ },
+ { status: 503 }
+ );
+ }
+ }
+
+ return NextResponse.json(response);
+ } catch (error: any) {
+ return NextResponse.json(
+ {
+ status: 'error',
+ message: error.message || 'Health check failed',
+ },
+ { status: 500 }
+ );
+ }
+}
diff --git a/src/app/globals.css b/src/app/globals.css
index d40575c..5f33e3a 100644
--- a/src/app/globals.css
+++ b/src/app/globals.css
@@ -4,25 +4,25 @@
@layer base {
:root {
- --background: 222.2 84% 4.9%;
- --foreground: 210 40% 98%;
- --card: 222.2 84% 4.9%;
- --card-foreground: 210 40% 98%;
- --popover: 222.2 84% 4.9%;
- --popover-foreground: 210 40% 98%;
- --primary: 217.2 91.2% 59.8%;
- --primary-foreground: 222.2 47.4% 11.2%;
- --secondary: 217.2 32.6% 17.5%;
- --secondary-foreground: 210 40% 98%;
- --muted: 217.2 32.6% 17.5%;
- --muted-foreground: 215 20.2% 65.1%;
- --accent: 217.2 32.6% 17.5%;
- --accent-foreground: 210 40% 98%;
- --destructive: 0 62.8% 30.6%;
- --destructive-foreground: 210 40% 98%;
- --border: 217.2 32.6% 17.5%;
- --input: 217.2 32.6% 17.5%;
- --ring: 224.3 76.3% 48%;
+ --background: 120 100% 4%;
+ --foreground: 120 100% 50%;
+ --card: 120 100% 6%;
+ --card-foreground: 120 100% 50%;
+ --popover: 120 100% 4%;
+ --popover-foreground: 120 100% 50%;
+ --primary: 120 100% 50%;
+ --primary-foreground: 120 100% 4%;
+ --secondary: 120 100% 8%;
+ --secondary-foreground: 120 100% 50%;
+ --muted: 120 100% 10%;
+ --muted-foreground: 120 100% 40%;
+ --accent: 120 100% 50%;
+ --accent-foreground: 120 100% 4%;
+ --destructive: 348 100% 50%;
+ --destructive-foreground: 120 100% 4%;
+ --border: 120 100% 10%;
+ --input: 120 100% 8%;
+ --ring: 120 100% 50%;
--radius: 0.5rem;
}
}
@@ -33,6 +33,24 @@
}
body {
@apply bg-background text-foreground;
- font-family: Arial, Helvetica, sans-serif;
+ font-family: 'JetBrains Mono', 'Fira Code', 'Courier New', monospace;
+ }
+}
+
+@layer base {
+ ::selection {
+ @apply bg-matrix/40 text-matrix;
+ }
+
+ ::-webkit-scrollbar {
+ width: 8px;
+ }
+
+ ::-webkit-scrollbar-track {
+ @apply bg-matrixDark;
+ }
+
+ ::-webkit-scrollbar-thumb {
+ @apply bg-matrix rounded;
}
}
diff --git a/src/app/layout.tsx b/src/app/layout.tsx
index 246d32b..1aa2405 100644
--- a/src/app/layout.tsx
+++ b/src/app/layout.tsx
@@ -13,6 +13,12 @@ export default function RootLayout({
}>) {
return (
+
+
+
{children}
);
diff --git a/src/app/page.tsx b/src/app/page.tsx
index 0664b40..b13635c 100644
--- a/src/app/page.tsx
+++ b/src/app/page.tsx
@@ -1,13 +1,23 @@
'use client';
-import { useState, useRef } from 'react';
+import { useState, useRef, useEffect } from 'react';
import Toolbox, { Tool } from '@/components/Toolbox';
import FileUpload from '@/components/FileUpload';
import CandleChart, { CandleChartHandle } from '@/components/CandleChart';
+interface Annotation {
+ id: number;
+ timestamp: number;
+ label_type: string;
+ geometry: any;
+ created_at: number;
+}
+
export default function Home() {
const [activeTool, setActiveTool] = useState(null);
const [selectedColor, setSelectedColor] = useState('#3b82f6');
+ const [selectedLabelId, setSelectedLabelId] = useState(null);
+ const [annotations, setAnnotations] = useState([]);
const chartRef = useRef(null);
const handleExport = () => {
@@ -19,11 +29,63 @@ export default function Home() {
chartRef.current?.refreshData();
};
- const handleAnnotationChange = () => {
+ const handleAnnotationChange = async () => {
// Refresh chart when annotations change
- chartRef.current?.refreshData();
+ await chartRef.current?.refreshData();
+ // Fetch annotations for sidebar
+ const response = await fetch('/api/annotations');
+ const data = await response.json();
+ setAnnotations(data);
};
+ const handleLabelDelete = async (id: number) => {
+ // Remove from local state
+ setAnnotations(annotations.filter((a) => a.id !== id));
+ if (selectedLabelId === id) {
+ setSelectedLabelId(null);
+ }
+ };
+
+ const handleLabelSelect = (id: number) => {
+ setSelectedLabelId(id === -1 ? null : id);
+ };
+
+ // Fetch annotations on mount
+ useEffect(() => {
+ const fetchAnnotations = async () => {
+ try {
+ const response = await fetch('/api/annotations');
+ const data = await response.json();
+ setAnnotations(data);
+ } catch (error) {
+ console.error('Failed to fetch annotations:', error);
+ }
+ };
+ fetchAnnotations();
+ }, []);
+
+ // Keyboard handler for Delete/Backspace key
+ useEffect(() => {
+ const handleKeyDown = async (e: KeyboardEvent) => {
+ 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);
+ }
+ }
+ };
+
+ window.addEventListener('keydown', handleKeyDown);
+ return () => window.removeEventListener('keydown', handleKeyDown);
+ }, [selectedLabelId]);
+
return (
{/* Sidebar */}
@@ -40,6 +102,10 @@ export default function Home() {
onExport={handleExport}
selectedColor={selectedColor}
onColorChange={setSelectedColor}
+ annotations={annotations}
+ selectedLabelId={selectedLabelId}
+ onLabelSelect={handleLabelSelect}
+ onLabelDelete={handleLabelDelete}
/>
@@ -50,6 +116,8 @@ export default function Home() {
activeTool={activeTool}
onAnnotationChange={handleAnnotationChange}
selectedColor={selectedColor}
+ selectedLabelId={selectedLabelId}
+ onLabelSelect={handleLabelSelect}
/>
diff --git a/src/components/CandleChart.tsx b/src/components/CandleChart.tsx
index 63522f0..90d36d6 100644
--- a/src/components/CandleChart.tsx
+++ b/src/components/CandleChart.tsx
@@ -29,6 +29,8 @@ interface CandleChartProps {
activeTool: string | null;
onAnnotationChange?: () => void;
selectedColor: string;
+ selectedLabelId?: number | null;
+ onLabelSelect?: (id: number) => void;
}
export interface CandleChartHandle {
@@ -36,7 +38,7 @@ export interface CandleChartHandle {
}
const CandleChart = forwardRef(
- ({ activeTool, onAnnotationChange, selectedColor }, ref) => {
+ ({ activeTool, onAnnotationChange, selectedColor, selectedLabelId, onLabelSelect }, ref) => {
const chartContainerRef = useRef(null);
const chartRef = useRef(null);
const seriesRef = useRef | null>(null);
@@ -159,17 +161,23 @@ const CandleChart = forwardRef(
);
const markers = markerAnnotations
- .map((annotation) => ({
- time: annotation.timestamp as Time,
- position: annotation.label_type === 'break_up' ? ('belowBar' as const) : ('aboveBar' as const),
- color: annotation.label_type === 'break_up' ? '#22c55e' : '#ef4444',
- shape: annotation.label_type === 'break_up' ? ('arrowUp' as const) : ('arrowDown' as const),
- text: annotation.label_type === 'break_up' ? 'Break Up' : 'Break Down',
- }))
+ .map((annotation) => {
+ const isSelected = annotation.id === selectedLabelId;
+ return {
+ time: annotation.timestamp as Time,
+ position: annotation.label_type === 'break_up' ? ('belowBar' as const) : ('aboveBar' as const),
+ color: isSelected
+ ? (annotation.label_type === 'break_up' ? '#00ff41' : '#ff0040')
+ : (annotation.label_type === 'break_up' ? '#22c55e' : '#ef4444'),
+ shape: annotation.label_type === 'break_up' ? ('arrowUp' as const) : ('arrowDown' as const),
+ text: annotation.label_type === 'break_up' ? 'Break Up' : 'Break Down',
+ size: isSelected ? 2 : 1,
+ };
+ })
.sort((a, b) => (a.time as number) - (b.time as number));
seriesRef.current.setMarkers(markers);
- }, [annotations]);
+ }, [annotations, selectedLabelId]);
// Handle chart clicks for annotation
useEffect(() => {
@@ -244,6 +252,23 @@ const CandleChart = forwardRef(
}
}
}
+
+ // Select/deselect label markers by clicking them
+ if (!activeTool || activeTool === 'break_up' || activeTool === 'break_down') {
+ const timestamp = typeof time === 'string' ? Date.parse(time) / 1000 : (time as number);
+
+ // 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') &&
+ Math.abs(a.timestamp - timestamp) < tolerance
+ );
+
+ if (annotation) {
+ onLabelSelect?.(annotation.id === selectedLabelId ? -1 : annotation.id);
+ }
+ }
};
chartRef.current.subscribeClick(handleClick);
diff --git a/src/components/Toolbox.tsx b/src/components/Toolbox.tsx
index 845584f..69febeb 100644
--- a/src/components/Toolbox.tsx
+++ b/src/components/Toolbox.tsx
@@ -1,20 +1,47 @@
'use client';
import { useState } from 'react';
-import { ArrowUpCircle, ArrowDownCircle, TrendingUp, Trash2, Download } from 'lucide-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;
+interface Annotation {
+ id: number;
+ timestamp: number;
+ label_type: string;
+ geometry: any;
+ created_at: number;
+}
+
interface ToolboxProps {
activeTool: Tool;
onToolChange: (tool: Tool) => void;
onExport: () => void;
selectedColor: string;
onColorChange: (color: string) => void;
+ annotations?: Annotation[];
+ selectedLabelId?: number | null;
+ onLabelSelect?: (id: number) => void;
+ onLabelDelete?: (id: number) => void;
}
-export default function Toolbox({ activeTool, onToolChange, onExport, selectedColor, onColorChange }: ToolboxProps) {
+export default function Toolbox({
+ activeTool,
+ onToolChange,
+ onExport,
+ selectedColor,
+ onColorChange,
+ annotations = [],
+ selectedLabelId = null,
+ onLabelSelect,
+ onLabelDelete,
+}: ToolboxProps) {
+ const [labelsExpanded, setLabelsExpanded] = useState(true);
+ const [searchText, setSearchText] = useState('');
+ const [filterType, setFilterType] = useState<'all' | 'break_up' | 'break_down'>('all');
+
const handleToolClick = (tool: Tool) => {
// Toggle: if clicking the active tool, deactivate it
if (activeTool === tool) {
@@ -24,14 +51,48 @@ export default function Toolbox({ activeTool, onToolChange, onExport, selectedCo
}
};
+ // Filter and sort annotations
+ const labelAnnotations = annotations
+ .filter((a) => a.label_type === 'break_up' || a.label_type === 'break_down')
+ .filter((a) => (filterType === 'all' ? true : a.label_type === filterType))
+ .sort((a, b) => b.timestamp - a.timestamp);
+
+ // Apply search filter
+ const filteredAnnotations = labelAnnotations.filter((a) => {
+ const formattedTime = new Date(a.timestamp * 1000).toLocaleString('en-US', {
+ month: 'short',
+ day: 'numeric',
+ hour: '2-digit',
+ minute: '2-digit',
+ });
+ 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;
+
+ const handleLabelDelete = async (id: number, e: React.MouseEvent) => {
+ e.stopPropagation();
+ try {
+ const response = await fetch(`/api/annotations/${id}`, {
+ method: 'DELETE',
+ });
+ if (response.ok) {
+ onLabelDelete?.(id);
+ }
+ } catch (error) {
+ console.error('Failed to delete label:', error);
+ }
+ };
+
return (
-
+
Annotation Tools