Compare commits

...

10 Commits

Author SHA1 Message Date
Jaume Garriga Maestre
4606f71bdc docs(manual): sección de la vista de mañana con captura real 2026-07-12 22:50:54 +02:00
Jaume Garriga Maestre
e5385f7ddd feat(hoy): vista previa de mañana en el kiosko del niño
Botón "Mañana ›" en la cabecera: muestra el día siguiente en solo
lectura para preparar la mochila la noche antes. La vista se calcula al
vuelo desde las plantillas SIN materializar DailyTask (los cambios del
panel de esa noche siguen entrando al generarse el día de verdad), con
ids sintéticos negativos no accionables, sin temporizador ni progreso.
Banner "Así será mañana" y estados vacíos propios. Test de integración:
la vista previa no persiste tareas del día siguiente.
2026-07-12 22:37:39 +02:00
Jaume Garriga Maestre
4eccee9b05 feat(demo): horario real de 4t A para Leo en el seeder
La familia demo siembra el horario escolar real de 4º de primaria para
Leo (11 asignaturas nuevas bilingües + 29 entradas semanales en orden
cronológico; Piscina lleva la Toalla asociada). Nora y Mía conservan el
horario genérico. Test que fija el lunes completo de Leo.
2026-07-12 22:29:17 +02:00
Jaume Garriga Maestre
1db262f8ea fix(hoy): el temporizador de salida desaparece sin cole o pasada la hora
buildTimer clavaba el contador en "0 min" el resto del día (Math.max(0,..)),
incluso en días sin cole. Ahora es función pura: sin tareas de mañana o con
la hora de salida ya pasada devuelve minutos null y el kiosko no lo pinta.
Test unitario determinista de los cinco casos.
2026-07-12 22:12:44 +02:00
Jaume Garriga Maestre
0e8bfbcc9c docs(adr): decisiones de auth, catálogo de rutinas y provisión inicial 2026-07-12 22:08:16 +02:00
Jaume Garriga Maestre
5c79480aa4 feat(home): estado vacío amable y vibración háptica al ganar monedas
Un día sin tareas muestra una tarjeta con la mascota del niño en vez de
una pantalla en blanco; la columna de tarde vacía tiene su propio mensaje.
El feedback de recompensa suma una vibración corta (Vibration API) que se
dispara junto al sonido sobre el mismo evento.
2026-07-12 22:06:03 +02:00
Jaume Garriga Maestre
3ec611d373 fix(rutinas): cascade al borrar del catálogo, orden por día y test de migración real
- Changeset 004: ON DELETE CASCADE en fk_routine_assignment_task; borrar
  una rutina del catálogo arrastra sus asignaciones en vez de fallar (test).
- El orderIndex de la asignación multi-día lo calcula el backend al final
  de CADA día destino (antes lo enviaba el front desde el día visible).
- MigrationIT con Testcontainers: el changelog completo y el mapeo JPA se
  verifican contra Postgres 16 real (H2 no detecta su tipado estricto).
- DataSeeder delega en InitialDatasetService: una sola fuente de verdad
  del dataset inicial; la demo solo añade sus extras.
- Eliminado .gitkeep de changes/ (WARN de Liquibase en cada arranque).
2026-07-12 22:02:22 +02:00
Jaume Garriga Maestre
5421080b04 feat(ui): respuesta táctil y áreas de toque según guía de fluidez
Tokens de movimiento centralizados (duraciones y curvas únicas), feedback
de pulsación visible e inmediato en tarjeta de tarea, TTS, chips, teclado
y navegación; áreas táctiles mínimas de 44-48px en controles pequeños;
tracking positivo en etiquetas en mayúsculas; touch-action manipulation
para eliminar el retardo de doble-tap en la tablet kiosko.
2026-07-12 21:35:28 +02:00
Jaume Garriga Maestre
08c7289f25 docs(manual): exportar manual de usuario a PDF 2026-06-23 14:49:25 +02:00
Jaume Garriga Maestre
c165b759b7 docs(manual): manual de usuario con capturas de la app 2026-06-23 14:32:21 +02:00
45 changed files with 1188 additions and 121 deletions

View File

@@ -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'
}

View File

@@ -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);
}
/** 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));
/** 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));
}
}
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) {
}
}

View File

@@ -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);
}

View File

@@ -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);
}
}

View File

@@ -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);

View File

@@ -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();

View File

@@ -1 +0,0 @@
# Los changesets de Liquibase de la Fase 2 (dominio) vivirán aquí.

View File

@@ -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

View File

@@ -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
}
}

View File

@@ -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);
}
}

View File

@@ -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();
}
}

View File

@@ -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();
}
}

View File

@@ -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);

View 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`

View 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}`

View 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)

Binary file not shown.

After

Width:  |  Height:  |  Size: 66 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 105 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 152 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 105 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 110 KiB

BIN
docs/manual/img/06-pin.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 68 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 117 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 178 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 102 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 238 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 166 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 82 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 191 KiB

View 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.
![Pantalla de inicio de sesión](img/01-login.png)
> 💡 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.
![Selección de perfil](img/02-perfiles.png)
- 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.
![Vista de tablero](img/03-home-board.png)
- 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**).
![Modo foco](img/04-home-foco.png)
### 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.
![Vista de mañana](img/13-manana.png)
- 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.
![Tienda de recompensas](img/05-tienda.png)
---
## 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`.)
![Teclado del PIN](img/06-pin.png)
> 🔐 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.
![Pestaña Niños](img/07-panel-padres.png)
> 💡 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.
![Pestaña Horario](img/09-horario.png)
### 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.
![Pestaña Materiales](img/10-materiales.png)
### 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.
![Pestaña Rutinas](img/08-rutinas.png)
> 💡 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.
![Pestaña Eventos](img/12-eventos.png)
### 6.6 Recompensas 🪙
Premios de la tienda: texto bilingüe, emoji, color y **coste en monedas**. Puedes
activarlos o desactivarlos.
![Pestaña Recompensas](img/11-recompensas.png)
---
## 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.*

Binary file not shown.

53
docs/manual/pdf.css Normal file
View 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; }

View File

@@ -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`, {});

View File

@@ -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!' },

View File

@@ -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);
}
}
}

View File

@@ -22,8 +22,12 @@ import { TaskCardComponent, ToggleEvent } from './task-card.component';
<section class="col">
<h2 class="col__head">🌙 {{ i18n.t('afternoon') }}</h2>
@for (task of afternoon; track task.id) {
<app-task-card [task]="task" [ttsEnabled]="ttsEnabled" (toggle)="toggle.emit($event)" />
@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>

View File

@@ -11,38 +11,65 @@
<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>
<!-- Conmutador de modo -->
<div class="modes">
<button type="button" class="modes__btn" [class.modes__btn--on]="mode() === 'BOARD'" (click)="setMode('BOARD')">
🗂️ {{ i18n.t('board') }}
</button>
<button type="button" class="modes__btn" [class.modes__btn--on]="mode() === 'FOCUS'" (click)="setMode('FOCUS')">
🎯 {{ i18n.t('focus') }}
</button>
</div>
<!-- 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" />
<!-- Progreso global -->
<app-progress-bar [done]="t.progress.totalDone" [total]="t.progress.total" />
@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')">
🗂️ {{ i18n.t('board') }}
</button>
<button type="button" class="modes__btn" [class.modes__btn--on]="mode() === 'FOCUS'" (click)="setMode('FOCUS')">
🎯 {{ i18n.t('focus') }}
</button>
</div>
<!-- Tareas: tablero o foco -->
@if (mode() === 'BOARD') {
<app-board-view
[morning]="t.morning"
[afternoon]="t.afternoon"
[ttsEnabled]="t.child.ttsEnabled"
(toggle)="onToggle($event)"
/>
<!-- Progreso global -->
<app-progress-bar [done]="t.progress.totalDone" [total]="t.progress.total" />
}
<!-- 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"
[ttsEnabled]="t.child.ttsEnabled"
(toggle)="onToggle($event)"
/>
} @else {
<app-focus-view [tasksInput]="allTasks()" [ttsEnabled]="t.child.ttsEnabled" (toggle)="onToggle($event)" />
}
} @else {
<app-focus-view [tasksInput]="allTasks()" [ttsEnabled]="t.child.ttsEnabled" (toggle)="onToggle($event)" />
<!-- 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>

View File

@@ -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;

View File

@@ -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();
}
}

View File

@@ -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);

View File

@@ -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; }

View File

@@ -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;

View File

@@ -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;

View File

@@ -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); }

View File

@@ -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;