"""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)