diff --git a/AGENTS.md b/AGENTS.md index a49d61b..2c0a552 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -61,6 +61,25 @@ renders as a blank canvas that looks exactly like a broken app. is missing" from "the data is off-screen" — which is how a viewport bug that three passing API checks had missed was finally found. +## Diagnostic mode + +Chart geometry bugs live in the browser, which is usually on a different machine +from whoever is debugging them. Rather than asking for console pastes: + +``` +open the chart with ?diag=1 # remembered until ?diag=0 +docker compose logs api | grep SNAPDBG +``` + +With it on, every snap the trendline tool computes is posted to +`/api/debug/snap` and logged server-side — the cursor's time, price and x, the +snapped time and price, how many bars were held, the first and last bar, and the +chart's width. Throttled to about one a second. It reads the client's own +numbers, which is exactly what "works in my headless run" cannot tell you. + +Extend it when the next geometry puzzle appears; the endpoint takes whatever +fields `SnapReport` declares. + ## Running tests ``` diff --git a/app/api/routes.py b/app/api/routes.py index 2d524ba..530748a 100644 --- a/app/api/routes.py +++ b/app/api/routes.py @@ -1,3 +1,4 @@ +import logging import time import uuid @@ -11,6 +12,8 @@ from app.api.deps import require_token # Everything here needs the token when CHART_AUTH_TOKEN is set. /health and # /version live in app.api.meta and stay open on purpose. +logger = logging.getLogger(__name__) + router = APIRouter(prefix="/api", dependencies=[Depends(require_token)]) @@ -198,6 +201,35 @@ def create_comment(request: Request, payload: CommentCreate): return line.to_dict() +class SnapReport(BaseModel): + """What the browser computed for one snap, for diagnosing chart geometry.""" + cursor_t: int | None = None + cursor_p: float | None = None + cursor_x: float | None = None + snapped_t: int | None = None + snapped_p: float | None = None + bars_held: int | None = None + first_bar_t: int | None = None + last_bar_t: int | None = None + tf: str | None = None + chart_w: float | None = None + note: str | None = None + + +@router.post("/debug/snap", status_code=204) +def debug_snap(payload: SnapReport): + """Record one snap sample from a browser running diagnostic mode. + + Chart geometry bugs live in the client, and the browser is usually on a + different machine from whoever is debugging it — so its own numbers cannot + be read any other way. Off unless the page is opened with ?diag=1; see + AGENTS.md. Logged at warning level so it appears without reconfiguring + uvicorn's log levels. + """ + logger.warning("SNAPDBG %s", payload.model_dump()) + return Response(status_code=204) + + @router.get("/drawings") def drawings(request: Request): """Every drawing, comments included, for the sidebar list.""" diff --git a/screenshot2.png b/screenshot2.png new file mode 100644 index 0000000..0218960 Binary files /dev/null and b/screenshot2.png differ diff --git a/static/chart.js b/static/chart.js index a389fd2..f0cdc6e 100644 --- a/static/chart.js +++ b/static/chart.js @@ -48,6 +48,20 @@ class ConfluenceChart { // rather than a click. Wide enough to survive a twitch on a deliberate click. static DRAG_THRESHOLD = 12; + /** + * Whether to report snap geometry to the server. + * + * `?diag=1` turns it on and remembers it; `?diag=0` turns it off. Kept out of + * the way rather than always on: it exists because the browser runs on a + * different machine from whoever is debugging it. + */ + static diagnosticMode() { + const asked = new URLSearchParams(location.search).get('diag'); + if (asked === '1') localStorage.setItem('chart-diag', '1'); + if (asked === '0') localStorage.removeItem('chart-diag'); + return localStorage.getItem('chart-diag') === '1'; + } + static snapToTick(price) { return Math.round(price / ConfluenceChart.TICK) * ConfluenceChart.TICK; } @@ -581,7 +595,7 @@ class ConfluenceChart { if (!this.armedTool) return; const point = this.eventPoint(event); if (!point) return; - if (this.armedTool !== 'level') this.showSnapDot(this.snapPoint(point)); + if (this.armedTool !== 'level') this.showSnapDot(this.snapPoint(point), point); if (this.gesture) { this.gesture.end = point; this.renderGesture(); @@ -591,7 +605,7 @@ class ConfluenceChart { if (this.pendingAnchor) this.renderPending(this.pendingAnchor, this.snapPoint(point)); } - showSnapDot(snapped) { + showSnapDot(snapped, point = {}) { if (snapped.t == null) return this.hideSnapDot(); const x = this.chart.timeScale().timeToCoordinate(snapped.t); const y = this.candles.priceToCoordinate(snapped.p); @@ -602,6 +616,38 @@ class ConfluenceChart { // Says which it is before you commit, since the extreme decides the side. this.snapDot.dataset.side = snapped.snappedSide || ''; + // Diagnostic mode: report what the client computed so it can be read on + // the server. Off unless asked for, because the browser is usually on a + // different machine and this is the only way to see its numbers. + // turn on: ?diag=1 (remembered) turn off: ?diag=0 + if (ConfluenceChart.diagnosticMode() && snapped.t != null && this.bars.length) { + const step = this.bars.length > 1 + ? this.bars[this.bars.length - 1].t - this.bars[this.bars.length - 2].t : 60; + const drift = point.t == null ? null : Math.abs(snapped.t - point.t); + { + const now = Date.now(); + if (!this._lastSnapReport || now - this._lastSnapReport > 900) { + this._lastSnapReport = now; + const body = { + cursor_t: point.t, cursor_p: point.p, cursor_x: Math.round(point.x), + snapped_t: snapped.t, snapped_p: snapped.p, + bars_held: this.bars.length, + first_bar_t: this.bars[0].t, last_bar_t: this.bars[this.bars.length - 1].t, + chart_w: this.chartEl.clientWidth, + note: point.t == null + ? 'coordinateToTime returned null' + : `drift ${drift}s (${Math.round(drift / step)} bars), step ${step}s`, + }; + fetch('/api/debug/snap', { + method: 'POST', + headers: { 'Content-Type': 'application/json', + ...(localStorage.getItem('chart-token') ? { 'X-Chart-Token': localStorage.getItem('chart-token') } : {}) }, + body: JSON.stringify(body), + }).catch(() => {}); + } + } + } + const side = snapped.snappedSide === 'resistance' ? 'H' : snapped.snappedSide === 'support' ? 'L' : ''; const clock = new Date(snapped.t * 1000) .toLocaleTimeString(undefined, { hour12: false, hour: '2-digit', minute: '2-digit' });