feat: initialize Next.js project with database schema

- Set up Next.js with App Router, TypeScript, Tailwind CSS
- Configure shadcn/ui with dark theme
- Install dependencies: lightweight-charts, papaparse, lucide-react
- Set up Drizzle ORM with better-sqlite3
- Create database schema for candles and annotations tables
- Generate migration SQL
This commit is contained in:
Marko Djordjevic 2026-02-12 10:23:02 +01:00
parent 7d2fc42b73
commit d04b673cfa
25 changed files with 903 additions and 0 deletions

3
.eslintrc.json Normal file
View file

@ -0,0 +1,3 @@
{
"extends": "next/core-web-vitals"
}

9
.gitignore vendored
View file

@ -1 +1,10 @@
.claude
node_modules
.next
dist
build
*.log
.env*.local
.DS_Store
data/
drizzle/

17
components.json Normal file
View file

@ -0,0 +1,17 @@
{
"$schema": "https://ui.shadcn.com/schema.json",
"style": "default",
"rsc": true,
"tsx": true,
"tailwind": {
"config": "tailwind.config.ts",
"css": "src/app/globals.css",
"baseColor": "slate",
"cssVariables": true,
"prefix": ""
},
"aliases": {
"components": "@/components",
"utils": "@/lib/utils"
}
}

10
drizzle.config.ts Normal file
View file

@ -0,0 +1,10 @@
import { defineConfig } from 'drizzle-kit';
export default defineConfig({
schema: './src/lib/db/schema.ts',
out: './drizzle',
dialect: 'sqlite',
dbCredentials: {
url: './data/candles.db',
},
});

4
next.config.js Normal file
View file

@ -0,0 +1,4 @@
/** @type {import('next').NextConfig} */
const nextConfig = {}
module.exports = nextConfig

View file

@ -0,0 +1,121 @@
## Context
This is a greenfield Next.js application for annotating EUR/USD candlestick charts. A trader uploads historical OHLC data as CSV, views it on an interactive chart, labels candles with pattern types (Break Up, Break Down), draws trend lines, and exports annotations for ML training pipelines. There is no existing codebase — the project starts from scratch.
The primary user is a single trader/researcher working locally. The app runs on localhost with a local SQLite database. There is no authentication, multi-user, or deployment requirement at this stage.
## Goals / Non-Goals
**Goals:**
- Provide a responsive candlestick chart with TradingView-like feel using lightweight-charts
- Enable point annotations (Break Up/Down markers) via click-on-candle interaction
- Enable two-click line drawing (trend lines) with persistent storage
- Store all candle data and annotations in local SQLite via Drizzle ORM
- Support CSV import of OHLC data and CSV export of annotations
- Dark mode UI with a sidebar toolbox
**Non-Goals:**
- Real-time data feeds or live trading integration
- Multi-user collaboration or authentication
- Cloud deployment or remote database
- Complex drawing tools beyond basic two-point lines
- Undo/redo system (v1 uses delete instead)
- Mobile-optimized layout
## Decisions
### 1. Next.js App Router with Server-Side SQLite
**Choice:** Next.js App Router with Route Handlers for API, SQLite accessed server-side only.
**Rationale:** App Router provides a clean separation between client components (chart, toolbox) and server-side API routes that access SQLite. SQLite + better-sqlite3 is synchronous and fast for a single-user local app — no need for a separate database server.
**Alternatives considered:**
- Pages Router: Older pattern, App Router is the current Next.js standard
- PostgreSQL: Overkill for local single-user; requires a running server
- API-less architecture (server components only): Doesn't work well since the chart is a heavy client component that needs to fetch data dynamically
### 2. Lightweight Charts for Charting
**Choice:** `lightweight-charts` v4 by TradingView.
**Rationale:** Purpose-built for financial candlestick charts. Has built-in `setMarkers()` API for annotation display. Performant with large datasets. Free and open source.
**Alternatives considered:**
- D3.js: Too low-level; would require building candlestick rendering from scratch
- Chart.js: Not designed for financial charts; no candlestick support without plugins
- Full TradingView widget: Requires API key and has usage restrictions
### 3. Annotation Overlay Architecture
**Choice:** Use lightweight-charts native `setMarkers()` for point annotations. For line drawings, use an absolutely-positioned transparent SVG overlay on top of the chart container that transforms coordinates using the chart's `timeScale()` and `priceScale()` APIs.
**Rationale:** `setMarkers()` handles Break Up/Down arrows natively with proper zoom/scroll behavior. Lines require custom rendering since lightweight-charts doesn't have a built-in line drawing tool. An SVG overlay keeps drawing logic separate from chart internals while allowing coordinate synchronization.
**Alternatives considered:**
- Canvas overlay: More complex coordinate handling; SVG is simpler for lines
- lightweight-charts plugins API: Still experimental and poorly documented
- Price lines only: Limited to horizontal lines; can't draw diagonal trend lines
### 4. Coordinate Mapping for Click Interaction
**Choice:** On chart click, use `chart.timeScale().coordinateToTime(x)` and `series.coordinateToPrice(y)` to map pixel coordinates to candle time/price. Snap to the nearest candle timestamp for point annotations.
**Rationale:** This is the documented approach from lightweight-charts. Snapping to candle timestamps ensures annotations align with actual data points rather than arbitrary positions.
### 5. Drizzle ORM with better-sqlite3
**Choice:** Drizzle ORM with better-sqlite3 driver.
**Rationale:** Type-safe schema definitions, lightweight migration system, synchronous SQLite access. Drizzle's schema-as-code approach keeps the database definition in TypeScript alongside the app code.
**Alternatives considered:**
- Prisma: Heavier, async-only, overkill for SQLite
- Raw better-sqlite3: No type safety, manual query building
- Turso/libsql: Cloud-oriented; unnecessary for local use
### 6. UI Component Strategy
**Choice:** Tailwind CSS + shadcn/ui for UI components, lucide-react for icons.
**Rationale:** shadcn/ui provides accessible, composable components that are copied into the project (not a runtime dependency). Dark mode is straightforward with Tailwind's `dark:` variants. lucide-react provides the icon set needed for toolbox buttons.
### 7. Database Schema
**Two tables:**
- **candles**: `id` (integer PK), `time` (integer, unix timestamp), `open` (real), `high` (real), `low` (real), `close` (real). Unique constraint on `time`.
- **annotations**: `id` (integer PK), `timestamp` (integer, references candle time), `label_type` (text: "break_up", "break_down", "line"), `geometry` (text, nullable JSON for line coordinates: `{startTime, startPrice, endTime, endPrice}`), `created_at` (integer, unix timestamp).
### 8. File Structure
```
src/
app/
layout.tsx — Root layout with dark theme
page.tsx — Main page composing chart + toolbox
api/
upload/route.ts — POST: parse CSV, store candles
annotations/route.ts — GET: fetch annotations, POST: create annotation
export/route.ts — GET: download annotations as CSV
components/
CandleChart.tsx — Chart wrapper (client component)
Toolbox.tsx — Sidebar with tool buttons
FileUpload.tsx — CSV upload dropzone
SvgOverlay.tsx — Line drawing overlay
lib/
db/
index.ts — Drizzle client instance
schema.ts — Table definitions
migrate.ts — Migration runner
```
## Risks / Trade-offs
- **[SVG overlay sync]** The SVG overlay for lines must stay in sync with chart zoom/scroll state. → Mitigation: Subscribe to `timeScale().subscribeVisibleTimeRangeChange()` and re-render overlay coordinates on every change.
- **[Large CSV files]** Very large CSV files (100k+ rows) may cause slow uploads. → Mitigation: Use streaming parse with papaparse and batch inserts in SQLite transactions.
- **[Single-user SQLite]** SQLite doesn't support concurrent writes. → Mitigation: Acceptable for a single-user local tool. Not a concern unless the app is deployed for multiple users (explicitly a non-goal).
- **[No undo]** Users can only delete annotations, not undo placement. → Mitigation: Delete tool is sufficient for v1. Undo/redo can be added later if needed.
- **[lightweight-charts v4 breaking changes]** The library has had breaking API changes between major versions. → Mitigation: Pin to a specific v4.x version in package.json.

View file

@ -0,0 +1,70 @@
## ADDED Requirements
### Requirement: Active tool mode
The system SHALL maintain an "active tool" state that determines what happens when the user clicks on the chart. Available tool modes are: "select" (default, no action on click), "break_up" (label Break Up), "break_down" (label Break Down), "line" (draw trend line), and "delete" (remove annotation). Only one tool SHALL be active at a time.
#### Scenario: Tool activation
- **WHEN** user clicks a tool button in the sidebar
- **THEN** that tool becomes the active tool and the button appears visually selected
#### Scenario: Tool deactivation
- **WHEN** user clicks the already-active tool button
- **THEN** the tool deactivates and the mode returns to "select"
### Requirement: Break Up labeling
When the "break_up" tool is active and the user clicks on the chart, the system SHALL identify the nearest candle to the click coordinates using `chart.timeScale().coordinateToTime()`. The system SHALL save an annotation with `label_type: "break_up"` and the candle's timestamp to the database. A green upward arrow marker SHALL appear on the chart immediately.
#### Scenario: Place Break Up label
- **WHEN** "break_up" tool is active and user clicks on a candle
- **THEN** system saves a "break_up" annotation for that candle's timestamp and displays a green arrow marker above the bar
#### Scenario: Click between candles
- **WHEN** "break_up" tool is active and user clicks between two candles
- **THEN** system snaps to the nearest candle timestamp and places the annotation there
### Requirement: Break Down labeling
When the "break_down" tool is active and the user clicks on the chart, the system SHALL behave identically to Break Up labeling but save `label_type: "break_down"` and display a red downward arrow below the bar.
#### Scenario: Place Break Down label
- **WHEN** "break_down" tool is active and user clicks on a candle
- **THEN** system saves a "break_down" annotation for that candle's timestamp and displays a red arrow marker below the bar
### Requirement: Two-click line drawing
When the "line" tool is active, the system SHALL implement a two-click drawing interaction. The first click sets the start point (time, price). The second click sets the end point (time, price). After the second click, the system SHALL save an annotation with `label_type: "line"` and `geometry` containing JSON: `{"startTime": <unix>, "startPrice": <float>, "endTime": <unix>, "endPrice": <float>}`. The line SHALL render immediately on the SVG overlay.
#### Scenario: Draw a trend line
- **WHEN** "line" tool is active and user clicks two points on the chart
- **THEN** system saves a line annotation with start/end coordinates and renders the line on the overlay
#### Scenario: Visual feedback during line drawing
- **WHEN** "line" tool is active and user has clicked the first point but not the second
- **THEN** system displays a preview line from the first point to the current cursor position
#### Scenario: Cancel line drawing
- **WHEN** user presses Escape during a two-click line drawing (after first click)
- **THEN** system cancels the line drawing and clears the preview without saving
### Requirement: Delete annotation
When the "delete" tool is active and the user clicks on or near an existing annotation (marker or line), the system SHALL remove that annotation from the database and update the chart display immediately.
#### Scenario: Delete a marker annotation
- **WHEN** "delete" tool is active and user clicks on a candle that has a marker annotation
- **THEN** system removes the annotation from the database and the marker disappears from the chart
#### Scenario: Delete a line annotation
- **WHEN** "delete" tool is active and user clicks near an existing line on the overlay
- **THEN** system removes the line annotation from the database and the line disappears from the overlay
### Requirement: Coordinate mapping
The system SHALL convert mouse click pixel coordinates to chart data coordinates (time and price) using the lightweight-charts API: `chart.timeScale().coordinateToTime(x)` for time and `series.coordinateToPrice(y)` for price. For point annotations, the time SHALL be snapped to the nearest candle timestamp.
#### Scenario: Pixel to data coordinate conversion
- **WHEN** user clicks at pixel position (x, y) on the chart
- **THEN** system correctly converts to the corresponding time and price values using the chart API
### Requirement: Annotations database table
The system SHALL store annotations in an `annotations` table with columns: `id` (integer primary key, auto-increment), `timestamp` (integer, Unix timestamp referencing a candle time), `label_type` (text: "break_up", "break_down", or "line"), `geometry` (text, nullable, JSON string for line coordinates), `created_at` (integer, Unix timestamp of creation).
#### Scenario: Schema structure
- **WHEN** the database is initialized
- **THEN** the `annotations` table exists with all required columns

