diff --git a/app/api/routes.py b/app/api/routes.py index b672c33..0c3391f 100644 --- a/app/api/routes.py +++ b/app/api/routes.py @@ -101,6 +101,39 @@ class PriceAlertCreate(BaseModel): alert_early_points: float | None = Field(None, ge=0) +class LineRestore(BaseModel): + """Re-insert a deleted drawing with the same id and number. + + Create always mints a new id. Undo of delete needs the original identity + back, including drawing number, or clusters and the sidebar # label drift. + """ + + id: str + tf: Timeframe + side: Side + anchor_t: int + anchor_p: float + slope: float + last_t: int + created_at: int | None = None + note: str = "" + label: str = "" + hidden: bool = False + color: str | None = None + line_width: int | None = None + number: int = 0 + cutoff_t: int | None = None + armed: bool = True + kind: str = "" + pinned: bool = True + x: float = Field(0.72, ge=0.0, le=1.0) + y: float = Field(0.12, ge=0.0, le=1.0) + collapsed: bool = False + icon: str = "" + alert_early_points: float | None = None + scale: float = Field(1.0, ge=0.5, le=3) + + class LinePatch(BaseModel): side: Side | None = None note: str | None = None @@ -243,6 +276,45 @@ def create_line(request: Request, payload: LineCreate): return positioned_level(runtime, line.id).to_dict() +@router.post("/lines/restore", status_code=201) +def restore_line(request: Request, payload: LineRestore): + runtime = request.app.state.runtime + if payload.id in runtime.manual_lines.lines: + raise HTTPException(409, "Line already exists") + kind = "" if payload.kind in {"", "manual", "trendline", "level"} else payload.kind + color = payload.color if payload.color and len(payload.color) == 7 and payload.color.startswith("#") else "#65b7cf" + line = ManualLine( + id=payload.id, + tf=payload.tf, + side=payload.side, + anchor_t=payload.anchor_t, + anchor_p=payload.anchor_p, + slope=payload.slope, + last_t=payload.last_t, + created_at=payload.created_at or int(time.time()), + note=payload.note or payload.label, + hidden=payload.hidden, + color=color, + line_width=payload.line_width or 2, + number=payload.number, + cutoff_t=payload.cutoff_t, + armed=payload.armed, + kind=kind, + pinned=payload.pinned, + x=payload.x, + y=payload.y, + collapsed=payload.collapsed, + icon=payload.icon, + alert_early_points=payload.alert_early_points, + scale=payload.scale, + ) + line = runtime.manual_lines.add(line) + runtime.rebuild_levels() + if line.is_overlay: + return {**line.to_dict(), "kind": line.drawing_kind} + return positioned_level(runtime, line.id).to_dict() + + @router.post("/lines/price", status_code=201) def create_price_alert(request: Request, payload: PriceAlertCreate): runtime = request.app.state.runtime diff --git a/docs/feature_undo.md b/docs/feature_undo.md index 193b173..7e99f9a 100644 --- a/docs/feature_undo.md +++ b/docs/feature_undo.md @@ -1,6 +1,9 @@ # Undo and Redo -Status: proposed. +Status: proposed. An in-session client stack (Ctrl/Cmd+Z and an undo +button) plus `POST /api/lines/restore` shipped as an interim so delete +undo keeps the same id and number. Redo and the command journal are still +unbuilt. ## Goal diff --git a/static/app.js b/static/app.js index 0b16819..7e251c3 100644 --- a/static/app.js +++ b/static/app.js @@ -141,6 +141,8 @@ createApp({ const selectedDrawing = ref(null); const selectedDrawings = ref([]); const drawingList = ref(null); + const undoStack = ref([]); + let applyingUndo = false; const alertPrice = ref(null); const alertNote = ref(''); const levelColor = ref('#e0a34a'); @@ -731,6 +733,92 @@ createApp({ chartApi.setComments(visible); } + const canUndo = computed(() => undoStack.value.length > 0); + const undoTitle = computed(() => { + const last = undoStack.value.at(-1); + return last ? `Undo ${last.label}` : 'Undo'; + }); + + function remember(label, invert) { + if (applyingUndo) return; + undoStack.value = [...undoStack.value.slice(-49), { label, invert }]; + } + + function overlayDrawing(item) { + return Boolean( + item.comment + || ['comment', 'symbol', 'fibonacci'].includes(item.kind) + || ['comment', 'symbol', 'fibonacci'].includes(item.line?.kind), + ); + } + + function drawingSnapshot(item) { + const drawing = item.comment || item.line; + if (!drawing) return null; + const kind = item.kind === 'fibonacci' || drawing.kind === 'fibonacci' ? 'fibonacci' + : item.kind === 'symbol' || drawing.kind === 'symbol' ? 'symbol' + : item.kind === 'comment' || drawing.kind === 'comment' ? 'comment' + : ''; + return { + id: drawing.id, + tf: drawing.tf, + side: drawing.side || 'support', + anchor_t: drawing.anchor_t, + anchor_p: drawing.anchor_p, + slope: drawing.slope ?? 0, + last_t: drawing.last_t ?? drawing.anchor_t, + created_at: drawing.created_at || Math.floor(Date.now() / 1000), + note: drawing.note || drawing.label || '', + hidden: !!drawing.hidden, + color: drawing.color || '#65b7cf', + line_width: drawing.line_width || 2, + number: drawing.number || 0, + cutoff_t: drawing.cutoff_t ?? null, + armed: drawing.armed !== false, + kind, + pinned: drawing.pinned !== false, + x: drawing.x ?? 0.72, + y: drawing.y ?? 0.12, + collapsed: !!drawing.collapsed, + icon: drawing.icon || '', + alert_early_points: drawing.alert_early_points ?? null, + scale: drawing.scale || 1, + }; + } + + async function restoreDrawings(snapshots) { + for (const snapshot of snapshots) { + const response = await apiFetch('/api/lines/restore', { + method: 'POST', headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(snapshot), + }); + if (!response.ok) { + console.error(`Unable to restore drawing: HTTP ${response.status}`); + continue; + } + const saved = await response.json(); + if (['comment', 'symbol', 'fibonacci'].includes(snapshot.kind)) continue; + levels.value = [...levels.value.filter(level => level.id !== saved.id), saved]; + } + if (snapshots.some(item => ['comment', 'symbol', 'fibonacci'].includes(item.kind))) { + await refreshComments(); + } + syncVisibleLevels(); + } + + async function undo() { + if (chartApi?.clearPendingAnchor()) return; + const last = undoStack.value.at(-1); + if (!last || applyingUndo) return; + applyingUndo = true; + undoStack.value = undoStack.value.slice(0, -1); + try { + await last.invert(); + } finally { + applyingUndo = false; + } + } + async function createComment(result) { const text = commentText.value.trim(); if (!text) return; @@ -748,8 +836,10 @@ createApp({ console.error('Unable to save comment', response.status); return; } + const saved = await response.json(); commentText.value = ''; await refreshComments(); + remember('comment', () => deleteDrawing({ id: saved.id, kind: 'comment' })); } async function createSymbol(result, icon = selectedSymbol.value) { @@ -766,7 +856,9 @@ createApp({ console.error('Unable to save symbol', response.status); return; } + const saved = await response.json(); await refreshComments(); + remember('mark', () => deleteDrawing({ id: saved.id, kind: 'symbol' })); } function chooseSymbol(symbol) { @@ -808,20 +900,36 @@ createApp({ method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(changes), }); - if (response.ok) await refreshComments(); + if (!response.ok) return false; + await refreshComments(); + return true; } const toggleComment = comment => patchComment(comment, { collapsed: !comment.collapsed }); - const moveComment = (comment, changes) => patchComment(comment, changes); - const togglePinned = comment => patchComment(comment, { pinned: !comment.pinned }); + async function moveComment(comment, changes) { + const previous = {}; + for (const key of Object.keys(changes)) previous[key] = comment[key]; + if (Object.keys(changes).every(key => changes[key] === previous[key])) return; + if (await patchComment(comment, changes)) { + remember('move', () => patchComment(comment, previous)); + } + } + async function togglePinned(comment) { + const previous = comment.pinned; + if (await patchComment(comment, { pinned: !comment.pinned })) { + remember('pin', () => patchComment(comment, { pinned: previous })); + } + } async function deleteDrawing(item) { + const snapshot = applyingUndo ? null : drawingSnapshot(item); await apiFetch(`/api/lines/${encodeURIComponent(item.id)}`, { method: 'DELETE' }); - if (item.comment) await refreshComments(); + if (overlayDrawing(item)) await refreshComments(); else levels.value = levels.value.filter(level => level.id !== item.id); if (selectedDrawing.value === item.id) selectedDrawing.value = null; selectedDrawings.value = selectedDrawings.value.filter(id => id !== item.id); syncVisibleLevels(); + if (snapshot) remember('delete', () => restoreDrawings([snapshot])); } async function createFibonacci(start, end) { @@ -839,10 +947,11 @@ createApp({ console.error(`Unable to save fibonacci: HTTP ${response.status}`); return; } - await refreshComments(); const saved = await response.json(); + await refreshComments(); selectedDrawing.value = saved.id; selectedDrawings.value = [saved.id]; + remember('fibonacci', () => deleteDrawing({ id: saved.id, kind: 'fibonacci' })); } async function createTrendline(first, second) { @@ -870,6 +979,7 @@ createApp({ if (!levels.value.some(level => level.id === saved.id)) levels.value.push(saved); selectedDrawing.value = saved.id; selectedDrawings.value = [saved.id]; + remember('trendline', () => deleteDrawing({ id: saved.id })); } catch (error) { levels.value = levels.value.filter(level => level.id !== temporaryId); console.error('Unable to save line', error); @@ -890,6 +1000,7 @@ createApp({ if (!response.ok) { console.error(`Unable to add price alert: HTTP ${response.status}`); return; } const saved = await response.json(); levels.value = [...levels.value.filter(level => level.id !== saved.id), saved]; + remember('level', () => deleteDrawing({ id: saved.id })); alertPrice.value = null; alertNote.value = ''; alertEarlyPoints.value = null; @@ -899,9 +1010,21 @@ createApp({ async function deleteSelected() { const ids = selectedDrawings.value.length ? selectedDrawings.value : [selectedDrawing.value].filter(Boolean); - for (const id of ids) { - const item = drawings.value.find(drawing => drawing.id === id); - if (item) await deleteDrawing(item); + const snapshots = ids + .map(id => drawings.value.find(drawing => drawing.id === id)) + .map(item => item && drawingSnapshot(item)) + .filter(Boolean); + applyingUndo = true; + try { + for (const id of ids) { + const item = drawings.value.find(drawing => drawing.id === id); + if (item) await deleteDrawing(item); + } + } finally { + applyingUndo = false; + } + if (snapshots.length) { + remember(snapshots.length > 1 ? 'delete drawings' : 'delete', () => restoreDrawings(snapshots)); } } @@ -948,10 +1071,15 @@ createApp({ selectedDrawing.value = saved.id; selectedDrawings.value = [saved.id]; syncVisibleLevels(); + remember('duplicate', () => deleteDrawing({ id: saved.id })); } async function deleteLines(ids) { if (!ids.length) return; + const snapshots = ids + .map(id => drawings.value.find(drawing => drawing.id === id)) + .map(item => item && drawingSnapshot(item)) + .filter(Boolean); const deleting = new Set(ids); levels.value = levels.value.filter(level => !deleting.has(level.id)); if (selectedDrawing.value && deleting.has(selectedDrawing.value)) selectedDrawing.value = null; @@ -964,6 +1092,9 @@ createApp({ responses.forEach(response => { if (!response.ok) console.error(`Unable to delete line: HTTP ${response.status}`); }); + if (snapshots.length) { + remember(snapshots.length > 1 ? 'delete drawings' : 'delete', () => restoreDrawings(snapshots)); + } } function selectComment(comment) { @@ -993,19 +1124,31 @@ createApp({ async function toggleSelectedVisibility() { const hidden = !selectedAreHidden.value; - for (const id of [...selectedDrawings.value]) { + const previous = [...selectedDrawings.value].map(id => { const item = drawings.value.find(drawing => drawing.id === id); const drawing = item?.line || item?.comment; - if (drawing) await updateLineStyle(drawing, { hidden }); + return drawing ? { drawing, hidden: drawing.hidden } : null; + }).filter(Boolean); + for (const entry of previous) await updateLineStyle(entry.drawing, { hidden }); + if (previous.length) { + remember(hidden ? 'hide' : 'show', async () => { + for (const entry of previous) await updateLineStyle(entry.drawing, { hidden: entry.hidden }); + }); } } async function setArmed(line, armed) { - await updateLineStyle(line, { armed }); + const previous = line.armed; + if (await updateLineStyle(line, { armed })) { + remember(armed ? 'arm' : 'disarm', () => updateLineStyle(line, { armed: previous })); + } } async function renameLine(line, name) { - await updateLineStyle(line, { note: name.trim() }); + const previous = line.note ?? line.label ?? ''; + if (await updateLineStyle(line, { note: name.trim() })) { + remember('rename', () => updateLineStyle(line, { note: previous })); + } } async function updateLineStyle(line, changes) { @@ -1013,23 +1156,27 @@ createApp({ method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(changes), }); - if (!response.ok) { console.error(`Unable to update line: HTTP ${response.status}`); return; } + if (!response.ok) { console.error(`Unable to update line: HTTP ${response.status}`); return false; } const saved = await response.json(); // A comment is not a level and must not be pushed into the level list, // where it would be drawn as a line. - if (['comment', 'symbol', 'fibonacci'].includes(line.kind)) { await refreshComments(); return; } + if (['comment', 'symbol', 'fibonacci'].includes(line.kind)) { await refreshComments(); return true; } levels.value = levels.value.map(level => level.id === line.id ? saved : level); syncVisibleLevels(); + return true; } async function updateLevelNumber(line, field, raw) { const value = raw === '' ? null : Number(raw); if (field === 'anchor_p' && !(value > 0)) return; if (field === 'alert_early_points' && value != null && value < 0) return; - await updateLineStyle(line, { [field]: value }); + const previous = line[field]; + if (await updateLineStyle(line, { [field]: value })) { + remember('edit', () => updateLineStyle(line, { [field]: previous })); + } } - async function updateLineGeometry(line) { + async function updateLineGeometry(line, previous = null) { const response = await apiFetch(`/api/lines/${encodeURIComponent(line.id)}`, { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ @@ -1039,12 +1186,19 @@ createApp({ }); if (!response.ok) { console.error(`Unable to move line: HTTP ${response.status}`); return; } const saved = await response.json(); - if (line.kind === 'fibonacci') { await refreshComments(); return; } - levels.value = levels.value.map(level => level.id === line.id ? saved : level); - syncVisibleLevels(); + if (line.kind === 'fibonacci') await refreshComments(); + else { + levels.value = levels.value.map(level => level.id === line.id ? saved : level); + syncVisibleLevels(); + } + if (previous) { + remember('move', () => updateLineGeometry(previous)); + } } async function updateCommentGeometry(comment, changes) { + const previous = {}; + for (const key of Object.keys(changes)) previous[key] = comment[key]; const response = await apiFetch(`/api/lines/${encodeURIComponent(comment.id)}`, { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(changes), @@ -1053,6 +1207,7 @@ createApp({ const saved = await response.json(); comments.value = comments.value.map(value => value.id === comment.id ? saved : value); syncComments(); + remember('move', () => updateCommentGeometry(comment, previous)); } async function nudgeSelectedDrawings(key, multiplier) { @@ -1066,9 +1221,10 @@ createApp({ if (!item) continue; if (item.line) { const line = { ...item.line }; + const previous = { ...item.line }; if (vertical) line.anchor_p = ConfluenceChart.snapToTick(line.anchor_p + vertical * ConfluenceChart.TICK); if (line.slope === 0) { - if (vertical) await updateLineGeometry(line); + if (vertical) await updateLineGeometry(line, previous); continue; } const endPrice = item.line.anchor_p @@ -1083,7 +1239,7 @@ createApp({ } } line.slope = (endPrice - line.anchor_p) / (line.last_t - line.anchor_t); - await updateLineGeometry(line); + await updateLineGeometry(line, previous); continue; } const comment = item.comment; @@ -1114,7 +1270,7 @@ createApp({ } } - async function endLineHere(line) { + async function endLineHere(line, previousCutoff) { const response = await apiFetch(`/api/lines/${encodeURIComponent(line.id)}`, { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ cutoff_t: line.cutoff_t }), @@ -1123,6 +1279,9 @@ createApp({ const saved = await response.json(); levels.value = levels.value.map(level => level.id === line.id ? saved : level); syncVisibleLevels(); + if (previousCutoff !== undefined) { + remember('end line', () => updateLineStyle(line, { cutoff_t: previousCutoff })); + } } // Backspace is a normal editing key inside a field. Without this guard, @@ -1158,6 +1317,13 @@ createApp({ } } if (isEditing(event.target)) return; + if ((event.ctrlKey || event.metaKey) && !event.shiftKey && event.key.toLowerCase() === 'z') { + if (chartApi?.pendingAnchor || canUndo.value) { + event.preventDefault(); + undo(); + } + return; + } if (event.key.startsWith('Arrow') && hasDrawingSelection.value) { event.preventDefault(); const multiplier = event.shiftKey ? 4 : 1; @@ -1318,8 +1484,8 @@ createApp({ chartApi.setRthEnabled(prefs.value.enabled.rth); chartApi.setClickHandler(handleChartClick); chartApi.setToolCompleteHandler(handleToolComplete); - chartApi.setLineChangeHandler(line => { - nudgeQueue = nudgeQueue.then(() => updateLineGeometry(line)); + chartApi.setLineChangeHandler((line, previous) => { + nudgeQueue = nudgeQueue.then(() => updateLineGeometry(line, previous)); }); chartApi.setDrawingColorDescriptor(drawingColorDescription); chartApi.setLineEndHandler(endLineHere); @@ -1351,6 +1517,6 @@ createApp({ window.removeEventListener('keydown', handleKeydown); }); - return { status, price, sessionOpen, quoteChange, animateCurrentPrice, autoScrollLivePrice, extraDetail, barAge, dataUpdatedAt, buildStamp, timeframe, timeframes, drawingColors, drawingColorRows, drawingColorName, colorRowLabels, symbolChoices, selectedSymbol, symbolColor, symbolScale, symbolScales, symbolPanelOpen, prefs, clusters, clustersByPrice, events, eventsMore, loadOlderEvents, diagnosticMode, captureBusy, captureDiagnostic, armedTool, drawName, drawColor, drawWidth, drawSide, snap, selectedDrawing, selectedDrawings, drawingList, startDrawingListResize, manualLines, hasDrawingSelection, allShownSelected, selectedAreHidden, alertPrice, alertNote, alertEarlyPoints, levelColor, levelWidth, addPriceAlert, armTool, selectTimeframe, allEnabled, toggleGroup, maValue, maAlertOn, toggleMaAlert, deleteSelected, deleteLine, toggleDrawingSelection, toggleSelectAll, toggleSelectedVisibility, renameLine, updateLineStyle, updateLevelNumber, setArmed, commentText, commentFloat, comments, drawings, filteredDrawings, drawingFilter, drawingKind, drawingTf, deleteDrawing, toggleComment, togglePinned, chooseSymbol, toggleSymbolPanel, startSymbolDrag, dropSymbol, optionExpirations, optionExpiryId, optionSide, optionMode, optionMin, optionMax, optionContracts, optionUnderlying, optionBusy, optionError, optionSearched, optionCopied, onOptionsToggle, searchOptions, copyOption }; + return { status, price, sessionOpen, quoteChange, animateCurrentPrice, autoScrollLivePrice, extraDetail, barAge, dataUpdatedAt, buildStamp, timeframe, timeframes, drawingColors, drawingColorRows, drawingColorName, colorRowLabels, symbolChoices, selectedSymbol, symbolColor, symbolScale, symbolScales, symbolPanelOpen, prefs, clusters, clustersByPrice, events, eventsMore, loadOlderEvents, diagnosticMode, captureBusy, captureDiagnostic, armedTool, drawName, drawColor, drawWidth, drawSide, snap, selectedDrawing, selectedDrawings, drawingList, startDrawingListResize, manualLines, hasDrawingSelection, allShownSelected, selectedAreHidden, alertPrice, alertNote, alertEarlyPoints, levelColor, levelWidth, addPriceAlert, armTool, selectTimeframe, allEnabled, toggleGroup, maValue, maAlertOn, toggleMaAlert, deleteSelected, deleteLine, toggleDrawingSelection, toggleSelectAll, toggleSelectedVisibility, renameLine, updateLineStyle, updateLevelNumber, setArmed, commentText, commentFloat, comments, drawings, filteredDrawings, drawingFilter, drawingKind, drawingTf, deleteDrawing, toggleComment, togglePinned, chooseSymbol, toggleSymbolPanel, startSymbolDrag, dropSymbol, optionExpirations, optionExpiryId, optionSide, optionMode, optionMin, optionMax, optionContracts, optionUnderlying, optionBusy, optionError, optionSearched, optionCopied, onOptionsToggle, searchOptions, copyOption, canUndo, undoTitle, undo }; }, }).mount('#app'); diff --git a/static/chart.js b/static/chart.js index 143322c..f245443 100644 --- a/static/chart.js +++ b/static/chart.js @@ -1520,6 +1520,13 @@ class ConfluenceChart { else this.detachPointers('tool'); } + clearPendingAnchor() { + if (!this.pendingAnchor) return false; + this.pendingAnchor = null; + this.clearLinePreview(); + return true; + } + /** * Pixels between the element's left edge and the plot area. * @@ -2738,9 +2745,10 @@ class ConfluenceChart { event.preventDefault(); const level = this.levels.find(value => value.id === this.draggingLine.id); const moved = this.draggingLine.moved; + const original = this.draggingLine.original; this.draggingLine = null; this.detachPointers('line'); - if (moved && level && this.onLineChange) this.onLineChange({ ...level }); + if (moved && level && this.onLineChange) this.onLineChange({ ...level }, { ...original }); } startAnchorDrag(event, anchor) { @@ -2750,6 +2758,7 @@ class ConfluenceChart { this.draggingAnchor = { id: this.selectedLineId, anchor: level && ConfluenceChart.isFlat(level) ? 'price' : anchor, + original: level ? { ...level } : null, }; event.currentTarget.setPointerCapture?.(event.pointerId); this.attachPointers('anchor'); @@ -2802,11 +2811,19 @@ class ConfluenceChart { finishAnchorDrag(event) { if (!this.draggingAnchor) return; event.preventDefault(); + const original = this.draggingAnchor.original; const level = this.handleLevel(this.draggingAnchor.id); this.draggingAnchor = null; this.detachPointers('anchor'); this.hideSnapDot(); - if (level && this.onLineChange) this.onLineChange({ ...level }); + if (level && original && this.onLineChange && ( + level.anchor_t !== original.anchor_t + || level.anchor_p !== original.anchor_p + || level.slope !== original.slope + || level.last_t !== original.last_t + )) { + this.onLineChange({ ...level }, { ...original }); + } } showContextMenu(event) { @@ -2845,23 +2862,25 @@ class ConfluenceChart { endSelectedLineHere() { const level = this.levels.find(value => value.id === this.selectedLineId); if (!level || this.contextCutoff == null) return; + const previousCutoff = level.cutoff_t; level.cutoff_t = this.contextCutoff; const entry = this.levelSeries.get(level.id); if (entry) entry.series.setData(this.seriesLineData(level)); this.renderLineBridges(); this.hideContextMenu(); - if (this.onLineEnd) this.onLineEnd({ ...level }); + if (this.onLineEnd) this.onLineEnd({ ...level }, previousCutoff); } extendSelectedLine() { const level = this.levels.find(value => value.id === this.selectedLineId); if (!level || level.cutoff_t == null) return; + const previousCutoff = level.cutoff_t; level.cutoff_t = null; const entry = this.levelSeries.get(level.id); if (entry) entry.series.setData(this.seriesLineData(level)); this.renderLineBridges(); this.hideContextMenu(); - if (this.onLineEnd) this.onLineEnd({ ...level }); + if (this.onLineEnd) this.onLineEnd({ ...level }, previousCutoff); } // Tinted by the bar's own direction, muted so the candles stay the subject. diff --git a/static/index.html b/static/index.html index 14a23e6..99b6401 100644 --- a/static/index.html +++ b/static/index.html @@ -22,7 +22,10 @@
{{ status.symbol || 'ES=F' }}{{ price == null ? '—' : price.toFixed(2) }}
-
+
+ +
+
@@ -44,6 +47,7 @@ Layers
+
diff --git a/static/style.css b/static/style.css index 9012368..289832b 100644 --- a/static/style.css +++ b/static/style.css @@ -11,7 +11,7 @@ main { display:grid; grid-template-columns:minmax(0, 1fr) 300px; gap:16px; } .chart-head { min-height:56px; padding:10px 14px; display:flex; align-items:center; justify-content:space-between; gap:12px; border-bottom:1px solid var(--line); } .quote { display:flex; align-items:baseline; gap:9px; flex-wrap:wrap; }.symbol { font-weight:700; margin-right:5px; }.price { color:var(--accent); font-size:19px; }.quote-change { color:var(--muted); font-size:11px; }.quote-change.positive { color:var(--green); }.quote-change.negative { color:var(--red); } button { border:1px solid var(--line); background:transparent; color:var(--muted); padding:6px 11px; font:inherit; cursor:pointer; }button.active { color:var(--bg); background:var(--accent); border-color:var(--accent); } -.timeframes { display:flex; flex-wrap:wrap; justify-content:flex-end; }.timeframes button+button { border-left:0; } +.chart-head-tools { display:flex; align-items:center; gap:8px; }.undo-btn { display:grid; place-items:center; width:32px; height:28px; padding:0; flex:none; }.undo-btn:disabled { opacity:.35; cursor:default; }.timeframes { display:flex; flex-wrap:wrap; justify-content:flex-end; }.timeframes button+button { border-left:0; } .drawing-tools { min-height:38px; padding:5px 12px; display:flex; align-items:center; gap:9px; border-bottom:1px solid var(--line); color:var(--muted); font-size:10px; }.drawing-tools button,.drawing-tools select,.drawing-tools .line-name { padding:4px 8px; font-size:10px; }.drawing-tools select,.drawing-tools .line-name { background:var(--panel); color:var(--fg); border:1px solid var(--line); }.drawing-tools .line-name { width:130px; font:inherit; }.drawing-tools label { display:flex; gap:4px; align-items:center; }.drawing-tools input { accent-color:var(--accent); } #chart { position:relative; height:calc(100vh - 190px); min-height:420px; touch-action:pan-y; }.chart-preview,.chart-line-bridges,.chart-handles { position:absolute; inset:0; width:100%; height:100%; overflow:hidden; pointer-events:none; }.chart-preview,.chart-line-bridges { z-index:4; }.chart-line-bridges line { stroke-linecap:round; }.chart-handles { z-index:6; }.chart-preview line[hidden],.chart-anchor[hidden],.chart-line-hit[hidden],.chart-line-focus[hidden],.chart-fib-focus[hidden] { display:none; }.chart-line-focus { stroke-linecap:round; pointer-events:none; animation:line-focus .75s ease-out forwards; }.chart-line-hit { fill:none; stroke:transparent; stroke-width:16px; pointer-events:stroke; cursor:move; touch-action:none; }.chart-anchor { stroke:var(--panel); stroke-width:2px; cursor:grab; pointer-events:all; touch-action:none; }.chart-anchor:active { cursor:grabbing; }.chart-tooltip { position:absolute; z-index:5; padding:4px 7px; border:1px solid var(--line); background:var(--panel); color:var(--fg); font-size:10px; white-space:pre-line; pointer-events:none; }.chart-tooltip[hidden] { display:none; } .current-price-pulse { position:absolute; left:0; right:0; z-index:5; height:3px; transform:translateY(-1.5px); overflow:hidden; opacity:0; background:repeating-linear-gradient(110deg,transparent 0 8px,color-mix(in srgb,var(--accent) 55%,white) 8px 13px,transparent 13px 19px); mask-image:linear-gradient(90deg,transparent 0,#000 8%,#000 100%); pointer-events:none; }.current-price-pulse::after { content:''; position:absolute; top:-2px; bottom:-2px; left:-24%; width:24%; background:linear-gradient(90deg,transparent,color-mix(in srgb,var(--accent) 35%,white),transparent); opacity:.8; }.current-price-pulse.active { opacity:.9; animation:current-price-flow 1.5s linear infinite; }.current-price-pulse.active::after { animation:current-price-scan 2.8s linear infinite; }@keyframes current-price-flow { to { background-position-x:57px; } }@keyframes current-price-scan { to { transform:translateX(520%); } } @@ -36,7 +36,7 @@ aside { padding:16px; }h2 { margin:0 0 12px; color:var(--muted); font-size:11px; .color-popover { grid-template-columns:1fr; width:178px; gap:3px; } .color-family-row { display:grid; grid-template-columns:70px 1fr; align-items:center; gap:5px; min-width:0; }.color-family-row:nth-child(3),.color-family-row:nth-child(5) { margin-top:4px; padding-top:4px; border-top:1px solid var(--line); }.color-family-input { width:70px; height:18px; padding:1px 3px; border:1px solid transparent; border-radius:2px; background:transparent; color:var(--muted); font:inherit; font-size:8px; }.color-family-input:hover,.color-family-input:focus { border-color:var(--line); background:var(--chart-bg); color:var(--fg); outline:0; }.color-family-swatches { display:grid; grid-template-columns:repeat(4,18px); gap:3px; }.color-family-swatches>button,.color-popover-footer button { width:18px; height:18px; padding:0; border:1px solid color-mix(in srgb,var(--fg) 20%,transparent); border-radius:2px; cursor:pointer; }.color-family-swatches>button.selected { outline:2px solid var(--fg); outline-offset:1px; }.color-popover-footer { display:grid; grid-template-columns:18px 1fr 18px; align-items:center; gap:3px; margin-top:4px; padding-top:5px; border-top:1px solid var(--line); }.color-popover-footer .palette-close { grid-column:3; color:var(--muted); background:var(--chart-bg); } .level-editors { display:flex; gap:4px; overflow:visible; text-transform:none; }.level-editors label { display:flex; align-items:center; gap:2px; }.level-editors input { width:54px; height:19px; padding:1px 3px; border:1px solid var(--line); border-radius:3px; background:var(--panel); color:var(--fg); font:inherit; font-size:8px; } -.layer-group { padding:9px 0; border-bottom:1px solid var(--line); display:grid; gap:7px; }.layer-group label,.score-hidden { display:flex; align-items:center; gap:7px; font-size:11px; cursor:pointer; }.layer-group input,.score-hidden input { accent-color:var(--accent); }.ma-fold { margin:0; }.ma-fold > summary { display:flex; align-items:center; gap:7px; cursor:pointer; list-style:none; user-select:none; }.ma-fold > summary::-webkit-details-marker { display:none; }.ma-rows { display:grid; gap:5px; padding:8px 0 2px 4px; }.ma-row { display:grid; grid-template-columns:auto 1fr auto auto; align-items:center; gap:8px; font-size:11px; }.ma-row label { color:var(--muted); gap:6px; }.ma-row input { width:12px; height:12px; margin:0; flex:none; }.ma-value { font-variant-numeric:tabular-nums; color:var(--fg); text-align:right; }.ma-alert { display:grid; place-items:center; width:16px; height:16px; padding:0; border:0; background:transparent; color:var(--muted); font-size:9px; }.ma-alert.on { color:var(--accent); }.layer-inline { display:flex; align-items:center; gap:12px; }.layer-inline .disabled { gap:2px; }.swatch { width:13px; height:3px; display:inline-block; background:var(--muted); }.tf-1d { background:#d96073; }.tf-1h { background:#efb643; }.manual { background:#65b7cf; }.drawings { background:#c4a36a; }.vwap { background:#b07ad6; }.horizontal { background:#9fb0c4; }.rth { background:rgba(44,41,36,.35); } +.layer-group { padding:9px 0; border-bottom:1px solid var(--line); display:grid; gap:7px; }.layer-group label,.score-hidden { display:flex; align-items:center; gap:7px; font-size:11px; cursor:pointer; }.layer-group input,.score-hidden input { accent-color:var(--accent); }.ma-fold { margin:0; }.ma-fold > summary { display:flex; align-items:center; gap:7px; cursor:pointer; list-style:none; user-select:none; }.ma-fold > summary::-webkit-details-marker { display:none; }.ma-fold-caret { width:10px; color:var(--muted); font-size:9px; transition:transform .15s; }.ma-fold[open] > summary .ma-fold-caret { transform:rotate(180deg); }.ma-rows { display:grid; gap:5px; padding:8px 0 2px 22px; }.ma-row { display:grid; grid-template-columns:auto 1fr auto auto; align-items:center; gap:8px; font-size:11px; }.ma-row label { color:var(--muted); gap:6px; }.ma-row input { width:12px; height:12px; margin:0; flex:none; }.ma-value { font-variant-numeric:tabular-nums; color:var(--fg); text-align:right; }.ma-alert { display:grid; place-items:center; width:16px; height:16px; padding:0; border:0; background:transparent; color:var(--muted); font-size:9px; }.ma-alert.on { color:var(--accent); }.layer-inline { display:flex; align-items:center; gap:12px; }.layer-inline .disabled { gap:2px; }.swatch { width:13px; height:3px; display:inline-block; background:var(--muted); }.tf-1d { background:#d96073; }.tf-1h { background:#efb643; }.manual { background:#65b7cf; }.drawings { background:#c4a36a; }.vwap { background:#b07ad6; }.horizontal { background:#9fb0c4; }.rth { background:rgba(44,41,36,.35); } .hint { margin:6px 0 2px; font-size:10px; color:var(--muted); line-height:1.35; } /* Tool palette: the head arms the tool, the body configures what it creates. */ diff --git a/tests/test_manual_lines.py b/tests/test_manual_lines.py index 6287a55..b08a7a9 100644 --- a/tests/test_manual_lines.py +++ b/tests/test_manual_lines.py @@ -162,3 +162,49 @@ def test_a_null_cutoff_clears_an_ended_line(tmp_path): cleared = client.patch(f"/api/lines/{created['id']}", json={"cutoff_t": None}).json() assert cleared["cutoff_t"] is None + + +def test_restoring_a_deleted_line_keeps_its_id_and_number(tmp_path): + from fastapi import FastAPI + from fastapi.testclient import TestClient + + from app.api.routes import router + from app.config import Settings + from app.runtime import Runtime + + app = FastAPI() + app.include_router(router) + app.state.runtime = Runtime(Settings(manual_lines_path=tmp_path / "manual_lines.json")) + client = TestClient(app) + created = client.post("/api/lines", json={ + "tf": "1m", "side": "support", + "anchor_t": 100, "anchor_p": 1.0, + "end_t": 200, "end_p": 2.0, + "note": "keep me", + }).json() + line_id, number = created["id"], created["number"] + assert client.delete(f"/api/lines/{line_id}").status_code == 204 + + restored = client.post("/api/lines/restore", json={ + "id": line_id, + "tf": "1m", + "side": "support", + "anchor_t": 100, + "anchor_p": 1.0, + "slope": 0.01, + "last_t": 200, + "note": "keep me", + "number": number, + }).json() + assert restored["id"] == line_id + assert restored["number"] == number + assert client.post("/api/lines/restore", json={ + "id": line_id, + "tf": "1m", + "side": "support", + "anchor_t": 100, + "anchor_p": 1.0, + "slope": 0.01, + "last_t": 200, + "number": number, + }).status_code == 409