Complete remaining ET specs identified in INTEGRATION-PLAN: - ET-EDU-007: Video Player Advanced (554 LOC component) - ET-MT4-001: WebSocket Integration (BLOCKER - 0% implemented) - ET-ML-009: Ensemble Signal (Multi-strategy aggregation) - ET-TRD-009: Risk-Based Position Sizer (391 LOC component) - ET-TRD-010: Drawing Tools Persistence (backend + store) - ET-TRD-011: Market Bias Indicator (multi-timeframe analysis) - ET-PFM-009: Custom Charts (SVG AllocationChart + Canvas PerformanceChart) - ET-ML-008: ICT Analysis Card (expanded - 294 LOC component) All specs include: - Architecture diagrams - Complete code examples - API contracts - Implementation guides - Testing scenarios Related: TASK-2026-01-25-002-FRONTEND-COMPREHENSIVE-AUDIT Priority: P1-P3 (mixed) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
38 KiB
38 KiB
ET-TRD-010: Drawing Tools Persistence
Versión: 1.0.0 Fecha: 2026-01-25 Epic: OQI-003 - Trading y Charts Componente: Backend API + Frontend Store + Database Schema Estado: 🟡 50% Implementado (UI funciona, falta persistencia) Prioridad: P2
Metadata
| Campo | Valor |
|---|---|
| ID | ET-TRD-010 |
| Tipo | Especificación Técnica |
| Epic | OQI-003 |
| US Relacionada | US-TRD-010 (Persistir Dibujos en Charts) |
| Componente Actual | ChartDrawingToolsPanel.tsx (420 líneas, ✅ funcional) |
| Gap | Persistencia (backend API + DB + Zustand store) |
| Complejidad | Media (CRUD + sincronización) |
| Esfuerzo Estimado | 3 horas |
1. Descripción General
Drawing Tools Persistence permite a los usuarios guardar sus análisis técnicos (líneas de tendencia, Fibonacci, rectángulos, anotaciones, etc.) en el backend para que persistan entre sesiones y dispositivos.
Estado Actual (50% Implementado)
✅ Frontend UI Completado:
- Componente
ChartDrawingToolsPanel.tsxcon 10 herramientas de dibujo - Gestión de estado local (color, grosor, visibilidad, bloqueo)
- Duplicación, eliminación, undo
- 420 líneas de código funcional
❌ Falta Implementar:
- Backend API REST (
/api/chart-drawings) - Tabla
chart_drawingsen PostgreSQL - Zustand store para gestión de estado
- Hook
useDrawingspara sincronización - Auto-save debounced (1 segundo)
- Import/export JSON
2. Arquitectura de Persistencia
2.1 Flujo de Datos
┌─────────────────────────────────────────────────────────────┐
│ Drawing Persistence Architecture │
├─────────────────────────────────────────────────────────────┤
│ │
│ User Interaction (Chart Canvas) │
│ │ │
│ v │
│ ┌──────────────────────────────────────┐ │
│ │ ChartDrawingToolsPanel.tsx │ │
│ │ • Draw, Edit, Delete │ │
│ │ • Local state management │ │
│ └──────────────┬───────────────────────┘ │
│ │ │
│ v │
│ ┌──────────────────────────────────────┐ │
│ │ useDrawings Hook │ │
│ │ • Debounce changes (1s) │ │
│ │ • Call Zustand actions │ │
│ └──────────────┬───────────────────────┘ │
│ │ │
│ v │
│ ┌──────────────────────────────────────┐ │
│ │ drawingsStore (Zustand) │ │
│ │ • Local cache │ │
│ │ • Optimistic updates │ │
│ │ • API calls │ │
│ └──────────────┬───────────────────────┘ │
│ │ │
│ ┌───────┴────────┐ │
│ │ │ │
│ v v │
│ POST /api/drawings GET /api/drawings/:symbol │
│ │ │ │
│ v v │
│ ┌──────────────────────────────────────┐ │
│ │ Express Backend │ │
│ │ • ChartDrawingsController │ │
│ │ • Validation │ │
│ │ • Auth check (JWT) │ │
│ └──────────────┬───────────────────────┘ │
│ │ │
│ v │
│ ┌──────────────────────────────────────┐ │
│ │ PostgreSQL: chart_drawings │ │
│ │ • user_id, symbol, timeframe │ │
│ │ • drawing_data (JSONB) │ │
│ │ • created_at, updated_at │ │
│ └──────────────────────────────────────┘ │
│ │
└─────────────────────────────────────────────────────────────┘
3. Database Schema
3.1 Tabla chart_drawings
-- Schema: trading
CREATE TABLE IF NOT EXISTS trading.chart_drawings (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID NOT NULL REFERENCES core.users(id) ON DELETE CASCADE,
-- Chart context
symbol VARCHAR(20) NOT NULL, -- BTCUSD, ETHUSD, etc.
timeframe VARCHAR(10) NOT NULL, -- 1m, 5m, 15m, 1h, 4h, 1d
-- Drawing metadata
tool_type VARCHAR(20) NOT NULL, -- trendline, fibonacci, rectangle, etc.
label VARCHAR(100), -- Optional user label
-- Drawing properties
drawing_data JSONB NOT NULL, -- Complete drawing object (color, points, etc.)
-- State
visible BOOLEAN DEFAULT true,
locked BOOLEAN DEFAULT false,
-- Timestamps
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
-- Indexes
CONSTRAINT unique_user_symbol_drawing UNIQUE (user_id, symbol, id)
);
-- Indexes for performance
CREATE INDEX idx_chart_drawings_user_symbol ON trading.chart_drawings(user_id, symbol);
CREATE INDEX idx_chart_drawings_user_symbol_timeframe ON trading.chart_drawings(user_id, symbol, timeframe);
CREATE INDEX idx_chart_drawings_created_at ON trading.chart_drawings(created_at DESC);
-- GIN index for JSONB queries
CREATE INDEX idx_chart_drawings_data ON trading.chart_drawings USING GIN(drawing_data);
-- Auto-update timestamp
CREATE TRIGGER update_chart_drawings_updated_at
BEFORE UPDATE ON trading.chart_drawings
FOR EACH ROW
EXECUTE FUNCTION core.update_updated_at_column();
3.2 Estructura de drawing_data (JSONB)
{
"id": "draw_1738045812345_abc123",
"tool": "trendline",
"color": "#3B82F6",
"lineWidth": 2,
"visible": true,
"locked": false,
"label": "Strong uptrend",
"points": [
{ "x": 100, "y": 200, "price": 43250.50, "time": "2026-01-25T10:00:00Z" },
{ "x": 500, "y": 150, "price": 44100.00, "time": "2026-01-25T14:00:00Z" }
],
"createdAt": "2026-01-25T10:30:15Z"
}
4. Backend API
4.1 REST Endpoints
| Método | Endpoint | Descripción | Auth |
|---|---|---|---|
| GET | /api/chart-drawings/:symbol |
Obtener todos los dibujos de un símbolo | ✅ JWT |
| GET | /api/chart-drawings/:symbol/:timeframe |
Filtrar por timeframe | ✅ JWT |
| POST | /api/chart-drawings |
Crear nuevo dibujo | ✅ JWT |
| PUT | /api/chart-drawings/:id |
Actualizar dibujo existente | ✅ JWT |
| DELETE | /api/chart-drawings/:id |
Eliminar dibujo | ✅ JWT |
| DELETE | /api/chart-drawings/:symbol/all |
Eliminar todos los dibujos de un símbolo | ✅ JWT |
| POST | /api/chart-drawings/import |
Importar dibujos desde JSON | ✅ JWT |
| GET | /api/chart-drawings/export/:symbol |
Exportar dibujos a JSON | ✅ JWT |
4.2 Request/Response Examples
POST /api/chart-drawings - Create Drawing
Request:
POST /api/chart-drawings
Authorization: Bearer <jwt_token>
Content-Type: application/json
{
"symbol": "BTCUSD",
"timeframe": "1h",
"tool_type": "trendline",
"label": "Strong uptrend",
"drawing_data": {
"id": "draw_1738045812345_abc123",
"tool": "trendline",
"color": "#3B82F6",
"lineWidth": 2,
"visible": true,
"locked": false,
"points": [
{ "x": 100, "y": 200, "price": 43250.50, "time": "2026-01-25T10:00:00Z" },
{ "x": 500, "y": 150, "price": 44100.00, "time": "2026-01-25T14:00:00Z" }
],
"createdAt": "2026-01-25T10:30:15Z"
},
"visible": true,
"locked": false
}
Response:
{
"success": true,
"data": {
"id": "550e8400-e29b-41d4-a716-446655440000",
"user_id": "123e4567-e89b-12d3-a456-426614174000",
"symbol": "BTCUSD",
"timeframe": "1h",
"tool_type": "trendline",
"label": "Strong uptrend",
"drawing_data": { /* ... */ },
"visible": true,
"locked": false,
"created_at": "2026-01-25T10:30:15Z",
"updated_at": "2026-01-25T10:30:15Z"
}
}
GET /api/chart-drawings/:symbol - Fetch Drawings
Request:
GET /api/chart-drawings/BTCUSD?timeframe=1h
Authorization: Bearer <jwt_token>
Response:
{
"success": true,
"data": [
{
"id": "550e8400-e29b-41d4-a716-446655440000",
"symbol": "BTCUSD",
"timeframe": "1h",
"tool_type": "trendline",
"label": "Strong uptrend",
"drawing_data": {
"id": "draw_1738045812345_abc123",
"tool": "trendline",
"color": "#3B82F6",
"lineWidth": 2,
"visible": true,
"locked": false,
"points": [
{ "x": 100, "y": 200, "price": 43250.50, "time": "2026-01-25T10:00:00Z" },
{ "x": 500, "y": 150, "price": 44100.00, "time": "2026-01-25T14:00:00Z" }
],
"createdAt": "2026-01-25T10:30:15Z"
},
"visible": true,
"locked": false,
"created_at": "2026-01-25T10:30:15Z",
"updated_at": "2026-01-25T10:30:15Z"
},
{
"id": "660f9511-f39c-52e5-b827-557766551111",
"symbol": "BTCUSD",
"timeframe": "1h",
"tool_type": "fibonacci",
"label": "Fib retracement",
"drawing_data": { /* ... */ },
"visible": true,
"locked": false,
"created_at": "2026-01-25T11:00:00Z",
"updated_at": "2026-01-25T11:00:00Z"
}
],
"meta": {
"total": 2,
"symbol": "BTCUSD",
"timeframe": "1h"
}
}
PUT /api/chart-drawings/:id - Update Drawing
Request:
PUT /api/chart-drawings/550e8400-e29b-41d4-a716-446655440000
Authorization: Bearer <jwt_token>
Content-Type: application/json
{
"drawing_data": {
"id": "draw_1738045812345_abc123",
"tool": "trendline",
"color": "#10B981", // Changed color
"lineWidth": 3, // Changed width
"visible": true,
"locked": true, // Locked
"points": [
{ "x": 100, "y": 200, "price": 43250.50, "time": "2026-01-25T10:00:00Z" },
{ "x": 500, "y": 150, "price": 44100.00, "time": "2026-01-25T14:00:00Z" }
],
"createdAt": "2026-01-25T10:30:15Z"
},
"locked": true
}
Response:
{
"success": true,
"data": {
"id": "550e8400-e29b-41d4-a716-446655440000",
"drawing_data": { /* updated data */ },
"locked": true,
"updated_at": "2026-01-25T11:15:30Z"
}
}
5. Backend Implementation
5.1 Controller: ChartDrawingsController.ts
// apps/backend/src/controllers/chart-drawings.controller.ts
import { Request, Response } from 'express';
import { pool } from '../config/database';
import { authenticateToken } from '../middleware/auth.middleware';
interface Drawing {
id: string;
tool: string;
color: string;
lineWidth: number;
visible: boolean;
locked: boolean;
label?: string;
points: { x: number; y: number; price?: number; time?: string }[];
createdAt: string;
}
interface CreateDrawingRequest {
symbol: string;
timeframe: string;
tool_type: string;
label?: string;
drawing_data: Drawing;
visible?: boolean;
locked?: boolean;
}
export class ChartDrawingsController {
// GET /api/chart-drawings/:symbol
static async getDrawings(req: Request, res: Response) {
const { symbol } = req.params;
const { timeframe } = req.query;
const userId = req.user!.id;
try {
let query = `
SELECT * FROM trading.chart_drawings
WHERE user_id = $1 AND symbol = $2
`;
const params: any[] = [userId, symbol];
if (timeframe) {
query += ` AND timeframe = $3`;
params.push(timeframe);
}
query += ` ORDER BY created_at DESC`;
const result = await pool.query(query, params);
res.json({
success: true,
data: result.rows,
meta: {
total: result.rows.length,
symbol,
timeframe: timeframe || 'all'
}
});
} catch (error) {
console.error('Error fetching drawings:', error);
res.status(500).json({
success: false,
error: 'Failed to fetch drawings'
});
}
}
// POST /api/chart-drawings
static async createDrawing(req: Request, res: Response) {
const userId = req.user!.id;
const { symbol, timeframe, tool_type, label, drawing_data, visible = true, locked = false } = req.body as CreateDrawingRequest;
// Validation
if (!symbol || !timeframe || !tool_type || !drawing_data) {
return res.status(400).json({
success: false,
error: 'Missing required fields: symbol, timeframe, tool_type, drawing_data'
});
}
try {
const query = `
INSERT INTO trading.chart_drawings (
user_id, symbol, timeframe, tool_type, label, drawing_data, visible, locked
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
RETURNING *
`;
const result = await pool.query(query, [
userId,
symbol,
timeframe,
tool_type,
label,
JSON.stringify(drawing_data),
visible,
locked
]);
res.status(201).json({
success: true,
data: result.rows[0]
});
} catch (error) {
console.error('Error creating drawing:', error);
res.status(500).json({
success: false,
error: 'Failed to create drawing'
});
}
}
// PUT /api/chart-drawings/:id
static async updateDrawing(req: Request, res: Response) {
const { id } = req.params;
const userId = req.user!.id;
const { drawing_data, visible, locked, label } = req.body;
try {
// Build dynamic update query
const updates: string[] = [];
const values: any[] = [];
let paramCount = 1;
if (drawing_data !== undefined) {
updates.push(`drawing_data = $${paramCount++}`);
values.push(JSON.stringify(drawing_data));
}
if (visible !== undefined) {
updates.push(`visible = $${paramCount++}`);
values.push(visible);
}
if (locked !== undefined) {
updates.push(`locked = $${paramCount++}`);
values.push(locked);
}
if (label !== undefined) {
updates.push(`label = $${paramCount++}`);
values.push(label);
}
if (updates.length === 0) {
return res.status(400).json({
success: false,
error: 'No fields to update'
});
}
const query = `
UPDATE trading.chart_drawings
SET ${updates.join(', ')}, updated_at = CURRENT_TIMESTAMP
WHERE id = $${paramCount} AND user_id = $${paramCount + 1}
RETURNING *
`;
values.push(id, userId);
const result = await pool.query(query, values);
if (result.rows.length === 0) {
return res.status(404).json({
success: false,
error: 'Drawing not found or unauthorized'
});
}
res.json({
success: true,
data: result.rows[0]
});
} catch (error) {
console.error('Error updating drawing:', error);
res.status(500).json({
success: false,
error: 'Failed to update drawing'
});
}
}
// DELETE /api/chart-drawings/:id
static async deleteDrawing(req: Request, res: Response) {
const { id } = req.params;
const userId = req.user!.id;
try {
const query = `
DELETE FROM trading.chart_drawings
WHERE id = $1 AND user_id = $2
RETURNING id
`;
const result = await pool.query(query, [id, userId]);
if (result.rows.length === 0) {
return res.status(404).json({
success: false,
error: 'Drawing not found or unauthorized'
});
}
res.json({
success: true,
message: 'Drawing deleted successfully'
});
} catch (error) {
console.error('Error deleting drawing:', error);
res.status(500).json({
success: false,
error: 'Failed to delete drawing'
});
}
}
// DELETE /api/chart-drawings/:symbol/all
static async clearAllDrawings(req: Request, res: Response) {
const { symbol } = req.params;
const userId = req.user!.id;
const { timeframe } = req.query;
try {
let query = `
DELETE FROM trading.chart_drawings
WHERE user_id = $1 AND symbol = $2
`;
const params: any[] = [userId, symbol];
if (timeframe) {
query += ` AND timeframe = $3`;
params.push(timeframe);
}
query += ` RETURNING id`;
const result = await pool.query(query, params);
res.json({
success: true,
message: `Deleted ${result.rows.length} drawings`,
count: result.rows.length
});
} catch (error) {
console.error('Error clearing drawings:', error);
res.status(500).json({
success: false,
error: 'Failed to clear drawings'
});
}
}
// POST /api/chart-drawings/import
static async importDrawings(req: Request, res: Response) {
const userId = req.user!.id;
const { drawings } = req.body;
if (!Array.isArray(drawings) || drawings.length === 0) {
return res.status(400).json({
success: false,
error: 'Invalid drawings array'
});
}
try {
const insertedDrawings = [];
for (const drawing of drawings) {
const { symbol, timeframe, tool_type, label, drawing_data, visible, locked } = drawing;
const query = `
INSERT INTO trading.chart_drawings (
user_id, symbol, timeframe, tool_type, label, drawing_data, visible, locked
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
RETURNING *
`;
const result = await pool.query(query, [
userId,
symbol,
timeframe,
tool_type,
label,
JSON.stringify(drawing_data),
visible ?? true,
locked ?? false
]);
insertedDrawings.push(result.rows[0]);
}
res.json({
success: true,
message: `Imported ${insertedDrawings.length} drawings`,
data: insertedDrawings
});
} catch (error) {
console.error('Error importing drawings:', error);
res.status(500).json({
success: false,
error: 'Failed to import drawings'
});
}
}
// GET /api/chart-drawings/export/:symbol
static async exportDrawings(req: Request, res: Response) {
const { symbol } = req.params;
const userId = req.user!.id;
const { timeframe } = req.query;
try {
let query = `
SELECT * FROM trading.chart_drawings
WHERE user_id = $1 AND symbol = $2
`;
const params: any[] = [userId, symbol];
if (timeframe) {
query += ` AND timeframe = $3`;
params.push(timeframe);
}
const result = await pool.query(query, params);
const exportData = {
version: '1.0.0',
exported_at: new Date().toISOString(),
symbol,
timeframe: timeframe || 'all',
count: result.rows.length,
drawings: result.rows
};
res.json({
success: true,
data: exportData
});
} catch (error) {
console.error('Error exporting drawings:', error);
res.status(500).json({
success: false,
error: 'Failed to export drawings'
});
}
}
}
5.2 Routes: chart-drawings.routes.ts
// apps/backend/src/routes/chart-drawings.routes.ts
import { Router } from 'express';
import { ChartDrawingsController } from '../controllers/chart-drawings.controller';
import { authenticateToken } from '../middleware/auth.middleware';
const router = Router();
// All routes require authentication
router.use(authenticateToken);
// CRUD operations
router.get('/chart-drawings/:symbol', ChartDrawingsController.getDrawings);
router.post('/chart-drawings', ChartDrawingsController.createDrawing);
router.put('/chart-drawings/:id', ChartDrawingsController.updateDrawing);
router.delete('/chart-drawings/:id', ChartDrawingsController.deleteDrawing);
router.delete('/chart-drawings/:symbol/all', ChartDrawingsController.clearAllDrawings);
// Import/Export
router.post('/chart-drawings/import', ChartDrawingsController.importDrawings);
router.get('/chart-drawings/export/:symbol', ChartDrawingsController.exportDrawings);
export default router;
6. Frontend Implementation
6.1 Zustand Store: drawingsStore.ts
// apps/frontend/src/stores/drawingsStore.ts
import { create } from 'zustand';
import { devtools } from 'zustand/middleware';
import { apiClient } from '../lib/apiClient';
export interface Drawing {
id: string;
tool: string;
color: string;
lineWidth: number;
visible: boolean;
locked: boolean;
label?: string;
points: { x: number; y: number; price?: number; time?: string }[];
createdAt: string;
}
interface ServerDrawing {
id: string;
user_id: string;
symbol: string;
timeframe: string;
tool_type: string;
label?: string;
drawing_data: Drawing;
visible: boolean;
locked: boolean;
created_at: string;
updated_at: string;
}
interface DrawingsState {
// State
drawings: Map<string, Drawing[]>; // key: "symbol:timeframe"
loading: boolean;
error: string | null;
// Actions
fetchDrawings: (symbol: string, timeframe: string) => Promise<void>;
addDrawing: (symbol: string, timeframe: string, drawing: Drawing) => Promise<void>;
updateDrawing: (symbol: string, timeframe: string, drawing: Drawing) => Promise<void>;
removeDrawing: (symbol: string, timeframe: string, drawingId: string) => Promise<void>;
clearAllDrawings: (symbol: string, timeframe: string) => Promise<void>;
importDrawings: (drawings: any[]) => Promise<void>;
exportDrawings: (symbol: string, timeframe?: string) => Promise<any>;
// Local actions (optimistic updates)
addDrawingLocally: (symbol: string, timeframe: string, drawing: Drawing) => void;
updateDrawingLocally: (symbol: string, timeframe: string, drawing: Drawing) => void;
removeDrawingLocally: (symbol: string, timeframe: string, drawingId: string) => void;
}
export const useDrawingsStore = create<DrawingsState>()(
devtools(
(set, get) => ({
drawings: new Map(),
loading: false,
error: null,
// Fetch all drawings for a symbol+timeframe
fetchDrawings: async (symbol: string, timeframe: string) => {
set({ loading: true, error: null });
try {
const response = await apiClient.get(`/api/chart-drawings/${symbol}`, {
params: { timeframe }
});
const serverDrawings: ServerDrawing[] = response.data.data;
const drawings = serverDrawings.map(d => d.drawing_data);
const key = `${symbol}:${timeframe}`;
const newMap = new Map(get().drawings);
newMap.set(key, drawings);
set({ drawings: newMap, loading: false });
} catch (error) {
console.error('Error fetching drawings:', error);
set({
error: error instanceof Error ? error.message : 'Failed to fetch drawings',
loading: false
});
}
},
// Add new drawing (optimistic update + API call)
addDrawing: async (symbol: string, timeframe: string, drawing: Drawing) => {
// Optimistic update
get().addDrawingLocally(symbol, timeframe, drawing);
try {
await apiClient.post('/api/chart-drawings', {
symbol,
timeframe,
tool_type: drawing.tool,
label: drawing.label,
drawing_data: drawing,
visible: drawing.visible,
locked: drawing.locked
});
} catch (error) {
console.error('Error saving drawing:', error);
// Rollback optimistic update
get().removeDrawingLocally(symbol, timeframe, drawing.id);
set({
error: error instanceof Error ? error.message : 'Failed to save drawing'
});
throw error;
}
},
// Update existing drawing
updateDrawing: async (symbol: string, timeframe: string, drawing: Drawing) => {
// Find server ID (we need to maintain a mapping or fetch it)
// For simplicity, we'll refetch after update
try {
// This requires knowing the server UUID
// In practice, we'd maintain a clientId -> serverId mapping
// For now, we'll use drawing.id as both (needs adjustment)
await apiClient.put(`/api/chart-drawings/${drawing.id}`, {
drawing_data: drawing,
visible: drawing.visible,
locked: drawing.locked,
label: drawing.label
});
// Optimistic update
get().updateDrawingLocally(symbol, timeframe, drawing);
} catch (error) {
console.error('Error updating drawing:', error);
set({
error: error instanceof Error ? error.message : 'Failed to update drawing'
});
throw error;
}
},
// Remove drawing
removeDrawing: async (symbol: string, timeframe: string, drawingId: string) => {
// Optimistic update
const key = `${symbol}:${timeframe}`;
const currentDrawings = get().drawings.get(key) || [];
const drawingToRemove = currentDrawings.find(d => d.id === drawingId);
get().removeDrawingLocally(symbol, timeframe, drawingId);
try {
await apiClient.delete(`/api/chart-drawings/${drawingId}`);
} catch (error) {
console.error('Error deleting drawing:', error);
// Rollback
if (drawingToRemove) {
get().addDrawingLocally(symbol, timeframe, drawingToRemove);
}
set({
error: error instanceof Error ? error.message : 'Failed to delete drawing'
});
throw error;
}
},
// Clear all drawings
clearAllDrawings: async (symbol: string, timeframe: string) => {
const key = `${symbol}:${timeframe}`;
const backup = get().drawings.get(key) || [];
// Optimistic clear
const newMap = new Map(get().drawings);
newMap.set(key, []);
set({ drawings: newMap });
try {
await apiClient.delete(`/api/chart-drawings/${symbol}/all`, {
params: { timeframe }
});
} catch (error) {
console.error('Error clearing drawings:', error);
// Rollback
const rollbackMap = new Map(get().drawings);
rollbackMap.set(key, backup);
set({
drawings: rollbackMap,
error: error instanceof Error ? error.message : 'Failed to clear drawings'
});
throw error;
}
},
// Import drawings
importDrawings: async (drawings: any[]) => {
try {
await apiClient.post('/api/chart-drawings/import', { drawings });
// Refetch current symbol/timeframe after import
// (caller should trigger refetch)
} catch (error) {
console.error('Error importing drawings:', error);
set({
error: error instanceof Error ? error.message : 'Failed to import drawings'
});
throw error;
}
},
// Export drawings
exportDrawings: async (symbol: string, timeframe?: string) => {
try {
const response = await apiClient.get(`/api/chart-drawings/export/${symbol}`, {
params: { timeframe }
});
return response.data.data;
} catch (error) {
console.error('Error exporting drawings:', error);
set({
error: error instanceof Error ? error.message : 'Failed to export drawings'
});
throw error;
}
},
// Local optimistic updates
addDrawingLocally: (symbol: string, timeframe: string, drawing: Drawing) => {
const key = `${symbol}:${timeframe}`;
const current = get().drawings.get(key) || [];
const newMap = new Map(get().drawings);
newMap.set(key, [...current, drawing]);
set({ drawings: newMap });
},
updateDrawingLocally: (symbol: string, timeframe: string, drawing: Drawing) => {
const key = `${symbol}:${timeframe}`;
const current = get().drawings.get(key) || [];
const updated = current.map(d => d.id === drawing.id ? drawing : d);
const newMap = new Map(get().drawings);
newMap.set(key, updated);
set({ drawings: newMap });
},
removeDrawingLocally: (symbol: string, timeframe: string, drawingId: string) => {
const key = `${symbol}:${timeframe}`;
const current = get().drawings.get(key) || [];
const filtered = current.filter(d => d.id !== drawingId);
const newMap = new Map(get().drawings);
newMap.set(key, filtered);
set({ drawings: newMap });
},
}),
{ name: 'drawings-store' }
)
);
6.2 Hook: useDrawings.ts
// apps/frontend/src/modules/trading/hooks/useDrawings.ts
import { useEffect, useRef, useCallback } from 'react';
import { useDrawingsStore } from '../../../stores/drawingsStore';
import { useDebouncedCallback } from 'use-debounce';
import type { Drawing } from '../../../stores/drawingsStore';
export const useDrawings = (symbol: string, timeframe: string) => {
const {
drawings,
loading,
error,
fetchDrawings,
addDrawing,
updateDrawing,
removeDrawing,
clearAllDrawings,
} = useDrawingsStore();
const key = `${symbol}:${timeframe}`;
const currentDrawings = drawings.get(key) || [];
// Fetch on mount and when symbol/timeframe changes
useEffect(() => {
fetchDrawings(symbol, timeframe);
}, [symbol, timeframe]);
// Debounced save (1 second delay)
const debouncedUpdate = useDebouncedCallback(
(drawing: Drawing) => {
updateDrawing(symbol, timeframe, drawing);
},
1000
);
// API methods
const handleAddDrawing = useCallback(async (drawing: Drawing) => {
await addDrawing(symbol, timeframe, drawing);
}, [symbol, timeframe, addDrawing]);
const handleUpdateDrawing = useCallback((drawing: Drawing, immediate = false) => {
if (immediate) {
updateDrawing(symbol, timeframe, drawing);
} else {
debouncedUpdate(drawing);
}
}, [symbol, timeframe, updateDrawing, debouncedUpdate]);
const handleRemoveDrawing = useCallback(async (drawingId: string) => {
await removeDrawing(symbol, timeframe, drawingId);
}, [symbol, timeframe, removeDrawing]);
const handleClearAll = useCallback(async () => {
await clearAllDrawings(symbol, timeframe);
}, [symbol, timeframe, clearAllDrawings]);
return {
drawings: currentDrawings,
loading,
error,
addDrawing: handleAddDrawing,
updateDrawing: handleUpdateDrawing,
removeDrawing: handleRemoveDrawing,
clearAll: handleClearAll,
refetch: () => fetchDrawings(symbol, timeframe),
};
};
6.3 Integration with ChartDrawingToolsPanel.tsx
// apps/frontend/src/modules/trading/components/ChartDrawingToolsPanel.tsx
// MODIFICACIÓN: Integrar con useDrawings hook
import { useDrawings } from '../hooks/useDrawings';
import { useTradingStore } from '../../../stores/tradingStore';
const ChartDrawingToolsPanelWithPersistence: React.FC = () => {
const { selectedSymbol, timeframe } = useTradingStore();
const {
drawings,
loading,
error,
addDrawing,
updateDrawing,
removeDrawing,
clearAll
} = useDrawings(selectedSymbol, timeframe);
const [activeTool, setActiveTool] = useState<DrawingTool | null>(null);
const handleDrawingAdd = async (drawing: Drawing) => {
try {
await addDrawing(drawing);
} catch (error) {
console.error('Failed to save drawing:', error);
// Show toast notification
}
};
const handleDrawingUpdate = (drawing: Drawing) => {
// Debounced auto-save (1 second)
updateDrawing(drawing, false);
};
const handleDrawingRemove = async (drawingId: string) => {
try {
await removeDrawing(drawingId);
} catch (error) {
console.error('Failed to delete drawing:', error);
}
};
const handleClearAll = async () => {
if (confirm(`Delete all ${drawings.length} drawings?`)) {
try {
await clearAll();
} catch (error) {
console.error('Failed to clear drawings:', error);
}
}
};
return (
<ChartDrawingToolsPanel
drawings={drawings}
activeTool={activeTool}
onToolSelect={setActiveTool}
onDrawingAdd={handleDrawingAdd}
onDrawingUpdate={handleDrawingUpdate}
onDrawingRemove={handleDrawingRemove}
onClearAll={handleClearAll}
/>
);
};
7. Tipos TypeScript
7.1 Frontend Types
// apps/frontend/src/types/drawings.types.ts
export type DrawingTool =
| 'trendline'
| 'horizontal'
| 'vertical'
| 'rectangle'
| 'circle'
| 'fibonacci'
| 'text'
| 'arrow'
| 'channel'
| 'pitchfork';
export interface DrawingPoint {
x: number;
y: number;
price?: number;
time?: string;
}
export interface Drawing {
id: string;
tool: DrawingTool;
color: string;
lineWidth: number;
visible: boolean;
locked: boolean;
label?: string;
points: DrawingPoint[];
createdAt: string;
}
export interface ServerDrawing {
id: string;
user_id: string;
symbol: string;
timeframe: string;
tool_type: DrawingTool;
label?: string;
drawing_data: Drawing;
visible: boolean;
locked: boolean;
created_at: string;
updated_at: string;
}
8. Performance Optimizations
8.1 Auto-Save Strategy
- Debounced Save: 1 segundo de delay después del último cambio
- Immediate Save: Eventos críticos (delete, lock, visibility toggle)
- Optimistic Updates: UI actualiza inmediatamente, rollback en error
8.2 Caching
- Zustand store mantiene caché por
symbol:timeframe - Re-fetch solo cuando cambia symbol/timeframe
- LocalStorage backup (opcional, para offline mode)
8.3 Database Indexes
- Index compuesto
(user_id, symbol, timeframe)para queries rápidas - GIN index en
drawing_dataJSONB para búsquedas por propiedades
9. Testing Checklist
Backend Tests
- Create drawing with valid data
- Create drawing with missing fields (validation)
- Fetch drawings by symbol
- Fetch drawings by symbol + timeframe
- Update drawing (color, position, lock)
- Delete drawing
- Delete all drawings for symbol
- Unauthorized access (different user)
- Import/export JSON
- JSONB query performance (>1000 drawings)
Frontend Tests
- Draw line, save, reload → persists
- Update color → auto-saves after 1 second
- Delete drawing → removed from DB
- Lock drawing → prevents edits
- Hide drawing → visibility toggle
- Clear all → confirms and deletes
- Switch symbol → loads different drawings
- Switch timeframe → filters correctly
- Offline mode → queues operations
- Concurrent edits → conflict resolution
10. Roadmap de Implementación
Fase 1: Backend Foundation (1h)
- Crear tabla
chart_drawingsen PostgreSQL - Implementar
ChartDrawingsControllercon CRUD básico - Agregar rutas y middleware de autenticación
- Test con Postman/Thunder Client
Fase 2: Frontend Store (1h)
- Crear
drawingsStore.tscon Zustand - Implementar optimistic updates
- Crear
useDrawingshook con debounce - Test con React DevTools
Fase 3: Integration (1h)
- Modificar
ChartDrawingToolsPanelpara usar hook - Conectar eventos (add, update, delete)
- Test end-to-end
- Agregar notificaciones de error/éxito
11. Referencias
- Componente Actual:
apps/frontend/src/modules/trading/components/ChartDrawingToolsPanel.tsx(420 líneas) - Código Backend:
apps/backend/src/controllers/chart-drawings.controller.ts(a crear) - Store:
apps/frontend/src/stores/drawingsStore.ts(a crear) - Hook:
apps/frontend/src/modules/trading/hooks/useDrawings.ts(a crear) - US:
US-TRD-010-persistir-dibujos.md
Última actualización: 2026-01-25 Responsable: Frontend Lead + Backend Engineer