Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
509 changes: 276 additions & 233 deletions docs/requisitos/historias-de-usuario.md

Large diffs are not rendered by default.

26 changes: 17 additions & 9 deletions docs/wiki/Historias-de-usuario.md
Original file line number Diff line number Diff line change
Expand Up @@ -26,15 +26,23 @@ En total hay **107 criterios de aceptación**; 94 son de historias hechas.

## Estimación

Cada historia tiene su tamaño en **puntos de historia** (Fibonacci) y una estimación de **horas sin IA y con IA**, según el tipo de trabajo. El método y la ficha de cada historia están en el [documento de historias](https://github.com/riverri/IS1/blob/main/docs/requisitos/historias-de-usuario.md#estimación).

| | Puntos | Horas sin IA | Horas con IA |
|---|---:|---:|---:|
| Historias hechas (45) | 185 | 740 | 312 |
| Historias descartadas (8) | 29 | 116 | 49 |

- **Sin IA:** 4 horas por punto (diseñar, programar, probar, revisar y documentar).
- **Con IA:** el 30 % en interfaz y datos, el 45 % en reglas del negocio y el 55 % en integración y seguridad. Lo que más tiempo sigue llevando es entender, revisar y probar lo que propone la IA.
Cada historia tiene su **tamaño** (puntos de historia y líneas de código), las **horas sin IA y con IA** y el **coste de la IA**. El método completo y la ficha de cada historia están en el [documento de historias](https://github.com/riverri/IS1/blob/main/docs/requisitos/historias-de-usuario.md#estimación).

| | Puntos | Líneas | Horas sin IA | Horas con IA | Coste de la IA |
|---|---:|---:|---:|---:|---:|
| Historias hechas (45) | 185 | 15.170 | 1.091 | 177 | 179 $ |
| Historias descartadas (8) | 29 | 2.378 | 171 | 28 | 28 $ |

- **Tamaño:** medido en el repositorio. Son 15.103 líneas de código real para 185 puntos, unas 82 líneas por punto.
- **Sin IA:** unas 14 líneas terminadas por hora. Sale de valores de referencia en tres puntos (25, 15 y 8 líneas/h).
- **Con IA:** se suman cuatro partes.
- **Dirigir a la IA:** medido.
- **Revisar el código:** unas 138 líneas/h.
- **Comprobar cada criterio:** 10 minutos por criterio.
- **Corregir fallos:** 2,5 fallos por cada mil líneas, medido en la revisión de los 34 fallos.

Revisar es lo que más tiempo lleva.
- **Coste de la IA:** medido con los tokens de la sesión de trabajo y las tarifas de la API. Son unos 11,8 $ por cada mil líneas, casi todo por volver a leer el contexto.

## Por tema

Expand Down
12 changes: 12 additions & 0 deletions src/main/java/es/ucm/fdi/is1/apuestas/ApuestasApplication.java
Original file line number Diff line number Diff line change
Expand Up @@ -5,11 +5,23 @@
import org.springframework.boot.context.properties.ConfigurationPropertiesScan;
import org.springframework.scheduling.annotation.EnableScheduling;

/**
* Punto de entrada de Better, la web de apuestas con moneditas virtuales.
* <ul>
* <li>{@code @SpringBootApplication}: busca los componentes (controladores, servicios, repositorios…) en este
* paquete y sus subpaquetes y configura Spring automáticamente.</li>
* <li>{@code @ConfigurationPropertiesScan}: carga los records de configuración, como
* {@link es.ucm.fdi.is1.apuestas.api.ApiProperties}.</li>
* <li>{@code @EnableScheduling}: activa las tareas programadas de
* {@link es.ucm.fdi.is1.apuestas.api.SincronizacionProgramada}.</li>
* </ul>
*/
@SpringBootApplication
@ConfigurationPropertiesScan
@EnableScheduling
public class ApuestasApplication {

/** Arranca el servidor web embebido (puerto 8080, o el de la variable de entorno PORT). */
public static void main(String[] args) {
SpringApplication.run(ApuestasApplication.class, args);
}
Expand Down
7 changes: 7 additions & 0 deletions src/main/java/es/ucm/fdi/is1/apuestas/api/ApiProperties.java
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@

/**
* Configuración de la API de datos deportivos (application.properties, prefijo apuestas.api).
* Spring la rellena al arrancar gracias a {@code @ConfigurationPropertiesScan}. Los intervalos de las tareas
* programadas no están aquí: los lee directamente {@link SincronizacionProgramada}.
*
* @param url dirección base de la API v4 de football-data.org
* @param token clave personal; se lee de la variable de entorno FOOTBALL_DATA_TOKEN y nunca se sube a Git
Expand All @@ -21,11 +23,16 @@ public record ApiProperties(String url, String token, List<String> competiciones
List<String> competicionesPlantillas, int diasAtras, int diasAdelante,
String zonaHoraria) {

/** Ligas de las que se descargan las plantillas: {@code competicionesPlantillas} o, si no hay, {@code competiciones}. */
public List<String> ligasDePlantillas() {
return competicionesPlantillas == null || competicionesPlantillas.isEmpty()
? competiciones : competicionesPlantillas;
}

/**
* Si hay clave de la API. Sin ella no se sincroniza nada: las tareas programadas no hacen nada y los botones
* de Gestión muestran un aviso.
*/
public boolean configurada() {
return token != null && !token.isBlank();
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,13 +6,20 @@
import org.springframework.stereotype.Component;
import org.springframework.web.client.RestClient;

/** Cliente de la API v4 de football-data.org (plan gratuito: 10 peticiones por minuto). */
/**
* Cliente de la API v4 de football-data.org (plan gratuito: 10 peticiones por minuto).
* Es la implementación real de {@link FuenteDatosDeportivos}: hace peticiones GET con el {@code RestClient} de
* Spring y convierte el JSON en {@link PartidoApi} y {@link PlantillaApi}. Los tiempos máximos de espera los pone
* {@link TiemposDeEsperaConfig}. Si la API da error o no responde, se lanza {@code RestClientException}.
*/
@Component
public class FootballDataCliente implements FuenteDatosDeportivos {

private final RestClient http;

public FootballDataCliente(RestClient.Builder builder, ApiProperties propiedades) {
// Todas las peticiones llevan la clave en la cabecera X-Auth-Token, que es donde la busca football-data.org.
// Sin clave se manda vacía; de todos modos, los servicios no llaman a la API si no está configurada
this.http = builder
.baseUrl(propiedades.url())
.defaultHeader("X-Auth-Token", propiedades.token() == null ? "" : propiedades.token())
Expand All @@ -21,10 +28,12 @@ public FootballDataCliente(RestClient.Builder builder, ApiProperties propiedades

@Override
public List<PartidoApi> partidos(String competicion, LocalDate desde, LocalDate hasta) {
// Las fechas van como AAAA-MM-DD, que es lo que da LocalDate.toString()
PartidoApi.Respuesta respuesta = http.get()
.uri("/competitions/{codigo}/matches?dateFrom={desde}&dateTo={hasta}", competicion, desde, hasta)
.retrieve()
.body(PartidoApi.Respuesta.class);
// Respuesta vacía o sin lista: no hay partidos
return respuesta == null || respuesta.matches() == null ? List.of() : respuesta.matches();
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,11 +3,24 @@
import java.time.LocalDate;
import java.util.List;

/** De dónde salen los partidos y resultados reales. Interfaz para poder sustituirla en las pruebas. */
/**
* De dónde salen los partidos y resultados reales. Interfaz para poder sustituirla en las pruebas.
* La implementa {@link FootballDataCliente}; en las pruebas de sincronización se usa un objeto simulado,
* así no hace falta Internet ni la clave de la API.
*/
public interface FuenteDatosDeportivos {

/**
* Partidos de una competición (código de la API, p. ej. PD) entre dos fechas.
*
* @throws org.springframework.web.client.RestClientException si la API da error o no responde
*/
List<PartidoApi> partidos(String competicion, LocalDate desde, LocalDate hasta);

/** Equipos de una competición con sus plantillas. */
/**
* Equipos de una competición con sus plantillas.
*
* @throws org.springframework.web.client.RestClientException si la API da error o no responde
*/
List<PlantillaApi> plantillas(String competicion);
}
19 changes: 19 additions & 0 deletions src/main/java/es/ucm/fdi/is1/apuestas/api/PartidoApi.java
Original file line number Diff line number Diff line change
Expand Up @@ -7,26 +7,42 @@
/**
* Partido tal como lo devuelve la API v4 de football-data.org
* ({@code GET /competitions/{codigo}/matches}). Solo se leen los campos que usamos.
* Con {@code ignoreUnknown} el resto del JSON se descarta sin dar error.
*
* @param id identificador del partido en la API; se guarda en el evento como {@code idExterno}
* @param utcDate fecha y hora de inicio en UTC, en formato ISO-8601 (p. ej. 2026-10-10T19:00:00Z)
* @param status estado: SCHEDULED, TIMED, IN_PLAY, PAUSED, FINISHED, POSTPONED, SUSPENDED, CANCELLED…
* @param matchday número de jornada; puede ser null
* @param homeTeam equipo local
* @param awayTeam equipo visitante
* @param score ganador y marcador
*/
@JsonIgnoreProperties(ignoreUnknown = true)
public record PartidoApi(Long id, String utcDate, String status, Integer matchday, EquipoApi homeTeam,
EquipoApi awayTeam, MarcadorApi score) {

/**
* Equipo de un partido: identificador en la API, nombre largo, nombre corto y dirección del escudo.
* En una eliminatoria con el rival aún por decidir llega sin identificador.
*/
@JsonIgnoreProperties(ignoreUnknown = true)
public record EquipoApi(Integer id, String name, String shortName, String crest) {
}

/**
* {@code winner}: HOME_TEAM, AWAY_TEAM, DRAW o null si no ha terminado.
* {@code duration}: REGULAR, EXTRA_TIME o PENALTY_SHOOTOUT. {@code fullTime}: goles al final.
* {@code regularTime}: goles de los 90 minutos, el que se usa si hubo prórroga.
*/
@JsonIgnoreProperties(ignoreUnknown = true)
public record MarcadorApi(String winner, String duration, GolesApi fullTime, GolesApi regularTime) {

/** Solo con el ganador, sin marcador (se usa en las pruebas). */
public MarcadorApi(String winner) {
this(winner, null, null, null);
}

/** Sin {@code regularTime} (se usa en las pruebas). */
public MarcadorApi(String winner, String duration, GolesApi fullTime) {
this(winner, duration, fullTime, null);
}
Expand All @@ -42,15 +58,18 @@ public boolean isProrroga() {
*/
public GolesApi marcador90() {
GolesApi goles = isProrroga() ? regularTime : fullTime;
// Un marcador a medias (un lado null) no sirve para resolver apuestas
return goles != null && goles.home() != null && goles.away() != null ? goles : null;
}

}

/** Goles del local y del visitante; null mientras no se conocen. */
@JsonIgnoreProperties(ignoreUnknown = true)
public record GolesApi(Integer home, Integer away) {
}

/** Respuesta completa de la API: la lista de partidos viene en el campo {@code matches}. */
@JsonIgnoreProperties(ignoreUnknown = true)
public record Respuesta(List<PartidoApi> matches) {
}
Expand Down
12 changes: 11 additions & 1 deletion src/main/java/es/ucm/fdi/is1/apuestas/api/PlantillaApi.java
Original file line number Diff line number Diff line change
Expand Up @@ -7,16 +7,26 @@
/**
* Equipo con su plantilla tal como lo devuelve la API v4 de football-data.org
* ({@code GET /competitions/{codigo}/teams}). Solo se leen los campos que usamos.
* Lo usa {@link SincronizacionPlantillas} (HU-50).
*
* @param id identificador del equipo en la API (el mismo que en {@link PartidoApi.EquipoApi})
* @param name nombre largo
* @param shortName nombre corto
* @param squad jugadores; vacía en las competiciones que no traen plantillas en el plan gratuito
*/
@JsonIgnoreProperties(ignoreUnknown = true)
public record PlantillaApi(Integer id, String name, String shortName, List<JugadorApi> squad) {

/** {@code position}: "Goalkeeper", "Defence", "Midfield", "Offence" o una más detallada. */
/**
* {@code position}: "Goalkeeper", "Defence", "Midfield", "Offence" o una más detallada.
* {@code id} se guarda en el jugador como {@code idExterno}; {@code dateOfBirth} empieza por AAAA-MM-DD.
*/
@JsonIgnoreProperties(ignoreUnknown = true)
public record JugadorApi(Integer id, String name, String position, String dateOfBirth, String nationality,
Integer shirtNumber) {
}

/** Respuesta completa de la API: la lista de equipos viene en el campo {@code teams}. */
@JsonIgnoreProperties(ignoreUnknown = true)
public record Respuesta(List<PlantillaApi> teams) {
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,11 @@
import java.util.ArrayList;
import java.util.List;

/** Lo que ha cambiado en una sincronización, para mostrarlo al creador de apuestas. */
/**
* Lo que ha cambiado en una sincronización, para mostrarlo al creador de apuestas.
* La rellena {@link SincronizacionService} mientras sincroniza: los métodos que cuentan son de paquete para que
* solo pueda hacerlo él. En Gestión se muestra como mensaje y los errores aparte (HU-21).
*/
public class ResumenSincronizacion {

private int eventosNuevos;
Expand All @@ -28,6 +32,7 @@ void equipoNuevo() {
equiposNuevos++;
}

/** Fallo en un partido o en una competición; la sincronización sigue con el resto. */
void error(String mensaje) {
errores.add(mensaje);
}
Expand All @@ -52,6 +57,7 @@ public List<String> getErrores() {
return errores;
}

/** Texto del mensaje de Gestión y del registro: "2 eventos nuevos, 1 actualizados, …". */
@Override
public String toString() {
return eventosNuevos + " eventos nuevos, " + eventosActualizados + " actualizados, " + resultados
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,9 @@
* Una petición por competición. Crea los jugadores nuevos, actualiza los datos de los que ya estaban
* (sin tocar su nota, que es del creador) y quita los que han dejado el equipo. Los jugadores dados de
* alta a mano no se tocan.
*
* <p>Implementa HU-50. La lanzan {@link SincronizacionProgramada} (una vez al día) y el botón
* <i>Descargar plantillas</i> de Gestión.
*/
@Service
public class SincronizacionPlantillas {
Expand Down Expand Up @@ -55,13 +58,22 @@ public String toString() {
}
}

/**
* Descarga y aplica las plantillas de cada liga de {@link ApiProperties#ligasDePlantillas()}.
* Si una liga falla, se apunta en los errores del resumen y se sigue con las demás.
*
* @return cuántos equipos se han actualizado y cuántos jugadores han entrado, cambiado o salido
* @throws IllegalStateException si no está la clave de la API
*/
@Transactional
public Resumen sincronizar() {
if (!propiedades.configurada()) {
throw new IllegalStateException(
"Falta la clave de la API: define la variable de entorno FOOTBALL_DATA_TOKEN");
}
// Equipos ya actualizados: si un equipo llega por dos competiciones, solo cuenta la primera
Set<Long> procesados = new HashSet<>();
// Contadores que rellena actualizar(): [nuevos, actualizados, retirados]
int[] cuenta = new int[3];
List<String> errores = new ArrayList<>();
for (String codigo : propiedades.ligasDePlantillas()) {
Expand All @@ -76,6 +88,7 @@ public Resumen sincronizar() {
actualizar(equipo, datos.squad(), cuenta);
}
} catch (RestClientException e) {
// Sin conexión, límite de peticiones…: se apunta y se pasa a la liga siguiente
LOG.warn("No se pudieron descargar las plantillas de {}: {}", codigo, e.getMessage());
errores.add(codigo + ": " + e.getMessage());
}
Expand All @@ -94,6 +107,7 @@ private void actualizar(Equipo equipo, List<PlantillaApi.JugadorApi> plantilla,
continue; // entrenadores y jugadores sin posición conocida
}
enLaPlantilla.add(datos.id());
// Por el identificador de la API: así se reconoce aunque cambie de nombre o venga de otro equipo
Jugador jugador = jugadores.findByIdExterno(datos.id()).orElse(null);
if (jugador == null) {
jugador = new Jugador(equipo, datos.name(), posicion);
Expand All @@ -102,10 +116,13 @@ private void actualizar(Equipo equipo, List<PlantillaApi.JugadorApi> plantilla,
} else {
cuenta[1]++;
}
// Solo los datos que da la API; la nota la pone el creador y no se toca (CA-50.1)
jugador.actualizar(equipo, datos.name(), posicion, datos.shirtNumber(), datos.nationality(),
fecha(datos.dateOfBirth()));
jugadores.save(jugador);
}
// Los de la API que ya no salen en la plantilla han dejado el equipo; los dados de alta a mano
// (sin identificador de la API) se quedan (CA-50.2)
for (Jugador jugador : jugadores.findByEquipo(equipo)) {
if (jugador.isDeLaApi() && !enLaPlantilla.contains(jugador.getIdExterno())) {
jugadores.delete(jugador);
Expand All @@ -114,7 +131,10 @@ private void actualizar(Equipo equipo, List<PlantillaApi.JugadorApi> plantilla,
}
}

/** Por su identificador en la API o por un nombre equivalente. No crea equipos nuevos. */
/**
* Por su identificador en la API o por un nombre equivalente. No crea equipos nuevos.
* El nombre se compara con {@link Nombres#clave(String)}, que quita tildes, mayúsculas y palabras como "FC".
*/
private Equipo buscar(PlantillaApi datos) {
if (datos.id() == null) {
return null;
Expand All @@ -132,6 +152,7 @@ private Equipo buscar(PlantillaApi datos) {
.orElse(null));
}

/** Fecha de nacimiento a partir de los 10 primeros caracteres (AAAA-MM-DD); null si falta o no es válida. */
private static LocalDate fecha(String texto) {
if (texto == null || texto.length() < 10) {
return null;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,17 @@
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Component;

/** Sincroniza con la API cada cierto tiempo (apuestas.api.intervalo), solo si hay clave configurada. */
/**
* Sincroniza con la API cada cierto tiempo (apuestas.api.intervalo y apuestas.api.intervalo-plantillas),
* solo si hay clave configurada.
* Son dos tareas: partidos y resultados ({@link SincronizacionService}, cada 30 minutos) y plantillas
* ({@link SincronizacionPlantillas}, una vez al día). Así los resultados llegan y las apuestas se pagan sin que
* el creador tenga que hacer nada (HU-21, HU-25, HU-50).
*
* <p>Los tiempos van en formato ISO-8601 (PT30S = 30 segundos, PT2M = 2 minutos, PT30M = 30 minutos,
* P1D = 1 día) y se pueden cambiar en application.properties. Con {@code fixedDelay} el intervalo se cuenta
* desde que termina la ejecución anterior, así que una tarea nunca se solapa consigo misma.
*/
@Component
public class SincronizacionProgramada {

Expand All @@ -26,16 +36,19 @@ public SincronizacionProgramada(SincronizacionService sincronizacion, Sincroniza
@Scheduled(initialDelayString = "${apuestas.api.retraso-inicial-plantillas:PT2M}",
fixedDelayString = "${apuestas.api.intervalo-plantillas:P1D}")
public void sincronizarPlantillas() {
// Sin clave no se hace nada (sincronizar() lanzaría una excepción cada vez)
if (!propiedades.configurada()) {
return;
}
try {
plantillas.sincronizar();
} catch (RuntimeException e) {
// Se apunta en el registro; la próxima ejecución se intenta igual
LOG.error("Error al descargar las plantillas", e);
}
}

/** Partidos y resultados: 30 segundos después de arrancar y luego cada 30 minutos. */
@Scheduled(initialDelayString = "${apuestas.api.retraso-inicial:PT30S}",
fixedDelayString = "${apuestas.api.intervalo:PT30M}")
public void sincronizar() {
Expand Down
Loading
Loading