View file

@ -0,0 +1,75 @@
## ADDED Requirements
### Requirement: Upload endpoint
The system SHALL provide a `POST /api/upload` endpoint that accepts a CSV file via multipart form data. The endpoint SHALL parse the CSV using papaparse, validate the format, and insert all candle records into the `candles` table within a single database transaction. On success, the endpoint SHALL return a JSON response with the count of inserted records. On failure, it SHALL return an appropriate error status and message.
#### Scenario: Successful upload
- **WHEN** a valid CSV file is sent to POST /api/upload
- **THEN** endpoint returns `{ "success": true, "count": <number> }` with HTTP 200
#### Scenario: Invalid CSV upload
- **WHEN** a CSV with missing or invalid headers is sent to POST /api/upload
- **THEN** endpoint returns `{ "error": "<description>" }` with HTTP 400
#### Scenario: No file provided
- **WHEN** POST /api/upload is called without a file
- **THEN** endpoint returns `{ "error": "No file provided" }` with HTTP 400
### Requirement: Get annotations endpoint
The system SHALL provide a `GET /api/annotations` endpoint that returns all annotations from the database as a JSON array. Each annotation object SHALL include: `id`, `timestamp`, `label_type`, `geometry` (parsed from JSON string or null), and `created_at`.
#### Scenario: Fetch all annotations
- **WHEN** GET /api/annotations is called
- **THEN** endpoint returns a JSON array of all annotation objects with HTTP 200
#### Scenario: No annotations exist
- **WHEN** GET /api/annotations is called and no annotations are in the database
- **THEN** endpoint returns an empty JSON array `[]` with HTTP 200
### Requirement: Create annotation endpoint
The system SHALL provide a `POST /api/annotations` endpoint that accepts a JSON body with fields: `timestamp` (required, integer), `label_type` (required, string), and `geometry` (optional, object). The endpoint SHALL validate the input, serialize geometry to JSON string if present, and insert the record into the `annotations` table. On success, it SHALL return the created annotation object with its assigned `id`.
#### Scenario: Create a marker annotation
- **WHEN** POST /api/annotations is called with `{ "timestamp": 1700000000, "label_type": "break_up" }`
- **THEN** endpoint saves the annotation and returns the created object with `id` and HTTP 201
#### Scenario: Create a line annotation
- **WHEN** POST /api/annotations is called with `{ "timestamp": 1700000000, "label_type": "line", "geometry": { "startTime": 1700000000, "startPrice": 1.05, "endTime": 1700100000, "endPrice": 1.06 } }`
- **THEN** endpoint saves the annotation with serialized geometry JSON and returns the created object with HTTP 201
#### Scenario: Invalid annotation data
- **WHEN** POST /api/annotations is called with missing required fields
- **THEN** endpoint returns `{ "error": "<description>" }` with HTTP 400
### Requirement: Delete annotation endpoint
The system SHALL provide a `DELETE /api/annotations/[id]` endpoint that removes an annotation by its ID. On success, it SHALL return HTTP 200. If the annotation does not exist, it SHALL return HTTP 404.
#### Scenario: Delete existing annotation
- **WHEN** DELETE /api/annotations/5 is called and annotation with id 5 exists
- **THEN** endpoint removes the annotation and returns HTTP 200
#### Scenario: Delete non-existent annotation
- **WHEN** DELETE /api/annotations/999 is called and no annotation with that id exists
- **THEN** endpoint returns HTTP 404
### Requirement: Export annotations endpoint
The system SHALL provide a `GET /api/export` endpoint that returns all annotations as a downloadable CSV file. The CSV SHALL have columns: `timestamp`, `label_type`, `price` (extracted from geometry for lines, or the candle's close price for markers). The response SHALL set `Content-Type: text/csv` and `Content-Disposition: attachment; filename="annotations.csv"` headers.
#### Scenario: Export as CSV
- **WHEN** GET /api/export is called and annotations exist
- **THEN** endpoint returns a CSV file download with all annotations
#### Scenario: Export with no data
- **WHEN** GET /api/export is called and no annotations exist
- **THEN** endpoint returns a CSV file with only the header row
### Requirement: Get candles endpoint
The system SHALL provide a `GET /api/candles` endpoint that returns all candle records from the database as a JSON array, ordered by time ascending. Each object SHALL include: `time`, `open`, `high`, `low`, `close`.
#### Scenario: Fetch all candles
- **WHEN** GET /api/candles is called
- **THEN** endpoint returns a JSON array of candle objects ordered by time ascending with HTTP 200
#### Scenario: No candles exist
- **WHEN** GET /api/candles is called and no candle data is in the database
- **THEN** endpoint returns an empty JSON array `[]` with HTTP 200

View file

@ -0,0 +1,52 @@
## ADDED Requirements
### Requirement: Candlestick chart rendering
The system SHALL render candle data as a candlestick chart using the `lightweight-charts` library (v4). The chart MUST display OHLC data with standard candlestick visuals (green for bullish, red for bearish). The chart SHALL be a client-side React component.
#### Scenario: Chart renders candle data
- **WHEN** candle data exists in the database and the page loads
- **THEN** the chart renders all candles as a candlestick series with correct open/high/low/close values
#### Scenario: Empty state
- **WHEN** no candle data exists in the database
- **THEN** the chart area displays an empty chart with a prompt to upload CSV data
### Requirement: Chart interactivity
The chart SHALL support zooming (mouse wheel), panning (click and drag on time axis), and crosshair display (showing price/time on hover). These are built-in lightweight-charts behaviors that MUST be enabled.
#### Scenario: Zoom and pan
- **WHEN** user scrolls the mouse wheel over the chart
- **THEN** the chart zooms in or out on the time axis
#### Scenario: Crosshair display
- **WHEN** user hovers the mouse over the chart
- **THEN** a crosshair displays with the current price and time at the cursor position
### Requirement: Responsive chart layout
The chart MUST fill the available width of the main content area (excluding the sidebar). The chart height SHALL be responsive, using the full viewport height minus header/toolbar areas. The chart MUST resize when the browser window is resized.
#### Scenario: Window resize
- **WHEN** user resizes the browser window
- **THEN** the chart resizes to fill the available space without requiring a page reload
### Requirement: Dark theme chart
The chart SHALL use a dark color scheme consistent with the application's Slate-900 dark theme. Background MUST be dark, grid lines subtle, and text/crosshair in light colors.
#### Scenario: Dark theme applied
- **WHEN** the chart renders
- **THEN** the chart background, grid, text, and crosshair colors match the dark theme (dark background, light text)
### Requirement: Annotation markers on chart
The chart SHALL display visual markers for existing annotations using the `series.setMarkers()` API. Break Up annotations MUST appear as green upward arrows above the bar. Break Down annotations MUST appear as red downward arrows below the bar. Markers MUST update when annotations are added or deleted.
#### Scenario: Break Up marker display
- **WHEN** a Break Up annotation exists for a candle timestamp
- **THEN** a green upward arrow marker appears above that candle on the chart
#### Scenario: Break Down marker display
- **WHEN** a Break Down annotation exists for a candle timestamp
- **THEN** a red downward arrow marker appears below that candle on the chart
#### Scenario: Marker updates on annotation change
- **WHEN** user adds or deletes an annotation
- **THEN** chart markers update immediately without requiring a page reload

View file

@ -0,0 +1,42 @@
## ADDED Requirements
### Requirement: CSV file upload
The system SHALL provide a file upload component that accepts CSV files containing OHLC candle data. The CSV format MUST have columns: `time`, `open`, `high`, `low`, `close`. The `time` column SHALL accept both `YYYY-MM-DD` date strings and Unix timestamps (integer seconds).
#### Scenario: Valid CSV upload
- **WHEN** user uploads a CSV file with valid headers (time, open, high, low, close) and valid data rows
- **THEN** system parses all rows and stores them in the `candles` database table
#### Scenario: CSV with Unix timestamps
- **WHEN** user uploads a CSV where the `time` column contains Unix timestamps (e.g., 1700000000)
- **THEN** system stores the timestamps as integers in the database and renders candles correctly on the chart
#### Scenario: CSV with date strings
- **WHEN** user uploads a CSV where the `time` column contains date strings (e.g., "2024-01-15")
- **THEN** system converts dates to Unix timestamps and stores them in the database
#### Scenario: Invalid CSV format
- **WHEN** user uploads a CSV missing required headers or containing malformed data
- **THEN** system displays an error message describing the issue and does not store any partial data
#### Scenario: Duplicate upload
- **WHEN** user uploads a CSV containing candle times that already exist in the database
- **THEN** system replaces existing candle records with the new data (upsert behavior)
### Requirement: CSV parsing with papaparse
The system SHALL use the `papaparse` library for CSV parsing. Parsing SHALL handle large files by using streaming mode for files exceeding 10,000 rows. Parsed records SHALL be inserted into SQLite within a single database transaction for atomicity.
#### Scenario: Large file parsing
- **WHEN** user uploads a CSV with more than 10,000 rows
- **THEN** system uses streaming parse and batch inserts within a transaction, completing without memory issues
#### Scenario: Transaction atomicity
- **WHEN** a parse error occurs midway through a CSV file
- **THEN** system rolls back the entire transaction and no partial data is stored
### Requirement: Candles database table
The system SHALL store candle data in a `candles` table with columns: `id` (integer primary key, auto-increment), `time` (integer, Unix timestamp, unique), `open` (real), `high` (real), `low` (real), `close` (real). The `time` column MUST have a unique constraint.
#### Scenario: Schema structure
- **WHEN** the database is initialized
- **THEN** the `candles` table exists with all required columns and constraints

View file

@ -0,0 +1,44 @@
## ADDED Requirements
### Requirement: Dark mode layout
The application SHALL use a dark theme based on Tailwind's Slate-900 color palette. The root layout MUST set a dark background. All UI components (sidebar, buttons, text) SHALL use colors consistent with the dark theme.
#### Scenario: Dark theme renders
- **WHEN** the application loads
- **THEN** the page background is Slate-900 (dark), text is light, and all UI elements follow the dark color scheme
### Requirement: Sidebar toolbox
The application SHALL display a fixed sidebar on the left side of the viewport. The sidebar SHALL contain tool buttons: "Label: Break Up", "Label: Break Down", "Draw Line", "Delete". Each button SHALL use a lucide-react icon and a text label. The currently active tool button SHALL be visually highlighted.
#### Scenario: Sidebar renders with tools
- **WHEN** the application loads
- **THEN** the sidebar displays all four tool buttons with icons and labels
#### Scenario: Active tool highlight
- **WHEN** user selects a tool
- **THEN** that tool's button is visually highlighted (distinct background/border color) and other buttons return to default state
### Requirement: Main chart area
The main content area SHALL occupy the full viewport width minus the sidebar width. The chart component MUST fill this area. The layout SHALL use CSS flexbox or grid to ensure the sidebar and chart area are side by side.
#### Scenario: Layout structure
- **WHEN** the application loads with candle data
- **THEN** the sidebar appears on the left and the chart fills the remaining space
### Requirement: File upload interface
The application SHALL provide a CSV file upload interface accessible from the UI. This MAY be a button in the sidebar or a dropzone overlay. The upload component SHALL trigger the POST /api/upload endpoint and refresh the chart data on success.
#### Scenario: Upload via UI
- **WHEN** user selects a CSV file through the upload interface
- **THEN** the file is sent to the upload API and the chart refreshes with the new data on success
#### Scenario: Upload error display
- **WHEN** the upload API returns an error
- **THEN** the UI displays the error message to the user
### Requirement: Export button
The application SHALL provide an "Export" button that triggers a download of the annotations CSV. Clicking the button SHALL navigate to or fetch from the `GET /api/export` endpoint, resulting in a CSV file download.
#### Scenario: Export annotations
- **WHEN** user clicks the "Export" button
- **THEN** a CSV file named "annotations.csv" downloads containing all annotation data

View file

@ -0,0 +1,91 @@
## 1. Project Setup & Configuration
- [ ] 1.1 Initialize Next.js project with App Router, TypeScript, and Tailwind CSS using `npx create-next-app@latest` (with src/ directory, App Router, Tailwind CSS enabled)
- [ ] 1.2 Install core dependencies: `lightweight-charts`, `papaparse`, `lucide-react`, and their type packages (`@types/papaparse`)
- [ ] 1.3 Install database dependencies: `drizzle-orm`, `better-sqlite3`, `@types/better-sqlite3`, and `drizzle-kit` (dev dependency)
- [ ] 1.4 Initialize shadcn/ui with dark theme defaults (`npx shadcn-ui@latest init` with slate color scheme)
- [ ] 1.5 Add shadcn/ui components needed: Button, Tooltip (via `npx shadcn-ui@latest add button tooltip`)
- [ ] 1.6 Configure `drizzle.config.ts` pointing to local SQLite database file (`./data/candles.db`)
## 2. Database Schema & ORM Setup
- [ ] 2.1 Create `src/lib/db/schema.ts` defining the `candles` table: id (integer PK auto-increment), time (integer, unique), open (real), high (real), low (real), close (real)
- [ ] 2.2 Add `annotations` table to `src/lib/db/schema.ts`: id (integer PK auto-increment), timestamp (integer), label_type (text), geometry (text, nullable), created_at (integer)
- [ ] 2.3 Create `src/lib/db/index.ts` with Drizzle client instance using better-sqlite3 driver, connecting to `./data/candles.db`
- [ ] 2.4 Create `src/lib/db/migrate.ts` to run Drizzle migrations on app startup (ensure tables exist)
- [ ] 2.5 Generate initial migration with `npx drizzle-kit generate` and verify schema creates correctly
- [ ] 2.6 Test database connection by running the migration and confirming tables exist in SQLite
## 3. Backend API — Data Ingestion
- [ ] 3.1 Create `src/app/api/upload/route.ts` with POST handler that accepts multipart form data containing a CSV file
- [ ] 3.2 Implement CSV parsing using papaparse: validate required headers (time, open, high, low, close), handle both date strings and Unix timestamps for the time column
- [ ] 3.3 Implement batch insert of parsed candle records into the `candles` table within a single SQLite transaction, with upsert behavior on duplicate timestamps
- [ ] 3.4 Return JSON response: `{ success: true, count: N }` on success, `{ error: "message" }` with HTTP 400 on failure
- [ ] 3.5 Test upload endpoint manually with a sample CSV file
## 4. Backend API — Annotations CRUD
- [ ] 4.1 Create `src/app/api/annotations/route.ts` with GET handler returning all annotations as JSON array (parsing geometry from JSON string)
- [ ] 4.2 Add POST handler to the annotations route: validate required fields (timestamp, label_type), serialize geometry to JSON string if present, insert into database, return created object with HTTP 201
- [ ] 4.3 Create `src/app/api/annotations/[id]/route.ts` with DELETE handler: remove annotation by ID, return 200 on success, 404 if not found
- [ ] 4.4 Create `src/app/api/candles/route.ts` with GET handler returning all candle records ordered by time ascending as JSON array
## 5. Backend API — Export
- [ ] 5.1 Create `src/app/api/export/route.ts` with GET handler that queries all annotations and formats them as CSV (columns: timestamp, label_type, price)
- [ ] 5.2 For marker annotations (break_up, break_down), look up the candle's close price by timestamp; for line annotations, use startPrice from geometry
- [ ] 5.3 Set response headers: `Content-Type: text/csv` and `Content-Disposition: attachment; filename="annotations.csv"`
- [ ] 5.4 Handle empty annotations case by returning CSV with header row only
## 6. UI Shell & Layout
- [ ] 6.1 Update `src/app/layout.tsx` to set dark theme: Slate-900 background, light text, proper font and metadata
- [ ] 6.2 Create `src/app/page.tsx` as the main page with a flexbox layout: fixed-width sidebar on the left, chart area filling remaining space
- [ ] 6.3 Create `src/components/Toolbox.tsx` sidebar component with tool buttons using lucide-react icons: "Label: Break Up" (ArrowUpCircle), "Label: Break Down" (ArrowDownCircle), "Draw Line" (Minus/TrendingUp), "Delete" (Trash2)
- [ ] 6.4 Implement active tool state management: clicking a tool highlights it, clicking again deactivates it, only one tool active at a time
- [ ] 6.5 Add the Export button to the sidebar that triggers a download from GET /api/export (using an anchor tag or `window.location`)
## 7. File Upload Component
- [ ] 7.1 Create `src/components/FileUpload.tsx` as a client component with a file input that accepts `.csv` files
- [ ] 7.2 On file selection, send the file to POST /api/upload using FormData and fetch
- [ ] 7.3 Display success message with row count or error message from the API response
- [ ] 7.4 After successful upload, trigger a callback to refresh candle data on the chart
- [ ] 7.5 Place the FileUpload component in the sidebar (inside Toolbox or above it)
## 8. Candlestick Chart Component
- [ ] 8.1 Create `src/components/CandleChart.tsx` as a client component ("use client") that initializes a lightweight-charts chart instance in a useEffect/useRef pattern
- [ ] 8.2 Fetch candle data from GET /api/candles on mount and set it on a CandlestickSeries
- [ ] 8.3 Apply dark theme options to the chart: dark background matching Slate-900, subtle grid lines, light crosshair and text colors
- [ ] 8.4 Enable built-in interactivity: crosshair, zoom (mouse wheel), pan (drag on time axis)
- [ ] 8.5 Implement chart resize handling: use ResizeObserver on the container div to call `chart.resize()` when dimensions change
- [ ] 8.6 Fetch annotations from GET /api/annotations on mount and convert marker annotations (break_up, break_down) to lightweight-charts marker format, apply with `series.setMarkers()`
- [ ] 8.7 Expose a `refreshData` method (via useImperativeHandle or callback prop) so parent can trigger re-fetching candles and annotations after upload or annotation changes
## 9. Annotation Click Handling
- [ ] 9.1 Subscribe to chart click events: on chart click, use `chart.timeScale().coordinateToTime(x)` and `series.coordinateToPrice(y)` to convert pixel coordinates to data coordinates
- [ ] 9.2 For "break_up" and "break_down" active tools: snap clicked time to nearest candle timestamp, POST annotation to /api/annotations with label_type and timestamp, then refresh markers on the chart
- [ ] 9.3 For "delete" active tool on marker click: identify if a marker annotation exists at the clicked candle timestamp, if so call DELETE /api/annotations/[id] and refresh markers
- [ ] 9.4 Handle edge cases: clicking outside the data range (no valid time coordinate), clicking when no tool is active (do nothing)
## 10. Line Drawing — SVG Overlay
- [ ] 10.1 Create `src/components/SvgOverlay.tsx` as a transparent SVG element absolutely positioned over the chart container, matching the chart's dimensions
- [ ] 10.2 Implement coordinate transformation functions: convert data coordinates (time, price) to SVG pixel coordinates using `chart.timeScale().timeToCoordinate()` and `series.priceToCoordinate()`
- [ ] 10.3 Render existing line annotations by fetching from GET /api/annotations, filtering for label_type "line", and drawing SVG `<line>` elements using transformed coordinates
- [ ] 10.4 Subscribe to chart visible range changes (`timeScale().subscribeVisibleTimeRangeChange()`) and re-render SVG lines when user zooms or pans
- [ ] 10.5 Implement two-click line drawing: first click stores start point (time, price), mouse move draws preview line from start to cursor, second click saves the line via POST /api/annotations with geometry JSON
- [ ] 10.6 Implement Escape key to cancel in-progress line drawing (clear preview, reset state)
- [ ] 10.7 Implement line deletion: when "delete" tool is active, detect clicks near an existing SVG line (within a pixel tolerance), call DELETE /api/annotations/[id] for that line
## 11. Integration & Final Wiring
- [ ] 11.1 Wire up state flow in page.tsx: active tool state passed from Toolbox to CandleChart and SvgOverlay, upload callback from FileUpload triggers chart refresh
- [ ] 11.2 Ensure annotations refresh after every create/delete operation (markers update via setMarkers, lines update via SVG re-render)
- [ ] 11.3 Add empty state handling: when no candles are loaded, display a message prompting the user to upload a CSV file
- [ ] 11.4 Verify full workflow end-to-end: upload CSV → view candles → place Break Up marker → place Break Down marker → draw a line → delete an annotation → export CSV
- [ ] 11.5 Create DEPLOYMENT.md with steps to run the app locally (npm install, database setup, npm run dev)
- [ ] 11.6 Create README.md with project overview, tech stack, and usage instructions

43
package.json Normal file
View file

@ -0,0 +1,43 @@
{
"name": "candle_annotator",
"version": "1.0.0",
"description": "",
"main": "index.js",
"scripts": {
"dev": "next dev",
"build": "next build",
"start": "next start",
"lint": "next lint"
},
"keywords": [],
"author": "",
"license": "ISC",
"type": "commonjs",
"dependencies": {
"@types/better-sqlite3": "^7.6.13",
"@types/node": "^25.2.3",
"@types/papaparse": "^5.5.2",
"@types/react": "^19.2.14",
"@types/react-dom": "^19.2.3",
"autoprefixer": "^10.4.24",
"better-sqlite3": "^12.6.2",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"drizzle-orm": "^0.45.1",
"eslint": "^9.39.2",
"eslint-config-next": "^16.1.6",
"lightweight-charts": "^5.1.0",
"lucide-react": "^0.563.0",
"next": "^16.1.6",
"papaparse": "^5.5.3",
"postcss": "^8.5.6",
"react": "^19.2.4",
"react-dom": "^19.2.4",
"tailwind-merge": "^3.4.0",
"tailwindcss": "^4.1.18",
"typescript": "^5.9.3"
},
"devDependencies": {
"drizzle-kit": "^0.31.9"
}
}

6
postcss.config.js Normal file
View file

@ -0,0 +1,6 @@
module.exports = {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
}

38
src/app/globals.css Normal file
View file

@ -0,0 +1,38 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
@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%;
--radius: 0.5rem;
}
}
@layer base {
* {
@apply border-border;
}
body {
@apply bg-background text-foreground;
font-family: Arial, Helvetica, sans-serif;
}
}

