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:
parent
7ce6d33bb5
commit
3222ce03f4
4 changed files with 99 additions and 2 deletions
19
AGENTS.md
19
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
|
is missing" from "the data is off-screen" — which is how a viewport bug that
|
||||||
three passing API checks had missed was finally found.
|
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
|
## Running tests
|
||||||
|
|
||||||
```
|
```
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,4 @@
|
||||||
|
import logging
|
||||||
import time
|
import time
|
||||||
import uuid
|
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
|
# Everything here needs the token when CHART_AUTH_TOKEN is set. /health and
|
||||||
# /version live in app.api.meta and stay open on purpose.
|
# /version live in app.api.meta and stay open on purpose.
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
router = APIRouter(prefix="/api", dependencies=[Depends(require_token)])
|
router = APIRouter(prefix="/api", dependencies=[Depends(require_token)])
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -198,6 +201,35 @@ def create_comment(request: Request, payload: CommentCreate):
|
||||||
return line.to_dict()
|
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")
|
@router.get("/drawings")
|
||||||
def drawings(request: Request):
|
def drawings(request: Request):
|
||||||
"""Every drawing, comments included, for the sidebar list."""
|
"""Every drawing, comments included, for the sidebar list."""
|
||||||
|
|
|
||||||
BIN
screenshot2.png
Normal file
BIN
screenshot2.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 457 KiB |
|
|
@ -48,6 +48,20 @@ class ConfluenceChart {
|
||||||
// rather than a click. Wide enough to survive a twitch on a deliberate click.
|
// rather than a click. Wide enough to survive a twitch on a deliberate click.
|
||||||
static DRAG_THRESHOLD = 12;
|
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) {
|
static snapToTick(price) {
|
||||||
return Math.round(price / ConfluenceChart.TICK) * ConfluenceChart.TICK;
|
return Math.round(price / ConfluenceChart.TICK) * ConfluenceChart.TICK;
|
||||||
}
|
}
|
||||||
|
|
@ -581,7 +595,7 @@ class ConfluenceChart {
|
||||||
if (!this.armedTool) return;
|
if (!this.armedTool) return;
|
||||||
const point = this.eventPoint(event);
|
const point = this.eventPoint(event);
|
||||||
if (!point) return;
|
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) {
|
if (this.gesture) {
|
||||||
this.gesture.end = point;
|
this.gesture.end = point;
|
||||||
this.renderGesture();
|
this.renderGesture();
|
||||||
|
|
@ -591,7 +605,7 @@ class ConfluenceChart {
|
||||||
if (this.pendingAnchor) this.renderPending(this.pendingAnchor, this.snapPoint(point));
|
if (this.pendingAnchor) this.renderPending(this.pendingAnchor, this.snapPoint(point));
|
||||||
}
|
}
|
||||||
|
|
||||||
showSnapDot(snapped) {
|
showSnapDot(snapped, point = {}) {
|
||||||
if (snapped.t == null) return this.hideSnapDot();
|
if (snapped.t == null) return this.hideSnapDot();
|
||||||
const x = this.chart.timeScale().timeToCoordinate(snapped.t);
|
const x = this.chart.timeScale().timeToCoordinate(snapped.t);
|
||||||
const y = this.candles.priceToCoordinate(snapped.p);
|
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.
|
// Says which it is before you commit, since the extreme decides the side.
|
||||||
this.snapDot.dataset.side = snapped.snappedSide || '';
|
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 side = snapped.snappedSide === 'resistance' ? 'H' : snapped.snappedSide === 'support' ? 'L' : '';
|
||||||
const clock = new Date(snapped.t * 1000)
|
const clock = new Date(snapped.t * 1000)
|
||||||
.toLocaleTimeString(undefined, { hour12: false, hour: '2-digit', minute: '2-digit' });
|
.toLocaleTimeString(undefined, { hour12: false, hour: '2-digit', minute: '2-digit' });
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue