# 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.tsx` con 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_drawings` en PostgreSQL - Zustand store para gestiΓ³n de estado - Hook `useDrawings` para 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` ```sql -- 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) ```json { "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:** ```http POST /api/chart-drawings Authorization: Bearer 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:** ```json { "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:** ```http GET /api/chart-drawings/BTCUSD?timeframe=1h Authorization: Bearer ``` **Response:** ```json { "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:** ```http PUT /api/chart-drawings/550e8400-e29b-41d4-a716-446655440000 Authorization: Bearer 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:** ```json { "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` ```typescript // 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` ```typescript // 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` ```typescript // 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; // key: "symbol:timeframe" loading: boolean; error: string | null; // Actions fetchDrawings: (symbol: string, timeframe: string) => Promise; addDrawing: (symbol: string, timeframe: string, drawing: Drawing) => Promise; updateDrawing: (symbol: string, timeframe: string, drawing: Drawing) => Promise; removeDrawing: (symbol: string, timeframe: string, drawingId: string) => Promise; clearAllDrawings: (symbol: string, timeframe: string) => Promise; importDrawings: (drawings: any[]) => Promise; exportDrawings: (symbol: string, timeframe?: string) => Promise; // 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()( 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` ```typescript // 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` ```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(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 ( ); }; ``` --- ## 7. Tipos TypeScript ### 7.1 Frontend Types ```typescript // 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_data` JSONB 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) 1. Crear tabla `chart_drawings` en PostgreSQL 2. Implementar `ChartDrawingsController` con CRUD bΓ‘sico 3. Agregar rutas y middleware de autenticaciΓ³n 4. Test con Postman/Thunder Client ### Fase 2: Frontend Store (1h) 1. Crear `drawingsStore.ts` con Zustand 2. Implementar optimistic updates 3. Crear `useDrawings` hook con debounce 4. Test con React DevTools ### Fase 3: Integration (1h) 1. Modificar `ChartDrawingToolsPanel` para usar hook 2. Conectar eventos (add, update, delete) 3. Test end-to-end 4. 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