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:
269
app/charts.py
Normal file
269
app/charts.py
Normal file
@@ -0,0 +1,269 @@
|
||||
"""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)
|
||||
Reference in New Issue
Block a user