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