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: