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: