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) <noreply@anthropic.com>
This commit is contained in:
2026-09-24 18:57:48 +02:00
parent 5ba5710db2
commit 365f676a1f
2 changed files with 72 additions and 1 deletions

View File

@@ -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"<b>{today:%A %d/%m/%Y}</b>"]
lines = [f"<b>{fecha_larga(today)}</b>"]
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"<b>Previsión · próximos {ndias} días</b>"]
laborables = 0
for i in range(ndias):
d = hoy + timedelta(days=i)
cabecera = f"<b>{fecha_corta(d)}</b>"
ov = self.svc.store.get_override(d)
if ov and ov["action"] == "off":
lines.append(f"{cabecera} 🚫 <i>{ov['reason'] or 'desactivado a mano'}</i>")
continue
if not cal.is_workday(d):
lines.append(f"{cabecera} 🌴 <i>{cal.why_not_workday(d)}</i>")
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} <code>{' · '.join(trozos)}</code>")
b = await asyncio.to_thread(self.svc.executor.balance)
lines.append(f"\n{laborables} días laborables · saldo acumulado <b>{hm(b['total'])}</b>")
lines.append("<i>Las horas futuras son el rango en que caerá el fichaje; "
"el segundo exacto se sortea esa madrugada.</i>")
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: