From 365f676a1fce7b8c5e8556135987b862755389dd Mon Sep 17 00:00:00 2001 From: omar Date: Thu, 24 Sep 2026 18:57:48 +0200 Subject: [PATCH] feat: comando /proximos con la prevision de los proximos dias Muestra que dias se fichara y cuales no (festivo, vacaciones, override o ausencia de Google Calendar), con el saldo acumulado al final. Para los dias futuros no se inventa una hora exacta: el instante se sortea esa madrugada y la costumbre deriva de un dia al siguiente, asi que se muestra el RANGO en que caera cada fichaje, que si es exacto. Para hoy se muestra el plan real con su estado. De paso, nombres de dia en castellano en /hoy y /proximos: dependian del locale del sistema y salian en ingles. Co-Authored-By: Claude Opus 5 (1M context) --- README.md | 1 + app/bot.py | 72 +++++++++++++++++++++++++++++++++++++++++++++++++++++- 2 files changed, 72 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 9037753..9af75d1 100644 --- a/README.md +++ b/README.md @@ -56,6 +56,7 @@ Se registran solos en el menú del chat al arrancar. | `/hoy` | plan y estado de hoy, con lo que Timenet ha registrado de verdad | | `/saldo` | saldo del mes y acumulado | | `/plan` | plan de hoy, con botón para re-tirar los dados | +| `/proximos [n]` | previsión de los próximos días laborables y sus rangos | | `/fichar` `/desfichar` | fichaje manual inmediato | | `/off [hoy\|dd/mm\|dd/mm-dd/mm]` | no fichar esos días | | `/on [fecha]` | volver a fichar ese día | diff --git a/app/bot.py b/app/bot.py index 506a27a..056c921 100644 --- a/app/bot.py +++ b/app/bot.py @@ -20,6 +20,18 @@ CB = "af" # prefijo de los callback_data # --------------------------------------------------------------------- formato +DIAS = ("lunes", "martes", "miércoles", "jueves", "viernes", "sábado", "domingo") +DIAS_CORTO = ("lun", "mar", "mié", "jue", "vie", "sáb", "dom") + + +def fecha_larga(d: date) -> str: + return f"{DIAS[d.weekday()]} {d:%d/%m/%Y}" + + +def fecha_corta(d: date) -> str: + return f"{DIAS_CORTO[d.weekday()]} {d:%d/%m}" + + def hm(minutes: int) -> str: """Minutos -> '+1h 23m' / '-4m' / '0m'.""" if not minutes: @@ -87,6 +99,7 @@ MENU: list[tuple[str, str]] = [ ("hoy", "Plan y estado de hoy"), ("saldo", "Saldo del mes y acumulado"), ("plan", "Ver el plan y re-tirar los dados"), + ("proximos", "Previsión de los próximos días [nº de días]"), ("fichar", "Fichar ENTRADA ahora"), ("desfichar", "Fichar SALIDA ahora"), ("off", "No fichar un dia [hoy|dd/mm|dd/mm-dd/mm]"), @@ -116,6 +129,7 @@ class BotUI: cmds = { "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, "off": self.cmd_off, "on": self.cmd_on, "fichar": self.cmd_fichar, "desfichar": self.cmd_desfichar, "pausa": self.cmd_pausa, "reanudar": self.cmd_reanudar, @@ -160,7 +174,7 @@ class BotUI: async def _status_text(self) -> str: today = self.svc.today() - lines = [f"{today:%A %d/%m/%Y}"] + lines = [f"{fecha_larga(today)}"] cal = await asyncio.to_thread(self.svc.executor.calendar) if not cal.is_workday(today): @@ -218,6 +232,62 @@ class BotUI: await update.effective_message.reply_text(await self._status_text(), parse_mode=ParseMode.HTML, reply_markup=kb) + async def cmd_proximos(self, update: Update, ctx) -> None: + try: + ndias = max(1, min(60, int(ctx.args[0]))) if ctx.args else 14 + except ValueError: + ndias = 14 + + cal = await asyncio.to_thread(self.svc.executor.calendar) + j = self.svc.cfg["jitter"] + hoy = self.svc.today() + lines = [f"Previsión · próximos {ndias} días"] + laborables = 0 + + for i in range(ndias): + d = hoy + timedelta(days=i) + cabecera = f"{fecha_corta(d)}" + + ov = self.svc.store.get_override(d) + if ov and ov["action"] == "off": + lines.append(f"{cabecera} 🚫 {ov['reason'] or 'desactivado a mano'}") + continue + if not cal.is_workday(d): + lines.append(f"{cabecera} 🌴 {cal.why_not_workday(d)}") + continue + + laborables += 1 + if d == hoy and self.svc.store.get_plan(d): + icon = {"pending": "⏳", "done": "✅", "cancelled": "🛑", + "skipped": "⤵️", "failed": "🟥"} + trozos = [] + for r in self.svc.store.get_plan(d): + t = datetime.fromisoformat(r["planned_at"]) + trozos.append(f"{icon.get(r['state'], '·')}{t:%H:%M:%S}") + lines.append(f"{cabecera} {' '.join(trozos)}") + continue + + # dias futuros: el instante exacto se sortea esa madrugada, pero el + # rango en que caera si es exacto + trozos = [] + for w in cal.windows(d): + ini = datetime.combine(d, datetime.min.time()) + ent = ini + timedelta(minutes=w.start) + sal = ini + timedelta(minutes=w.end) + trozos.append( + f"{ent - timedelta(seconds=j['entry_before_max']):%H:%M}" + f"-{ent - timedelta(seconds=j['entry_before_min']):%H:%M}" + f" → {sal + timedelta(seconds=j['exit_after_min']):%H:%M}" + f"-{sal + timedelta(seconds=j['exit_after_max']):%H:%M}") + lines.append(f"{cabecera} {' · '.join(trozos)}") + + b = await asyncio.to_thread(self.svc.executor.balance) + lines.append(f"\n{laborables} días laborables · saldo acumulado {hm(b['total'])}") + lines.append("Las horas futuras son el rango en que caerá el fichaje; " + "el segundo exacto se sortea esa madrugada.") + await update.effective_message.reply_text("\n".join(lines), + parse_mode=ParseMode.HTML) + async def cmd_off(self, update: Update, ctx) -> None: days = parse_range(ctx.args, self.svc.today()) if not days: