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:
72
app/bot.py
72
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"<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:
|
||||
|
||||
Reference in New Issue
Block a user