19
src/app/layout.tsx Normal file
View file

@ -0,0 +1,19 @@
import type { Metadata } from "next";
import "./globals.css";
export const metadata: Metadata = {
title: "Candle Annotator",
description: "Annotate candlestick charts for ML training",
};
export default function RootLayout({
children,
}: Readonly<{
children: React.ReactNode;
}>) {
return (
<html lang="en">
<body className="antialiased">{children}</body>
</html>
);
}

10
src/app/page.tsx Normal file
View file

@ -0,0 +1,10 @@
export default function Home() {
return (
<main className="min-h-screen p-8">
<h1 className="text-2xl font-bold">Candle Annotator</h1>
<p className="mt-4 text-slate-400">
Upload CSV data and annotate candlestick charts
</p>
</main>
);
}

View file

@ -0,0 +1,51 @@
import * as React from "react"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
const buttonVariants = cva(
"inline-flex items-center justify-center whitespace-nowrap rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50",
{
variants: {
variant: {
default: "bg-primary text-primary-foreground hover:bg-primary/90",
destructive:
"bg-destructive text-destructive-foreground hover:bg-destructive/90",
outline:
"border border-input bg-background hover:bg-accent hover:text-accent-foreground",
secondary:
"bg-secondary text-secondary-foreground hover:bg-secondary/80",
ghost: "hover:bg-accent hover:text-accent-foreground",
link: "text-primary underline-offset-4 hover:underline",
},
size: {
default: "h-10 px-4 py-2",
sm: "h-9 rounded-md px-3",
lg: "h-11 rounded-md px-8",
icon: "h-10 w-10",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
}
)
export interface ButtonProps
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
VariantProps<typeof buttonVariants> {}
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
({ className, variant, size, ...props }, ref) => {
return (
<button
className={cn(buttonVariants({ variant, size, className }))}
ref={ref}
{...props}
/>
)
}
)
Button.displayName = "Button"
export { Button, buttonVariants }

View file

@ -0,0 +1,53 @@
"use client"
import * as React from "react"
import { cn } from "@/lib/utils"
const TooltipProvider = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ children, ...props }, ref) => (
<div ref={ref} {...props}>
{children}
</div>
))
TooltipProvider.displayName = "TooltipProvider"
const Tooltip = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ children, ...props }, ref) => (
<div ref={ref} {...props}>
{children}
</div>
))
Tooltip.displayName = "Tooltip"
const TooltipTrigger = React.forwardRef<
HTMLButtonElement,
React.ButtonHTMLAttributes<HTMLButtonElement>
>(({ children, ...props }, ref) => (
<button ref={ref} {...props}>
{children}
</button>
))
TooltipTrigger.displayName = "TooltipTrigger"
const TooltipContent = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, children, ...props }, ref) => (
<div
ref={ref}
className={cn(
"z-50 overflow-hidden rounded-md border bg-popover px-3 py-1.5 text-sm text-popover-foreground shadow-md animate-in fade-in-0 zoom-in-95",
className
)}
{...props}
>
{children}
</div>
))
TooltipContent.displayName = "TooltipContent"
export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider }

