feat: perfiles de jitter intercambiables desde Telegram
Cinco perfiles con distinto punto en el compromiso entre variedad visible y
minutos de presencia de mas. Todos dan saldo 0. Se cambian en caliente con
/perfil, con botones, y al cambiar se vuelven a tirar los dados de todos los
dias del horizonte que no se hayan fichado ya.
min/dia madrugon entrada 09:00
discreto 2,8 1,3 2 minutos, moda 75%
natural 4,1 1,8 5 minutos, moda 51% <- por defecto
camuflado 5,5 2,1 4 minutos, moda 43%
disperso 7,9 3,0 6 minutos, moda 29%
sin_madrugar 5,7 1,3 2 minutos, moda 77%
(historico real del usuario: 4 minutos, moda 38%)
La distincion util no es solo el coste total sino DONDE cae. Adelantar la
entrada de la mañana alarga el dia; adelantar la vuelta de comer solo acorta la
comida y se sale a la misma hora. De ahi "sin_madrugar": entrada de la mañana lo
mas pegada posible a las 9 (madrugon 1,26 min/dia, el mas bajo de todos, por
debajo incluso de "discreto") y toda la variedad en la pausa y las salidas.
Para eso los perfiles admiten afinar por tramo con sufijo _w<n>:
entry_median_w0 gana a entry_median para el tramo de la mañana.
tools/perfiles.py mide cada perfil sobre los 226 dias laborables del calendario
real; las cifras de arriba salen de ahi y van guardadas como metadatos para que
/perfil las muestre sin recalcular.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
67
app/bot.py
67
app/bot.py
@@ -13,7 +13,7 @@ from telegram import (BotCommand, BotCommandScopeChat, InlineKeyboardButton,
|
||||
from telegram.constants import ParseMode
|
||||
from telegram.ext import Application, CallbackQueryHandler, CommandHandler, ContextTypes
|
||||
|
||||
from app import jitter
|
||||
from app import jitter, profiles
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -101,6 +101,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"),
|
||||
("perfil", "Ver y cambiar el perfil de jitter"),
|
||||
("proximos", "Previsión de los próximos días [nº de días]"),
|
||||
("fichar", "Fichar ENTRADA ahora"),
|
||||
("desfichar", "Fichar SALIDA ahora"),
|
||||
@@ -131,7 +132,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,
|
||||
"proximos": self.cmd_proximos, "perfil": self.cmd_perfil,
|
||||
"off": self.cmd_off, "on": self.cmd_on,
|
||||
"fichar": self.cmd_fichar, "desfichar": self.cmd_desfichar,
|
||||
"pausa": self.cmd_pausa, "reanudar": self.cmd_reanudar,
|
||||
@@ -276,6 +277,56 @@ class BotUI:
|
||||
await update.effective_message.reply_text("\n".join(lines),
|
||||
parse_mode=ParseMode.HTML)
|
||||
|
||||
def _perfil_texto(self) -> str:
|
||||
actual = self.svc.store.get("perfil", profiles.DEFECTO)
|
||||
lines = ["<b>Perfil de jitter</b>",
|
||||
"Cuánta variedad tienen las horas de fichaje, y lo que cuesta "
|
||||
"en minutos de estar ahí. Todos dan saldo 0.\n"]
|
||||
for n in profiles.nombres():
|
||||
st = profiles.stats(n)
|
||||
marca = "▶️ " if n == actual else "· "
|
||||
lines.append(
|
||||
f"{marca}<b>{profiles.titulo(n)}</b> — {st['coste']:.1f} min/día "
|
||||
f"(madrugón {st['madrugon']:.1f}) · {st['mins']} minutos, moda {st['moda']}%")
|
||||
lines.append(f" <i>{profiles.resumen(n)}</i>")
|
||||
lines.append("\n<i>madrugón = de esos minutos, los que te levantas antes "
|
||||
"por la entrada de la mañana. Tu histórico real: 4 minutos, moda 38%.</i>")
|
||||
return "\n".join(lines)
|
||||
|
||||
def _perfil_teclado(self) -> InlineKeyboardMarkup:
|
||||
actual = self.svc.store.get("perfil", profiles.DEFECTO)
|
||||
botones = [InlineKeyboardButton(
|
||||
("✅ " if n == actual else "") + profiles.titulo(n),
|
||||
callback_data=f"{CB}:perfil:{n}") for n in profiles.nombres()]
|
||||
filas = [botones[i:i + 2] for i in range(0, len(botones), 2)]
|
||||
return InlineKeyboardMarkup(filas)
|
||||
|
||||
async def cmd_perfil(self, update: Update, ctx) -> None:
|
||||
if ctx.args:
|
||||
n = ctx.args[0].strip().lower()
|
||||
if not profiles.existe(n):
|
||||
await update.effective_message.reply_text(
|
||||
"No conozco ese perfil. Son: " + ", ".join(profiles.nombres()))
|
||||
return
|
||||
await self.svc.cambiar_perfil(n)
|
||||
await update.effective_message.reply_text(
|
||||
f"✅ Perfil <b>{profiles.titulo(n)}</b>. Replanificados los días futuros.",
|
||||
parse_mode=ParseMode.HTML)
|
||||
return
|
||||
await update.effective_message.reply_text(
|
||||
self._perfil_texto(), parse_mode=ParseMode.HTML,
|
||||
reply_markup=self._perfil_teclado())
|
||||
|
||||
async def _cambiar_perfil(self, q, nombre: str) -> None:
|
||||
if not profiles.existe(nombre):
|
||||
await q.answer("Perfil desconocido")
|
||||
return
|
||||
await q.answer(f"Cambiando a {profiles.titulo(nombre)}…")
|
||||
await self.svc.cambiar_perfil(nombre)
|
||||
await q.edit_message_text(
|
||||
self._perfil_texto() + "\n\n<b>Replanificados los días futuros.</b>",
|
||||
parse_mode=ParseMode.HTML, reply_markup=self._perfil_teclado())
|
||||
|
||||
async def cmd_off(self, update: Update, ctx) -> None:
|
||||
days = parse_range(ctx.args, self.svc.today())
|
||||
if not days:
|
||||
@@ -348,10 +399,16 @@ class BotUI:
|
||||
if not self._mine(update):
|
||||
await q.answer()
|
||||
return
|
||||
partes = q.data.split(":")
|
||||
action = partes[1] if len(partes) > 1 else ""
|
||||
|
||||
if action == "perfil":
|
||||
await self._cambiar_perfil(q, partes[2])
|
||||
return
|
||||
|
||||
try:
|
||||
_, action, day_s, idx_s = q.data.split(":")
|
||||
day, idx = date.fromisoformat(day_s), int(idx_s)
|
||||
except ValueError:
|
||||
day, idx = date.fromisoformat(partes[2]), int(partes[3])
|
||||
except (IndexError, ValueError):
|
||||
await q.answer("Botón no reconocido")
|
||||
return
|
||||
|
||||
|
||||
Reference in New Issue
Block a user