Files
autoficher/app/charts.py
omar f2ed694f98 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>
2026-09-24 20:05:20 +02:00

270 lines
11 KiB
Python

"""Graficos para Telegram. Devuelven PNG en bytes.
Paleta: la de referencia del proyecto, validada con el comprobador de daltonismo
en modo claro y oscuro (2 series azul/naranja, peor par CVD delta-E 24.7 claro /
26.8 oscuro, muy por encima del umbral de 8).
Un PNG no tiene hover, asi que lo que en una pagina web resolveria el tooltip
aqui va en etiquetas directas sobre las marcas que importan.
"""
from __future__ import annotations
import io
import logging
from datetime import date, datetime, timedelta
import matplotlib
matplotlib.use("Agg")
import matplotlib.pyplot as plt # noqa: E402
from matplotlib.ticker import MaxNLocator # noqa: E402
logger = logging.getLogger(__name__)
TEMAS = {
"dark": {
"surface": "#1a1a19", "ink": "#ffffff", "ink2": "#c3c2b7", "muted": "#6b6a63",
"s1": "#3987e5", "s2": "#d95926", "pos": "#3987e5", "neg": "#e66767",
"zero": "#383835", "grid": "#2b2b29",
},
"light": {
"surface": "#fcfcfb", "ink": "#0b0b0b", "ink2": "#52514e", "muted": "#8a8880",
"s1": "#2a78d6", "s2": "#eb6834", "pos": "#2a78d6", "neg": "#e34948",
"zero": "#f0efec", "grid": "#e8e7e3",
},
}
DIAS_CORTO = ("L", "M", "X", "J", "V", "S", "D")
def _hhmm(minutos_del_dia: int) -> str:
return "%02d:%02d" % (minutos_del_dia // 60, minutos_del_dia % 60)
def _fig(t, w=10.0, h=5.0, filas=1, **kw):
fig, ax = plt.subplots(filas, 1, figsize=(w, h), dpi=150,
facecolor=t["surface"], **kw)
ejes = ax if filas > 1 else [ax]
for a in ejes:
a.set_facecolor(t["surface"])
for lado in ("top", "right"):
a.spines[lado].set_visible(False)
for lado in ("left", "bottom"):
a.spines[lado].set_color(t["grid"])
a.spines[lado].set_linewidth(1)
a.tick_params(colors=t["ink2"], labelsize=9, length=0)
a.grid(axis="y", color=t["grid"], linewidth=0.8, alpha=0.7)
a.set_axisbelow(True)
return fig, (ax if filas > 1 else ax)
def _salida(fig) -> bytes:
buf = io.BytesIO()
fig.savefig(buf, format="png", bbox_inches="tight", pad_inches=0.35,
facecolor=fig.get_facecolor())
plt.close(fig)
return buf.getvalue()
def _titulo(ax, t, texto, sub=""):
ax.set_title(texto, color=t["ink"], fontsize=13, fontweight="bold",
loc="left", pad=30 if sub else 12)
if sub:
ax.text(0, 1.035, sub, transform=ax.transAxes, color=t["ink2"],
fontsize=9.5, va="bottom")
# --------------------------------------------------------------------- saldo
def saldo(hist: list[dict], xant: int, tema="dark") -> bytes:
"""Saldo acumulado (linea) y saldo de cada dia (barras divergentes)."""
t = TEMAS[tema]
fechas = [r["fecha"] for r in hist]
diario = [r["X"] for r in hist]
acum, s = [], xant
for x in diario:
s += x
acum.append(s)
fig, (a1, a2) = _fig(t, h=6.4, filas=2, sharex=True,
gridspec_kw={"height_ratios": [2, 1], "hspace": 0.28})
a1.axhline(0, color=t["zero"], linewidth=1.5)
a1.plot(fechas, acum, color=t["s1"], linewidth=2, solid_capstyle="round")
a1.fill_between(fechas, acum, 0, color=t["s1"], alpha=0.10)
a1.scatter([fechas[-1]], [acum[-1]], s=42, color=t["s1"],
edgecolor=t["surface"], linewidth=2, zorder=5)
a1.annotate(f"{acum[-1]:+d} min ({acum[-1] / 60:+.1f} h)",
(fechas[-1], acum[-1]), textcoords="offset points",
xytext=(-8, 14), ha="right", color=t["ink"],
fontsize=11, fontweight="600")
_titulo(a1, t, "Saldo acumulado",
"minutos que Timenet te debe (negativo = trabajas de más sin que cuente)")
a1.set_ylabel("minutos", color=t["ink2"], fontsize=9)
a2.axhline(0, color=t["zero"], linewidth=1.5)
a2.bar(fechas, diario, width=1.0,
color=[t["pos"] if v >= 0 else t["neg"] for v in diario])
a2.set_ylabel("por día", color=t["ink2"], fontsize=9)
a2.yaxis.set_major_locator(MaxNLocator(5, integer=True))
peor = min(hist, key=lambda r: r["X"])
a2.annotate(f"{peor['X']} min ({peor['fecha']:%d/%m})", (peor["fecha"], peor["X"]),
textcoords="offset points", xytext=(10, 6), ha="left",
color=t["neg"], fontsize=9)
fig.autofmt_xdate(rotation=0, ha="center")
return _salida(fig)
# ---------------------------------------------------------------- puntualidad
def puntualidad(hist: list[dict], tema="dark") -> bytes:
"""Desviacion de cada ENTRADA respecto de su hora teorica, en el tiempo.
Es el grafico que explica el problema: por encima de cero = llegas tarde y te
lo debitan; por debajo = llegas antes y no te cuesta nada.
"""
t = TEMAS[tema]
fx, fy = [], []
for r in hist:
for i, c in enumerate(r["checks"]):
if c["typ"] != 0 or not c["HR"]:
continue
teo = 9 * 60 if i == 0 else 15 * 60
hh, mm = c["HR"][:5].split(":")
fx.append(r["fecha"])
fy.append(int(hh) * 60 + int(mm) - teo)
fig, ax = _fig(t, h=4.6)
# un dia suelto de +64 min aplastaria el resto contra el cero: se acota el eje
# y los que quedan fuera se anotan uno a uno
tope = max(8, sorted(fy)[int(0.98 * len(fy))] + 2) if fy else 8
suelo = min(-6, min(fy) - 2) if fy else -6
ax.axhspan(0, tope, color=t["neg"], alpha=0.05)
ax.axhline(0, color=t["zero"], linewidth=1.5)
dentro = [(x, y) for x, y in zip(fx, fy) if y <= tope]
ax.scatter([x for x, _ in dentro], [y for _, y in dentro], s=26, alpha=0.8,
color=[t["neg"] if y > 0 else t["pos"] for _, y in dentro],
edgecolor=t["surface"], linewidth=0.6)
for x, y in zip(fx, fy):
if y > tope:
ax.annotate(f"▲ {y:+d} min ({x:%d/%m})", (x, tope), ha="center",
textcoords="offset points", xytext=(0, -12),
color=t["neg"], fontsize=8.5)
ax.set_ylim(suelo, tope)
ax.text(0.995, 0.96, "tarde · te lo descuentan", transform=ax.transAxes,
color=t["neg"], fontsize=9.5, va="top", ha="right")
ax.text(0.995, 0.04, "pronto · sale gratis", transform=ax.transAxes,
color=t["pos"], fontsize=9.5, va="bottom", ha="right")
_titulo(ax, t, "Puntualidad de las entradas",
"minutos de desviación respecto de las 09:00 y las 15:00")
ax.set_ylabel("minutos", color=t["ink2"], fontsize=9)
ax.yaxis.set_major_locator(MaxNLocator(7, integer=True))
fig.autofmt_xdate(rotation=0, ha="center")
return _salida(fig)
# ---------------------------------------------------------------- regalado
def regalado(hist: list[dict], tema="dark") -> bytes:
"""Minutos por mes en los que estas presente pero Timenet no cuenta."""
t = TEMAS[tema]
meses: dict[str, int] = {}
for r in hist:
k = r["fecha"].strftime("%Y-%m")
meses[k] = meses.get(k, 0) + max(0, r["A"] - r["W"])
ks = sorted(meses)
vals = [meses[k] for k in ks]
fig, ax = _fig(t, h=4.4)
barras = ax.bar(range(len(ks)), vals, color=t["s2"], width=0.62)
for i, (b, v) in enumerate(zip(barras, vals)):
ax.annotate(f"{v}", (b.get_x() + b.get_width() / 2, v),
textcoords="offset points", xytext=(0, 5), ha="center",
color=t["ink"], fontsize=10, fontweight="600")
ax.set_xticks(range(len(ks)))
ax.set_xticklabels([datetime.strptime(k, "%Y-%m").strftime("%b %y") for k in ks])
_titulo(ax, t, "Tiempo regalado",
"minutos al mes en los que estás pero el reloj no corre")
ax.set_ylabel("minutos", color=t["ink2"], fontsize=9)
return _salida(fig)
# ------------------------------------------------------------------- horas
def horas(hist: list[dict], previstos: list[int], tema="dark") -> bytes:
"""A que minuto llega la entrada de la mañana: historico frente a lo planificado."""
t = TEMAS[tema]
hh: dict[int, int] = {}
for r in hist:
cs = r["checks"]
if cs and cs[0]["typ"] == 0 and cs[0]["HR"]:
a, b = cs[0]["HR"][:5].split(":")
hh[int(a) * 60 + int(b) - 540] = hh.get(int(a) * 60 + int(b) - 540, 0) + 1
pp: dict[int, int] = {}
for v in previstos:
pp[v] = pp.get(v, 0) + 1
def pct(d):
tot = sum(d.values()) or 1
return {k: 100 * v / tot for k, v in d.items()}
ph, pv = pct(hh), pct(pp)
todos = sorted(set(ph) | set(pv))
# se acota a la zona con datos; lo de fuera (algun dia muy tarde) va en el pie
xs = [x for x in todos if -14 <= x <= 8]
fuera = [x for x in todos if x not in xs]
fig, ax = _fig(t, h=4.6)
ancho = 0.4
idx = list(range(len(xs)))
ax.bar([i - ancho / 2 - 0.01 for i in idx], [ph.get(x, 0) for x in xs],
width=ancho, color=t["s2"], label="antes (histórico)")
ax.bar([i + ancho / 2 + 0.01 for i in idx], [pv.get(x, 0) for x in xs],
width=ancho, color=t["s1"], label="ahora (planificado)")
if 0 in xs:
ax.axvline(xs.index(0) - 0.5, color=t["zero"], linewidth=1.5)
ax.set_xticks(idx)
ax.set_xticklabels([_hhmm(540 + x) for x in xs], fontsize=8.5, rotation=45,
ha="right")
pie = ("a la derecha de la línea roja se pierden minutos"
+ (f" · {len(fuera)} día(s) fuera del rango: "
+ ", ".join(_hhmm(540 + x) for x in fuera) if fuera else ""))
_titulo(ax, t, "Hora de la entrada de la mañana", "% de días · " + pie)
ax.set_ylabel("% de días", color=t["ink2"], fontsize=9)
leg = ax.legend(frameon=False, loc="upper left", fontsize=9.5)
for txt in leg.get_texts():
txt.set_color(t["ink2"])
return _salida(fig)
# ---------------------------------------------------------------- prevision
def prevision(dias: list[dict], tema="dark") -> bytes:
"""Proximos dias: la ventana teorica y donde cae cada fichaje."""
t = TEMAS[tema]
dias = [d for d in dias if d["windows"]][:14]
fig, ax = _fig(t, h=0.42 * max(6, len(dias)) + 2.0)
for i, d in enumerate(dias):
y = len(dias) - i
for w in d["windows"]:
ax.barh(y, w[1] - w[0], left=w[0], height=0.5,
color=t["grid"], zorder=1)
for p in d["checks"]:
ax.scatter([p["min"]], [y], s=54, zorder=3,
color=t["s1"] if p["typ"] == 0 else t["s2"],
edgecolor=t["surface"], linewidth=1.5)
ax.set_yticks(range(1, len(dias) + 1))
ax.set_yticklabels([f"{DIAS_CORTO[d['fecha'].weekday()]} {d['fecha']:%d/%m}"
for d in reversed(dias)], fontsize=9)
horas_x = list(range(8 * 60, 20 * 60 + 1, 60))
ax.set_xticks(horas_x)
ax.set_xticklabels(["%d:00" % (h // 60) for h in horas_x], fontsize=8.5)
ax.set_xlim(8 * 60 + 30, 19 * 60 + 30)
ax.grid(axis="y", visible=False)
ax.grid(axis="x", color=t["grid"], linewidth=0.8, alpha=0.6)
ax.set_ylim(0.3, len(dias) + 0.7)
_titulo(ax, t, "Previsión de fichajes",
"la barra es la franja que Timenet cuenta")
ax.scatter([], [], s=54, color=t["s1"], label="entrada")
ax.scatter([], [], s=54, color=t["s2"], label="salida")
leg = ax.legend(frameon=False, fontsize=9.5, ncol=2,
loc="lower right", bbox_to_anchor=(1.0, 1.005))
for txt in leg.get_texts():
txt.set_color(t["ink2"])
return _salida(fig)