15
src/lib/db/index.ts Normal file
View file

@ -0,0 +1,15 @@
import Database from 'better-sqlite3';
import { drizzle } from 'drizzle-orm/better-sqlite3';
import * as schema from './schema';
import path from 'path';
import fs from 'fs';
// Ensure data directory exists
const dataDir = path.join(process.cwd(), 'data');
if (!fs.existsSync(dataDir)) {
fs.mkdirSync(dataDir, { recursive: true });
}
const dbPath = path.join(dataDir, 'candles.db');
const sqlite = new Database(dbPath);
export const db = drizzle(sqlite, { schema });

23
src/lib/db/migrate.ts Normal file
View file

@ -0,0 +1,23 @@
import Database from 'better-sqlite3';
import { drizzle } from 'drizzle-orm/better-sqlite3';
import { migrate } from 'drizzle-orm/better-sqlite3/migrator';
import path from 'path';
import fs from 'fs';
export function runMigrations() {
// Ensure data directory exists
const dataDir = path.join(process.cwd(), 'data');
if (!fs.existsSync(dataDir)) {
fs.mkdirSync(dataDir, { recursive: true });
}
const dbPath = path.join(dataDir, 'candles.db');
const sqlite = new Database(dbPath);
const db = drizzle(sqlite);
// Run migrations
migrate(db, { migrationsFolder: './drizzle' });
sqlite.close();
console.log('✅ Migrations complete');
}

