diff --git a/app/bot.py b/app/bot.py
index 41007fc..702baf0 100644
--- a/app/bot.py
+++ b/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\nReplanificados los días futuros.",
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(
+ "Gráficos\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"{len(dias)} días 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 {hm(b['total'])} · "
+ 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"{tot} min ({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):
diff --git a/app/charts.py b/app/charts.py
new file mode 100644
index 0000000..a5ead94
--- /dev/null
+++ b/app/charts.py
@@ -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)
diff --git a/app/daemon.py b/app/daemon.py
index db72f1e..8d3715b 100644
--- a/app/daemon.py
+++ b/app/daemon.py
@@ -43,6 +43,8 @@ class Service:
self._stop = asyncio.Event()
self._planned_for: date | None = None
self._gcal = None
+ self._hist: list | None = None
+ self._hist_at: datetime | None = None
# ------------------------------------------------------------------ basico
def now(self) -> datetime:
@@ -72,6 +74,49 @@ class Service:
logger.exception("no se pudo enviar el mensaje por Telegram")
return None
+ async def historico(self, meses: int = 6, force: bool = False) -> list:
+ """Historico de fichajes, cacheado media hora (son 6 llamadas a la API)."""
+ fresco = self._hist_at and datetime.now(self.tz) - self._hist_at < timedelta(minutes=30)
+ if self._hist is not None and fresco and not force:
+ return self._hist
+ self._hist = await asyncio.to_thread(self.executor.historico, meses)
+ self._hist_at = datetime.now(self.tz)
+ return self._hist
+
+ def datos_prevision(self, dias: int = 21) -> list[dict]:
+ """Lo planificado por delante, en el formato que espera charts.prevision."""
+ cal = self.executor._cal
+ if cal is None:
+ return []
+ out = []
+ hoy = self.today()
+ for i in range(dias):
+ d = hoy + timedelta(days=i)
+ filas = self.store.get_plan(d)
+ if not filas:
+ continue
+ out.append({
+ "fecha": d,
+ "windows": [(w.start, w.end) for w in cal.windows(d)],
+ "checks": [{"typ": r["typ"],
+ "min": datetime.fromisoformat(r["planned_at"]).hour * 60
+ + datetime.fromisoformat(r["planned_at"]).minute}
+ for r in filas],
+ })
+ return out
+
+ def entradas_planificadas(self) -> list[int]:
+ """Minutos de desviacion de las entradas de la mañana ya sorteadas."""
+ out = []
+ hoy = self.today()
+ for i in range(60):
+ for r in self.store.get_plan(hoy + timedelta(days=i)):
+ t = datetime.fromisoformat(r["target"])
+ if r["typ"] == ENTRADA and (t.hour, t.minute) == (9, 0):
+ p = datetime.fromisoformat(r["planned_at"])
+ out.append(p.hour * 60 + p.minute - 540)
+ return out
+
# --------------------------------------------------------------- calendario
async def _personal_absence(self, day: date) -> str | None:
"""Ausencias personales desde Google Calendar. Solo eso: festivos y
diff --git a/app/executor.py b/app/executor.py
index 441daf6..288e18f 100644
--- a/app/executor.py
+++ b/app/executor.py
@@ -5,6 +5,7 @@ Todo lo de este modulo es sincrono (usa ``requests``). El daemon lo invoca con
"""
from __future__ import annotations
+import calendar
import logging
import random
from datetime import date, datetime, timedelta
@@ -108,6 +109,52 @@ class Executor:
return {"P": m.get("P") or 0, "W": m.get("W") or 0,
"X": x, "XAnt": xant, "total": x + xant}
+ def historico(self, meses: int = 6) -> list[dict]:
+ """Un registro por dia laborable de los ultimos `meses`, ordenado.
+
+ Cada registro: fecha, previsto (P), contado (W), saldo (X), presencia real
+ (A, el tiempo entre fichajes sin recortar) y los fichajes con su hora real
+ (HR) y la que cuenta (H).
+ """
+ hoy = date.today()
+ y, m = hoy.year, hoy.month
+ periodos = []
+ for _ in range(meses):
+ periodos.append((y, m))
+ m -= 1
+ if m == 0:
+ m, y = 12, y - 1
+
+ out: list[dict] = []
+ for y, m in reversed(periodos):
+ ultimo = calendar.monthrange(y, m)[1]
+ try:
+ raw = self._with_login(self.cli.raw_checks,
+ "01/%02d/%d" % (m, y), "%02d/%02d/%d" % (ultimo, m, y))
+ except TimenetError as e:
+ logger.warning("no se pudo leer %d-%02d: %s", y, m, e)
+ continue
+ for dia in raw.get("C") or []:
+ cs = dia.get("C") or []
+ if not cs or not (dia.get("P") or 0):
+ continue
+ try:
+ f = datetime.strptime(dia["D"][:10], "%d/%m/%Y").date()
+ except (KeyError, ValueError):
+ continue
+ out.append({
+ "fecha": f,
+ "P": dia.get("P") or 0,
+ "W": dia.get("W") or 0,
+ "X": dia.get("X") or 0,
+ "A": (dia.get("A") or [{}])[0].get("M") or 0,
+ "tipo": (dia.get("T") or "").strip(),
+ "checks": [{"typ": c.get("T"), "H": c.get("H"), "HR": c.get("HR")}
+ for c in cs],
+ })
+ out.sort(key=lambda r: r["fecha"])
+ return out
+
def today_summary(self, day: date | None = None) -> dict:
"""Fichajes de hoy segun Timenet, con hora real y hora contada."""
raw = self.today_raw(day)
diff --git a/requirements.txt b/requirements.txt
index c712f33..6c3299c 100644
--- a/requirements.txt
+++ b/requirements.txt
@@ -68,3 +68,4 @@ urllib3==1.26.5
wcwidth==0.2.8
websocket-client==0.57.0
zipp==1.0.0
+matplotlib>=3.8