diff --git a/openspec/changes/multi-chart-management/tasks.md b/openspec/changes/multi-chart-management/tasks.md index be610ff..007d567 100644 --- a/openspec/changes/multi-chart-management/tasks.md +++ b/openspec/changes/multi-chart-management/tasks.md @@ -21,10 +21,10 @@ ## 4. Candles & Annotations API Scoping -- [ ] 4.1 Modify `GET /api/candles` to accept `?chartId=` query param and filter by chart_id (fall back to most recent chart if omitted) -- [ ] 4.2 Modify `GET /api/annotations` to accept `?chartId=` query param and filter by chart_id (fall back to most recent chart if omitted) -- [ ] 4.3 Modify `POST /api/annotations` to require `chart_id` in request body and store it -- [ ] 4.4 Modify `GET /api/export` to accept `?chartId=` query param and scope exported annotations to that chart +- [x] 4.1 Modify `GET /api/candles` to accept `?chartId=` query param and filter by chart_id (fall back to most recent chart if omitted) +- [x] 4.2 Modify `GET /api/annotations` to accept `?chartId=` query param and filter by chart_id (fall back to most recent chart if omitted) +- [x] 4.3 Modify `POST /api/annotations` to require `chart_id` in request body and store it +- [x] 4.4 Modify `GET /api/export` to accept `?chartId=` query param and scope exported annotations to that chart ## 5. Chart Selector UI Component diff --git a/src/app/api/annotations/route.ts b/src/app/api/annotations/route.ts index 6dcfeff..cf57b8c 100644 --- a/src/app/api/annotations/route.ts +++ b/src/app/api/annotations/route.ts @@ -1,12 +1,27 @@ import { NextRequest, NextResponse } from 'next/server'; import { db } from '@/lib/db'; -import { annotations } from '@/lib/db/schema'; -import { eq, inArray } from 'drizzle-orm'; +import { annotations, charts } from '@/lib/db/schema'; +import { eq, inArray, and, desc } from 'drizzle-orm'; -// GET all annotations -export async function GET() { +// GET annotations scoped by chartId +export async function GET(request: NextRequest) { try { - const allAnnotations = await db.select().from(annotations); + const { searchParams } = request.nextUrl; + let chartId = searchParams.get('chartId'); + + // Fall back to most recent chart if no chartId provided + if (!chartId) { + const latest = await db.select({ id: charts.id }).from(charts).orderBy(desc(charts.created_at)).limit(1); + if (latest.length === 0) { + return NextResponse.json([]); + } + chartId = String(latest[0].id); + } + + const allAnnotations = await db + .select() + .from(annotations) + .where(eq(annotations.chart_id, parseInt(chartId, 10))); // Parse geometry from JSON string const parsed = allAnnotations.map((annotation) => ({ @@ -23,26 +38,36 @@ export async function GET() { } } -// POST create new annotation +// POST create new annotation (requires chart_id) export async function POST(request: NextRequest) { try { const body = await request.json(); - const { timestamp, label_type, geometry, color } = body; + const { timestamp, label_type, chart_id, geometry, color } = body; // Validate required fields - if (!timestamp || !label_type) { + if (!timestamp || !label_type || !chart_id) { return NextResponse.json( - { error: 'timestamp and label_type are required' }, + { error: 'timestamp, label_type, and chart_id are required' }, { status: 400 } ); } + // Verify chart exists + const chartExists = await db.select({ id: charts.id }).from(charts).where(eq(charts.id, chart_id)).limit(1); + if (chartExists.length === 0) { + return NextResponse.json( + { error: 'Chart not found' }, + { status: 404 } + ); + } + // Serialize geometry to JSON string if present const geometryString = geometry ? JSON.stringify(geometry) : null; const result = await db .insert(annotations) .values({ + chart_id, timestamp, label_type, geometry: geometryString, @@ -68,27 +93,36 @@ export async function POST(request: NextRequest) { } } -// DELETE annotations with bulk operations +// DELETE annotations with bulk operations (scoped by chartId) export async function DELETE(request: NextRequest) { try { const { searchParams } = request.nextUrl; const type = searchParams.get('type'); const all = searchParams.get('all'); + const chartId = searchParams.get('chartId'); let result; if (all === 'true') { - // Delete all annotations - result = await db.delete(annotations).returning(); + if (chartId) { + result = await db.delete(annotations).where(eq(annotations.chart_id, parseInt(chartId, 10))).returning(); + } else { + 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(); + if (chartId) { + result = await db + .delete(annotations) + .where(and(inArray(annotations.label_type, types), eq(annotations.chart_id, parseInt(chartId, 10)))) + .returning(); + } else { + 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 } diff --git a/src/app/api/candles/route.ts b/src/app/api/candles/route.ts index cd90f6b..cce9088 100644 --- a/src/app/api/candles/route.ts +++ b/src/app/api/candles/route.ts @@ -1,13 +1,32 @@ -import { NextResponse } from 'next/server'; +import { NextRequest, NextResponse } from 'next/server'; import { db } from '@/lib/db'; -import { candles } from '@/lib/db/schema'; -import { asc } from 'drizzle-orm'; +import { candles, charts } from '@/lib/db/schema'; +import { asc, desc, eq } from 'drizzle-orm'; -export async function GET() { +export async function GET(request: NextRequest) { try { + const { searchParams } = request.nextUrl; + let chartId = searchParams.get('chartId'); + + // Fall back to most recent chart if no chartId provided + if (!chartId) { + const latest = await db.select({ id: charts.id }).from(charts).orderBy(desc(charts.created_at)).limit(1); + if (latest.length === 0) { + return NextResponse.json([]); + } + chartId = String(latest[0].id); + } + const allCandles = await db - .select() + .select({ + time: candles.time, + open: candles.open, + high: candles.high, + low: candles.low, + close: candles.close, + }) .from(candles) + .where(eq(candles.chart_id, parseInt(chartId, 10))) .orderBy(asc(candles.time)); return NextResponse.json(allCandles); diff --git a/src/app/api/export/route.ts b/src/app/api/export/route.ts index 075b504..17d202f 100644 --- a/src/app/api/export/route.ts +++ b/src/app/api/export/route.ts @@ -1,11 +1,32 @@ -import { NextResponse } from 'next/server'; +import { NextRequest, NextResponse } from 'next/server'; import { db } from '@/lib/db'; -import { annotations, candles } from '@/lib/db/schema'; -import { eq } from 'drizzle-orm'; +import { annotations, candles, charts } from '@/lib/db/schema'; +import { eq, and, desc } from 'drizzle-orm'; -export async function GET() { +export async function GET(request: NextRequest) { try { - const allAnnotations = await db.select().from(annotations); + const { searchParams } = request.nextUrl; + let chartId = searchParams.get('chartId'); + + // Fall back to most recent chart if no chartId provided + if (!chartId) { + const latest = await db.select({ id: charts.id }).from(charts).orderBy(desc(charts.created_at)).limit(1); + if (latest.length === 0) { + return new NextResponse('timestamp,label_type,price\n', { + headers: { + 'Content-Type': 'text/csv', + 'Content-Disposition': 'attachment; filename="annotations.csv"', + }, + }); + } + chartId = String(latest[0].id); + } + + const chartIdNum = parseInt(chartId, 10); + const allAnnotations = await db + .select() + .from(annotations) + .where(eq(annotations.chart_id, chartIdNum)); // Build CSV content const csvRows = ['timestamp,label_type,price']; @@ -14,18 +35,16 @@ export async function GET() { let price: number | null = null; if (annotation.label_type === 'break_up' || annotation.label_type === 'break_down') { - // For marker annotations, look up the candle's close price const candleResult = await db .select() .from(candles) - .where(eq(candles.time, annotation.timestamp)) + .where(and(eq(candles.chart_id, chartIdNum), eq(candles.time, annotation.timestamp))) .limit(1); if (candleResult.length > 0) { price = candleResult[0].close; } } else if (annotation.label_type === 'line' && annotation.geometry) { - // For line annotations, use startPrice from geometry const geometry = JSON.parse(annotation.geometry); price = geometry.startPrice || null; }