Miguel Ángel Ballesteros bio photo

Miguel Ángel Ballesteros

CTO and co-founder of GoKoan. I build AI products such as Koanly, learning systems and agentic software workflows that turn complex knowledge into usable tools.

Email LinkedIn Github
RSS Feed

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:

  1. Planificar y ejecutar clases (sesiones manuales) para tus alumnos.
  2. 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.

Un Work es un ítem reutilizable que puede planificarse en sesiones.

Contenido:

  • name, subtitle
  • descriptionMarkdown (markdown)
  • notes (markdown libre)
  • videoUrls (URLs; YouTube)
  • estimatedMinutes

Clasificación y grafo:

  • nodeType?: string (definiciones en workTaxonomy.nodeTypes)
  • schedule?: { kind: 'day_of_year' | 'day_of_month' | 'day_of_week', number: number } (solo se usa para nodeType=reading; ver sección 12)
  • ebookRef?: { ebookId: string, indexUrl: string, mode: 'daily_fixed' | 'sequential' } (solo se usa para nodeType=ebook; ver sección 13)
  • tags: string[] (definiciones en workTaxonomy.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 | public
  • ownerId / ownerEmail
  • collaborators / 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, startTime
  • kind: class | personal
  • planId: 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: boolean
    • result?: ok | doubt | fail
    • effort?: 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, timestamps
  • SessionAttendance: 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)
  • personal redirige a /?plan=personal-kungfu
  • catalog/taxonomy (editor de taxonomy)
  • plan (Planner legacy; el flujo principal actual está en Sesiones)

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 completed o result definido.
    • grupos “nota”: “done” si el texto no está vacío.
  • 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=true y result=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.
  • Grupos “nota”: textarea con autosave al perder foco; si vacío ⇒ no hecho.

Editar

  • SessionEditor reutilizado 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 focusLabel si representa un foco interno real)
  • tags visibles
  • “Última vez” (pequeño)
  • detalle expandible:
    • descriptionMarkdown
    • notes
    • 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-exclude excluye el work del auto-planning personal.

Taxonomy:

  • catalog/taxonomy permite crear/borrar nodeTypes y tags (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 por nodeType + overrides por tags.
  • todayPlan: lista de grupos (N), límites y estrategias.
    • defaultMinutesByNodeType: fallback de minutos por nodeType (incluye reading).

Grupos Cada grupo puede definir:

  • type: work o note
  • daysOfWeek (0=domingo … 6=sábado)
  • include[] / exclude[] por:
    • byNodeTypes
    • byTags (AND)
    • byWorkIds
  • strategy: overdue o weighted
  • hierarchyRule: allow_all o prefer_leaves
  • límites: por count / minutes / both
    • maxItems solo aplica en count/both
    • minutesBudget solo aplica en minutes/both
    • el editor ajusta el limitMode automáticamente si introduces maxItems>0 estando en minutes, o minutesBudget>0 estando en count (para evitar configuraciones “que parecen correctas” pero no limitan).
    • maxItems=0 (o vacío) se interpreta como “sin límite” (se persiste como undefined).
    • post-proceso defensivo: tras planificar, el cliente deduplica por workId y recorta cualquier grupo count/both que exceda maxItems (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 targetDays por nodeType, y usa el resultado previo para ponderar (en weighted).
  • Lecturas (nodeType=reading) con schedule: solo entran en el pool si su schedule coincide 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ón conserva 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.
    • works incluye schedule_kind + schedule_number (para nodeType=reading), reflejados en cliente como work.schedule.
    • works incluye ebook_ref (JSONB) para nodeType=ebook, reflejado en cliente como work.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 de planner_states no incluye el catálogo; el catálogo se carga desde works (Realtime) y se cachea localmente si cabe.
  • Al hidratar planner_states, el cliente no sobrescribe works con 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.webmanifest generado en build (Vite PWA plugin), con:
    • name: Sport Planner
    • display: standalone
    • start_url y scope: /sport-planner/
    • iconos 192x192 y 512x512 (purpose: maskable en 512)
  • Service Worker autogenerado (Workbox) con registerType: autoUpdate.
  • Registro del SW en cliente (virtual:pwa-register) solo en PROD.
  • Metadatos iOS en index.html:
    • apple-mobile-web-app-capable=yes
    • apple-mobile-web-app-title=Sport Planner
    • apple-touch-icon dedicado.

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_year con number 1–366
    • kind = day_of_month con number 1–31
    • kind = day_of_week con number 0–6 (dayjs; 0=domingo)
  • Persistencia en Supabase: works.schedule_kind + works.schedule_number (con constraints).
  • Planner personal:
    • Si el Work es reading y tiene schedule, 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).

Configuración típica:

  • Crear un grupo de tipo work con:
    • 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=reading funciona 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=ebook reduce el catálogo: un solo work por ebook y la app resuelve qué entrada mostrar.

Modelo:

  • Work.nodeType = 'ebook'
  • Work.ebookRef:
    • ebookId: id de ebooks.json (p.ej. diario-del-guerrero, poder-etico)
    • indexUrl: URL (absoluta o relativa) al index.json del ebook
    • mode:
      • 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.json y si no existe cae a https://maballesteros.com/ebooks/ebooks.json).
  • Cada ebook tiene un index.json con basePath + capítulos + secciones (path a markdown).

Resolución en sesiones personales:

  • La sesión mantiene SessionWork.contentRef para “fijar” qué sección se planificó (evita sorpresas si cambian reglas).
  • Trazabilidad y progreso:
    • SessionWork.readPaths[] guarda cada sectionPath marcado como leído en la sesión.
    • En modo sequential, la siguiente sesión busca el último readPaths[-1] previo en el mismo planId para continuar.
  • Casos:
    • daily_fixed: intenta encontrar la sección con título tipo DD <Mes>: (y fallback por nombre de fichero). 29-feb no tiene entrada ⇒ no hay sección.
    • sequential: muestra sections[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: marca completed=true, result=ok y añade el sectionPath a readPaths[].
    • 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 en sequential): permite avanzar a la siguiente sección dentro de la misma sesión (sin crear nuevos ítems).