diff --git a/CHANGELOG.md b/CHANGELOG.md index 6138455..838a9fc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,15 @@ Update an installed kit with `sudo sdrkit update`. ## [Unreleased] +## [3.11.0] - 2026-10-22 + +### Added +- **Business panel** (Recordings → **Panel del negocio / Business panel**): charts of how much the radio is used — transmissions + per hour of the day and per day, total airtime, the busiest hour, the five busiest channels and the longest transmission — + for Today, 7 or 30 days, computed from the activity log. New API `GET /api/activity/stats?days=N`. The activity log now + keeps the last 5000 transmissions (was 1000). Demo mode shows sample data (in memory only). Covered by + `tests/test_activity_stats.py`; help sheet ⓘ and English translation included. + ## [3.10.0] - 2026-10-18 ### Changed diff --git a/ROADMAP.md b/ROADMAP.md index b077aae..f4c0aa6 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -31,7 +31,7 @@ Estado: ☐ pendiente · ◐ hecha en parte (ver nota) · ✅ terminada (fecha | # | Estado | Función | Listo cuando… | |---|---|---|---| | 1 | ✅ 2026-10-18 · 3.7.0 | **Repetición instantánea** «⏪ ¿qué dijo?» | El radio guarda siempre los últimos 30 s en memoria y un botón los reproduce o los guarda como grabación. | -| 2 | ☐ | **Panel del negocio** | Gráficos de actividad del radio por hora y por día, canal más usado, transmisión más larga; a partir del registro de actividad. | +| 2 | ✅ 2026-10-22 · 3.11.0 | **Panel del negocio** | Gráficos de actividad del radio por hora y por día, canal más usado, transmisión más larga; a partir del registro de actividad. | | 3 | ☐ | **Alertas de aviones** | Aviso (panel + ntfy) por códigos 7700/7600/7500, aviones militares o helicópteros cerca, con filtros configurables. | | 4 | ☐ | **Mapa de barcos (AIS)** | Modo AIS con mapa sin internet como el radar de aviones (decodificador opcional, p. ej. AIS-catcher); barcos simulados en demo. | | 5 | ☐ | **Home Assistant (MQTT)** | Sensores de 433 MHz, aviones y actividad del radio publicados por MQTT con *discovery* automático; apagado por defecto. | diff --git a/VERSION b/VERSION index 30291cb..afad818 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -3.10.0 +3.11.0 diff --git a/docs/es/GUIA.md b/docs/es/GUIA.md index b1fe641..2d6ffa3 100644 --- a/docs/es/GUIA.md +++ b/docs/es/GUIA.md @@ -209,7 +209,7 @@ Con GitHub, la configuración queda en `/etc/sdrkit/sdrkit.conf` con valores por - **Analizador:** espectro, cascada, sintonizador, marcadores, mediciones, span cero, límites y vigilancia. - **Radio:** escuchar (también digital), buscar señal, subtonos, datos, grabar, escáner, memorias y modo cazador. - **Decodificar:** mensajes (POCSAG/FLEX/APRS/EAS), sensores de 433 MHz, radar de aviones y la lista de todos los modos. -- **Grabaciones:** audios por día y el registro de actividad. +- **Grabaciones:** audios por día, el **panel del negocio** (gráficos de uso por hora y por día, canales más usados) y el registro de actividad. - **Más:** - **Canales**: todos los servicios. - Guía de frecuencias y tonos. diff --git a/docs/user-guide.md b/docs/user-guide.md index 2c2b3d3..66ecd04 100644 --- a/docs/user-guide.md +++ b/docs/user-guide.md @@ -345,6 +345,7 @@ Antenna: a vertical quarter-wave of **6.5–6.9 cm**, as high as possible. Typic - The header shows how many recordings there are, the space used against `REC_MAX_MB`, and the free space on the card. - Recordings are grouped by day, with time, frequency, mode, duration and badges (*auto*/*manual*, CTCSS, DCS, DTMF). Search by frequency, name or tone. - ▶ plays, ⬇ downloads; select several (or **Select all**) for **⬇ ZIP** or **🗑 Delete**. +- The **Business panel** (above the activity log) charts the log: transmissions per hour and per day, airtime, the busiest channels and the longest transmission, for today, 7 or 30 days. - The **Activity log** records every transmission the radio or scanner detects, even when nothing is recorded: time, frequency, duration, peak level, tones and digital type. **⬇ CSV (Excel)** exports it; 🎧 returns to that frequency. Over SSH, `sdrkit recs` shows the folder, the number of files and how to copy them to a PC with `scp`. diff --git a/files/sdrkit_radio.py b/files/sdrkit_radio.py index 61e6515..0803717 100644 --- a/files/sdrkit_radio.py +++ b/files/sdrkit_radio.py @@ -10,6 +10,7 @@ """ import collections +import datetime import io import json import math @@ -729,7 +730,7 @@ def snapshot(self): class ActivityLog: - def __init__(self, path, keep=1000): + def __init__(self, path, keep=5000): self.path = path self.lock = threading.Lock() self.items = collections.deque(maxlen=keep) @@ -768,6 +769,67 @@ def clear(self): except OSError: pass + def stats(self, days=7, now=None): + with self.lock: + items = list(self.items) + return activity_stats(items, days, now) + + +def demo_activity(now=None, days=7): + """Made-up activity for demo mode (shown only in memory), so the business panel has something to draw.""" + now = time.time() if now is None else now + chans = [("Canal 1 (FRS/GMRS)", 462562500), ("Taxi", 151625000), ("Canal 16 marina", 156800000), ("Seguridad", 464550000)] + out = [] + for d in range(days): + for i in range(18 + (d * 7) % 9): + hour = 7 + (i * 5 + d) % 14 + t = now - d * 86400 - ((now % 86400) - hour * 3600) % 86400 + (i * 97) % 3000 + if t > now: + continue + name, freq = chans[(i * 3 + d) % 4 if i % 3 else 0] + out.append({"t": round(t, 1), "freq": freq, "mod": "nfm", "name": name, "peak_db": -40.0, "dur": round(2 + (i * 13 + d * 7) % 40 / 2, 1)}) + return sorted(out, key=lambda a: a["t"]) + + +def activity_stats(items, days=7, now=None): + """Summary of the activity log for the business panel: per hour of the day, per day, busiest channels, longest transmission.""" + now = time.time() if now is None else now + days = max(1, min(int(days), 90)) + today = datetime.date.fromtimestamp(now) + first = today - datetime.timedelta(days=days - 1) + start = time.mktime(first.timetuple()) + hours = [{"n": 0, "secs": 0.0} for _ in range(24)] + per_day = {(first + datetime.timedelta(days=i)).isoformat(): {"n": 0, "secs": 0.0} for i in range(days)} + chans, longest, total, secs = {}, None, 0, 0.0 + for a in items: + t, dur = a.get("t"), a.get("dur") + if not isinstance(t, (int, float)) or not isinstance(dur, (int, float)) or t < start or t > now + 60: + continue + lt = time.localtime(t) + day = time.strftime("%Y-%m-%d", lt) + if day not in per_day: + continue + total += 1 + secs += dur + hours[lt.tm_hour]["n"] += 1 + hours[lt.tm_hour]["secs"] += dur + per_day[day]["n"] += 1 + per_day[day]["secs"] += dur + key = (a.get("name") or "").strip() or "%.4f" % (a.get("freq", 0) / 1e6) + c = chans.setdefault(key, {"name": a.get("name") or "", "freq": a.get("freq"), "mod": a.get("mod"), "n": 0, "secs": 0.0}) + c["n"] += 1 + c["secs"] += dur + if longest is None or dur > longest["dur"]: + longest = dict(a) + for h in hours: + h["secs"] = round(h["secs"], 1) + top = sorted(chans.values(), key=lambda c: (-c["n"], -c["secs"]))[:5] + for c in top: + c["secs"] = round(c["secs"], 1) + return {"days": days, "total": total, "secs": round(secs, 1), "hours": hours, + "per_day": [{"day": d, "n": v["n"], "secs": round(v["secs"], 1)} for d, v in per_day.items()], + "top": top, "longest": longest} + def ui_lang(conf=None): """Idioma de los textos que crea el servidor (memorias de ejemplo): SDRKIT_LANG o el idioma del sistema.""" @@ -969,6 +1031,8 @@ def __init__(self, conf, state, open_device, error_cls, folder, demo=False): max_mb=float(conf.get("REC_MAX_MB", 2000) or 2000), min_sec=float(conf.get("REC_MIN_SEC", 1.0) or 1.0)) self.activity = ActivityLog(os.path.join(folder, "actividad.jsonl")) + if demo and not self.activity.items: + self.activity.items.extend(demo_activity()) rs = str(conf.get("REPLAY_SEC", "30")).strip() self.replay = ReplayBuffer(float(rs) if re.match(r"^\d+(\.\d+)?$", rs) else 30.0) self._clip = None # última repetición pedida: lo que se escuchó es lo que se guarda diff --git a/files/sdrkit_server.py b/files/sdrkit_server.py index 3be6c40..ba28ebe 100755 --- a/files/sdrkit_server.py +++ b/files/sdrkit_server.py @@ -55,7 +55,7 @@ def _read_version(): return v except OSError: pass - return "3.10.0" + return "3.11.0" VERSION = _read_version() @@ -2333,6 +2333,8 @@ def do_GET(self): return self._zip(q.get("f") or []) if path == "/api/activity": return self._send(200, {"items": app.radio.activity.list(cint((q.get("n") or ["300"])[0], 300))}) + if path == "/api/activity/stats": + return self._send(200, app.radio.activity.stats(cint((q.get("days") or ["7"])[0], 7))) if path == "/api/activity.csv": return self._activity_csv() if path == "/api/monitor": diff --git a/files/web/app.css b/files/web/app.css index 27bfcae..772d5bc 100644 --- a/files/web/app.css +++ b/files/web/app.css @@ -269,6 +269,15 @@ pre.logs{white-space:pre-wrap;word-break:break-all;font:11px/1.45 var(--mono);ba .fchip.bad{border-color:#7a3333;color:#ffb3b3} #radar{display:block;width:100%;aspect-ratio:1;max-height:70vh;background:#04120a;border-radius:10px;border:1px solid #164a2c} .tbl tr[data-hex]{cursor:pointer} +.biz-tiles{display:grid;grid-template-columns:repeat(3,1fr);gap:8px;margin-bottom:12px} +.biz-tiles div{background:var(--panel2,rgba(255,255,255,.04));border:1px solid var(--line);border-radius:10px;padding:8px;text-align:center} +.biz-tiles b{display:block;font:700 20px var(--mono);font-variant-numeric:tabular-nums} +.biz-tiles span{color:var(--muted);font-size:11px} +.biz-h{font:600 11px system-ui,sans-serif;text-transform:uppercase;letter-spacing:.5px;color:var(--muted);margin:12px 0 4px} +.biz-chart{display:flex;align-items:flex-end;gap:2px;height:84px;border-bottom:1px solid var(--line)} +.biz-chart i{flex:1;min-width:2px;background:var(--accent,#ffd400);border-radius:2px 2px 0 0;opacity:.85} +.biz-chart i.zero{background:var(--line);height:2px!important} +.biz-axis{display:flex;justify-content:space-between;color:var(--muted);font-size:10px;margin-top:2px} .tbl tr.sel td{background:rgba(255,212,0,.08)} .tbl tr.emerg td{color:#ffb3b3;background:rgba(255,92,92,.08)} code{font-family:var(--mono);font-size:.92em;background:var(--bg2);border:1px solid var(--line);border-radius:5px;padding:0 4px} diff --git a/files/web/help.js b/files/web/help.js index 7d2cd7c..c498aac 100644 --- a/files/web/help.js +++ b/files/web/help.js @@ -99,6 +99,7 @@ const Help = (() => { u: ["En el panel: DMR, P25, D-Star, Fusion, NXDN, dPMR y M17 con DSD-FME (opcional).", "En el receptor avanzado: los mismos con una llave AMBE3000 (camino con licencia).", "Imposible con cualquier equipo: voz cifrada (AES, ARC4, TETRA con TEA), celulares, WiFi o Bluetooth cifrados."]}, grabaciones: {t: "Grabaciones", p: "Todos los audios grabados, por día, con frecuencia, duración y subtono.", u: ["▶ escucha, ⬇ descarga, selecciona varias para bajar un ZIP o borrarlas.", "Busca por frecuencia, nombre o tono."]}, + negocio: {t: "Panel del negocio", p: "Resume el registro de actividad: cuántas transmisiones hubo, a qué horas y en qué días, qué canales se usan más y cuál fue la transmisión más larga.", u: ["Elige Hoy, 7 días o 30 días.", "Las barras muestran el número de transmisiones; toca una barra (o déjala pulsada) para ver el valor.", "🎧 en un canal vuelve a esa frecuencia."], c: "Se calcula con el registro de actividad: si lo borras, los gráficos empiezan de cero. En modo demo se usan datos de ejemplo."}, actividad: {t: "Registro de actividad", p: "Cada transmisión que el radio o el escáner detectan queda anotada aunque no grabes: hora, frecuencia, duración, nivel, tono y tipo digital.", u: ["Descárgalo en CSV para Excel.", "🎧 vuelve a esa frecuencia."]}, paises: {t: "Tu país", p: "Elige tu país y el panel te muestra lo que corresponde allí: qué walkie-talkies sin licencia se usan, las bandas de radioaficionado, dónde están las salidas de las repetidoras y qué subtonos suelen usar.", u: ["Los servicios propios de tu país aparecen primero en la lista de abajo; los de otros países, más tenues.", "«📡 Buscar» escanea las salidas de repetidoras de esa banda: el escáner se detiene en cada una que esté activa.", @@ -141,7 +142,7 @@ const Help = (() => { ["limite", "#limUp"], ["vigilancia", "#monOn"], ["estados", "#stSave"], ["picos", "#peakRows"], ["squelch", "#rSq"], ["scrambler", "#rInv"], ["panorama", "#pan"], ["subtonos", "#dCtcss"], ["dsd", "#dsdModes"], ["datos", "#rData"], ["grabar", "#rRec"], ["repeticion", "#rReplay"], ["escaner", "#scStart"], ["memorias", "#memList"], ["mensajes", "#dmKinds"], ["sensores", "#snPreset"], ["adsb", "#radar"], - ["ubicacion", "#adLat"], ["todos", "#allList"], ["vozdigital", "#ambeState"], ["grabaciones", "#recSearch"], ["actividad", "#actTable"], + ["ubicacion", "#adLat"], ["todos", "#allList"], ["vozdigital", "#ambeState"], ["grabaciones", "#recSearch"], ["negocio", "#bizBody"], ["actividad", "#actTable"], ["paises", "#paisSel"], ["decsub", "#toneF"], ["canales", "#chServices"], ["guia", "#refTable"], ["tablas", "#ctcssGrid"], ["tablas", "#dcsGrid"], ["antenas", "#antF"], ["cable", "#cabT"], ["owrx", "#btnRxOpen"], ["wifi", "#btnHotspot"], ["sistema", "#tiles"], ["actualizar", "#btnUpdCheck"], ]; diff --git a/files/web/index.html b/files/web/index.html index b536071..4bc4f1f 100644 --- a/files/web/index.html +++ b/files/web/index.html @@ -599,6 +599,16 @@
Cuánto se usa la radio: transmisiones por hora y por día, los canales más activos y la transmisión más larga.
+Cada vez que alguien transmite (en el radio o el escáner) queda anotado: hora, frecuencia, duración, nivel y tonos.
diff --git a/files/web/lang-en-help.js b/files/web/lang-en-help.js index 725f101..687dad5 100644 --- a/files/web/lang-en-help.js +++ b/files/web/lang-en-help.js @@ -133,6 +133,7 @@ I18N.data.help = { u: ["In the panel: DMR, P25, D-Star, Fusion, NXDN, dPMR and M17 with DSD-FME (optional).", "In the advanced receiver: the same modes with an AMBE3000 USB stick (the licensed route).", "Impossible with any equipment: encrypted voice (AES, ARC4, TETRA with TEA), cell phones, encrypted WiFi or Bluetooth."]}, grabaciones: {t: "Recordings", p: "All recorded audio, by day, with frequency, duration and tone.", u: ["▶ plays, ⬇ downloads; select several to download a ZIP or delete them.", "Search by frequency, name or tone."]}, + negocio: {t: "Business panel", p: "Summarizes the activity log: how many transmissions there were, at what hours and on which days, which channels are used most and which transmission was the longest.", u: ["Choose Today, 7 days or 30 days.", "The bars show the number of transmissions; tap a bar (or hold it) to see the value.", "🎧 on a channel goes back to that frequency."], c: "It is computed from the activity log: if you clear it, the charts start from zero. Demo mode uses sample data."}, actividad: {t: "Activity log", p: "Every transmission the radio or the scanner detects is logged even if you're not recording: time, frequency, duration, level, tone and digital type.", u: ["Download it as CSV for Excel.", "🎧 goes back to that frequency."]}, paises: {t: "Your country", p: "Choose your country and the panel shows what applies there: which license-free walkie-talkies are used, the ham bands, where the repeater outputs are and which tones they usually use.", u: ["Your country's own services come first in the list below; other countries' services are dimmed.", "“📡 Search” scans that band's repeater outputs: the scanner stops on each one that is active.", diff --git a/files/web/lang-en-html.js b/files/web/lang-en-html.js index f2a0f71..5593e57 100644 --- a/files/web/lang-en-html.js +++ b/files/web/lang-en-html.js @@ -311,6 +311,11 @@ I18N.add({ "Seleccionar todo": "Select all", "🗑 Borrar": "🗑 Delete", "Registro de actividad": "Activity log", + "Panel del negocio": "Business panel", + "Cuánto se usa la radio: transmisiones por hora y por día, los canales más activos y la transmisión más larga.": "How much the radio is used: transmissions per hour and per day, the busiest channels and the longest transmission.", + "Hoy": "Today", + "7 días": "7 days", + "30 días": "30 days", "Cada vez que alguien transmite (en el radio o el escáner) queda anotado: hora, frecuencia, duración, nivel y tonos.": "Every time someone transmits (on the radio or the scanner) it is logged: time, frequency, duration, level and tones.", "Borrar registro": "Clear log", diff --git a/files/web/lang-en-radio.js b/files/web/lang-en-radio.js index e73ea41..0355dee 100644 --- a/files/web/lang-en-radio.js +++ b/files/web/lang-en-radio.js @@ -231,6 +231,16 @@ I18N.add({ // ---------------------------------------------------------------- recs.js · recordings and activity log "{n} grabación · {used} de {max} · libre en la microSD: {free}": "{n} recording · {used} of {max} · free on the microSD: {free}", "{n} grabaciones · {used} de {max} · libre en la microSD: {free}": "{n} recordings · {used} of {max} · free on the microSD: {free}", + "Todavía no hay actividad en este período.": "No activity in this period yet.", + "transmisiones": "transmissions", + "en el aire": "on the air", + "hora más activa": "busiest hour", + "Por hora del día": "By hour of the day", + "Por día": "By day", + "Canales más usados": "Busiest channels", + "Transmisión más larga": "Longest transmission", + "{n} transmisión": "{n} transmission", + "{n} transmisiones": "{n} transmissions", "Nada coincide con la búsqueda.": "Nothing matches your search.", "Todavía no hay grabaciones. En la pestaña Radio pulsa «Grabar ahora» o activa «Grabar automáticamente cada transmisión».": "No recordings yet. On the Radio tab tap “Record now” or turn on “Automatically record every transmission”.", diff --git a/files/web/recs.js b/files/web/recs.js index 3237ca5..74c6fac 100644 --- a/files/web/recs.js +++ b/files/web/recs.js @@ -72,9 +72,51 @@ const Recs = (() => { } catch (e) {} } + let bizDays = 7; + const fmtDur = s => s >= 3600 ? (s / 3600).toFixed(1) + " h" : s >= 60 ? Math.round(s / 60) + " min" : Math.round(s) + " s"; + function bars(vals, labels) { + const max = Math.max(1, ...vals); + return `${SDR.esc(_t("Todavía no hay actividad en este período."))}
`; return; } + let h = `| ${SDR.esc(c.name ? _t(c.name) : (c.freq / 1e6).toFixed(4) + " MHz")} | ${_tn(c.n, "{n} transmisión", "{n} transmisiones")} | ${fmtDur(c.secs)} | ` + + `
${fmtDur(l.dur)} · ${SDR.esc(l.name ? _t(l.name) : (l.freq / 1e6).toFixed(4) + " MHz")} · ` + + d.toLocaleDateString(I18N.locale, {day: "2-digit", month: "2-digit"}) + " " + d.toLocaleTimeString(I18N.locale, {hour: "2-digit", minute: "2-digit"}) + "
"; + } + box.innerHTML = h; + } catch (e) {} + } + function init() { + $("#bizDays").addEventListener("click", ev => { + const b = ev.target.closest("[data-d]"); if (!b) return; + bizDays = +b.dataset.d; + $$("#bizDays button").forEach(x => x.classList.toggle("active", x === b)); + loadBiz(); + }); + $("#bizBody").addEventListener("click", ev => { + const t = ev.target.closest("[data-tune]"); if (t) SDR.openRadio(+t.dataset.tune, t.dataset.mod); + }); $("#recSearch").addEventListener("input", render); - $("#recRefresh").addEventListener("click", () => { load(); loadActivity(); }); + $("#recRefresh").addEventListener("click", () => { load(); loadActivity(); loadBiz(); }); $("#recList").addEventListener("click", ev => { const card = ev.target.closest(".recitem"); if (!card) return; const f = card.dataset.f; @@ -121,12 +163,12 @@ const Recs = (() => { }); $("#actClear").addEventListener("click", async () => { if (!confirm(_t("¿Borrar todo el registro de actividad? (las grabaciones no se borran)"))) return; - await SDR.api("/api/activity/clear", {}); loadActivity(); + await SDR.api("/api/activity/clear", {}); loadActivity(); loadBiz(); }); } return { init, - onShow() { load(); loadActivity(); clearInterval(timer); timer = setInterval(() => { if (!document.hidden && SDR.tab === "recs") loadActivity(); }, 10000); }, + onShow() { load(); loadActivity(); loadBiz(); clearInterval(timer); timer = setInterval(() => { if (!document.hidden && SDR.tab === "recs") loadActivity(); }, 10000); }, onHide() { clearInterval(timer); $$("#recList audio").forEach(a => a.pause()); }, }; })(); diff --git a/files/web/roadmap.js b/files/web/roadmap.js index 193500a..95648fb 100644 --- a/files/web/roadmap.js +++ b/files/web/roadmap.js @@ -6,7 +6,7 @@ const Roadmap = (() => { const PHASES = [ [1, "Sacarle más jugo a lo que ya existe", [ [1, "done", "Repetición instantánea", "«⏪ ¿Qué dijo?»: repite los últimos 30 s que se oyeron."], - [2, "todo", "Panel del negocio", "Gráficos de actividad del radio por hora y por día, canal más usado."], + [2, "done", "Panel del negocio", "Gráficos de actividad del radio por hora y por día, canal más usado."], [3, "todo", "Alertas de aviones", "Aviso por emergencias (7700/7600/7500), militares y helicópteros cerca."], [4, "todo", "Mapa de barcos (AIS)", "Barcos en un mapa sin internet, como el radar de aviones."], [5, "todo", "Home Assistant (MQTT)", "Sensores, aviones y actividad del radio en tu casa inteligente."], diff --git a/tests/test_activity_stats.py b/tests/test_activity_stats.py new file mode 100644 index 0000000..3c149f6 --- /dev/null +++ b/tests/test_activity_stats.py @@ -0,0 +1,51 @@ +"""Business panel: summary of the activity log (per hour, per day, busiest channels, longest transmission).""" +import json +import time +import urllib.request + +import sdrkit_radio as radio +from test_server import server # noqa: F401 (the demo server fixture) + +NOON = time.mktime((2026, 10, 18, 12, 0, 0, 0, 0, -1)) + + +def tx(day_offset, hour, dur, name="Taxi", freq=151625000): + t = time.mktime((2026, 10, 18 - day_offset, hour, 30, 0, 0, 0, -1)) + return {"t": t, "freq": freq, "mod": "nfm", "name": name, "dur": dur} + + +def test_counts_per_hour_day_and_channel(): + items = [tx(0, 9, 4.0), tx(0, 9, 6.0, "Obra", 462562500), tx(1, 22, 30.0), tx(0, 10, 2.0)] + s = radio.activity_stats(items, days=7, now=NOON) + assert s["total"] == 4 and s["secs"] == 42.0 + assert s["hours"][9]["n"] == 2 and s["hours"][22]["n"] == 1 and s["hours"][3]["n"] == 0 + assert [d["n"] for d in s["per_day"]][-2:] == [1, 3] and len(s["per_day"]) == 7 + assert s["top"][0]["name"] == "Taxi" and s["top"][0]["n"] == 3 + assert s["longest"]["dur"] == 30.0 + + +def test_ignores_old_and_broken_entries(): + items = [tx(9, 9, 5.0), {"t": "x", "dur": 3}, {"freq": 1}, tx(0, 8, 1.0, "", 462562500)] + s = radio.activity_stats(items, days=7, now=NOON) + assert s["total"] == 1 and s["top"][0]["name"] == "" and s["top"][0]["freq"] == 462562500 + + +def test_empty_log(): + s = radio.activity_stats([], days=3, now=NOON) + assert s["total"] == 0 and s["longest"] is None and s["top"] == [] and len(s["per_day"]) == 3 + + +def test_days_are_clamped(): + assert radio.activity_stats([], days=0, now=NOON)["days"] == 1 + assert radio.activity_stats([], days=9999, now=NOON)["days"] == 90 + + +def test_demo_data_fills_the_panel(): + s = radio.activity_stats(radio.demo_activity(NOON), days=7, now=NOON) + assert s["total"] > 50 and s["top"] and s["longest"] and sum(h["n"] for h in s["hours"]) == s["total"] + + +def test_endpoint_in_demo_mode(server): # noqa: F811 + with urllib.request.urlopen(server + "/api/activity/stats?days=7", timeout=10) as r: + s = json.loads(r.read()) + assert s["days"] == 7 and len(s["hours"]) == 24 and s["total"] > 0 diff --git a/tests/ui_smoke.mjs b/tests/ui_smoke.mjs index f2873b9..17ca323 100644 --- a/tests/ui_smoke.mjs +++ b/tests/ui_smoke.mjs @@ -88,6 +88,15 @@ for (const lang of ["en", "es"]) { await page.click('.tabbar [data-tab="an"]'); } catch (e) { fail(`${lang}: instant replay: ${e.message.split("\n")[0]}`); } + // business panel: charts drawn from the activity log (demo data) + try { + await page.click('.tabbar [data-tab="recs"]'); + await page.waitForFunction(() => document.querySelectorAll("#bizBody .biz-chart i").length >= 24, null, { timeout: 10000 }); + await page.click('#bizDays [data-d="30"]'); + await page.waitForFunction(() => document.querySelectorAll("#bizBody .biz-chart").length === 2, null, { timeout: 10000 }); + await page.click('.tabbar [data-tab="an"]'); + } catch (e) { fail(`${lang}: business panel: ${e.message.split("\n")[0]}`); } + const missing = await page.evaluate(() => Array.from(I18N.missing)); if (lang === "en" && missing.length) { console.log(` note: ${missing.length} Spanish-looking string(s) without English (often live demo data):`);