Compare commits
10 Commits
242282412c
...
4606f71bdc
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4606f71bdc | ||
|
|
e5385f7ddd | ||
|
|
4eccee9b05 | ||
|
|
1db262f8ea | ||
|
|
0e8bfbcc9c | ||
|
|
5c79480aa4 | ||
|
|
3ec611d373 | ||
|
|
5421080b04 | ||
|
|
08c7289f25 | ||
|
|
c165b759b7 |
@@ -25,11 +25,16 @@ dependencies {
|
||||
implementation 'org.springframework.boot:spring-boot-starter-web'
|
||||
implementation 'org.liquibase:liquibase-core'
|
||||
runtimeOnly 'org.postgresql:postgresql'
|
||||
// H2 solo en test: permite que el contexto cargue sin un Postgres real en la
|
||||
// Fase 1 (esqueleto). La infraestructura de test realista (Testcontainers)
|
||||
// se introducirá en la Fase 2, junto con el dominio.
|
||||
// H2 en test para los tests rápidos de servicio/web. La verificación de las
|
||||
// migraciones Liquibase y del mapeo JPA (ddl-auto=validate) se hace contra un
|
||||
// Postgres REAL vía Testcontainers (MigrationIT): H2 no detecta
|
||||
// incompatibilidades de tipos/SQL propias de Postgres (lección aprendida con
|
||||
// el CAST(NULL AS INTEGER) del changeset 003).
|
||||
testRuntimeOnly 'com.h2database:h2'
|
||||
testImplementation 'org.springframework.boot:spring-boot-starter-test'
|
||||
testImplementation 'org.springframework.boot:spring-boot-testcontainers'
|
||||
testImplementation 'org.testcontainers:junit-jupiter'
|
||||
testImplementation 'org.testcontainers:postgresql'
|
||||
testRuntimeOnly 'org.junit.platform:junit-platform-launcher'
|
||||
}
|
||||
|
||||
|
||||
@@ -7,18 +7,16 @@ import es.asepeyo.recordalexia.domain.EventType;
|
||||
import es.asepeyo.recordalexia.domain.Family;
|
||||
import es.asepeyo.recordalexia.domain.MaterialItem;
|
||||
import es.asepeyo.recordalexia.domain.Reward;
|
||||
import es.asepeyo.recordalexia.domain.RoutineTask;
|
||||
import es.asepeyo.recordalexia.domain.SpecialEvent;
|
||||
import es.asepeyo.recordalexia.domain.WeeklyTemplateEntry;
|
||||
import es.asepeyo.recordalexia.repository.ActivityRepository;
|
||||
import es.asepeyo.recordalexia.repository.AfternoonRoutineRepository;
|
||||
import es.asepeyo.recordalexia.repository.ChildRepository;
|
||||
import es.asepeyo.recordalexia.repository.FamilyRepository;
|
||||
import es.asepeyo.recordalexia.repository.MaterialItemRepository;
|
||||
import es.asepeyo.recordalexia.repository.RewardRepository;
|
||||
import es.asepeyo.recordalexia.repository.RoutineTaskRepository;
|
||||
import es.asepeyo.recordalexia.repository.SpecialEventRepository;
|
||||
import es.asepeyo.recordalexia.repository.WeeklyTemplateEntryRepository;
|
||||
import es.asepeyo.recordalexia.service.InitialDatasetService;
|
||||
import java.time.Clock;
|
||||
import java.time.DayOfWeek;
|
||||
import java.time.LocalDate;
|
||||
@@ -52,29 +50,26 @@ public class DataSeeder implements ApplicationRunner {
|
||||
private final MaterialItemRepository materialRepository;
|
||||
private final ActivityRepository activityRepository;
|
||||
private final WeeklyTemplateEntryRepository templateRepository;
|
||||
private final AfternoonRoutineRepository routineRepository;
|
||||
private final RoutineTaskRepository routineTaskRepository;
|
||||
private final SpecialEventRepository eventRepository;
|
||||
private final RewardRepository rewardRepository;
|
||||
private final InitialDatasetService initialDataset;
|
||||
private final PasswordEncoder passwordEncoder;
|
||||
private final Clock clock;
|
||||
|
||||
public DataSeeder(FamilyRepository familyRepository, ChildRepository childRepository,
|
||||
MaterialItemRepository materialRepository, ActivityRepository activityRepository,
|
||||
WeeklyTemplateEntryRepository templateRepository,
|
||||
AfternoonRoutineRepository routineRepository,
|
||||
RoutineTaskRepository routineTaskRepository,
|
||||
SpecialEventRepository eventRepository, RewardRepository rewardRepository,
|
||||
InitialDatasetService initialDataset,
|
||||
PasswordEncoder passwordEncoder, Clock clock) {
|
||||
this.familyRepository = familyRepository;
|
||||
this.childRepository = childRepository;
|
||||
this.materialRepository = materialRepository;
|
||||
this.activityRepository = activityRepository;
|
||||
this.templateRepository = templateRepository;
|
||||
this.routineRepository = routineRepository;
|
||||
this.routineTaskRepository = routineTaskRepository;
|
||||
this.eventRepository = eventRepository;
|
||||
this.rewardRepository = rewardRepository;
|
||||
this.initialDataset = initialDataset;
|
||||
this.passwordEncoder = passwordEncoder;
|
||||
this.clock = clock;
|
||||
}
|
||||
@@ -89,18 +84,26 @@ public class DataSeeder implements ApplicationRunner {
|
||||
Family family = familyRepository.save(new Family(DEMO_EMAIL,
|
||||
passwordEncoder.encode(DEMO_PASSWORD), "Familia demo", passwordEncoder.encode(DEMO_PIN)));
|
||||
|
||||
var materials = seedMaterials(family);
|
||||
var activities = seedActivities(family, materials);
|
||||
var routines = seedRoutineCatalog(family);
|
||||
// Catálogo base (materiales + rutinas): la MISMA provisión que recibe cualquier
|
||||
// familia real al registrarse. La demo solo añade sus extras encima.
|
||||
initialDataset.provisionFamily(family);
|
||||
var activities = seedActivities(family);
|
||||
seedRewards(family);
|
||||
|
||||
Child nora = childRepository.save(child(family, "Nora", "🦊", "#F2A65A", 7, 42, LocalTime.of(8, 30)));
|
||||
Child leo = childRepository.save(child(family, "Leo", "🐢", "#5BC0BE", 9, 28, LocalTime.of(8, 30)));
|
||||
Child mia = childRepository.save(child(family, "Mía", "🦉", "#A78BD0", 6, 55, LocalTime.of(8, 15)));
|
||||
|
||||
for (Child c : List.of(nora, leo, mia)) {
|
||||
// Nora y Mía llevan el horario genérico de ejemplo; Leo (9 años, 4º de
|
||||
// primaria) lleva un horario real de 4t A, como en una familia de verdad.
|
||||
for (Child c : List.of(nora, mia)) {
|
||||
seedWeeklyMornings(c, activities);
|
||||
seedAfternoonRoutines(c, routines);
|
||||
}
|
||||
seedWeekly4tA(leo, family, activities);
|
||||
|
||||
for (Child c : List.of(nora, leo, mia)) {
|
||||
// Rutinas de tarde escalonadas por edad, desde el catálogo de la familia.
|
||||
initialDataset.provisionChildRoutines(c);
|
||||
}
|
||||
|
||||
LocalDate today = LocalDate.now(clock);
|
||||
@@ -110,38 +113,40 @@ public class DataSeeder implements ApplicationRunner {
|
||||
"Ficha de mates", "Fitxa de mates", "📎", "#5B8DEF"));
|
||||
}
|
||||
|
||||
private MaterialsCatalog seedMaterials(Family family) {
|
||||
return new MaterialsCatalog(
|
||||
material(family, "Estuche", "Estoig", "✏️", "#F4C95D", "general"),
|
||||
material(family, "Libro de mates", "Llibre de mates", "📘", "#5B8DEF", "matematicas"),
|
||||
material(family, "Regla", "Regle", "📏", "#5B8DEF", "matematicas"),
|
||||
material(family, "Flauta", "Flauta", "🎵", "#A78BD0", "musica"),
|
||||
material(family, "Libreta", "Llibreta", "📓", "#A78BD0", "musica"),
|
||||
material(family, "Ropa de gimnasia", "Roba d'EF", "👕", "#7FBF6B", "gimnasia"),
|
||||
material(family, "Zapatillas", "Sabatilles", "👟", "#7FBF6B", "gimnasia"),
|
||||
material(family, "Toalla", "Tovallola", "🧖", "#5BC0BE", "gimnasia"),
|
||||
material(family, "Agua", "Aigua", "💧", "#5BC0BE", "gimnasia"),
|
||||
material(family, "Lectura", "Lectura", "📖", "#F2A65A", "lengua"),
|
||||
material(family, "Cuaderno", "Quadern", "📒", "#F2A65A", "lengua"),
|
||||
material(family, "Almuerzo", "Esmorzar", "🍎", "#EC8FA4", "general"));
|
||||
}
|
||||
|
||||
private MaterialItem material(Family family, String es, String ca, String icon, String color, String category) {
|
||||
MaterialItem m = new MaterialItem(es, ca, icon, color, category);
|
||||
m.setFamily(family);
|
||||
return materialRepository.save(m);
|
||||
}
|
||||
|
||||
private ActivitiesCatalog seedActivities(Family family, MaterialsCatalog m) {
|
||||
/**
|
||||
* Actividades de la demo. El material sale del catálogo base ya provisionado
|
||||
* (por su etiqueta ES) más tres extras que solo la demo necesita.
|
||||
*/
|
||||
private ActivitiesCatalog seedActivities(Family family) {
|
||||
List<MaterialItem> base = materialRepository.findByFamilyId(family.getId());
|
||||
MaterialItem flauta = material(family, "Flauta", "Flauta", "🎵", "#A78BD0", "musica");
|
||||
MaterialItem regla = material(family, "Regla", "Regle", "📏", "#5B8DEF", "matematicas");
|
||||
MaterialItem toalla = material(family, "Toalla", "Tovallola", "🧖", "#5BC0BE", "gimnasia");
|
||||
|
||||
Activity gimnasia = activity(family, "Gimnasia", "Gimnàstica", "🤸", "#7FBF6B",
|
||||
m.ropaGimnasia, m.zapatillas, m.toalla, m.agua);
|
||||
Activity musica = activity(family, "Música", "Música", "🎵", "#A78BD0", m.flauta, m.libreta);
|
||||
fromBase(base, "Ropa de gimnasia"), fromBase(base, "Zapatillas"), toalla,
|
||||
fromBase(base, "Botella de agua"));
|
||||
Activity musica = activity(family, "Música", "Música", "🎵", "#A78BD0",
|
||||
flauta, fromBase(base, "Libreta"));
|
||||
Activity mates = activity(family, "Matemáticas", "Matemàtiques", "📘", "#5B8DEF",
|
||||
m.libroMates, m.regla, m.estuche);
|
||||
Activity lengua = activity(family, "Lengua", "Llengua", "📖", "#F2A65A", m.lectura, m.cuaderno);
|
||||
fromBase(base, "Libro de texto"), regla, fromBase(base, "Estuche"));
|
||||
Activity lengua = activity(family, "Lengua", "Llengua", "📖", "#F2A65A",
|
||||
fromBase(base, "Lectura"), fromBase(base, "Libreta"));
|
||||
return new ActivitiesCatalog(gimnasia, musica, mates, lengua);
|
||||
}
|
||||
|
||||
/** Localiza un material del catálogo base por su etiqueta ES (clave estable del seed). */
|
||||
private MaterialItem fromBase(List<MaterialItem> base, String labelEs) {
|
||||
return base.stream().filter(m -> m.getLabelEs().equals(labelEs)).findFirst()
|
||||
.orElseThrow(() -> new IllegalStateException("Falta el material base: " + labelEs));
|
||||
}
|
||||
|
||||
private Activity activity(Family family, String es, String ca, String icon, String color, MaterialItem... mats) {
|
||||
Activity activity = new Activity(es, ca, icon, color);
|
||||
activity.setFamily(family);
|
||||
@@ -191,45 +196,45 @@ public class DataSeeder implements ApplicationRunner {
|
||||
templateRepository.save(new WeeklyTemplateEntry(child, day, activity, 0));
|
||||
}
|
||||
|
||||
/** Catálogo de rutinas reutilizables de la familia (se definen una sola vez). */
|
||||
private RoutinesCatalog seedRoutineCatalog(Family family) {
|
||||
return new RoutinesCatalog(
|
||||
routineTask(family, "Deshacer la mochila", "Buidar la motxilla", "🎒", "#F2A65A"),
|
||||
routineTask(family, "Merendar", "Berenar", "🥪", "#F4C95D"),
|
||||
routineTask(family, "Hacer los deberes", "Fer els deures", "📝", "#5B8DEF"),
|
||||
routineTask(family, "Practicar piano", "Practicar piano", "🎹", "#A78BD0"),
|
||||
routineTask(family, "Recoger la mesa", "Parar taula", "🍽️", "#7FBF6B"));
|
||||
/**
|
||||
* Horario REAL de 4º de primaria (4t A, curso 2025-26) para Leo: asignaturas
|
||||
* propias del curso más las genéricas reutilizadas (Matemáticas, Música,
|
||||
* Gimnasia=Ed. Física), en el orden cronológico de cada día. El patio no se
|
||||
* siembra: no es una actividad con material que preparar.
|
||||
*/
|
||||
private void seedWeekly4tA(Child leo, Family family, ActivitiesCatalog base) {
|
||||
// La toalla (extra de la demo) acompaña a Piscina: hay que meterla en la mochila.
|
||||
MaterialItem toalla = fromBase(materialRepository.findByFamilyId(family.getId()), "Toalla");
|
||||
|
||||
Activity catala = activity(family, "Català", "Català", "📕", "#F4C95D");
|
||||
Activity castellano = activity(family, "Castellano", "Castellà", "📙", "#F2A65A");
|
||||
Activity medio = activity(family, "Medio", "Medi", "🌍", "#7FBF6B");
|
||||
Activity ingles = activity(family, "Inglés", "Anglès", "🇬🇧", "#A78BD0");
|
||||
Activity piscina = activity(family, "Piscina", "Piscina", "🏊", "#5BC0BE", toalla);
|
||||
Activity lectura = activity(family, "Lectura", "Lectura", "📖", "#EC8FA4");
|
||||
Activity religiosa = activity(family, "Cultura religiosa", "Cultura religiosa", "🙏", "#F2A65A");
|
||||
Activity workshop = activity(family, "Workshop", "Workshop", "🛠️", "#A78BD0");
|
||||
Activity robotica = activity(family, "Robótica", "Robòtica", "🤖", "#EC8FA4");
|
||||
Activity plastica = activity(family, "Plástica", "Plàstica", "🎨", "#EC8FA4");
|
||||
Activity tutoria = activity(family, "Tutoría", "Tutoria", "💬", "#5BC0BE");
|
||||
|
||||
addDay(leo, DayOfWeek.MONDAY, base.matematicas(), medio, castellano, piscina, ingles, catala);
|
||||
addDay(leo, DayOfWeek.TUESDAY, catala, base.musica(), base.gimnasia(), ingles, religiosa,
|
||||
base.matematicas());
|
||||
addDay(leo, DayOfWeek.WEDNESDAY, catala, medio, ingles, lectura, base.matematicas(), castellano);
|
||||
addDay(leo, DayOfWeek.THURSDAY, workshop, base.matematicas(), medio, base.musica(), robotica,
|
||||
base.gimnasia());
|
||||
addDay(leo, DayOfWeek.FRIDAY, base.matematicas(), plastica, catala, castellano, tutoria);
|
||||
}
|
||||
|
||||
private RoutineTask routineTask(Family family, String es, String ca, String icon, String color) {
|
||||
RoutineTask rt = new RoutineTask(es, ca, icon, color);
|
||||
rt.setFamily(family);
|
||||
return routineTaskRepository.save(rt);
|
||||
/** Añade las asignaturas de un día en orden cronológico (orderIndex incremental). */
|
||||
private void addDay(Child child, DayOfWeek day, Activity... subjects) {
|
||||
for (int i = 0; i < subjects.length; i++) {
|
||||
templateRepository.save(new WeeklyTemplateEntry(child, day, subjects[i], i));
|
||||
}
|
||||
|
||||
/** Asigna las rutinas del catálogo a cada día laborable, reutilizando las mismas. */
|
||||
private void seedAfternoonRoutines(Child child, RoutinesCatalog r) {
|
||||
for (DayOfWeek day : List.of(DayOfWeek.MONDAY, DayOfWeek.TUESDAY, DayOfWeek.WEDNESDAY,
|
||||
DayOfWeek.THURSDAY, DayOfWeek.FRIDAY)) {
|
||||
routineRepository.save(new AfternoonRoutine(child, day, r.mochila, 0));
|
||||
routineRepository.save(new AfternoonRoutine(child, day, r.merienda, 1));
|
||||
routineRepository.save(new AfternoonRoutine(child, day, r.deberes, 2));
|
||||
routineRepository.save(new AfternoonRoutine(child, day, r.piano, 3));
|
||||
routineRepository.save(new AfternoonRoutine(child, day, r.mesa, 4));
|
||||
}
|
||||
}
|
||||
|
||||
private record MaterialsCatalog(
|
||||
MaterialItem estuche, MaterialItem libroMates, MaterialItem regla, MaterialItem flauta,
|
||||
MaterialItem libreta, MaterialItem ropaGimnasia, MaterialItem zapatillas, MaterialItem toalla,
|
||||
MaterialItem agua, MaterialItem lectura, MaterialItem cuaderno, MaterialItem almuerzo) {
|
||||
}
|
||||
|
||||
private record ActivitiesCatalog(Activity gimnasia, Activity musica, Activity matematicas,
|
||||
Activity lengua) {
|
||||
}
|
||||
|
||||
private record RoutinesCatalog(RoutineTask mochila, RoutineTask merienda, RoutineTask deberes,
|
||||
RoutineTask piano, RoutineTask mesa) {
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,4 +11,7 @@ public interface AfternoonRoutineRepository extends JpaRepository<AfternoonRouti
|
||||
List<AfternoonRoutine> findByChildIdAndDayOfWeekOrderByOrderIndexAsc(Long childId, DayOfWeek dayOfWeek);
|
||||
|
||||
List<AfternoonRoutine> findByChildIdOrderByDayOfWeekAscOrderIndexAsc(Long childId);
|
||||
|
||||
/** Nº de asignaciones de un niño en un día; da el siguiente orderIndex al asignar. */
|
||||
int countByChildIdAndDayOfWeek(Long childId, DayOfWeek dayOfWeek);
|
||||
}
|
||||
|
||||
@@ -5,8 +5,10 @@ import es.asepeyo.recordalexia.domain.DailyTask;
|
||||
import es.asepeyo.recordalexia.domain.Slot;
|
||||
import es.asepeyo.recordalexia.domain.SpecialEvent;
|
||||
import es.asepeyo.recordalexia.exception.NotFoundException;
|
||||
import es.asepeyo.recordalexia.repository.AfternoonRoutineRepository;
|
||||
import es.asepeyo.recordalexia.repository.ChildRepository;
|
||||
import es.asepeyo.recordalexia.repository.SpecialEventRepository;
|
||||
import es.asepeyo.recordalexia.repository.WeeklyTemplateEntryRepository;
|
||||
import es.asepeyo.recordalexia.security.FamilyContext;
|
||||
import es.asepeyo.recordalexia.web.dto.TodayResponse;
|
||||
import es.asepeyo.recordalexia.web.dto.TodayResponse.ChildInfo;
|
||||
@@ -15,11 +17,13 @@ import es.asepeyo.recordalexia.web.dto.TodayResponse.TaskView;
|
||||
import es.asepeyo.recordalexia.web.dto.TodayResponse.TimerInfo;
|
||||
import es.asepeyo.recordalexia.web.dto.TodayResponse.WalletInfo;
|
||||
import java.time.Clock;
|
||||
import java.time.DayOfWeek;
|
||||
import java.time.Duration;
|
||||
import java.time.LocalDate;
|
||||
import java.time.LocalTime;
|
||||
import java.time.format.DateTimeFormatter;
|
||||
import java.util.List;
|
||||
import java.util.concurrent.atomic.AtomicLong;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
@@ -32,17 +36,23 @@ public class TodayService {
|
||||
private final DayGenerationService dayGenerationService;
|
||||
private final ChildRepository childRepository;
|
||||
private final SpecialEventRepository specialEventRepository;
|
||||
private final WeeklyTemplateEntryRepository templateRepository;
|
||||
private final AfternoonRoutineRepository routineRepository;
|
||||
private final FamilyContext familyContext;
|
||||
private final Clock clock;
|
||||
|
||||
public TodayService(DayGenerationService dayGenerationService,
|
||||
ChildRepository childRepository,
|
||||
SpecialEventRepository specialEventRepository,
|
||||
WeeklyTemplateEntryRepository templateRepository,
|
||||
AfternoonRoutineRepository routineRepository,
|
||||
FamilyContext familyContext,
|
||||
Clock clock) {
|
||||
this.dayGenerationService = dayGenerationService;
|
||||
this.childRepository = childRepository;
|
||||
this.specialEventRepository = specialEventRepository;
|
||||
this.templateRepository = templateRepository;
|
||||
this.routineRepository = routineRepository;
|
||||
this.familyContext = familyContext;
|
||||
this.clock = clock;
|
||||
}
|
||||
@@ -76,7 +86,65 @@ public class TodayService {
|
||||
events,
|
||||
ProgressCalculator.from(tasks),
|
||||
new WalletInfo(child.getCoins()),
|
||||
buildTimer(child));
|
||||
buildTimer(child.getDepartureTime(), LocalTime.now(clock), morning.isEmpty()));
|
||||
}
|
||||
|
||||
/**
|
||||
* Vista previa de MAÑANA, en solo lectura. Se calcula al vuelo desde las
|
||||
* plantillas (horario semanal y rutinas) SIN materializar DailyTask: así los
|
||||
* cambios que los padres hagan esta noche en el panel siguen entrando cuando
|
||||
* el día se genere de verdad. Los ids son sintéticos y negativos para que
|
||||
* nunca sean accionables (el toggle con id negativo no existe). Sin
|
||||
* temporizador y con progreso a cero: nada que marcar todavía.
|
||||
*/
|
||||
@Transactional(readOnly = true)
|
||||
public TodayResponse getTomorrow(Long childId) {
|
||||
Child child = childRepository.findByIdAndFamilyId(childId, familyContext.currentFamilyId())
|
||||
.orElseThrow(() -> new NotFoundException("No existe el niño con id " + childId));
|
||||
|
||||
LocalDate tomorrow = LocalDate.now(clock).plusDays(1);
|
||||
DayOfWeek dayOfWeek = tomorrow.getDayOfWeek();
|
||||
AtomicLong syntheticId = new AtomicLong(-1);
|
||||
|
||||
List<TaskView> morning = templateRepository
|
||||
.findByChildIdAndDayOfWeekOrderByOrderIndexAsc(childId, dayOfWeek).stream()
|
||||
.map(entry -> {
|
||||
var actividad = entry.getActivity();
|
||||
int monedas = entry.getCoinsReward() != null ? entry.getCoinsReward()
|
||||
: child.getCoinsPerTask();
|
||||
return new TaskView(syntheticId.getAndDecrement(), actividad.getLabelEs(),
|
||||
actividad.getLabelCa(), actividad.getIcon(), actividad.getColor(),
|
||||
false, monedas, entry.getOrderIndex());
|
||||
})
|
||||
.toList();
|
||||
|
||||
List<TaskView> afternoon = routineRepository
|
||||
.findByChildIdAndDayOfWeekOrderByOrderIndexAsc(childId, dayOfWeek).stream()
|
||||
.map(rutina -> {
|
||||
var tarea = rutina.getRoutineTask();
|
||||
int monedas = rutina.getCoinsReward() != null ? rutina.getCoinsReward()
|
||||
: tarea.getCoinsReward() != null ? tarea.getCoinsReward()
|
||||
: child.getCoinsPerTask();
|
||||
return new TaskView(syntheticId.getAndDecrement(), tarea.getLabelEs(),
|
||||
tarea.getLabelCa(), tarea.getIcon(), tarea.getColor(),
|
||||
false, monedas, rutina.getOrderIndex());
|
||||
})
|
||||
.toList();
|
||||
|
||||
List<EventView> events = specialEventRepository
|
||||
.findByChildIdAndEventDate(childId, tomorrow).stream()
|
||||
.map(this::toEventView)
|
||||
.toList();
|
||||
|
||||
int total = morning.size() + afternoon.size();
|
||||
return new TodayResponse(
|
||||
toChildInfo(child),
|
||||
morning,
|
||||
afternoon,
|
||||
events,
|
||||
new TodayResponse.ProgressView(0, morning.size(), 0, afternoon.size(), 0, total),
|
||||
new WalletInfo(child.getCoins()),
|
||||
new TimerInfo(null, null));
|
||||
}
|
||||
|
||||
private TaskView toTaskView(DailyTask t) {
|
||||
@@ -95,13 +163,20 @@ public class TodayService {
|
||||
c.isDyslexiaFont());
|
||||
}
|
||||
|
||||
/** Temporizador de salida: minutos que faltan hasta departureTime (>= 0). */
|
||||
private TimerInfo buildTimer(Child child) {
|
||||
LocalTime departure = child.getDepartureTime();
|
||||
if (departure == null) {
|
||||
/**
|
||||
* Temporizador de salida. Solo tiene sentido si HOY hay cole (hay tareas de
|
||||
* mañana) y aún no ha pasado la hora: pasado ese momento desaparece, en vez de
|
||||
* quedarse clavado en "0 min" el resto del día (o en domingo). Con minutos a
|
||||
* null el kiosko no lo pinta.
|
||||
*/
|
||||
static TimerInfo buildTimer(LocalTime departure, LocalTime now, boolean sinColeHoy) {
|
||||
if (departure == null || sinColeHoy) {
|
||||
return new TimerInfo(null, null);
|
||||
}
|
||||
long minutes = Duration.between(LocalTime.now(clock), departure).toMinutes();
|
||||
return new TimerInfo(departure.format(HHMM), (int) Math.max(0, minutes));
|
||||
long minutes = Duration.between(now, departure).toMinutes();
|
||||
if (minutes < 0) {
|
||||
return new TimerInfo(departure.format(HHMM), null);
|
||||
}
|
||||
return new TimerInfo(departure.format(HHMM), (int) minutes);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -46,6 +46,12 @@ public class ChildController {
|
||||
return todayService.getToday(id);
|
||||
}
|
||||
|
||||
/** Vista previa de mañana (solo lectura): para preparar la mochila la noche antes. */
|
||||
@GetMapping("/{id}/tomorrow")
|
||||
public TodayResponse tomorrow(@PathVariable Long id) {
|
||||
return todayService.getTomorrow(id);
|
||||
}
|
||||
|
||||
@GetMapping("/{id}/wallet")
|
||||
public WalletResponse wallet(@PathVariable Long id) {
|
||||
return walletService.getWallet(id);
|
||||
|
||||
@@ -109,9 +109,12 @@ public class ParentScheduleController {
|
||||
Child child = requireChild(req.childId());
|
||||
RoutineTask task = routineTaskRepository.findByIdAndFamilyId(req.routineTaskId(), fid())
|
||||
.orElseThrow(() -> new NotFoundException("No existe la rutina con id " + req.routineTaskId()));
|
||||
int orderIndex = req.orderIndex() != null ? req.orderIndex() : 0;
|
||||
List<RoutineView> created = req.daysOfWeek().stream().map(day -> {
|
||||
AfternoonRoutine routine = new AfternoonRoutine(child, DayOfWeek.valueOf(day), task, orderIndex);
|
||||
DayOfWeek dayOfWeek = DayOfWeek.valueOf(day);
|
||||
// Sin orden explícito, la rutina se añade al final DE CADA día destino.
|
||||
int orderIndex = req.orderIndex() != null ? req.orderIndex()
|
||||
: routineRepository.countByChildIdAndDayOfWeek(child.getId(), dayOfWeek);
|
||||
AfternoonRoutine routine = new AfternoonRoutine(child, dayOfWeek, task, orderIndex);
|
||||
routine.setCoinsReward(req.coinsReward());
|
||||
return toRoutineView(routineRepository.save(routine));
|
||||
}).toList();
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
# Los changesets de Liquibase de la Fase 2 (dominio) vivirán aquí.
|
||||
@@ -0,0 +1,30 @@
|
||||
# Borrado en cascada del catálogo de rutinas.
|
||||
# Al eliminar una rutina del catálogo (routine_task) deben desaparecer sus
|
||||
# asignaciones a días (afternoon_routine): es el modelo mental del padre
|
||||
# ("quito Merendar" = desaparece de todos los días). Sin esto, el DELETE
|
||||
# fallaba con violación de FK y el panel devolvía un error 500.
|
||||
databaseChangeLog:
|
||||
- changeSet:
|
||||
id: 400-cascade-routine-assignment
|
||||
author: recordalexia
|
||||
changes:
|
||||
- dropForeignKeyConstraint:
|
||||
baseTableName: afternoon_routine
|
||||
constraintName: fk_routine_assignment_task
|
||||
- addForeignKeyConstraint:
|
||||
baseTableName: afternoon_routine
|
||||
baseColumnNames: routine_task_id
|
||||
referencedTableName: routine_task
|
||||
referencedColumnNames: id
|
||||
constraintName: fk_routine_assignment_task
|
||||
onDelete: CASCADE
|
||||
rollback:
|
||||
- dropForeignKeyConstraint:
|
||||
baseTableName: afternoon_routine
|
||||
constraintName: fk_routine_assignment_task
|
||||
- addForeignKeyConstraint:
|
||||
baseTableName: afternoon_routine
|
||||
baseColumnNames: routine_task_id
|
||||
referencedTableName: routine_task
|
||||
referencedColumnNames: id
|
||||
constraintName: fk_routine_assignment_task
|
||||
@@ -0,0 +1,45 @@
|
||||
package es.asepeyo.recordalexia;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
import es.asepeyo.recordalexia.repository.AfternoonRoutineRepository;
|
||||
import es.asepeyo.recordalexia.repository.FamilyRepository;
|
||||
import es.asepeyo.recordalexia.repository.RoutineTaskRepository;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.boot.test.context.SpringBootTest;
|
||||
import org.springframework.boot.testcontainers.service.connection.ServiceConnection;
|
||||
import org.springframework.test.context.TestPropertySource;
|
||||
import org.testcontainers.containers.PostgreSQLContainer;
|
||||
import org.testcontainers.junit.jupiter.Container;
|
||||
import org.testcontainers.junit.jupiter.Testcontainers;
|
||||
|
||||
/**
|
||||
* Verifica el changelog completo de Liquibase contra un Postgres REAL (misma
|
||||
* imagen que producción). Cubre lo que H2 no puede: tipado estricto de Postgres
|
||||
* en la migración SQL, y que el esquema resultante casa con el mapeo JPA
|
||||
* (ddl-auto=validate). Con el seeder activado comprueba además el estado final.
|
||||
*
|
||||
* Se desactiva sola si Docker no está disponible en la máquina.
|
||||
*/
|
||||
@SpringBootTest
|
||||
@Testcontainers(disabledWithoutDocker = true)
|
||||
@TestPropertySource(properties = "recordalexia.seed.enabled=true")
|
||||
class MigrationIT {
|
||||
|
||||
@Container
|
||||
@ServiceConnection
|
||||
static PostgreSQLContainer<?> postgres = new PostgreSQLContainer<>("postgres:16-alpine");
|
||||
|
||||
@Autowired private FamilyRepository familyRepository;
|
||||
@Autowired private RoutineTaskRepository routineTaskRepository;
|
||||
@Autowired private AfternoonRoutineRepository routineRepository;
|
||||
|
||||
@Test
|
||||
void lasMigracionesAplicanYelSeederPueblaEnPostgresReal() {
|
||||
// Si llegamos aquí, Liquibase aplicó 001-004 y Hibernate validó el esquema.
|
||||
assertThat(familyRepository.findByEmailIgnoreCase("demo@recordalexia.local")).isPresent();
|
||||
assertThat(routineTaskRepository.count()).isEqualTo(14);
|
||||
assertThat(routineRepository.count()).isEqualTo(75); // 3 niños x 5 rutinas x 5 días
|
||||
}
|
||||
}
|
||||
@@ -3,18 +3,23 @@ package es.asepeyo.recordalexia.bootstrap;
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
import es.asepeyo.recordalexia.repository.ActivityRepository;
|
||||
import es.asepeyo.recordalexia.repository.AfternoonRoutineRepository;
|
||||
import es.asepeyo.recordalexia.repository.ChildRepository;
|
||||
import es.asepeyo.recordalexia.repository.FamilyRepository;
|
||||
import es.asepeyo.recordalexia.repository.MaterialItemRepository;
|
||||
import es.asepeyo.recordalexia.repository.RewardRepository;
|
||||
import es.asepeyo.recordalexia.repository.RoutineTaskRepository;
|
||||
import es.asepeyo.recordalexia.repository.WeeklyTemplateEntryRepository;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.boot.test.context.SpringBootTest;
|
||||
import org.springframework.test.context.TestPropertySource;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
/**
|
||||
* Verifica que el sembrado del prototipo (familia demo + sus datos) se ejecuta
|
||||
* correctamente. Usa una BD H2 propia y el seeder activado.
|
||||
* correctamente. La demo se apoya en la MISMA provisión inicial que una familia
|
||||
* real (InitialDatasetService) y añade encima sus extras. Usa una BD H2 propia.
|
||||
*/
|
||||
@SpringBootTest
|
||||
@TestPropertySource(properties = {
|
||||
@@ -28,13 +33,37 @@ class DataSeederIT {
|
||||
@Autowired private RewardRepository rewardRepository;
|
||||
@Autowired private ActivityRepository activityRepository;
|
||||
@Autowired private WeeklyTemplateEntryRepository templateRepository;
|
||||
@Autowired private MaterialItemRepository materialRepository;
|
||||
@Autowired private RoutineTaskRepository routineTaskRepository;
|
||||
@Autowired private AfternoonRoutineRepository routineRepository;
|
||||
|
||||
@Test
|
||||
void siembraLaFamiliaDemoConSusDatos() {
|
||||
assertThat(familyRepository.findByEmailIgnoreCase("demo@recordalexia.local")).isPresent();
|
||||
assertThat(childRepository.count()).isEqualTo(3); // Nora, Leo, Mía
|
||||
assertThat(rewardRepository.count()).isEqualTo(6); // 6 premios
|
||||
assertThat(activityRepository.count()).isEqualTo(4); // 4 actividades
|
||||
assertThat(templateRepository.count()).isEqualTo(15); // 3 niños x 5 días
|
||||
assertThat(activityRepository.count()).isEqualTo(15); // 4 genéricas + 11 de 4t A
|
||||
// Nora y Mía: 5 días x 1 asignatura; Leo: horario real 4t A (29 entradas).
|
||||
assertThat(templateRepository.count()).isEqualTo(5 + 5 + 29);
|
||||
}
|
||||
|
||||
// Transaccional: navega la relación LAZY entrada→actividad, que requiere sesión.
|
||||
@Test
|
||||
@Transactional(readOnly = true)
|
||||
void leoLlevaElHorarioRealDe4tA() {
|
||||
Long leoId = childRepository.findAll().stream()
|
||||
.filter(c -> c.getName().equals("Leo")).findFirst().orElseThrow().getId();
|
||||
var lunes = templateRepository
|
||||
.findByChildIdAndDayOfWeekOrderByOrderIndexAsc(leoId, java.time.DayOfWeek.MONDAY);
|
||||
assertThat(lunes).extracting(e -> e.getActivity().getLabelEs())
|
||||
.containsExactly("Matemáticas", "Medio", "Castellano", "Piscina", "Inglés", "Català");
|
||||
}
|
||||
|
||||
@Test
|
||||
void laDemoUsaLaProvisionInicialComun() {
|
||||
assertThat(materialRepository.count()).isEqualTo(12); // 9 base + 3 extras demo
|
||||
assertThat(routineTaskRepository.count()).isEqualTo(14); // catálogo común completo
|
||||
// 3 niños (6, 7 y 9 años) -> tramo base: 5 rutinas x 5 días laborables cada uno.
|
||||
assertThat(routineRepository.count()).isEqualTo(75);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
package es.asepeyo.recordalexia.repository;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
import es.asepeyo.recordalexia.domain.AfternoonRoutine;
|
||||
import es.asepeyo.recordalexia.domain.Child;
|
||||
import es.asepeyo.recordalexia.domain.Family;
|
||||
import es.asepeyo.recordalexia.domain.RoutineTask;
|
||||
import jakarta.persistence.EntityManager;
|
||||
import jakarta.persistence.PersistenceContext;
|
||||
import java.time.DayOfWeek;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.boot.test.context.SpringBootTest;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
/**
|
||||
* Borrar una rutina del catálogo arrastra sus asignaciones a días (ON DELETE
|
||||
* CASCADE del changeset 004). Es el modelo mental del padre: "quito Merendar"
|
||||
* significa que desaparece de todos los días, no un error 500.
|
||||
*/
|
||||
@SpringBootTest
|
||||
@Transactional
|
||||
class RoutineTaskCascadeTest {
|
||||
|
||||
@Autowired private FamilyRepository familyRepository;
|
||||
@Autowired private ChildRepository childRepository;
|
||||
@Autowired private RoutineTaskRepository routineTaskRepository;
|
||||
@Autowired private AfternoonRoutineRepository routineRepository;
|
||||
@PersistenceContext private EntityManager entityManager;
|
||||
|
||||
@Test
|
||||
void borrarRutinaDelCatalogoArrastraSusAsignaciones() {
|
||||
Family family = familyRepository.save(new Family("cascade@x.com", "h", "C", "p"));
|
||||
Child child = new Child();
|
||||
child.setFamily(family);
|
||||
child.setName("Test");
|
||||
child.setMascot("🦊");
|
||||
child.setAccentColor("#F2A65A");
|
||||
child.setAge(8);
|
||||
child = childRepository.save(child);
|
||||
|
||||
RoutineTask merendar = new RoutineTask("Merendar", "Berenar", "🥪", "#F4C95D");
|
||||
merendar.setFamily(family);
|
||||
merendar = routineTaskRepository.save(merendar);
|
||||
routineRepository.save(new AfternoonRoutine(child, DayOfWeek.MONDAY, merendar, 0));
|
||||
routineRepository.save(new AfternoonRoutine(child, DayOfWeek.TUESDAY, merendar, 0));
|
||||
routineRepository.flush();
|
||||
// Contexto limpio, como en una petición real: el cascade lo resuelve la BD,
|
||||
// no la sesión de Hibernate (que si tuviera las asignaciones cargadas se
|
||||
// quejaría de referencias a una entidad borrada).
|
||||
entityManager.clear();
|
||||
|
||||
routineTaskRepository.deleteById(merendar.getId());
|
||||
routineTaskRepository.flush();
|
||||
|
||||
assertThat(routineRepository.findByChildIdOrderByDayOfWeekAscOrderIndexAsc(child.getId()))
|
||||
.isEmpty();
|
||||
assertThat(routineTaskRepository.findById(merendar.getId())).isEmpty();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
package es.asepeyo.recordalexia.service;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
import es.asepeyo.recordalexia.web.dto.TodayResponse.TimerInfo;
|
||||
import java.time.LocalTime;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
/**
|
||||
* Reglas del temporizador "SALIMOS EN X min": solo aparece si hoy hay cole y la
|
||||
* hora de salida no ha pasado; nunca se queda clavado en "0 min" el resto del día.
|
||||
*/
|
||||
class TodayServiceTimerTest {
|
||||
|
||||
private static final LocalTime SALIDA = LocalTime.of(8, 30);
|
||||
|
||||
@Test
|
||||
void sinHoraDeSalidaNoHayTemporizador() {
|
||||
TimerInfo timer = TodayService.buildTimer(null, LocalTime.of(8, 0), false);
|
||||
assertThat(timer.departureTime()).isNull();
|
||||
assertThat(timer.minutesUntilDeparture()).isNull();
|
||||
}
|
||||
|
||||
@Test
|
||||
void sinColeHoyNoHayTemporizador() {
|
||||
TimerInfo timer = TodayService.buildTimer(SALIDA, LocalTime.of(8, 0), true);
|
||||
assertThat(timer.minutesUntilDeparture()).isNull();
|
||||
}
|
||||
|
||||
@Test
|
||||
void antesDeLaHoraMuestraLosMinutosQueFaltan() {
|
||||
TimerInfo timer = TodayService.buildTimer(SALIDA, LocalTime.of(8, 10), false);
|
||||
assertThat(timer.departureTime()).isEqualTo("08:30");
|
||||
assertThat(timer.minutesUntilDeparture()).isEqualTo(20);
|
||||
}
|
||||
|
||||
@Test
|
||||
void justoALaHoraMuestraCero() {
|
||||
TimerInfo timer = TodayService.buildTimer(SALIDA, LocalTime.of(8, 30), false);
|
||||
assertThat(timer.minutesUntilDeparture()).isZero();
|
||||
}
|
||||
|
||||
@Test
|
||||
void pasadaLaHoraDesaparece() {
|
||||
TimerInfo timer = TodayService.buildTimer(SALIDA, LocalTime.of(10, 0), false);
|
||||
assertThat(timer.minutesUntilDeparture()).isNull();
|
||||
}
|
||||
}
|
||||
@@ -19,6 +19,7 @@ import es.asepeyo.recordalexia.domain.WeeklyTemplateEntry;
|
||||
import es.asepeyo.recordalexia.repository.ActivityRepository;
|
||||
import es.asepeyo.recordalexia.repository.AfternoonRoutineRepository;
|
||||
import es.asepeyo.recordalexia.repository.ChildRepository;
|
||||
import es.asepeyo.recordalexia.repository.DailyTaskRepository;
|
||||
import es.asepeyo.recordalexia.repository.FamilyRepository;
|
||||
import es.asepeyo.recordalexia.repository.FamilySessionRepository;
|
||||
import es.asepeyo.recordalexia.repository.RewardRepository;
|
||||
@@ -52,6 +53,7 @@ class TodayFlowIT {
|
||||
@Autowired private RewardRepository rewardRepository;
|
||||
@Autowired private FamilyRepository familyRepository;
|
||||
@Autowired private FamilySessionRepository sessionRepository;
|
||||
@Autowired private DailyTaskRepository dailyTaskRepository;
|
||||
|
||||
@Test
|
||||
void flujoVerDiaMarcarTareaYcanjear() throws Exception {
|
||||
@@ -99,6 +101,39 @@ class TodayFlowIT {
|
||||
assertThat(childRepository.findById(nino.getId()).orElseThrow().getCoins()).isEqualTo(60);
|
||||
}
|
||||
|
||||
@Test
|
||||
void laVistaDeMananaEsSoloLecturaYnoMaterializaTareas() throws Exception {
|
||||
Family family = familyRepository.save(new Family("tmrw@x.com", "h", "Tmrw", "p"));
|
||||
String session = "tomorrow-session";
|
||||
sessionRepository.save(new FamilySession(session, family, Instant.now().plus(1, ChronoUnit.DAYS)));
|
||||
|
||||
LocalDate manana = LocalDate.now().plusDays(1);
|
||||
Child nino = childRepository.save(nuevoNino(family));
|
||||
Activity lengua = new Activity("Lengua", "Llengua", "📖", "#F2A65A");
|
||||
lengua.setFamily(family);
|
||||
lengua = activityRepository.save(lengua);
|
||||
templateRepository.save(new WeeklyTemplateEntry(nino, manana.getDayOfWeek(), lengua, 0));
|
||||
RoutineTask merendar = new RoutineTask("Merendar", "Berenar", "🥪", "#F4C95D");
|
||||
merendar.setFamily(family);
|
||||
merendar = routineTaskRepository.save(merendar);
|
||||
routineRepository.save(new AfternoonRoutine(nino, manana.getDayOfWeek(), merendar, 0));
|
||||
|
||||
mockMvc.perform(get("/api/children/{id}/tomorrow", nino.getId())
|
||||
.header(SessionAuthFilter.HEADER, session))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.morning.length()").value(1))
|
||||
.andExpect(jsonPath("$.morning[0].labelEs").value("Lengua"))
|
||||
.andExpect(jsonPath("$.morning[0].done").value(false))
|
||||
// Ids sintéticos negativos: la vista previa no es accionable.
|
||||
.andExpect(jsonPath("$.morning[0].id").value(-1))
|
||||
.andExpect(jsonPath("$.afternoon.length()").value(1))
|
||||
.andExpect(jsonPath("$.timer.minutesUntilDeparture").doesNotExist());
|
||||
|
||||
// Lo esencial: mirar mañana NO genera las tareas de mañana (el padre aún
|
||||
// puede cambiar el plan esta noche y entrará al generarse de verdad).
|
||||
assertThat(dailyTaskRepository.existsByChildIdAndTaskDate(nino.getId(), manana)).isFalse();
|
||||
}
|
||||
|
||||
private Child nuevoNino(Family family) {
|
||||
Child c = new Child();
|
||||
c.setFamily(family);
|
||||
|
||||
105
docs/adr/adr-001-autenticacion-propia-sesion-familia.md
Normal file
@@ -0,0 +1,105 @@
|
||||
# ADR-001: Autenticación propia por sesión de familia (sin Keycloak)
|
||||
|
||||
| Metadata | Valor |
|
||||
|----------|-------|
|
||||
| **Estado** | Aceptado (implementado) |
|
||||
| **Fecha** | 2026-07-12 |
|
||||
| **Autor(es)** | Jaume Garriga Maestre |
|
||||
| **Supersede** | — |
|
||||
|
||||
## Contexto
|
||||
|
||||
recordaLexia es una app **doméstica/homelab** para niños con TDAH que corre en una
|
||||
tablet en modo kiosko. Fuera del stack corporativo ECA por diseño: no hay Gravitee,
|
||||
ni Kubernetes, ni infraestructura gestionada donde apoyar un IdP.
|
||||
|
||||
Requisitos que condicionan la autenticación:
|
||||
|
||||
- **Fricción cero para el niño**: abre la tablet y ve su día. Un niño de 7 años con
|
||||
TDAH no puede depender de contraseñas ni de flujos de login.
|
||||
- **Multi-tenant real**: varias familias en la misma instancia, con aislamiento
|
||||
estricto de datos (un niño de la familia A no existe para la familia B).
|
||||
- **Zona de configuración protegida**: el panel de padres debe quedar fuera del
|
||||
alcance del niño sin romper el kiosko.
|
||||
- **Operación doméstica**: un contenedor más en docker-compose es coste asumible;
|
||||
operar un Keycloak (memoria, actualizaciones, backups del realm) no lo es.
|
||||
|
||||
## Opciones Consideradas
|
||||
|
||||
### Opción A: Keycloak OAuth2 (estándar corporativo)
|
||||
|
||||
| Pros | Contras |
|
||||
|------|---------|
|
||||
| Estándar Asepeyo, flujos probados | Un servicio pesado más que operar en un homelab |
|
||||
| SSO, federación, MFA disponibles | Fricción de login incompatible con el kiosko infantil |
|
||||
| Separación IdP/aplicación | Sobredimensionado: no hay federación ni múltiples clientes |
|
||||
|
||||
### Opción B: Auth propia por sesión de familia + PIN (elegida)
|
||||
|
||||
| Pros | Contras |
|
||||
|------|---------|
|
||||
| Cero fricción para el niño (la sesión vive en el dispositivo) | Implementación propia: superficie de error en código nuestro |
|
||||
| Dos niveles con un mecanismo (rol FAMILY / rol PARENT vía PIN) | Sin SSO ni MFA (irrelevantes en el contexto doméstico) |
|
||||
| Nada extra que operar; BCrypt + tabla de sesiones | Rotación/expiración de sesión hay que diseñarla a mano |
|
||||
|
||||
## Decisión
|
||||
|
||||
**Opción B.** El adulto se registra/loguea una vez; el dispositivo conserva una
|
||||
sesión de familia (`FamilySession`, handle opaco + expiración) que da el rol
|
||||
`FAMILY` a toda la API del kiosko. El panel de padres (`/api/parents/**`) exige el
|
||||
rol `PARENT`, que se obtiene desbloqueando con PIN (`POST /api/parents/unlock`) y
|
||||
caduca solo. Contraseña y PIN con hash BCrypt. Todo scopeado a
|
||||
`FamilyContext.currentFamilyId()`.
|
||||
|
||||
La clase `AuthService` queda **encapsulada** precisamente para poder sustituirla por
|
||||
un IdP externo sin tocar el resto del código, si el contexto cambia.
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant A as Adulto
|
||||
participant K as Tablet kiosko
|
||||
participant API as Backend
|
||||
A->>API: register/login (una vez)
|
||||
API-->>K: sesión de familia (rol FAMILY)
|
||||
Note over K: el niño usa la app sin loguearse
|
||||
A->>API: PIN → unlock (rol PARENT, temporal)
|
||||
API-->>A: panel de padres
|
||||
```
|
||||
|
||||
## Trade-offs
|
||||
|
||||
### Optimizamos para:
|
||||
- **Usabilidad del niño**: el requisito nº 1 del producto; cualquier fricción de
|
||||
auth rompe el caso de uso.
|
||||
- **Operabilidad doméstica**: menos piezas que mantener en un homelab.
|
||||
|
||||
### Sacrificamos:
|
||||
- **Robustez de un IdP maduro**: aceptable porque la superficie expuesta es la red
|
||||
local doméstica y los datos, aunque personales, no salen de casa.
|
||||
- **Estándares corporativos (Keycloak)**: desviación consciente y documentada;
|
||||
el proyecto está explícitamente fuera del ECA.
|
||||
|
||||
### Asunciones que podrían invalidar esta decisión:
|
||||
- Si la app se expusiera a Internet o se ofreciera como SaaS multi-hogar,
|
||||
reconsiderar: haría falta IdP real, MFA y hardening de sesiones.
|
||||
- Si Asepeyo integrara este producto en su cartera, aplicaría el stack ECA
|
||||
(Keycloak + Gravitee) y este ADR quedaría supersedido.
|
||||
|
||||
## Consecuencias
|
||||
|
||||
- (+) Kiosko sin fricción; multi-tenant con una sola tabla extra.
|
||||
- (+) CORS/CSRF simplificados (API stateless con cabecera de sesión propia).
|
||||
- (−) El equipo asume el mantenimiento del código de auth → mitigado con tests de
|
||||
integración (`AuthIT`: registro, login fallido, gate por PIN, aislamiento entre
|
||||
familias).
|
||||
|
||||
## Criterios de Éxito
|
||||
|
||||
- [x] El niño usa la app sin credenciales tras el primer login del adulto.
|
||||
- [x] `/api/parents/**` inaccesible sin PIN (test verde).
|
||||
- [x] Una familia no puede ver datos de otra (test verde, 404 para no filtrar existencia).
|
||||
|
||||
## Referencias
|
||||
|
||||
- `backend/src/main/java/es/asepeyo/recordalexia/security/` (SecurityConfig, AuthService, SessionAuthFilter, FamilyContext)
|
||||
- `backend/src/test/java/es/asepeyo/recordalexia/web/AuthIT.java`
|
||||
98
docs/adr/adr-002-catalogo-rutinas-reutilizable.md
Normal file
@@ -0,0 +1,98 @@
|
||||
# ADR-002: Normalización de las rutinas de tarde (catálogo + asignación)
|
||||
|
||||
| Metadata | Valor |
|
||||
|----------|-------|
|
||||
| **Estado** | Aceptado (implementado) |
|
||||
| **Fecha** | 2026-07-12 |
|
||||
| **Autor(es)** | Jaume Garriga Maestre |
|
||||
| **Supersede** | — |
|
||||
|
||||
## Contexto
|
||||
|
||||
El modelo original de `AfternoonRoutine` mezclaba en una fila la **definición** de
|
||||
la rutina (labelEs/labelCa, emoji, color, monedas) y su **asignación** (niño, día,
|
||||
orden). Para tener "Merendar" de lunes a viernes había que crear la misma rutina
|
||||
cinco veces, y editar el emoji exigía tocar cinco filas. El usuario lo detectó como
|
||||
fricción real de uso.
|
||||
|
||||
Las **mañanas ya estaban bien normalizadas**: `Activity` (catálogo por familia) +
|
||||
`WeeklyTemplateEntry` (asignación niño+día+orden+override de monedas). Las tardes
|
||||
eran la anomalía.
|
||||
|
||||
## Opciones Consideradas
|
||||
|
||||
### Opción A: Mantener el modelo y duplicar en la UI ("crear en varios días")
|
||||
|
||||
| Pros | Contras |
|
||||
|------|---------|
|
||||
| Sin migración de datos | La duplicación persiste en BD: editar sigue costando N filas |
|
||||
| Cambio solo de frontend | La asimetría mañana/tarde se consolida |
|
||||
|
||||
### Opción B: Reutilizar `Activity` también para las tardes
|
||||
|
||||
| Pros | Contras |
|
||||
|------|---------|
|
||||
| Una sola entidad de catálogo | Semántica forzada: Activity arrastra material del cole (N:M) que la rutina no necesita |
|
||||
| Menos código | Acopla dos conceptos de dominio distintos |
|
||||
|
||||
### Opción C: Nueva entidad `RoutineTask` gemela de `Activity` (elegida)
|
||||
|
||||
| Pros | Contras |
|
||||
|------|---------|
|
||||
| Simetría total mañana/tarde: mismo patrón mental y de código | Migración de datos con deduplicación |
|
||||
| Definir una vez, asignar a N días y N niños | Una entidad y un CRUD más |
|
||||
| Monedas con cascada: override por día → catálogo → niño | |
|
||||
|
||||
## Decisión
|
||||
|
||||
**Opción C.** `RoutineTask` = catálogo por familia (labels, emoji, color, monedas
|
||||
por defecto). `AfternoonRoutine` queda como pura asignación (`child + dayOfWeek +
|
||||
routineTask + orderIndex + coinsReward` como override). La migración Liquibase
|
||||
(changeset 003) consolida los duplicados existentes por clave natural
|
||||
(familia + labels + emoji + color) sin pérdida; el changeset 004 añade
|
||||
`ON DELETE CASCADE` para que borrar del catálogo arrastre las asignaciones
|
||||
(el modelo mental del padre).
|
||||
|
||||
```mermaid
|
||||
erDiagram
|
||||
FAMILY ||--o{ ROUTINE_TASK : "define una vez"
|
||||
ROUTINE_TASK ||--o{ AFTERNOON_ROUTINE : "se asigna a N dias/niños"
|
||||
CHILD ||--o{ AFTERNOON_ROUTINE : ""
|
||||
FAMILY ||--o{ ACTIVITY : "(patrón gemelo de mañanas)"
|
||||
ACTIVITY ||--o{ WEEKLY_TEMPLATE_ENTRY : ""
|
||||
CHILD ||--o{ WEEKLY_TEMPLATE_ENTRY : ""
|
||||
```
|
||||
|
||||
## Trade-offs
|
||||
|
||||
### Optimizamos para:
|
||||
- **Coste de edición del padre**: cambiar una rutina se hace UNA vez y se propaga.
|
||||
- **Consistencia del dominio**: un solo patrón (catálogo+asignación) para todo el día.
|
||||
|
||||
### Sacrificamos:
|
||||
- **Simplicidad de una tabla única**: aceptable; el patrón ya existía en mañanas.
|
||||
- **Riesgo puntual de migración**: mitigado con rollback explícito por changeset y
|
||||
verificación contra Postgres real (Testcontainers, `MigrationIT`).
|
||||
|
||||
### Asunciones que podrían invalidar esta decisión:
|
||||
- Si apareciera la necesidad de rutinas con material asociado (como Activity),
|
||||
reconsiderar la unificación con `Activity` (Opción B) en lugar de duplicar N:M.
|
||||
|
||||
## Consecuencias
|
||||
|
||||
- (+) Alta multi-día en una llamada (`POST /routines` con lista de días).
|
||||
- (+) Cascada de monedas expresiva: día > catálogo > `coinsPerTask` del niño.
|
||||
- (−) Lección aprendida en la migración: H2 no detecta el tipado estricto de
|
||||
Postgres (`CAST(NULL AS INTEGER)`); por eso existe `MigrationIT` con Testcontainers.
|
||||
|
||||
## Criterios de Éxito
|
||||
|
||||
- [x] Una rutina definida una vez y asignada a varios días/niños (tests verdes).
|
||||
- [x] Migración sin pérdida verificada contra Postgres 16 real.
|
||||
- [x] Borrar del catálogo arrastra asignaciones sin error 500 (test de cascade).
|
||||
|
||||
## Referencias
|
||||
|
||||
- `backend/.../domain/RoutineTask.java`, `domain/AfternoonRoutine.java`
|
||||
- `backend/src/main/resources/db/changelog/changes/003-routine-catalog.yaml`, `004-routine-task-cascade.yaml`
|
||||
- Artefactos SDD en engram: `sdd/rutinas-reutilizables/{proposal,spec,design,tasks,verify-report}`
|
||||
90
docs/adr/adr-003-provision-inicial-dataset.md
Normal file
@@ -0,0 +1,90 @@
|
||||
# ADR-003: Provisión inicial de datos al alta (familia y niño)
|
||||
|
||||
| Metadata | Valor |
|
||||
|----------|-------|
|
||||
| **Estado** | Aceptado (implementado) |
|
||||
| **Fecha** | 2026-07-12 |
|
||||
| **Autor(es)** | Jaume Garriga Maestre |
|
||||
| **Supersede** | — |
|
||||
|
||||
## Contexto
|
||||
|
||||
Una familia recién registrada empezaba con la app vacía: había que crear a mano
|
||||
todos los materiales, actividades y rutinas. El objetivo es que cualquier familia
|
||||
arranque con un **dataset inicial útil**: material escolar típico y rutinas de
|
||||
tarde apropiadas a la edad de cada niño (tramos 8-9 / 10-11 / 12-14, tomados de la
|
||||
literatura de desarrollo de autonomía en TDAH), sin impedir personalizarlo después.
|
||||
|
||||
Restricción estructural clave: los datos cuelgan de `family_id` y `child_id`, que
|
||||
son **datos de runtime** (se crean al registrarse), no de esquema.
|
||||
|
||||
## Opciones Consideradas
|
||||
|
||||
### Opción A: INSERTs en un changeset Liquibase
|
||||
|
||||
| Pros | Contras |
|
||||
|------|---------|
|
||||
| Versionado junto al esquema | **Inviable**: Liquibase corre ANTES que cualquier ApplicationRunner; en la migración no existe aún ninguna familia a la que apuntar (FK NOT NULL) |
|
||||
|
||||
### Opción B: Ampliar el DataSeeder
|
||||
|
||||
| Pros | Contras |
|
||||
|------|---------|
|
||||
| Sencillo | Solo actúa con BD vacía (familia demo); las familias reales que se registren después no reciben nada |
|
||||
|
||||
### Opción C: Servicio de provisión invocado en el alta (elegida)
|
||||
|
||||
| Pros | Contras |
|
||||
|------|---------|
|
||||
| Se ejecuta exactamente cuando existen los datos padre (familia/niño) | Lógica de negocio adicional que testear |
|
||||
| La edad del niño está disponible en su alta → tramo correcto | El catálogo posterior al alta no se "resincroniza" (decisión consciente) |
|
||||
| Transaccional con el registro: o todo o nada | |
|
||||
|
||||
## Decisión
|
||||
|
||||
**Opción C.** `InitialDatasetService` con dos operaciones:
|
||||
|
||||
- `provisionFamily(family)` — en `AuthService.register()`: catálogo de materiales
|
||||
escolares típicos (9) + catálogo completo de rutinas (14).
|
||||
- `provisionChildRoutines(child)` — en `ChildService.create()`: asigna a L-V las
|
||||
rutinas del catálogo cuyo `minAge` ≤ edad del niño (acumulativo: base 0 /
|
||||
organización 10 / corresponsabilidad 12).
|
||||
|
||||
El `DataSeeder` demo **delega en el mismo servicio** (una sola fuente de verdad) y
|
||||
solo añade sus extras (actividades del horario, premios, eventos).
|
||||
|
||||
## Trade-offs
|
||||
|
||||
### Optimizamos para:
|
||||
- **Time-to-value de la familia**: la app es útil desde el minuto uno.
|
||||
- **Adecuación evolutiva**: las responsabilidades crecen con la edad del niño.
|
||||
|
||||
### Sacrificamos:
|
||||
- **Sincronización posterior**: si la familia borra rutinas del catálogo, los niños
|
||||
creados después reciben solo las que sigan existiendo (se busca por etiqueta ES).
|
||||
Aceptable: respeta la personalización de la familia.
|
||||
- **Contenido opinado**: el set inicial es una propuesta pedagógica, no neutra;
|
||||
mitigado porque todo es editable desde el panel.
|
||||
|
||||
### Asunciones que podrían invalidar esta decisión:
|
||||
- Si el dataset inicial necesitara variar por idioma/región o ser configurable por
|
||||
el admin, reconsiderar moverlo a datos (tabla de plantillas) en vez de código.
|
||||
|
||||
## Consecuencias
|
||||
|
||||
- (+) El registro deja a la familia lista para usar; el alta de un niño de 13 años
|
||||
le pone 14 rutinas x 5 días sin tocar nada.
|
||||
- (−) El seed vive en código Java (constantes): cambiarlo requiere release. Asumido
|
||||
para v1 doméstica.
|
||||
|
||||
## Criterios de Éxito
|
||||
|
||||
- [x] Registro → 9 materiales + 14 rutinas de catálogo (test verde).
|
||||
- [x] Niño de 8 → 25 asignaciones; niño de 13 → 70 (test verde).
|
||||
- [x] Demo y familias reales comparten la misma provisión (DataSeederIT verde).
|
||||
|
||||
## Referencias
|
||||
|
||||
- `backend/.../service/InitialDatasetService.java` (+ test)
|
||||
- `backend/.../security/AuthService.java`, `service/ChildService.java`, `bootstrap/DataSeeder.java`
|
||||
- ADR-002 (el catálogo reutilizable es prerrequisito de esta provisión)
|
||||
BIN
docs/manual/img/01-login.png
Normal file
|
After Width: | Height: | Size: 66 KiB |
BIN
docs/manual/img/02-perfiles.png
Normal file
|
After Width: | Height: | Size: 105 KiB |
BIN
docs/manual/img/03-home-board.png
Normal file
|
After Width: | Height: | Size: 152 KiB |
BIN
docs/manual/img/04-home-foco.png
Normal file
|
After Width: | Height: | Size: 105 KiB |
BIN
docs/manual/img/05-tienda.png
Normal file
|
After Width: | Height: | Size: 110 KiB |
BIN
docs/manual/img/06-pin.png
Normal file
|
After Width: | Height: | Size: 68 KiB |
BIN
docs/manual/img/07-panel-padres.png
Normal file
|
After Width: | Height: | Size: 117 KiB |
BIN
docs/manual/img/08-rutinas.png
Normal file
|
After Width: | Height: | Size: 178 KiB |
BIN
docs/manual/img/09-horario.png
Normal file
|
After Width: | Height: | Size: 102 KiB |
BIN
docs/manual/img/10-materiales.png
Normal file
|
After Width: | Height: | Size: 238 KiB |
BIN
docs/manual/img/11-recompensas.png
Normal file
|
After Width: | Height: | Size: 166 KiB |
BIN
docs/manual/img/12-eventos.png
Normal file
|
After Width: | Height: | Size: 82 KiB |
BIN
docs/manual/img/13-manana.png
Normal file
|
After Width: | Height: | Size: 191 KiB |
187
docs/manual/manual-usuario.md
Normal file
@@ -0,0 +1,187 @@
|
||||
# Manual de usuario · recordaLexia
|
||||
|
||||
**recordaLexia** es una app familiar para niños con TDAH. Cada mañana muestra, en
|
||||
una tablet en modo kiosko, qué material llevar al cole y qué rutinas hacer por la
|
||||
tarde, con monedas y una tienda de recompensas. Es multi-niño y bilingüe (español /
|
||||
catalán).
|
||||
|
||||
Hay dos mundos en la app:
|
||||
|
||||
- **El kiosko del niño** — sin fricción: el niño toca su cara y ve su día. No escribe
|
||||
contraseñas ni configura nada.
|
||||
- **El panel de padres** — protegido por un PIN: aquí configuras niños, materiales,
|
||||
horario, rutinas, eventos y recompensas.
|
||||
|
||||
---
|
||||
|
||||
## 1. Entrar (cuenta de familia)
|
||||
|
||||
El adulto inicia sesión **una sola vez** en la tablet con el correo y la contraseña de
|
||||
la familia. A partir de ahí, el dispositivo recuerda la sesión y el niño ya no necesita
|
||||
volver a entrar.
|
||||
|
||||

|
||||
|
||||
> 💡 Cuenta de demostración: `demo@recordalexia.local` / `demo1234`. Si aún no tienes
|
||||
> cuenta, pulsa **«Crear una»**.
|
||||
|
||||
---
|
||||
|
||||
## 2. ¿Quién entra hoy?
|
||||
|
||||
Tras entrar, aparece la pantalla de perfiles. Cada niño tiene su **mascota**, su
|
||||
**color** y sus **monedas** 🪙. El niño solo tiene que tocar su tarjeta.
|
||||
|
||||

|
||||
|
||||
- Toca la tarjeta de un niño para abrir **su día**.
|
||||
- El botón **⚙️ Padres** (abajo) lleva al panel de configuración (pide PIN).
|
||||
|
||||
---
|
||||
|
||||
## 3. El día del niño (kiosko)
|
||||
|
||||
Es la pantalla principal. Arriba se ve el saludo, la fecha, el temporizador
|
||||
**«Salimos en X min»** ⏰, el acceso a la **Tienda** 🎁 y el saldo de monedas 🪙.
|
||||
|
||||
El día se divide en dos bloques:
|
||||
|
||||
- **🎒 COLE** — el material/actividades de la mañana.
|
||||
- **🌙 TARDE** — las rutinas de la tarde.
|
||||
|
||||

|
||||
|
||||
- El niño **toca cada tarea** para marcarla como hecha y ganar monedas.
|
||||
- El botón 🔊 **lee la tarea en voz alta**.
|
||||
- La barra **«X/Y listo ✨»** muestra el progreso del día.
|
||||
|
||||
### Modo Foco 🎯
|
||||
|
||||
Pensado para TDAH: muestra **una sola tarea a la vez** para evitar distracciones. Se
|
||||
activa con el botón **🎯 Foco** (y se vuelve a la vista completa con **🗂️ Tablero**).
|
||||
|
||||

|
||||
|
||||
### Ver mañana 👀
|
||||
|
||||
El botón **«Mañana ›»** de la cabecera muestra el día siguiente: qué toca en el cole
|
||||
y qué rutinas habrá por la tarde. Es la herramienta para **preparar la mochila la
|
||||
noche antes**, un hábito clave para empezar el día sin estrés.
|
||||
|
||||

|
||||
|
||||
- El aviso **«👀 Así será mañana»** deja claro que no es el día de hoy.
|
||||
- Es una vista **solo de consulta**: las tareas no se pueden marcar (¡las monedas se
|
||||
ganan mañana!).
|
||||
- También muestra los **eventos** del día siguiente (exámenes, entregas), para no
|
||||
llevarse sorpresas.
|
||||
- Se vuelve con el botón **«‹ Hoy»**.
|
||||
|
||||
---
|
||||
|
||||
## 4. La tienda de recompensas 🎁
|
||||
|
||||
El niño gasta las monedas que ha ganado en premios configurados por los padres. Si no
|
||||
le llega el saldo, el premio aparece bloqueado con las monedas que le faltan.
|
||||
|
||||

|
||||
|
||||
---
|
||||
|
||||
## 5. Entrar al panel de padres 🔒
|
||||
|
||||
Desde la pantalla de perfiles, pulsa **⚙️ Padres** e introduce el **PIN** en el
|
||||
teclado. (En la demo, el PIN es `1234`.)
|
||||
|
||||

|
||||
|
||||
> 🔐 El PIN es configurable y separa el mundo del niño del de configuración. El niño
|
||||
> nunca necesita el PIN para usar su día.
|
||||
|
||||
---
|
||||
|
||||
## 6. Panel de padres
|
||||
|
||||
El panel tiene seis pestañas. Arriba a la derecha, **🔓 Salir** vuelve a bloquear el
|
||||
panel.
|
||||
|
||||
### 6.1 Niños 👦
|
||||
|
||||
Crea perfiles (nombre, mascota, color, edad y hora de salida) y consulta los
|
||||
existentes.
|
||||
|
||||

|
||||
|
||||
> 💡 Al crear un niño, la app le **asigna automáticamente rutinas de tarde acordes a su
|
||||
> edad** (más responsabilidades cuanto mayor es). No partes de cero.
|
||||
|
||||
### 6.2 Horario 📅 (mañanas)
|
||||
|
||||
Define qué **actividad del cole** tiene cada niño cada día de la semana. Cada actividad
|
||||
arrastra el material que hay que llevar.
|
||||
|
||||

|
||||
|
||||
### 6.3 Materiales 🎒
|
||||
|
||||
Catálogo de material escolar de la familia (estuche, libreta, ropa de gimnasia…). Cada
|
||||
material tiene texto bilingüe, emoji y color. Al registrar una familia ya se siembra un
|
||||
catálogo típico para no empezar en blanco.
|
||||
|
||||

|
||||
|
||||
### 6.4 Rutinas 🌙 (tardes) — *reutilizables*
|
||||
|
||||
Esta es la pestaña clave, y funciona en **dos pasos**:
|
||||
|
||||
1. **Catálogo de rutinas** — defines una rutina **una sola vez** (texto, emoji, color):
|
||||
«Merendar», «Hacer los deberes», etc.
|
||||
2. **Asignar a días** — eliges una rutina del catálogo y marcas **los días** en los que
|
||||
toca. Una misma rutina sirve para varios días y varios niños, sin recrearla.
|
||||
|
||||
Abajo, **Rutinas por día** muestra lo asignado a cada día, donde puedes **reordenar** (▲▼)
|
||||
o **quitar** (✕) una rutina de ese día.
|
||||
|
||||

|
||||
|
||||
> 💡 Si cambias el emoji o el texto de una rutina en el catálogo, el cambio se refleja
|
||||
> automáticamente en **todos los días** donde está asignada.
|
||||
|
||||
### 6.5 Eventos 📋
|
||||
|
||||
Avisos puntuales del día (exámenes, deberes a entregar…). Aparecen como banner
|
||||
informativo en el día del niño; no son tareas marcables.
|
||||
|
||||

|
||||
|
||||
### 6.6 Recompensas 🪙
|
||||
|
||||
Premios de la tienda: texto bilingüe, emoji, color y **coste en monedas**. Puedes
|
||||
activarlos o desactivarlos.
|
||||
|
||||

|
||||
|
||||
---
|
||||
|
||||
## 7. Preguntas frecuentes
|
||||
|
||||
**¿El niño puede tocar la configuración por error?**
|
||||
No. Todo lo de configuración está tras el PIN del panel de padres.
|
||||
|
||||
**¿Tengo que crear las rutinas para cada día?**
|
||||
No. Defines la rutina una vez en el catálogo y la asignas a los días que quieras.
|
||||
|
||||
**¿Puede el niño ver qué le toca mañana?**
|
||||
Sí, con el botón «Mañana ›» de su pantalla. Es solo de consulta (no se pueden marcar
|
||||
tareas por adelantado) y siempre refleja los últimos cambios del panel de padres.
|
||||
|
||||
**¿Qué pasa con el histórico al cambiar de día?**
|
||||
Se conserva. No se borran tareas ni canjes anteriores.
|
||||
|
||||
**¿En qué idiomas está?**
|
||||
Español y catalán. Todos los textos visibles tienen ambas variantes.
|
||||
|
||||
---
|
||||
|
||||
*recordaLexia · Manual de usuario · Capturas tomadas de la aplicación en
|
||||
funcionamiento.*
|
||||
BIN
docs/manual/manual-usuario.pdf
Normal file
53
docs/manual/pdf.css
Normal file
@@ -0,0 +1,53 @@
|
||||
/* Estilo de impresión del manual de usuario de recordaLexia. */
|
||||
@page {
|
||||
size: A4;
|
||||
margin: 2cm 1.8cm;
|
||||
@bottom-center { content: "recordaLexia · Manual de usuario"; font-size: 8pt; color: #9aa0ad; }
|
||||
@bottom-right { content: counter(page) " / " counter(pages); font-size: 8pt; color: #9aa0ad; }
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: "Nunito", "Helvetica Neue", Arial, sans-serif;
|
||||
color: #2A3142;
|
||||
font-size: 10.5pt;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
h1, h2, h3 {
|
||||
font-family: "Fredoka", "Nunito", Arial, sans-serif;
|
||||
color: #2A3142;
|
||||
line-height: 1.2;
|
||||
}
|
||||
h1 { font-size: 22pt; color: #5B8DEF; border-bottom: 3px solid #F2A65A; padding-bottom: 6px; }
|
||||
h2 { font-size: 15pt; margin-top: 1.4em; border-left: 5px solid #A78BD0; padding-left: 10px; }
|
||||
h3 { font-size: 12.5pt; color: #5B8DEF; margin-top: 1.1em; }
|
||||
|
||||
/* Cada sección de primer nivel (## n.) empieza en página nueva, salvo la primera. */
|
||||
h2 { break-before: page; }
|
||||
h2:first-of-type { break-before: avoid; }
|
||||
|
||||
img {
|
||||
max-width: 78%;
|
||||
display: block;
|
||||
margin: 12px auto;
|
||||
border: 1px solid #e6e8ee;
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 2px 8px rgba(42,49,66,0.08);
|
||||
}
|
||||
|
||||
blockquote {
|
||||
background: #FFF6E0;
|
||||
border-left: 4px solid #F4C95D;
|
||||
margin: 12px 0;
|
||||
padding: 8px 14px;
|
||||
border-radius: 6px;
|
||||
color: #5a4a1f;
|
||||
}
|
||||
|
||||
table { border-collapse: collapse; width: 100%; margin: 10px 0; font-size: 9.5pt; }
|
||||
th, td { border: 1px solid #e0e3ea; padding: 6px 10px; text-align: left; }
|
||||
th { background: #5B8DEF; color: #fff; }
|
||||
tr:nth-child(even) td { background: #f6f8fc; }
|
||||
|
||||
code { background: #f0f2f6; padding: 1px 5px; border-radius: 4px; font-size: 9.5pt; }
|
||||
a { color: #5B8DEF; text-decoration: none; }
|
||||
@@ -30,6 +30,11 @@ export class ApiService {
|
||||
return this.http.get<TodayResponse>(`${this.base}/children/${childId}/today`);
|
||||
}
|
||||
|
||||
/** Vista previa de MAÑANA (solo lectura): preparar la mochila la noche antes. */
|
||||
getTomorrow(childId: number): Observable<TodayResponse> {
|
||||
return this.http.get<TodayResponse>(`${this.base}/children/${childId}/tomorrow`);
|
||||
}
|
||||
|
||||
/** Marca/desmarca una tarea y devuelve el saldo y progreso actualizados. */
|
||||
toggleTask(taskId: number): Observable<ToggleResult> {
|
||||
return this.http.post<ToggleResult>(`${this.base}/tasks/${taskId}/toggle`, {});
|
||||
|
||||
@@ -21,6 +21,13 @@ export class I18nService {
|
||||
afternoon: { ES: 'TARDE', CA: 'TARDA' },
|
||||
ready: { ES: 'listo', CA: 'fet' },
|
||||
noSchool: { ES: 'HOY NO HAY COLE', CA: 'AVUI NO HI HA COLE' },
|
||||
freeAfternoon: { ES: 'TARDE LIBRE', CA: 'TARDA LLIURE' },
|
||||
emptyDayTitle: { ES: '¡Hoy no hay tareas!', CA: 'Avui no hi ha tasques!' },
|
||||
emptyTomorrowTitle: { ES: '¡Mañana no hay tareas!', CA: 'Demà no hi ha tasques!' },
|
||||
emptyDaySub: { ES: 'Disfruta del día', CA: 'Gaudeix del dia' },
|
||||
today: { ES: 'Hoy', CA: 'Avui' },
|
||||
tomorrow: { ES: 'Mañana', CA: 'Demà' },
|
||||
tomorrowBanner: { ES: 'Así será mañana', CA: 'Així serà demà' },
|
||||
allDone: { ES: '¡TODO LISTO!', CA: 'TOT FET!' },
|
||||
great: { ES: '¡GENIAL!', CA: 'GENIAL!' },
|
||||
done: { ES: '¡HECHO!', CA: 'FET!' },
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import { Injectable } from '@angular/core';
|
||||
|
||||
/**
|
||||
* Sonido de recompensa sintetizado con WebAudio (sin ficheros de audio). Un par
|
||||
* de notas alegres al ganar monedas. Silencioso si WebAudio no está disponible.
|
||||
* Feedback de recompensa: sonido sintetizado con WebAudio (sin ficheros de
|
||||
* audio) y vibración háptica sutil en dispositivos que la soportan (tablets).
|
||||
* Ambos se disparan sobre el MISMO evento (causalidad) para que se perciban
|
||||
* como una sola respuesta. Silencioso/inerte si el navegador no lo soporta.
|
||||
*/
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class SoundService {
|
||||
@@ -47,4 +49,11 @@ export class SoundService {
|
||||
osc.stop(start + 0.28);
|
||||
});
|
||||
}
|
||||
|
||||
/** Vibración corta al ganar monedas (no-op si el dispositivo no vibra). */
|
||||
buzz(): void {
|
||||
if (typeof navigator !== 'undefined' && 'vibrate' in navigator) {
|
||||
navigator.vibrate(15);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -22,9 +22,13 @@ import { TaskCardComponent, ToggleEvent } from './task-card.component';
|
||||
|
||||
<section class="col">
|
||||
<h2 class="col__head">🌙 {{ i18n.t('afternoon') }}</h2>
|
||||
@if (afternoon.length) {
|
||||
@for (task of afternoon; track task.id) {
|
||||
<app-task-card [task]="task" [ttsEnabled]="ttsEnabled" (toggle)="toggle.emit($event)" />
|
||||
}
|
||||
} @else {
|
||||
<div class="empty">🌈 {{ i18n.t('freeAfternoon') }}</div>
|
||||
}
|
||||
</section>
|
||||
</div>
|
||||
`,
|
||||
|
||||
@@ -11,12 +11,31 @@
|
||||
|
||||
<app-morning-timer [minutes]="t.timer.minutesUntilDeparture" />
|
||||
|
||||
<!-- Ver mañana / volver a hoy (la vista de mañana es solo lectura) -->
|
||||
<button type="button" class="daybtn" (click)="toggleDay()">
|
||||
@if (viewingTomorrow()) {
|
||||
‹ {{ i18n.t('today') }}
|
||||
} @else {
|
||||
{{ i18n.t('tomorrow') }} ›
|
||||
}
|
||||
</button>
|
||||
|
||||
<div class="home__wallet">
|
||||
<button type="button" class="iconbtn" (click)="goStore()" aria-label="Tienda">🎁</button>
|
||||
<app-wallet #wallet [coins]="t.wallet.coins" />
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<!-- Aviso de vista previa: que el niño sepa que está mirando mañana -->
|
||||
@if (viewingTomorrow()) {
|
||||
<div class="tomorrow">👀 {{ i18n.t('tomorrowBanner') }}</div>
|
||||
}
|
||||
|
||||
<!-- Eventos del día -->
|
||||
<app-event-banner [events]="t.specialEvents" [ttsEnabled]="t.child.ttsEnabled" />
|
||||
|
||||
@if (t.progress.total > 0) {
|
||||
@if (!viewingTomorrow()) {
|
||||
<!-- Conmutador de modo -->
|
||||
<div class="modes">
|
||||
<button type="button" class="modes__btn" [class.modes__btn--on]="mode() === 'BOARD'" (click)="setMode('BOARD')">
|
||||
@@ -27,14 +46,12 @@
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Eventos del día -->
|
||||
<app-event-banner [events]="t.specialEvents" [ttsEnabled]="t.child.ttsEnabled" />
|
||||
|
||||
<!-- Progreso global -->
|
||||
<app-progress-bar [done]="t.progress.totalDone" [total]="t.progress.total" />
|
||||
}
|
||||
|
||||
<!-- Tareas: tablero o foco -->
|
||||
@if (mode() === 'BOARD') {
|
||||
<!-- Tareas: tablero o foco (la vista de mañana siempre en tablero, solo lectura) -->
|
||||
@if (mode() === 'BOARD' || viewingTomorrow()) {
|
||||
<app-board-view
|
||||
[morning]="t.morning"
|
||||
[afternoon]="t.afternoon"
|
||||
@@ -44,6 +61,16 @@
|
||||
} @else {
|
||||
<app-focus-view [tasksInput]="allTasks()" [ttsEnabled]="t.child.ttsEnabled" (toggle)="onToggle($event)" />
|
||||
}
|
||||
} @else {
|
||||
<!-- Día sin tareas: mensaje amable, nunca una pantalla en blanco. -->
|
||||
<div class="freeday">
|
||||
<span class="freeday__mascot">{{ t.child.mascot }}</span>
|
||||
<p class="freeday__title">
|
||||
{{ i18n.t(viewingTomorrow() ? 'emptyTomorrowTitle' : 'emptyDayTitle') }}
|
||||
</p>
|
||||
<p class="freeday__sub">{{ i18n.t('emptyDaySub') }} 🎉</p>
|
||||
</div>
|
||||
}
|
||||
</main>
|
||||
|
||||
<!-- Monedas voladoras (capa superpuesta) -->
|
||||
|
||||
@@ -63,6 +63,36 @@
|
||||
flex: none;
|
||||
}
|
||||
|
||||
// Botón "Mañana ›" / "‹ Hoy" de la cabecera.
|
||||
.daybtn {
|
||||
all: unset;
|
||||
cursor: pointer;
|
||||
min-height: var(--touch-min);
|
||||
padding: 0 18px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
border-radius: var(--radius-pill);
|
||||
background: var(--surface);
|
||||
box-shadow: var(--shadow-btn);
|
||||
font-family: var(--font-display);
|
||||
font-weight: 700;
|
||||
color: var(--text-1);
|
||||
transition: transform var(--dur-press) var(--ease-out);
|
||||
}
|
||||
.daybtn:active { transform: scale(0.95); }
|
||||
|
||||
// Aviso de vista previa de mañana (solo lectura).
|
||||
.tomorrow {
|
||||
align-self: center;
|
||||
padding: 8px 20px;
|
||||
border-radius: var(--radius-pill);
|
||||
background: color-mix(in srgb, var(--accent-purple) 16%, #fff);
|
||||
font-family: var(--font-display);
|
||||
font-weight: 700;
|
||||
color: var(--text-strong);
|
||||
animation: slideUp 0.3s ease both;
|
||||
}
|
||||
|
||||
// Conmutador Tablero / Foco.
|
||||
.modes {
|
||||
display: inline-flex;
|
||||
@@ -89,6 +119,38 @@
|
||||
}
|
||||
}
|
||||
|
||||
// Día sin tareas: tarjeta amable con la mascota, nunca una pantalla vacía.
|
||||
.freeday {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
padding: var(--space-6) var(--space-4);
|
||||
background: var(--surface);
|
||||
border-radius: var(--radius-card);
|
||||
box-shadow: var(--shadow-card);
|
||||
text-align: center;
|
||||
animation: slideUp 0.4s ease both;
|
||||
|
||||
&__mascot {
|
||||
font-size: 96px;
|
||||
line-height: 1;
|
||||
animation: floatY 3s ease-in-out infinite;
|
||||
}
|
||||
&__title {
|
||||
margin: var(--space-3) 0 0;
|
||||
font-family: var(--font-display);
|
||||
font-weight: 700;
|
||||
font-size: 1.8rem;
|
||||
color: var(--text-strong);
|
||||
}
|
||||
&__sub {
|
||||
margin: 0;
|
||||
font-size: 1.2rem;
|
||||
color: var(--text-1);
|
||||
}
|
||||
}
|
||||
|
||||
// Moneda voladora: parte de la tarea y viaja al monedero.
|
||||
.flycoin {
|
||||
position: fixed;
|
||||
|
||||
@@ -59,6 +59,8 @@ export class HomeComponent implements OnInit {
|
||||
protected readonly coins = signal<FlyingCoin[]>([]);
|
||||
protected readonly celebrating = signal(false);
|
||||
protected readonly lastEarned = signal(0);
|
||||
/** true mientras se muestra la vista previa (solo lectura) de mañana. */
|
||||
protected readonly viewingTomorrow = signal(false);
|
||||
|
||||
private childId!: number;
|
||||
private coinSeq = 0;
|
||||
@@ -69,14 +71,18 @@ export class HomeComponent implements OnInit {
|
||||
return t ? [...t.morning, ...t.afternoon] : [];
|
||||
});
|
||||
|
||||
/** Fecha de hoy formateada en el idioma activo. */
|
||||
/** Fecha mostrada (hoy o mañana, según la vista) formateada en el idioma activo. */
|
||||
protected readonly dateLabel = computed(() => {
|
||||
const locale = this.i18n.lang() === 'CA' ? 'ca-ES' : 'es-ES';
|
||||
const date = new Date();
|
||||
if (this.viewingTomorrow()) {
|
||||
date.setDate(date.getDate() + 1);
|
||||
}
|
||||
return new Intl.DateTimeFormat(locale, {
|
||||
weekday: 'long',
|
||||
day: 'numeric',
|
||||
month: 'long',
|
||||
}).format(new Date());
|
||||
}).format(date);
|
||||
});
|
||||
|
||||
ngOnInit(): void {
|
||||
@@ -107,8 +113,25 @@ export class HomeComponent implements OnInit {
|
||||
this.api.updateSettings(this.childId, { viewMode: mode }).subscribe();
|
||||
}
|
||||
|
||||
/** Alterna entre hoy y la vista previa (solo lectura) de mañana. */
|
||||
toggleDay(): void {
|
||||
if (this.viewingTomorrow()) {
|
||||
this.viewingTomorrow.set(false);
|
||||
this.load();
|
||||
return;
|
||||
}
|
||||
this.api.getTomorrow(this.childId).subscribe((data) => {
|
||||
this.today.set(data);
|
||||
this.viewingTomorrow.set(true);
|
||||
});
|
||||
}
|
||||
|
||||
/** Marca/desmarca una tarea y aplica el feedback (monedas, sonido, celebración). */
|
||||
onToggle(ev: ToggleEvent): void {
|
||||
// La vista de mañana es solo lectura: mirar, no marcar.
|
||||
if (this.viewingTomorrow()) {
|
||||
return;
|
||||
}
|
||||
this.api.toggleTask(ev.taskId).subscribe((result) => {
|
||||
const current = this.today();
|
||||
if (!current) {
|
||||
@@ -127,12 +150,14 @@ export class HomeComponent implements OnInit {
|
||||
};
|
||||
this.today.set(updated);
|
||||
|
||||
// Feedback positivo solo al ganar monedas (marcar, no desmarcar).
|
||||
// Feedback positivo solo al ganar monedas (marcar, no desmarcar). Sonido y
|
||||
// vibración se disparan juntos sobre el mismo evento (armonía multimodal).
|
||||
if (result.coinsEarned > 0) {
|
||||
this.flyCoins(ev.x, ev.y, result.coinsEarned);
|
||||
this.wallet?.bump();
|
||||
if (current.child.soundEnabled) {
|
||||
this.sound.playReward();
|
||||
this.sound.buzz();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -48,9 +48,11 @@ export interface ToggleEvent {
|
||||
border-radius: var(--radius-card);
|
||||
box-shadow: var(--shadow-card);
|
||||
cursor: pointer;
|
||||
transition: transform 0.15s, border-color 0.25s, background 0.25s;
|
||||
transition: transform var(--dur-press) var(--ease-out),
|
||||
border-color var(--dur-settle) var(--ease-out), background var(--dur-settle) var(--ease-out);
|
||||
}
|
||||
.card:active { transform: scale(0.99); }
|
||||
/* Respuesta al toque: visible e inmediata (es el control que el niño más pulsa). */
|
||||
.card:active { transform: scale(0.97); }
|
||||
.card__tile {
|
||||
width: var(--tile-size);
|
||||
height: var(--tile-size);
|
||||
@@ -68,16 +70,25 @@ export interface ToggleEvent {
|
||||
font-weight: 700;
|
||||
font-size: 1.15rem;
|
||||
text-transform: uppercase;
|
||||
/* Las mayúsculas necesitan tracking positivo para respirar (legibilidad). */
|
||||
letter-spacing: 0.03em;
|
||||
color: var(--text-strong);
|
||||
}
|
||||
.card__tts {
|
||||
all: unset;
|
||||
cursor: pointer;
|
||||
font-size: 26px;
|
||||
padding: 6px;
|
||||
width: var(--touch-min);
|
||||
height: var(--touch-min);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 50%;
|
||||
flex: none;
|
||||
transition: transform var(--dur-press) var(--ease-out),
|
||||
background var(--dur-settle) var(--ease-out);
|
||||
}
|
||||
.card__tts:active { transform: scale(0.9); background: var(--surface-softer); }
|
||||
.card__check {
|
||||
width: var(--touch-check);
|
||||
height: var(--touch-check);
|
||||
|
||||
@@ -56,7 +56,9 @@ import { AuthService } from '../../core/auth.service';
|
||||
justify-content: center;
|
||||
font-size: 28px;
|
||||
color: var(--text-2);
|
||||
transition: transform var(--dur-press) var(--ease-out);
|
||||
}
|
||||
.key-screen__back:active { transform: scale(0.94); }
|
||||
.key-screen__title { margin: 0; font-family: var(--font-display); font-weight: 700; font-size: 1.4rem; }
|
||||
.dots { display: flex; gap: 18px; }
|
||||
.dots--error { animation: shake 0.4s ease; }
|
||||
@@ -79,7 +81,7 @@ import { AuthService } from '../../core/auth.service';
|
||||
font-weight: 700;
|
||||
font-size: 1.8rem;
|
||||
color: var(--text-strong);
|
||||
transition: transform 0.1s;
|
||||
transition: transform var(--dur-press) var(--ease-out);
|
||||
}
|
||||
.key:active { transform: scale(0.94); }
|
||||
.key--fn { background: var(--surface-softer); font-size: 1.4rem; }
|
||||
|
||||
@@ -210,13 +210,12 @@ export class RoutinesTabComponent {
|
||||
if (this.selectedTaskId === null || this.selectedDays().length === 0) {
|
||||
return;
|
||||
}
|
||||
const order = this.routinesForDay().length;
|
||||
// El backend calcula el orden al final de CADA día destino.
|
||||
this.api
|
||||
.createRoutine({
|
||||
childId: this._childId,
|
||||
routineTaskId: this.selectedTaskId,
|
||||
daysOfWeek: this.selectedDays(),
|
||||
orderIndex: order,
|
||||
})
|
||||
.subscribe(() => {
|
||||
this.selectedTaskId = null;
|
||||
|
||||
@@ -73,14 +73,15 @@
|
||||
border: var(--card-border-width) solid color-mix(in srgb, var(--c) 35%, #fff);
|
||||
border-radius: var(--radius-card);
|
||||
box-shadow: var(--shadow-card);
|
||||
transition: transform 0.15s, box-shadow 0.2s;
|
||||
transition: transform var(--dur-press) var(--ease-out), box-shadow var(--dur-settle) var(--ease-out);
|
||||
animation: slideUp 0.4s ease both;
|
||||
|
||||
&:hover {
|
||||
transform: translateY(-4px);
|
||||
box-shadow: var(--shadow-soft);
|
||||
}
|
||||
&:active { transform: scale(0.98); }
|
||||
// Mantiene la elevación del hover al pulsar: sin salto entre ambos estados.
|
||||
&:active { transform: translateY(-2px) scale(0.97); }
|
||||
|
||||
&__mascot {
|
||||
font-size: 84px;
|
||||
|
||||
@@ -81,6 +81,16 @@ button {
|
||||
font-family: var(--font-display);
|
||||
}
|
||||
|
||||
// Mata el retardo de doble-tap del navegador en la tablet kiosko: la pulsación
|
||||
// debe responder al instante (se conserva el scroll y el pinch-zoom de página).
|
||||
button,
|
||||
a,
|
||||
select,
|
||||
input,
|
||||
[role='button'] {
|
||||
touch-action: manipulation;
|
||||
}
|
||||
|
||||
// --- Helpers del panel de padres (formularios de administración) ---
|
||||
.adm-card {
|
||||
background: var(--surface);
|
||||
@@ -112,6 +122,7 @@ button {
|
||||
background: var(--accent-blue);
|
||||
color: #fff;
|
||||
cursor: pointer;
|
||||
transition: transform var(--dur-press) var(--ease-out);
|
||||
}
|
||||
.adm-btn:active { transform: scale(0.97); }
|
||||
.adm-btn:disabled { opacity: 0.4; cursor: default; }
|
||||
@@ -120,9 +131,17 @@ button {
|
||||
cursor: pointer;
|
||||
color: var(--accent-pink);
|
||||
font-size: 1.2rem;
|
||||
padding: 4px 8px;
|
||||
// Área táctil mínima real aunque el glifo sea pequeño.
|
||||
min-width: var(--touch-min);
|
||||
min-height: var(--touch-min);
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 8px;
|
||||
transition: transform var(--dur-press) var(--ease-out), background var(--dur-settle) var(--ease-out);
|
||||
}
|
||||
.adm-del:active { transform: scale(0.9); background: var(--surface-softer); }
|
||||
.adm-del:disabled { opacity: 0.35; cursor: default; }
|
||||
.adm-list { display: flex; flex-direction: column; gap: 10px; }
|
||||
.adm-item {
|
||||
display: flex;
|
||||
@@ -136,12 +155,21 @@ button {
|
||||
.adm-chip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 6px;
|
||||
padding: 4px 12px;
|
||||
// Los chips actúan como botones (selector de días): área táctil de 44px mínimo.
|
||||
min-height: 44px;
|
||||
padding: 8px 16px;
|
||||
border: 0;
|
||||
border-radius: var(--radius-pill);
|
||||
background: var(--surface-softer);
|
||||
font-family: var(--font-display);
|
||||
font-size: 0.9rem;
|
||||
color: var(--text-1);
|
||||
cursor: pointer;
|
||||
transition: transform var(--dur-press) var(--ease-out), background var(--dur-settle) var(--ease-out),
|
||||
color var(--dur-settle) var(--ease-out);
|
||||
}
|
||||
.adm-chip:active { transform: scale(0.95); }
|
||||
.adm-empty { color: var(--text-3); padding: var(--space-4); text-align: center; }
|
||||
.adm-label { font-weight: 600; color: var(--text-1); }
|
||||
|
||||
@@ -68,9 +68,19 @@
|
||||
--touch-check: 60px; // check de completar
|
||||
--touch-nav: 64px; // botones de navegación ‹ ›
|
||||
--touch-pin: 84px; // teclas del PIN
|
||||
--touch-min: 48px; // mínimo absoluto para cualquier control tocable
|
||||
--card-min-height: 92px;
|
||||
--card-border-width: 3px;
|
||||
|
||||
// ----- Movimiento (duraciones y curvas únicas de la app) -----
|
||||
// La pulsación responde al instante (~100ms); el asentamiento es corto y sin
|
||||
// rebote por defecto. La curva "spring" (con sobreimpulso) se reserva para
|
||||
// momentos con impulso físico (completar tarea, celebraciones).
|
||||
--dur-press: 100ms;
|
||||
--dur-settle: 250ms;
|
||||
--ease-out: cubic-bezier(0.25, 1, 0.5, 1);
|
||||
--ease-spring: cubic-bezier(0.34, 1.56, 0.64, 1);
|
||||
|
||||
// ----- Espaciado base -----
|
||||
--space-1: 4px;
|
||||
--space-2: 8px;
|
||||
|
||||