Number alerts, stamp them locally, and compact the zone list
Alerts get a number, assigned server-side and shown in both the push and the Events list, so a notification on a phone can be matched to a row on a screen when several fire together. It could not come from the browser: that counter restarts on reload and differs between tabs. It is persisted next to the cooldown state, because numbering restarting after a deploy would collide with a phone's existing notification history — which changed that file from a list to an object, with the loader still reading the old shape. Pushes now carry a timestamp in the configured zone rather than the server's. ALERT_TIMEZONE defaults to America/Chicago; containers run UTC, and a push reading 02:14 to someone seeing 21:14 costs a translation every time. The browser already formats its own times locally and is unchanged. Confluence zones are one line each, ordered by price rather than by proximity, so the list reads top to bottom the way the chart does and all of them fit on screen — sixteen zones in 394px, about 25px each, where each previously took a four-line block. Ordering is a display concern only: the server still returns them nearest-first, which is what the alert path wants. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
parent
e957991242
commit
32e25b84aa
10 changed files with 180 additions and 15 deletions
|
|
@ -1,4 +1,6 @@
|
||||||
import json
|
import json
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from zoneinfo import ZoneInfo
|
||||||
import logging
|
import logging
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
@ -16,6 +18,12 @@ class Alert:
|
||||||
# Hand-placed levels that caused this alert; the caller disarms them so a
|
# Hand-placed levels that caused this alert; the caller disarms them so a
|
||||||
# one-shot alert stays one-shot.
|
# one-shot alert stays one-shot.
|
||||||
tripped: tuple[str, ...] = ()
|
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)
|
@dataclass(slots=True)
|
||||||
|
|
@ -44,10 +52,13 @@ class AlertEngine:
|
||||||
min_score: float,
|
min_score: float,
|
||||||
cooldown_seconds: int = 900,
|
cooldown_seconds: int = 900,
|
||||||
state_path: Path | None = None,
|
state_path: Path | None = None,
|
||||||
|
timezone_name: str = "UTC",
|
||||||
):
|
):
|
||||||
self.min_score = min_score
|
self.min_score = min_score
|
||||||
self.cooldown_seconds = cooldown_seconds
|
self.cooldown_seconds = cooldown_seconds
|
||||||
|
self.timezone = timezone_name
|
||||||
self.state_path = Path(state_path) if state_path else None
|
self.state_path = Path(state_path) if state_path else None
|
||||||
|
self._next_number = 1
|
||||||
self._fired: list[_Fired] = self._load()
|
self._fired: list[_Fired] = self._load()
|
||||||
|
|
||||||
def _load(self) -> list[_Fired]:
|
def _load(self) -> list[_Fired]:
|
||||||
|
|
@ -55,6 +66,10 @@ class AlertEngine:
|
||||||
return []
|
return []
|
||||||
try:
|
try:
|
||||||
payload = json.loads(self.state_path.read_text(encoding="utf-8"))
|
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]
|
return [_Fired(float(item["center"]), int(item["at"])) for item in payload]
|
||||||
except Exception:
|
except Exception:
|
||||||
# Corrupt state costs one burst of duplicate alerts, which is a far
|
# 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)
|
logger.warning("Could not read alert state; starting empty", exc_info=True)
|
||||||
return []
|
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:
|
def _save(self) -> None:
|
||||||
if not self.state_path:
|
if not self.state_path:
|
||||||
return
|
return
|
||||||
|
|
@ -70,7 +99,12 @@ class AlertEngine:
|
||||||
temporary = self.state_path.with_suffix(self.state_path.suffix + ".tmp")
|
temporary = self.state_path.with_suffix(self.state_path.suffix + ".tmp")
|
||||||
temporary.write_text(
|
temporary.write_text(
|
||||||
json.dumps(
|
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,
|
indent=2,
|
||||||
sort_keys=True,
|
sort_keys=True,
|
||||||
)
|
)
|
||||||
|
|
@ -152,7 +186,22 @@ class AlertEngine:
|
||||||
message = (
|
message = (
|
||||||
f"{direction} {headline} {symbol} {current_price:.2f}\n{detail}\n{timeframes}"
|
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:
|
if changed:
|
||||||
self._save()
|
self._save()
|
||||||
return alerts
|
return alerts
|
||||||
|
|
|
||||||
|
|
@ -129,6 +129,8 @@ async def websocket_endpoint(websocket: WebSocket):
|
||||||
await websocket.send_json(
|
await websocket.send_json(
|
||||||
{
|
{
|
||||||
"type": "alert",
|
"type": "alert",
|
||||||
|
"number": event.get("number", 0),
|
||||||
|
"at": event.get("at", 0),
|
||||||
"cluster": event["cluster"].to_dict(),
|
"cluster": event["cluster"].to_dict(),
|
||||||
"message": event["message"],
|
"message": event["message"],
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -61,6 +61,9 @@ class Settings(BaseSettings):
|
||||||
ntfy_server: str = "https://ntfy.sh"
|
ntfy_server: str = "https://ntfy.sh"
|
||||||
chart_auth_token: str = ""
|
chart_auth_token: str = ""
|
||||||
chart_password: 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
|
replay_file: Path | None = None
|
||||||
|
|
||||||
@property
|
@property
|
||||||
|
|
|
||||||
|
|
@ -67,6 +67,7 @@ class Runtime:
|
||||||
self.settings.confluence_min_score,
|
self.settings.confluence_min_score,
|
||||||
self.settings.alert_cooldown_seconds,
|
self.settings.alert_cooldown_seconds,
|
||||||
self.settings.alert_state_path,
|
self.settings.alert_state_path,
|
||||||
|
self.settings.alert_timezone,
|
||||||
)
|
)
|
||||||
self.levels = self.manual_lines.levels()
|
self.levels = self.manual_lines.levels()
|
||||||
self.stream = StreamService(live_source(self.settings), self.settings.live_symbol)
|
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:
|
def dispatch_alerts(self, alerts: list[Alert]) -> None:
|
||||||
tripped: set[str] = set()
|
tripped: set[str] = set()
|
||||||
for alert in alerts:
|
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))
|
task = asyncio.create_task(self.notify(alert.message))
|
||||||
# Held so the task is not garbage collected mid-flight.
|
# Held so the task is not garbage collected mid-flight.
|
||||||
self._notify_tasks.add(task)
|
self._notify_tasks.add(task)
|
||||||
|
|
|
||||||
|
|
@ -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
|
Both handlers moved from `meta.py` to `routes.py`. `meta.py` is the deliberately
|
||||||
unauthenticated router — health, version, login and logout — and a screenshot
|
unauthenticated router — health, version, login and logout — and a screenshot
|
||||||
endpoint did not belong in that company.
|
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.
|
||||||
|
|
|
||||||
|
|
@ -189,6 +189,12 @@ createApp({
|
||||||
|
|
||||||
// One list for everything drawn. Lines come from the levels socket, which
|
// One list for everything drawn. Lines come from the levels socket, which
|
||||||
// keeps them live; comments come from the drawings endpoint.
|
// 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 drawings = computed(() => {
|
||||||
const lines = manualLines.value.map(line => ({
|
const lines = manualLines.value.map(line => ({
|
||||||
id: line.id,
|
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 = [
|
events.value = [
|
||||||
{ key: ++eventSequence, at: new Date().toLocaleTimeString(), message, kind, url },
|
{ key: ++eventSequence, at: new Date().toLocaleTimeString(), message, kind, url, number },
|
||||||
...events.value,
|
...events.value,
|
||||||
].slice(0, 20);
|
].slice(0, 20);
|
||||||
}
|
}
|
||||||
|
|
@ -272,7 +278,9 @@ createApp({
|
||||||
} else if (message.type === 'alert') {
|
} else if (message.type === 'alert') {
|
||||||
// Keyed on a counter, not the timestamp: two alerts inside the same
|
// Keyed on a counter, not the timestamp: two alerts inside the same
|
||||||
// second would collide and Vue would reuse the wrong row.
|
// 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();
|
playAlert();
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
@ -861,6 +869,6 @@ createApp({
|
||||||
window.removeEventListener('keydown', handleKeydown);
|
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');
|
}).mount('#app');
|
||||||
|
|
|
||||||
|
|
@ -259,18 +259,21 @@
|
||||||
<details class="sidebar-section">
|
<details class="sidebar-section">
|
||||||
<summary>Confluence zones</summary>
|
<summary>Confluence zones</summary>
|
||||||
<div v-if="!clusters.length" class="empty">No active zones near current structure.</div>
|
<div v-if="!clusters.length" class="empty">No active zones near current structure.</div>
|
||||||
<div v-for="cluster in clusters" :key="cluster.id" class="cluster" :class="cluster.side">
|
<!-- One line each, ordered by price: the list then reads like the
|
||||||
<div class="cluster-top"><b>{{ cluster.side }}</b><strong>{{ cluster.score.toFixed(1) }}</strong></div>
|
chart does, highest zone at the top, and they all fit on screen. -->
|
||||||
<div class="zone">{{ cluster.low.toFixed(2) }} – {{ cluster.high.toFixed(2) }}</div>
|
<div v-for="cluster in clustersByPrice" :key="cluster.id" class="cluster-row" :class="cluster.side"
|
||||||
<div class="members">{{ cluster.members.map(member => member.label).join(' · ') }}</div>
|
:title="cluster.members.map(member => member.label).join(' · ')">
|
||||||
<div class="distance">{{ cluster.distance > 0 ? '+' : '' }}{{ cluster.distance.toFixed(2) }} pts</div>
|
<span class="cluster-band">{{ cluster.low.toFixed(2) }}–{{ cluster.high.toFixed(2) }}</span>
|
||||||
|
<span class="cluster-score">{{ cluster.score.toFixed(1) }}</span>
|
||||||
|
<span class="cluster-dist">{{ cluster.distance > 0 ? '+' : '' }}{{ cluster.distance.toFixed(1) }}</span>
|
||||||
|
<span class="cluster-members">{{ cluster.members.map(member => member.label).join(' · ') }}</span>
|
||||||
</div>
|
</div>
|
||||||
</details>
|
</details>
|
||||||
<details class="sidebar-section">
|
<details class="sidebar-section">
|
||||||
<summary>Events ({{ events.length }})</summary>
|
<summary>Events ({{ events.length }})</summary>
|
||||||
<div v-if="!events.length" class="empty">No events yet.</div>
|
<div v-if="!events.length" class="empty">No events yet.</div>
|
||||||
<div v-for="event in events" :key="event.key" class="alert-entry" :class="event.kind">
|
<div v-for="event in events" :key="event.key" class="alert-entry" :class="event.kind">
|
||||||
<time>{{ event.at }}</time>
|
<time><b v-if="event.number" class="event-number">#{{ event.number }}</b>{{ event.at }}</time>
|
||||||
<a v-if="event.url" :href="event.url" target="_blank" rel="noopener">{{ event.message }}</a>
|
<a v-if="event.url" :href="event.url" target="_blank" rel="noopener">{{ event.message }}</a>
|
||||||
<template v-else>{{ event.message }}</template>
|
<template v-else>{{ event.message }}</template>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -94,3 +94,5 @@ aside { padding:16px; }h2 { margin:0 0 12px; color:var(--muted); font-size:11px;
|
||||||
.chart-snap-leader { position:absolute; inset:0; pointer-events:none; z-index:5; overflow:visible; }.chart-snap-leader line { stroke:var(--muted); stroke-width:1; stroke-dasharray:3 3; opacity:.75; }
|
.chart-snap-leader { position:absolute; inset:0; pointer-events:none; z-index:5; overflow:visible; }.chart-snap-leader line { stroke:var(--muted); stroke-width:1; stroke-dasharray:3 3; opacity:.75; }
|
||||||
.chart-overlays { position:absolute; left:0; top:0; pointer-events:none; overflow:visible; z-index:3; }.chart-overlays > * { pointer-events:none; }.chart-overlays .chart-context-menu { pointer-events:auto; }.chart-overlays .chart-comments, .chart-overlays .chart-snap-leader { position:absolute; inset:0; }
|
.chart-overlays { position:absolute; left:0; top:0; pointer-events:none; overflow:visible; z-index:3; }.chart-overlays > * { pointer-events:none; }.chart-overlays .chart-context-menu { pointer-events:auto; }.chart-overlays .chart-comments, .chart-overlays .chart-snap-leader { position:absolute; inset:0; }
|
||||||
.chart-context-labels { position:absolute; inset:0; z-index:4; overflow:visible; pointer-events:none; }.chart-context-label { position:absolute; left:4px; transform:translateY(-50%); max-width:170px; padding:2px 5px; border-left:3px solid var(--muted); border-radius:2px; background:color-mix(in srgb,var(--chart-bg) 92%,transparent); color:var(--fg); font-size:9px; line-height:1.25; white-space:nowrap; box-shadow:0 1px 2px rgba(0,0,0,.12); }
|
.chart-context-labels { position:absolute; inset:0; z-index:4; overflow:visible; pointer-events:none; }.chart-context-label { position:absolute; left:4px; transform:translateY(-50%); max-width:170px; padding:2px 5px; border-left:3px solid var(--muted); border-radius:2px; background:color-mix(in srgb,var(--chart-bg) 92%,transparent); color:var(--fg); font-size:9px; line-height:1.25; white-space:nowrap; box-shadow:0 1px 2px rgba(0,0,0,.12); }
|
||||||
|
.event-number { margin-right:7px; padding:1px 5px; border-radius:3px; background:color-mix(in srgb,var(--accent) 22%,transparent); color:var(--fg); font-weight:600; }
|
||||||
|
.cluster-row { display:grid; grid-template-columns:auto auto auto minmax(0,1fr); gap:8px; align-items:baseline; padding:3px 7px; border-left:3px solid var(--green); font-size:10px; white-space:nowrap; }.cluster-row.resistance { border-left-color:var(--red); }.cluster-row + .cluster-row { border-top:1px solid var(--line); }.cluster-band { font-size:11px; color:var(--fg); }.cluster-score { font-weight:600; color:var(--accent); }.cluster-dist { color:var(--muted); }.cluster-members { color:var(--muted); overflow:hidden; text-overflow:ellipsis; }
|
||||||
|
|
|
||||||
|
|
@ -61,5 +61,8 @@ def test_corrupt_state_does_not_prevent_alerting(tmp_path):
|
||||||
def test_state_file_records_centre_and_time(tmp_path):
|
def test_state_file_records_centre_and_time(tmp_path):
|
||||||
engine(tmp_path).evaluate(zone(), 100, 1, 42, "/ES")
|
engine(tmp_path).evaluate(zone(), 100, 1, 42, "/ES")
|
||||||
payload = json.loads((tmp_path / "alert_state.json").read_text(encoding="utf-8"))
|
payload = json.loads((tmp_path / "alert_state.json").read_text(encoding="utf-8"))
|
||||||
assert len(payload) == 1
|
# The file carries the alert counter as well as the fired zones, so numbers
|
||||||
assert payload[0]["at"] == 42
|
# do not restart from 1 after a deploy and collide with a phone's history.
|
||||||
|
assert len(payload["fired"]) == 1
|
||||||
|
assert payload["fired"][0]["at"] == 42
|
||||||
|
assert payload["next_number"] == 2
|
||||||
|
|
|
||||||
|
|
@ -98,3 +98,45 @@ def test_a_genuinely_separate_zone_still_alerts_during_cooldown():
|
||||||
|
|
||||||
far = cluster_levels([level("c", 120, 3), level("d", 120.1, 4)], 100, 120, 1)
|
far = cluster_levels([level("c", 120, 3), level("d", 120.1, 4)], 100, 120, 1)
|
||||||
assert len(engine.evaluate(far, 120, 1, 60, "/ES")) == 1
|
assert len(engine.evaluate(far, 120, 1, 60, "/ES")) == 1
|
||||||
|
|
||||||
|
|
||||||
|
def test_alerts_are_numbered_and_stamped_in_local_time(tmp_path):
|
||||||
|
# A push and a screen have to agree on which alert is which. The browser
|
||||||
|
# cannot supply that: its counter restarts on reload and differs per tab.
|
||||||
|
from app.analysis.alerts import AlertEngine
|
||||||
|
|
||||||
|
engine = AlertEngine(
|
||||||
|
1.0, cooldown_seconds=0, state_path=tmp_path / "alerts.json",
|
||||||
|
timezone_name="America/Chicago",
|
||||||
|
)
|
||||||
|
engine._next_number = 41
|
||||||
|
stamp = engine._stamp(1786360000) # Mon 2026-08-10 11:06:40 UTC
|
||||||
|
|
||||||
|
assert "06:06:40" in stamp and "CDT" in stamp, stamp
|
||||||
|
assert "11:06" not in stamp, "stamped in UTC rather than the configured zone"
|
||||||
|
|
||||||
|
|
||||||
|
def test_alert_numbers_survive_a_restart(tmp_path):
|
||||||
|
# Numbers restarting from 1 after a deploy would collide with the ones
|
||||||
|
# already sitting in a phone's notification history.
|
||||||
|
from app.analysis.alerts import AlertEngine
|
||||||
|
|
||||||
|
path = tmp_path / "alerts.json"
|
||||||
|
first = AlertEngine(1.0, cooldown_seconds=0, state_path=path)
|
||||||
|
first._next_number = 87
|
||||||
|
first._save()
|
||||||
|
|
||||||
|
assert AlertEngine(1.0, cooldown_seconds=0, state_path=path)._next_number == 87
|
||||||
|
|
||||||
|
|
||||||
|
def test_an_old_bare_list_state_file_still_loads(tmp_path):
|
||||||
|
# The file predates numbering and was a plain list of fired zones.
|
||||||
|
import json
|
||||||
|
from app.analysis.alerts import AlertEngine
|
||||||
|
|
||||||
|
path = tmp_path / "alerts.json"
|
||||||
|
path.write_text(json.dumps([{"center": 5000.0, "at": 1786360000}]), encoding="utf-8")
|
||||||
|
|
||||||
|
engine = AlertEngine(1.0, cooldown_seconds=0, state_path=path)
|
||||||
|
assert len(engine._fired) == 1
|
||||||
|
assert engine._next_number == 1
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue