feat: comando /grafico con cinco visualizaciones
Cinco graficos generados con matplotlib y enviados como foto a Telegram, con
botones para elegir. La paleta es la de referencia del proyecto, validada con el
comprobador de daltonismo en claro y oscuro (peor par CVD delta-E 24.7 / 26.8,
umbral 8).
- saldo acumulado (linea) + saldo de cada dia (barras), dos paneles
- puntualidad desviacion de cada entrada en el tiempo. Es el que explica el
problema de un vistazo: en 6 meses no hay ni un solo punto por
debajo de cero, o sea que nunca se ha llegado antes de la hora
- horas histograma de la hora de entrada, historico frente a planificado.
Las dos distribuciones no se solapan en absoluto
- regalado minutos al mes presente sin que corra el reloj
- prevision linea de tiempo de los proximos dias con la franja que cuenta
Un PNG no tiene hover, asi que las cifras que en una pagina resolveria el
tooltip van en el pie de foto y en etiquetas directas sobre las marcas.
Al mirarlos renderizados aparecieron cuatro defectos que el codigo no delata:
titulo y subtitulo pisandose, la etiqueta del peor dia chocando con el eje de
fechas, un dia de +64 min aplastando el resto del eje contra el cero, y un bug
de formato que pintaba "09:64" en vez de "10:04". Corregidos: ejes acotados con
los valores extremos anotados aparte, y las horas formateadas con un helper.
Executor.historico() extrae los dias de varios meses ya parseados; el servicio
lo cachea media hora porque son seis llamadas a la API.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
83
app/bot.py
83
app/bot.py
@@ -13,7 +13,7 @@ from telegram import (BotCommand, BotCommandScopeChat, InlineKeyboardButton,
|
||||
from telegram.constants import ParseMode
|
||||
from telegram.ext import Application, CallbackQueryHandler, CommandHandler, ContextTypes
|
||||
|
||||
from app import jitter, profiles
|
||||
from app import charts, jitter, profiles
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -103,6 +103,7 @@ MENU: list[tuple[str, str]] = [
|
||||
("plan", "Ver el plan y re-tirar los dados"),
|
||||
("perfil", "Ver y cambiar el perfil de jitter"),
|
||||
("proximos", "Previsión de los próximos días [nº de días]"),
|
||||
("grafico", "Gráficos: saldo, puntualidad, previsión…"),
|
||||
("fichar", "Fichar ENTRADA ahora"),
|
||||
("desfichar", "Fichar SALIDA ahora"),
|
||||
("off", "No fichar un dia [hoy|dd/mm|dd/mm-dd/mm]"),
|
||||
@@ -133,6 +134,7 @@ class BotUI:
|
||||
"start": self.cmd_hoy, "help": self.cmd_help, "ayuda": self.cmd_help,
|
||||
"hoy": self.cmd_hoy, "saldo": self.cmd_saldo, "plan": self.cmd_plan,
|
||||
"proximos": self.cmd_proximos, "perfil": self.cmd_perfil,
|
||||
"grafico": self.cmd_grafico, "graficos": self.cmd_grafico,
|
||||
"off": self.cmd_off, "on": self.cmd_on,
|
||||
"fichar": self.cmd_fichar, "desfichar": self.cmd_desfichar,
|
||||
"pausa": self.cmd_pausa, "reanudar": self.cmd_reanudar,
|
||||
@@ -349,6 +351,80 @@ class BotUI:
|
||||
self._perfil_texto() + "\n\n<b>Replanificados los días futuros.</b>",
|
||||
parse_mode=ParseMode.HTML, reply_markup=self._perfil_teclado())
|
||||
|
||||
GRAFICOS = {
|
||||
"saldo": "📉 Saldo acumulado",
|
||||
"puntualidad": "🎯 Puntualidad",
|
||||
"horas": "🕘 Hora de entrada",
|
||||
"regalado": "🎁 Tiempo regalado",
|
||||
"prevision": "📅 Previsión",
|
||||
}
|
||||
|
||||
async def cmd_grafico(self, update: Update, ctx) -> None:
|
||||
if ctx.args and ctx.args[0].lower() in self.GRAFICOS:
|
||||
await update.effective_message.reply_text("⏳ Generando…")
|
||||
await self._enviar_grafico(ctx.args[0].lower())
|
||||
return
|
||||
botones = [InlineKeyboardButton(txt, callback_data=f"{CB}:graf:{k}")
|
||||
for k, txt in self.GRAFICOS.items()]
|
||||
filas = [botones[i:i + 2] for i in range(0, len(botones), 2)]
|
||||
await update.effective_message.reply_text(
|
||||
"<b>Gráficos</b>\nElige uno:", parse_mode=ParseMode.HTML,
|
||||
reply_markup=InlineKeyboardMarkup(filas))
|
||||
|
||||
async def _enviar_grafico(self, cual: str) -> None:
|
||||
try:
|
||||
png, pie = await self._generar(cual)
|
||||
except Exception as e:
|
||||
logger.exception("fallo generando el gráfico %s", cual)
|
||||
await self.send(f"🟥 No se pudo generar el gráfico: {e}")
|
||||
return
|
||||
await self.svc.app.bot.send_photo(
|
||||
chat_id=self.chat_id, photo=png, caption=pie, parse_mode=ParseMode.HTML)
|
||||
|
||||
async def _generar(self, cual: str) -> tuple[bytes, str]:
|
||||
"""Devuelve (png, pie de foto). El pie lleva las cifras, porque una imagen
|
||||
no tiene tooltip donde consultarlas."""
|
||||
tema = self.svc.cfg.get("chart_theme", "dark")
|
||||
|
||||
if cual == "prevision":
|
||||
dias = self.svc.datos_prevision()
|
||||
if not dias:
|
||||
raise ValueError("no hay nada planificado todavía")
|
||||
png = await asyncio.to_thread(charts.prevision, dias, tema)
|
||||
n = sum(len(d["checks"]) for d in dias)
|
||||
return png, f"<b>{len(dias)} días</b> planificados · {n} fichajes"
|
||||
|
||||
hist = await self.svc.historico()
|
||||
if not hist:
|
||||
raise ValueError("no hay histórico que dibujar")
|
||||
b = await asyncio.to_thread(self.svc.executor.balance)
|
||||
|
||||
if cual == "saldo":
|
||||
xant = b["total"] - sum(r["X"] for r in hist)
|
||||
png = await asyncio.to_thread(charts.saldo, hist, xant, tema)
|
||||
peor = min(hist, key=lambda r: r["X"])
|
||||
return png, (f"Acumulado <b>{hm(b['total'])}</b> · "
|
||||
f"{len(hist)} días · peor día {peor['fecha']:%d/%m} ({peor['X']} min)")
|
||||
|
||||
if cual == "puntualidad":
|
||||
png = await asyncio.to_thread(charts.puntualidad, hist, tema)
|
||||
ents = [c for r in hist for c in r["checks"] if c["typ"] == ENTRADA]
|
||||
return png, (f"{len(ents)} entradas · ninguna por debajo de cero: "
|
||||
f"nunca has llegado antes de la hora")
|
||||
|
||||
if cual == "horas":
|
||||
png = await asyncio.to_thread(charts.horas, hist,
|
||||
self.svc.entradas_planificadas(), tema)
|
||||
return png, "Naranja pierde minutos, azul no. Las dos nubes no se tocan."
|
||||
|
||||
if cual == "regalado":
|
||||
png = await asyncio.to_thread(charts.regalado, hist, tema)
|
||||
tot = sum(max(0, r["A"] - r["W"]) for r in hist)
|
||||
return png, (f"<b>{tot} min</b> ({tot / 60:.1f} h) presente sin que "
|
||||
f"corra el reloj, en {len(hist)} días")
|
||||
|
||||
raise ValueError(f"gráfico desconocido: {cual}")
|
||||
|
||||
async def cmd_off(self, update: Update, ctx) -> None:
|
||||
days = parse_range(ctx.args, self.svc.today())
|
||||
if not days:
|
||||
@@ -428,6 +504,11 @@ class BotUI:
|
||||
await self._cambiar_perfil(q, partes[2])
|
||||
return
|
||||
|
||||
if action == "graf":
|
||||
await q.answer("Generando…")
|
||||
await self._enviar_grafico(partes[2])
|
||||
return
|
||||
|
||||
try:
|
||||
day, idx = date.fromisoformat(partes[2]), int(partes[3])
|
||||
except (IndexError, ValueError):
|
||||
|
||||
Reference in New Issue
Block a user