Sport Planner — Specs (single source of truth)
Sport Planner — Specs (single source of truth)
Este documento describe lo que hace hoy Sport Planner según el código actual. Debe mantenerse siempre sincronizado con el comportamiento real (modelo, persistencia y UI).
1) Propósito (insight clave)
Sport Planner es un instrumento para dos usos, bajo el mismo modelo:
- Planificar y ejecutar clases (sesiones manuales) para tus alumnos.
- Eliminar fricción diaria para implementar hábitos y construir identidad: kungfu (formas/técnicas/segmentos/aplicaciones), fuerza, baile y una dieta de información (lecturas curadas tipo “post” diario, como el Diario del Guerrero).
El producto está diseñado para que cada día puedas entrar y “ver” qué toca, ejecutarlo con mínimo coste, y registrar el histórico.
2) Arquitectura (tal cual)
- SPA (React + Vite + TypeScript), desplegada como archivos estáticos bajo
/sport-planner/. - Router:
HashRouter(#/…) para hosting estático (GitHub Pages / static hosting). - Estado: Zustand con persistencia en
localStorage. - Supabase (consumido directamente desde el cliente):
planner_states: snapshot JSON por usuario (estado completo).works+work_collaborators: catálogo compartible (visibilidad + colaboradores), refresco por Realtime.
- No hay servidor propio ni endpoints custom.
3) Modelo de dominio
3.1 Objective
Objetivos agrupan trabajos y aportan identidad visual (tinte de cards).
Campos: id, name, colorHex, descriptionMarkdown, createdAt, updatedAt.
3.2 Work (catálogo)
Un Work es un ítem reutilizable que puede planificarse en sesiones.
Contenido:
name,subtitledescriptionMarkdown(markdown)notes(markdown libre)videoUrls(URLs; YouTube)estimatedMinutes
Clasificación y grafo:
nodeType?: string(definiciones enworkTaxonomy.nodeTypes)schedule?: { kind: 'day_of_year' | 'day_of_month' | 'day_of_week', number: number }(solo se usa paranodeType=reading; ver sección 12)ebookRef?: { ebookId: string, indexUrl: string, mode: 'daily_fixed' | 'sequential' }(solo se usa paranodeType=ebook; ver sección 13)tags: string[](definiciones enworkTaxonomy.tags)parentWorkId?: string | null(jerarquía)nextWorkId?: string | null(secuencia)variantOfWorkId?: string | null(variante)orderHint?: number(orden auxiliar entre hermanos)
Colaboración (Supabase):
visibility:private | shared | publicownerId/ownerEmailcollaborators/collaboratorEmails- flags calculadas en cliente:
canEdit,isOwner
3.3 Session
Una sesión es una ejecución planificada en un día (YYYY-MM-DD).
Campos:
id,date,title,description,notes,startTimekind:class | personalplanId: referencia al Plan que gobierna esa “agenda”workItems: SessionWork[]attendance: SessionAttendance[](relevante en clases)notesByGroupId?: Record<string,string>(notas por grupo en personal)createdAt,updatedAt
3.4 SessionWork
Un Work dentro de una sesión:
id,workId,order- personalización por sesión:
focusLabel,customDescriptionMarkdown,customDurationMinutes,notes - tracking:
completed: booleanresult?: ok | doubt | faileffort?: number(puede existir en datos; su presencia en UI ha ido variando)contentRef?(payload persistido de “qué contenido se resolvió” en el momento de la sesión; hoy se usa para ebooks)readPaths?: string[](para ebooks: paths marcados como leídos en esta sesión; trazabilidad + progreso secuencial por plan)
3.5 Assistant + SessionAttendance
Asistentes (alumnos) y asistencia por sesión:
Assistant:id,name,notes,active, timestampsSessionAttendance:assistantId,status(previsión),actualStatus(real),notes,actualNotes
3.6 Plan
Un Plan es una configuración con nombre y tipo:
id,name,kind(class | personal),enabled- En personal:
cadence+todayPlan
4) Navegación y pantallas (rutas reales)
Cabecera fija (menú):
Sesiones(/)Trabajos(/catalog)Ajustes(/settings)Objetivos(/objectives)Asistentes(/assistants)Backups(/backups)
Rutas adicionales existentes:
personal/sessions(histórico personal)personalredirige a/?plan=personal-kungfucatalog/taxonomy(editor de taxonomy)plan(Planner legacy; el flujo principal actual está enSesiones)
Login obligatorio (/login).
5) Sesiones (vista unificada por Plan)
/ muestra una vista unificada con tabs por Plan activo. Según Plan.kind:
class→ UI de clases (manual, con calendario).personal→ UI personal (por fecha, con grupos y progreso).
Orden de tabs:
- Los planes se ordenan por nombre (colación con números), soportando prefijos tipo
1. ...,2. ...para forzar el orden visual.
6) Clases (plan manual)
Calendario
- Calendario mensual para saltar de día y gestionar sesiones.
- Crear, duplicar y eliminar sesiones.
Header por sesión
- Fecha + título.
- Duración estimada (suma de duraciones).
- Progreso (ítems completados).
- Switch
Vista/Editar. - “Ir a fecha”.
Vista
- Lista de works con card unificada (checkbox).
- Detalle expandible (markdown + vídeos).
- “Última vez” (calculada a partir de sesiones anteriores del mismo plan de clases, si existe).
- Asistencia “en vivo” para asistentes activos (marcar presentes/ausentes).
Editar
SessionEditor: reordenar (drag&drop), añadir desde catálogo, swap, duplicar item, eliminar, ajustar duración/descr/foco.- Previsión de asistencia (status) editable.
7) Personal (plan automático por día)
Navegación
- La sesión personal es por fecha (prev / hoy / next + date picker).
- Switch
Vista/Editar.
Header
- Duración estimada.
- Progreso global:
- works: “done” si
completedoresultdefinido. - grupos “nota”: “done” si el texto no está vacío.
- works: “done” si
- Acción de planificación:
Recrear sesión(rehace la planificación del día manteniendo lo registrado)
Vista
- Render por grupos.
- Cards unificadas con:
- checkbox = marcar hecho (en personal marca
completed=trueyresult=ok). - tags visibles.
- “Última vez” en pequeño (desde histórico personal).
- detalle expandible (markdown + vídeos) y acción “Editar en catálogo” dentro del detalle.
- checkbox = marcar hecho (en personal marca
- Grupos “nota”: textarea con autosave al perder foco; si vacío ⇒ no hecho.
Editar
SessionEditorreutilizado para reordenar/swap/add/remove.- En personal se ocultan los campos meta de sesión en el editor (título/fecha/desc/notas), porque la fecha se controla desde el header y las notas son por grupo.
8) Card unificada (modo vista)
En modo vista, clases y personal comparten un componente de card con:
- checkbox principal de “hecho”
- número de orden (cambia de estilo al marcar)
- tinte por
Objective.colorHex+ badge del objective - título + “extra en azul” (normalmente
focusLabelsi representa un foco interno real) - tags visibles
- “Última vez” (pequeño)
- detalle expandible:
descriptionMarkdownnotes- grid de vídeos (3 columnas en desktop)
- acciones dentro del detalle (según pantalla)
9) Catálogo (Works)
/catalog:
- Agrupa por objetivo y muestra jerarquía por
parentWorkId. - Los grupos por objetivo aparecen colapsados por defecto (para facilitar catálogos grandes). Permite colapsar/expandir grupos y colapsar ramas (trabajo padre ↔ hijos).
- Cards tintadas por
nodeType(badge + acento visual). - Permite crear/editar/duplicar/borrar works (si
canEdit). - Campos editables clave: objetivo, parent,
nodeType, schedule (solo lecturas),tags,orderHint,nextWorkId,variantOfWorkId, markdown, vídeos, visibilidad y colaboradores. - Control de inclusión en personal:
- Tag especial
personal-excludeexcluye el work del auto-planning personal.
- Tag especial
Taxonomy:
catalog/taxonomypermite crear/borrarnodeTypesytags(borrado bloqueado si están en uso).
10) Configuración del plan personal (algoritmo actual)
La configuración vive dentro del Plan personal (persistida en planner_states):
cadence: objetivos de días pornodeType+ overrides por tags.todayPlan: lista de grupos (N), límites y estrategias.defaultMinutesByNodeType: fallback de minutos pornodeType(incluyereading).
Grupos Cada grupo puede definir:
type:workonotedaysOfWeek(0=domingo … 6=sábado)include[]/exclude[]por:byNodeTypesbyTags(AND)byWorkIds
strategy:overdueoweightedhierarchyRule:allow_alloprefer_leaves- límites: por
count/minutes/bothmaxItemssolo aplica encount/bothminutesBudgetsolo aplica enminutes/both- el editor ajusta el
limitModeautomáticamente si introducesmaxItems>0estando enminutes, ominutesBudget>0estando encount(para evitar configuraciones “que parecen correctas” pero no limitan). maxItems=0(o vacío) se interpreta como “sin límite” (se persiste comoundefined).- post-proceso defensivo: tras planificar, el cliente deduplica por
workIdy recorta cualquier grupocount/bothque excedamaxItems(mantiene lo ya marcado como hecho y elimina sobrantes no hechos).
Cadencia / due scoring
- El sistema calcula
lastSeen/histórico desde sesiones personales. - Estima “vencido” con
targetDayspornodeType, y usa el resultado previo para ponderar (en weighted). - Lecturas (
nodeType=reading) con schedule: solo entran en el pool si suschedulecoincide con la fecha activa (ver sección 12). Si no se leen, no se arrastran (regla strict por diseño: simplemente no “tocan” hasta la próxima ocurrencia).
Actualizar vs Recrear
Recrear sesiónconserva lo registrado (done/result) y notas, elimina el resto y vuelve a planificar.
11) Persistencia, sync y backups
Local
- Zustand persiste colecciones en
localStorage(objetivos, works, sesiones, asistentes, planes, taxonomy, config personal).
Supabase
planner_states: snapshot JSON por usuario, con debounce (last-write-wins).works/work_collaborators: catálogo con RLS y refresco por Realtime.worksincluyeschedule_kind+schedule_number(paranodeType=reading), reflejados en cliente comowork.schedule.worksincluyeebook_ref(JSONB) paranodeType=ebook, reflejado en cliente comowork.ebookRef.
Notas de escala:
- El catálogo (
works) puede ser grande (ej. 365 lecturas con markdown). Para evitar snapshots enormes y problemas de cuota, el sync deplanner_statesno incluye el catálogo; el catálogo se carga desdeworks(Realtime) y se cachea localmente si cabe. - Al hidratar
planner_states, el cliente no sobrescribeworkscon vacío; conserva el catálogo vigente y evita estados transitorios donde “faltan trabajos” hasta que termine una recarga. - La resuscripción/recarga de catálogo está anclada a
user.id/email (no a la identidad completa del objeto sesión), para evitar refetch innecesario en renovaciones de token al volver a foco.
Backups
version: 1- export/import incluye: objetivos, works, sesiones, asistentes, relaciones, planes, config personal y taxonomy.
- importar sobrescribe estado (sin undo).
11.1) PWA instalable (iPhone/Android/Desktop)
La app se publica como PWA bajo /sport-planner/:
manifest.webmanifestgenerado en build (Vite PWA plugin), con:name: Sport Plannerdisplay: standalonestart_urlyscope:/sport-planner/- iconos
192x192y512x512(purpose: maskableen 512)
- Service Worker autogenerado (Workbox) con
registerType: autoUpdate. - Registro del SW en cliente (
virtual:pwa-register) solo enPROD. - Metadatos iOS en
index.html:apple-mobile-web-app-capable=yesapple-mobile-web-app-title=Sport Plannerapple-touch-icondedicado.
Notas:
- En iPhone la instalación se hace desde Safari: Compartir → Añadir a pantalla de inicio.
- El routing interno sigue basado en
HashRouter; la PWA abre en modo standalone y conserva ese comportamiento.
12) Lecturas programadas (nodeType=reading) — dieta de información
Objetivo funcional:
- Añadir ítems de tipo “post/lectura” para una dieta de información (ej. Diario del Guerrero), planificables automáticamente dentro de un grupo del plan personal.
Implementación actual:
Work.nodeType = 'reading'(taxonomy).Work.schedule?: { kind, number }:kind = day_of_yearconnumber1–366kind = day_of_monthconnumber1–31kind = day_of_weekconnumber0–6 (dayjs; 0=domingo)
- Persistencia en Supabase:
works.schedule_kind+works.schedule_number(con constraints). - Planner personal:
- Si el Work es
readingy tieneschedule, solo entra en el pool cuando la fecha activa coincide. - Regla strict: si no se lee, no se “arrastra” (simplemente no aparece hasta la siguiente ocurrencia).
- Si el Work es
Configuración típica:
- Crear un grupo de tipo
workcon:include: [{ byNodeTypes: ['reading'], byTags: ['diario-del-guerrero'] }]- límite por count (p.ej.
maxItems=1) para mostrar solo la lectura del día.
13) Ebooks (nodeType=ebook) — dieta de información sin “1 work por entrada”
Motivación:
nodeType=readingfunciona pero obliga a crear un work por entrada (p.ej. 365 para un diario), lo que hace caro dar de alta un ebook completo.nodeType=ebookreduce el catálogo: un solo work por ebook y la app resuelve qué entrada mostrar.
Modelo:
Work.nodeType = 'ebook'Work.ebookRef:ebookId: id deebooks.json(p.ej.diario-del-guerrero,poder-etico)indexUrl: URL (absoluta o relativa) alindex.jsondel ebookmode:daily_fixed: la entrada depende de la fecha del calendario (ej. diario).sequential: la entrada es la siguiente a la última marcada como leída.
Datos publicados (fuente):
- La app consume el catálogo público
ebooks.json(intenta/ebooks/ebooks.jsony si no existe cae ahttps://maballesteros.com/ebooks/ebooks.json). - Cada ebook tiene un
index.jsonconbasePath+ capítulos + secciones (patha markdown).
Resolución en sesiones personales:
- La sesión mantiene
SessionWork.contentRefpara “fijar” qué sección se planificó (evita sorpresas si cambian reglas). - Trazabilidad y progreso:
SessionWork.readPaths[]guarda cadasectionPathmarcado como leído en la sesión.- En modo
sequential, la siguiente sesión busca el últimoreadPaths[-1]previo en el mismoplanIdpara continuar.
- Casos:
daily_fixed: intenta encontrar la sección con título tipoDD <Mes>:(y fallback por nombre de fichero). 29-feb no tiene entrada ⇒ no hay sección.sequential: muestrasections[0]si nunca se leyó; si se llegó al final, no hay sección.
UI (hoy):
- En la card, el preview muestra el título de la sección resuelta (sin expandir).
- El detalle expandido renderiza el markdown de la sección del ebook.
- Acciones:
Marcar leído: marcacompleted=true,result=oky añade elsectionPathareadPaths[].Copiar:Copiar (Markdown): copia el markdown (respetando formato) + URL al final.Copiar (WhatsApp): copia una versión “WhatsApp-friendly” (básica) + URL al final.
Ver más(solo ensequential): permite avanzar a la siguiente sección dentro de la misma sesión (sin crear nuevos ítems).