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:
@@ -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 |
|
| `/hoy` | plan y estado de hoy, con lo que Timenet ha registrado de verdad |
|
||||||
| `/saldo` | saldo del mes y acumulado |
|
| `/saldo` | saldo del mes y acumulado |
|
||||||
| `/plan` | plan de hoy, con botón para re-tirar los dados |
|
| `/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 |
|
| `/fichar` `/desfichar` | fichaje manual inmediato |
|
||||||
| `/off [hoy\|dd/mm\|dd/mm-dd/mm]` | no fichar esos días |
|
| `/off [hoy\|dd/mm\|dd/mm-dd/mm]` | no fichar esos días |
|
||||||
| `/on [fecha]` | volver a fichar ese día |
|
| `/on [fecha]` | volver a fichar ese día |
|
||||||
|
|||||||
72
app/bot.py
72
app/bot.py
@@ -20,6 +20,18 @@ CB = "af" # prefijo de los callback_data
|
|||||||
|
|
||||||
|
|
||||||
# --------------------------------------------------------------------- formato
|
# --------------------------------------------------------------------- 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:
|
def hm(minutes: int) -> str:
|
||||||
"""Minutos -> '+1h 23m' / '-4m' / '0m'."""
|
"""Minutos -> '+1h 23m' / '-4m' / '0m'."""
|
||||||
if not minutes:
|
if not minutes:
|
||||||
@@ -87,6 +99,7 @@ MENU: list[tuple[str, str]] = [
|
|||||||
("hoy", "Plan y estado de hoy"),
|
("hoy", "Plan y estado de hoy"),
|
||||||
("saldo", "Saldo del mes y acumulado"),
|
("saldo", "Saldo del mes y acumulado"),
|
||||||
("plan", "Ver el plan y re-tirar los dados"),
|
("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"),
|
("fichar", "Fichar ENTRADA ahora"),
|
||||||
("desfichar", "Fichar SALIDA ahora"),
|
("desfichar", "Fichar SALIDA ahora"),
|
||||||
("off", "No fichar un dia [hoy|dd/mm|dd/mm-dd/mm]"),
|
("off", "No fichar un dia [hoy|dd/mm|dd/mm-dd/mm]"),
|
||||||
@@ -116,6 +129,7 @@ class BotUI:
|
|||||||
cmds = {
|
cmds = {
|
||||||
"start": self.cmd_hoy, "help": self.cmd_help, "ayuda": self.cmd_help,
|
"start": self.cmd_hoy, "help": self.cmd_help, "ayuda": self.cmd_help,
|
||||||
"hoy": self.cmd_hoy, "saldo": self.cmd_saldo, "plan": self.cmd_plan,
|
"hoy": self.cmd_hoy, "saldo": self.cmd_saldo, "plan": self.cmd_plan,
|
||||||
|
"proximos": self.cmd_proximos,
|
||||||
"off": self.cmd_off, "on": self.cmd_on,
|
"off": self.cmd_off, "on": self.cmd_on,
|
||||||
"fichar": self.cmd_fichar, "desfichar": self.cmd_desfichar,
|
"fichar": self.cmd_fichar, "desfichar": self.cmd_desfichar,
|
||||||
"pausa": self.cmd_pausa, "reanudar": self.cmd_reanudar,
|
"pausa": self.cmd_pausa, "reanudar": self.cmd_reanudar,
|
||||||
@@ -160,7 +174,7 @@ class BotUI:
|
|||||||
|
|
||||||
async def _status_text(self) -> str:
|
async def _status_text(self) -> str:
|
||||||
today = self.svc.today()
|
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)
|
cal = await asyncio.to_thread(self.svc.executor.calendar)
|
||||||
if not cal.is_workday(today):
|
if not cal.is_workday(today):
|
||||||
@@ -218,6 +232,62 @@ class BotUI:
|
|||||||
await update.effective_message.reply_text(await self._status_text(),
|
await update.effective_message.reply_text(await self._status_text(),
|
||||||
parse_mode=ParseMode.HTML, reply_markup=kb)
|
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:
|
async def cmd_off(self, update: Update, ctx) -> None:
|
||||||
days = parse_range(ctx.args, self.svc.today())
|
days = parse_range(ctx.args, self.svc.today())
|
||||||
if not days:
|
if not days:
|
||||||
|
|||||||
Reference in New Issue
Block a user