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:
2026-09-24 20:05:20 +02:00
parent ba0d6b5f08
commit f2ed694f98
5 changed files with 444 additions and 1 deletions

View File

@@ -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):