18
src/lib/db/schema.ts Normal file
View file

@ -0,0 +1,18 @@
import { sqliteTable, integer, real, text } from 'drizzle-orm/sqlite-core';
export const candles = sqliteTable('candles', {
id: integer('id').primaryKey({ autoIncrement: true }),
time: integer('time').notNull().unique(),
open: real('open').notNull(),
high: real('high').notNull(),
low: real('low').notNull(),
close: real('close').notNull(),
});
export const annotations = sqliteTable('annotations', {
id: integer('id').primaryKey({ autoIncrement: true }),
timestamp: integer('timestamp').notNull(),
label_type: text('label_type').notNull(),
geometry: text('geometry'), // JSON string for line coordinates
created_at: integer('created_at').notNull(),
});

6
src/lib/utils.ts Normal file
View file

@ -0,0 +1,6 @@
import { type ClassValue, clsx } from "clsx"
import { twMerge } from "tailwind-merge"
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs))
}

57
tailwind.config.ts Normal file
View file

@ -0,0 +1,57 @@
import type { Config } from "tailwindcss";
const config: Config = {
darkMode: ["class"],
content: [
"./src/pages/**/*.{js,ts,jsx,tsx,mdx}",
"./src/components/**/*.{js,ts,jsx,tsx,mdx}",
"./src/app/**/*.{js,ts,jsx,tsx,mdx}",
],
theme: {
extend: {
colors: {
border: "hsl(var(--border))",
input: "hsl(var(--input))",
ring: "hsl(var(--ring))",
background: "hsl(var(--background))",
foreground: "hsl(var(--foreground))",
primary: {
DEFAULT: "hsl(var(--primary))",
foreground: "hsl(var(--primary-foreground))",
},
secondary: {
DEFAULT: "hsl(var(--secondary))",
foreground: "hsl(var(--secondary-foreground))",
},
destructive: {
DEFAULT: "hsl(var(--destructive))",
foreground: "hsl(var(--destructive-foreground))",
},
muted: {
DEFAULT: "hsl(var(--muted))",
foreground: "hsl(var(--muted-foreground))",
},
accent: {
DEFAULT: "hsl(var(--accent))",
foreground: "hsl(var(--accent-foreground))",
},
popover: {
DEFAULT: "hsl(var(--popover))",
foreground: "hsl(var(--popover-foreground))",
},
card: {
DEFAULT: "hsl(var(--card))",
foreground: "hsl(var(--card-foreground))",
},
},
borderRadius: {
lg: "var(--radius)",
md: "calc(var(--radius) - 2px)",
sm: "calc(var(--radius) - 4px)",
},
},
},
plugins: [],
};
export default config;

26
tsconfig.json Normal file
View file

@ -0,0 +1,26 @@
{
"compilerOptions": {
"lib": ["dom", "dom.iterable", "esnext"],
"allowJs": true,
"skipLibCheck": true,
"strict": true,
"noEmit": true,
"esModuleInterop": true,
"module": "esnext",
"moduleResolution": "bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"jsx": "preserve",
"incremental": true,
"plugins": [
{
"name": "next"
}
],
"paths": {
"@/*": ["./src/*"]
}
},
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
"exclude": ["node_modules"]
}