Add a diagnostic mode that reports chart geometry to the server

Chart geometry bugs live in the browser, and the browser is usually on a
different machine from whoever is debugging it — so "it passes in my headless
run" keeps being said about a chart that is unusable on someone's screen. This
session spent hours on that gap: a screenshot showed the crosshair reading 22:42
while the snap label read 22:58, sixteen bars apart on a 1m chart, and no
headless run reproduced it at any viewport, any device pixel ratio, before or
after a resize, or across ten scripted gestures.

Opening the chart with ?diag=1 makes every snap post what the client computed —
the cursor's time, price and x, the snapped time and price, how many bars are
held, the first and last of them, and the chart width — which the server logs as
SNAPDBG. ?diag=0 turns it off; the setting is remembered. Throttled to about one
report a second, and off by default, so it costs nothing when unused.

Kept as a permanent facility rather than scaffolding to delete: this will not be
the last geometry puzzle, and the endpoint takes whatever fields SnapReport
declares. Documented in AGENTS.md alongside the note about where things run.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Chris Amow 2026-08-10 23:19:42 -05:00
parent 7ce6d33bb5
commit 3222ce03f4
4 changed files with 99 additions and 2 deletions

View file

@ -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
```

View file

@ -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."""

BIN
screenshot2.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 457 KiB

View file

@ -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' });