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:
83
app/bot.py
83
app/bot.py
@@ -13,7 +13,7 @@ from telegram import (BotCommand, BotCommandScopeChat, InlineKeyboardButton,
|
|||||||
from telegram.constants import ParseMode
|
from telegram.constants import ParseMode
|
||||||
from telegram.ext import Application, CallbackQueryHandler, CommandHandler, ContextTypes
|
from telegram.ext import Application, CallbackQueryHandler, CommandHandler, ContextTypes
|
||||||
|
|
||||||
from app import jitter, profiles
|
from app import charts, jitter, profiles
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
@@ -103,6 +103,7 @@ MENU: list[tuple[str, str]] = [
|
|||||||
("plan", "Ver el plan y re-tirar los dados"),
|
("plan", "Ver el plan y re-tirar los dados"),
|
||||||
("perfil", "Ver y cambiar el perfil de jitter"),
|
("perfil", "Ver y cambiar el perfil de jitter"),
|
||||||
("proximos", "Previsión de los próximos días [nº de días]"),
|
("proximos", "Previsión de los próximos días [nº de días]"),
|
||||||
|
("grafico", "Gráficos: saldo, puntualidad, previsión…"),
|
||||||
("fichar", "Fichar ENTRADA ahora"),
|
("fichar", "Fichar ENTRADA ahora"),
|
||||||
("desfichar", "Fichar SALIDA ahora"),
|
("desfichar", "Fichar SALIDA ahora"),
|
||||||
("off", "No fichar un dia [hoy|dd/mm|dd/mm-dd/mm]"),
|
("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,
|
"start": self.cmd_hoy, "help": self.cmd_help, "ayuda": self.cmd_help,
|
||||||
"hoy": self.cmd_hoy, "saldo": self.cmd_saldo, "plan": self.cmd_plan,
|
"hoy": self.cmd_hoy, "saldo": self.cmd_saldo, "plan": self.cmd_plan,
|
||||||
"proximos": self.cmd_proximos, "perfil": self.cmd_perfil,
|
"proximos": self.cmd_proximos, "perfil": self.cmd_perfil,
|
||||||
|
"grafico": self.cmd_grafico, "graficos": self.cmd_grafico,
|
||||||
"off": self.cmd_off, "on": self.cmd_on,
|
"off": self.cmd_off, "on": self.cmd_on,
|
||||||
"fichar": self.cmd_fichar, "desfichar": self.cmd_desfichar,
|
"fichar": self.cmd_fichar, "desfichar": self.cmd_desfichar,
|
||||||
"pausa": self.cmd_pausa, "reanudar": self.cmd_reanudar,
|
"pausa": self.cmd_pausa, "reanudar": self.cmd_reanudar,
|
||||||
@@ -349,6 +351,80 @@ class BotUI:
|
|||||||
self._perfil_texto() + "\n\n<b>Replanificados los días futuros.</b>",
|
self._perfil_texto() + "\n\n<b>Replanificados los días futuros.</b>",
|
||||||
parse_mode=ParseMode.HTML, reply_markup=self._perfil_teclado())
|
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(
|
||||||
|
"<b>Gráficos</b>\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"<b>{len(dias)} días</b> 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 <b>{hm(b['total'])}</b> · "
|
||||||
|
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"<b>{tot} min</b> ({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:
|
async def cmd_off(self, update: Update, ctx) -> None:
|
||||||
days = parse_range(ctx.args, self.svc.today())
|
days = parse_range(ctx.args, self.svc.today())
|
||||||
if not days:
|
if not days:
|
||||||
@@ -428,6 +504,11 @@ class BotUI:
|
|||||||
await self._cambiar_perfil(q, partes[2])
|
await self._cambiar_perfil(q, partes[2])
|
||||||
return
|
return
|
||||||
|
|
||||||
|
if action == "graf":
|
||||||
|
await q.answer("Generando…")
|
||||||
|
await self._enviar_grafico(partes[2])
|
||||||
|
return
|
||||||
|
|
||||||
try:
|
try:
|
||||||
day, idx = date.fromisoformat(partes[2]), int(partes[3])
|
day, idx = date.fromisoformat(partes[2]), int(partes[3])
|
||||||
except (IndexError, ValueError):
|
except (IndexError, ValueError):
|
||||||
|
|||||||
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)
|
||||||
@@ -43,6 +43,8 @@ class Service:
|
|||||||
self._stop = asyncio.Event()
|
self._stop = asyncio.Event()
|
||||||
self._planned_for: date | None = None
|
self._planned_for: date | None = None
|
||||||
self._gcal = None
|
self._gcal = None
|
||||||
|
self._hist: list | None = None
|
||||||
|
self._hist_at: datetime | None = None
|
||||||
|
|
||||||
# ------------------------------------------------------------------ basico
|
# ------------------------------------------------------------------ basico
|
||||||
def now(self) -> datetime:
|
def now(self) -> datetime:
|
||||||
@@ -72,6 +74,49 @@ class Service:
|
|||||||
logger.exception("no se pudo enviar el mensaje por Telegram")
|
logger.exception("no se pudo enviar el mensaje por Telegram")
|
||||||
return None
|
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
|
# --------------------------------------------------------------- calendario
|
||||||
async def _personal_absence(self, day: date) -> str | None:
|
async def _personal_absence(self, day: date) -> str | None:
|
||||||
"""Ausencias personales desde Google Calendar. Solo eso: festivos y
|
"""Ausencias personales desde Google Calendar. Solo eso: festivos y
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ Todo lo de este modulo es sincrono (usa ``requests``). El daemon lo invoca con
|
|||||||
"""
|
"""
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import calendar
|
||||||
import logging
|
import logging
|
||||||
import random
|
import random
|
||||||
from datetime import date, datetime, timedelta
|
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,
|
return {"P": m.get("P") or 0, "W": m.get("W") or 0,
|
||||||
"X": x, "XAnt": xant, "total": x + xant}
|
"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:
|
def today_summary(self, day: date | None = None) -> dict:
|
||||||
"""Fichajes de hoy segun Timenet, con hora real y hora contada."""
|
"""Fichajes de hoy segun Timenet, con hora real y hora contada."""
|
||||||
raw = self.today_raw(day)
|
raw = self.today_raw(day)
|
||||||
|
|||||||
@@ -68,3 +68,4 @@ urllib3==1.26.5
|
|||||||
wcwidth==0.2.8
|
wcwidth==0.2.8
|
||||||
websocket-client==0.57.0
|
websocket-client==0.57.0
|
||||||
zipp==1.0.0
|
zipp==1.0.0
|
||||||
|
matplotlib>=3.8
|
||||||
|
|||||||
Reference in New Issue
Block a user