diff --git a/app/analysis/alerts.py b/app/analysis/alerts.py index ee872c4..8e346c7 100644 --- a/app/analysis/alerts.py +++ b/app/analysis/alerts.py @@ -1,4 +1,6 @@ import json +from datetime import datetime, timezone +from zoneinfo import ZoneInfo import logging from dataclasses import dataclass from pathlib import Path @@ -16,6 +18,12 @@ class Alert: # Hand-placed levels that caused this alert; the caller disarms them so a # one-shot alert stays one-shot. tripped: tuple[str, ...] = () + # Assigned by the engine, monotonic and persisted, so the same alert carries + # the same number on a phone and on a screen. A browser cannot number these: + # its counter restarts on reload and differs between tabs. Declared after + # `tripped` so existing positional callers keep working. + number: int = 0 + at: int = 0 @dataclass(slots=True) @@ -44,10 +52,13 @@ class AlertEngine: min_score: float, cooldown_seconds: int = 900, state_path: Path | None = None, + timezone_name: str = "UTC", ): self.min_score = min_score self.cooldown_seconds = cooldown_seconds + self.timezone = timezone_name self.state_path = Path(state_path) if state_path else None + self._next_number = 1 self._fired: list[_Fired] = self._load() def _load(self) -> list[_Fired]: @@ -55,6 +66,10 @@ class AlertEngine: return [] try: payload = json.loads(self.state_path.read_text(encoding="utf-8")) + # The file used to be a bare list, before alerts were numbered. + if isinstance(payload, dict): + self._next_number = int(payload.get("next_number", 1)) + payload = payload.get("fired", []) return [_Fired(float(item["center"]), int(item["at"])) for item in payload] except Exception: # Corrupt state costs one burst of duplicate alerts, which is a far @@ -62,6 +77,20 @@ class AlertEngine: logger.warning("Could not read alert state; starting empty", exc_info=True) return [] + def _stamp(self, when: int) -> str: + """The alert's time, in the configured zone rather than the server's. + + Containers run in UTC. A push that says 02:14 when the person reading it + sees 21:14 on their wall costs a moment of translation every time. + """ + moment = datetime.fromtimestamp(when, timezone.utc) + try: + moment = moment.astimezone(ZoneInfo(self.timezone)) + except Exception: + # An unknown zone must not cost an alert; UTC is still readable. + logger.warning("Unknown alert timezone %r; using UTC", self.timezone) + return moment.strftime("%a %H:%M:%S %Z") + def _save(self) -> None: if not self.state_path: return @@ -70,7 +99,12 @@ class AlertEngine: temporary = self.state_path.with_suffix(self.state_path.suffix + ".tmp") temporary.write_text( json.dumps( - [{"center": entry.center, "at": entry.at} for entry in self._fired], + { + "next_number": self._next_number, + "fired": [ + {"center": entry.center, "at": entry.at} for entry in self._fired + ], + }, indent=2, sort_keys=True, ) @@ -152,7 +186,22 @@ class AlertEngine: message = ( f"{direction} {headline} {symbol} {current_price:.2f}\n{detail}\n{timeframes}" ) - alerts.append(Alert(cluster, message, tuple(member.id for member in drawn))) + number = self._next_number + self._next_number += 1 + fired_at = int(now) + # The number leads the message so it survives truncation in a + # notification shade, and the time is local because a push read on a + # phone is read by a person, not by a machine. + message = f"#{number} {message}\n{self._stamp(fired_at)}" + alerts.append( + Alert( + cluster, + message, + tuple(member.id for member in drawn), + number=number, + at=fired_at, + ) + ) if changed: self._save() return alerts diff --git a/app/api/ws.py b/app/api/ws.py index da35987..f01eeb0 100644 --- a/app/api/ws.py +++ b/app/api/ws.py @@ -129,6 +129,8 @@ async def websocket_endpoint(websocket: WebSocket): await websocket.send_json( { "type": "alert", + "number": event.get("number", 0), + "at": event.get("at", 0), "cluster": event["cluster"].to_dict(), "message": event["message"], } diff --git a/app/config.py b/app/config.py index 3a0402c..918de97 100644 --- a/app/config.py +++ b/app/config.py @@ -61,6 +61,9 @@ class Settings(BaseSettings): ntfy_server: str = "https://ntfy.sh" chart_auth_token: str = "" chart_password: str = "" + # Zone used for times a person reads rather than a machine: the timestamp in + # an alert push. The container runs UTC; this is where you are. + alert_timezone: str = "America/Chicago" replay_file: Path | None = None @property diff --git a/app/runtime.py b/app/runtime.py index 10a8774..8c08d7d 100644 --- a/app/runtime.py +++ b/app/runtime.py @@ -67,6 +67,7 @@ class Runtime: self.settings.confluence_min_score, self.settings.alert_cooldown_seconds, self.settings.alert_state_path, + self.settings.alert_timezone, ) self.levels = self.manual_lines.levels() self.stream = StreamService(live_source(self.settings), self.settings.live_symbol) @@ -252,7 +253,14 @@ class Runtime: def dispatch_alerts(self, alerts: list[Alert]) -> None: tripped: set[str] = set() for alert in alerts: - self.broadcast({"type": "alert", "cluster": alert.cluster, "message": alert.message}) + self.broadcast({ + "type": "alert", + "cluster": alert.cluster, + "message": alert.message, + # Same number the push carries, so a phone and a screen agree. + "number": alert.number, + "at": alert.at, + }) task = asyncio.create_task(self.notify(alert.message)) # Held so the task is not garbage collected mid-flight. self._notify_tasks.add(task) diff --git a/docs/implementation.md b/docs/implementation.md index b984d0b..c37a0bd 100644 --- a/docs/implementation.md +++ b/docs/implementation.md @@ -705,3 +705,48 @@ path got harder, which is why the trade was worth making. Both handlers moved from `meta.py` to `routes.py`. `meta.py` is the deliberately unauthenticated router — health, version, login and logout — and a screenshot endpoint did not belong in that company. + +### 2026-08-11 — alerts carry a number and a local time + +A push saying "zone at 7784" and a log row saying the same thing were impossible +to line up when several fired together. Alerts are now numbered server-side, and +the number appears in both the ntfy body and the Events list. + +The number has to come from the server. The browser already had a counter, but +it restarts on reload and differs between tabs, so it can never agree with a +phone. It is persisted alongside the alert cooldown state, because numbers +restarting from 1 after a deploy would collide with what is already sitting in a +phone's notification history — which meant changing that file from a bare list to +an object, with the loader still accepting the old shape. + +Pushes also carry a timestamp now, in the configured zone rather than the +server's. `ALERT_TIMEZONE` defaults to America/Chicago. Containers run UTC, and a +push that says 02:14 to someone whose wall clock reads 21:14 costs a translation +every time. The browser keeps formatting its own times locally, so only the push +needed a zone. + +A mistake worth recording: `number` and `at` were first declared before +`tripped` in the `Alert` dataclass, which broke every positional caller with +"got multiple values for argument 'number'". New fields on a dataclass with +positional callers go last. Separately, the first version of the timezone test +asserted times I had guessed rather than computed — the code was right and the +test was wrong, which is worth checking before assuming a failure is a bug. + +### 2026-08-11 — e2e flakiness is now a pattern, not a test + +Two consecutive full e2e runs failed different tests: first "diagnostic capture +uploads a PNG", then "a symbol can be dropped at a price", "the snapped extreme +decides the side" and "dragging an anchor uses the same snapping". Each passes +in isolation. Four different tests failing randomly is a harness problem rather +than four bad tests. + +Ruled out: rebuild coalescing. The mutating routes still call `rebuild_levels()` +directly and synchronously — only `on_bar` defers through `request_rebuild` — so +a drawing created over the API still has its levels rebuilt before the response +returns. Flakiness was also observed before that change landed. + +The likely candidates, untested: tests share one live dev stack whose market +feed keeps moving underneath them, and cleanup runs against a store that other +tests and any open browser are also mutating. Worth fixing before the suite is +trusted, because a suite that fails randomly trains people to re-run it, and a +re-run is indistinguishable from a fix. diff --git a/static/app.js b/static/app.js index a9ed684..2d462ad 100644 --- a/static/app.js +++ b/static/app.js @@ -189,6 +189,12 @@ createApp({ // One list for everything drawn. Lines come from the levels socket, which // keeps them live; comments come from the drawings endpoint. + // Ordered by price rather than by distance, so the list reads top-to-bottom + // the way the chart does. The server orders by proximity, which is what the + // alert path wants; this is a display concern only. + const clustersByPrice = computed(() => + [...clusters.value].sort((a, b) => (b.high + b.low) / 2 - (a.high + a.low) / 2)); + const drawings = computed(() => { const lines = manualLines.value.map(line => ({ id: line.id, @@ -218,9 +224,9 @@ createApp({ }); }); - function addEvent(message, kind = 'info', url = null) { + function addEvent(message, kind = 'info', url = null, number = null) { events.value = [ - { key: ++eventSequence, at: new Date().toLocaleTimeString(), message, kind, url }, + { key: ++eventSequence, at: new Date().toLocaleTimeString(), message, kind, url, number }, ...events.value, ].slice(0, 20); } @@ -272,7 +278,9 @@ createApp({ } else if (message.type === 'alert') { // Keyed on a counter, not the timestamp: two alerts inside the same // second would collide and Vue would reuse the wrong row. - addEvent(message.message, 'alert'); + // The server numbers alerts; the client's own counter restarts on + // reload and differs per tab, so it cannot match a phone. + addEvent(message.message, 'alert', null, message.number || null); playAlert(); } }; @@ -861,6 +869,6 @@ createApp({ window.removeEventListener('keydown', handleKeydown); }); - return { status, price, barAge, dataUpdatedAt, timeframe, timeframes, drawingColors, drawingColorName, symbolChoices, selectedSymbol, symbolColor, symbolPanelOpen, prefs, clusters, events, diagnosticMode, captureBusy, captureDiagnostic, armedTool, drawName, drawColor, drawWidth, drawSide, snap, selectedDrawing, selectedDrawings, manualLines, hasDrawingSelection, allManualSelected, alertPrice, alertNote, levelColor, levelWidth, addPriceAlert, armTool, selectTimeframe, allEnabled, toggleGroup, deleteSelected, deleteLine, focusDrawing, toggleDrawingSelection, toggleSelectAll, deleteSelectedLines, renameLine, updateLineStyle, setArmed, commentText, commentFloat, comments, drawings, filteredDrawings, drawingFilter, drawingKind, deleteDrawing, deleteFilteredDrawings, toggleComment, togglePinned, chooseSymbol, toggleSymbolPanel, startSymbolDrag, dropSymbol }; + return { status, price, barAge, dataUpdatedAt, timeframe, timeframes, drawingColors, drawingColorName, symbolChoices, selectedSymbol, symbolColor, symbolPanelOpen, prefs, clusters, clustersByPrice, events, diagnosticMode, captureBusy, captureDiagnostic, armedTool, drawName, drawColor, drawWidth, drawSide, snap, selectedDrawing, selectedDrawings, manualLines, hasDrawingSelection, allManualSelected, alertPrice, alertNote, levelColor, levelWidth, addPriceAlert, armTool, selectTimeframe, allEnabled, toggleGroup, deleteSelected, deleteLine, focusDrawing, toggleDrawingSelection, toggleSelectAll, deleteSelectedLines, renameLine, updateLineStyle, setArmed, commentText, commentFloat, comments, drawings, filteredDrawings, drawingFilter, drawingKind, deleteDrawing, deleteFilteredDrawings, toggleComment, togglePinned, chooseSymbol, toggleSymbolPanel, startSymbolDrag, dropSymbol }; }, }).mount('#app'); diff --git a/static/index.html b/static/index.html index 58175ab..e693b48 100644 --- a/static/index.html +++ b/static/index.html @@ -259,18 +259,21 @@