From e5385f7dddbb10709ea2965375d05782206d03aa Mon Sep 17 00:00:00 2001 From: Jaume Garriga Maestre Date: Sun, 12 Jul 2026 22:37:39 +0200 Subject: [PATCH] =?UTF-8?q?feat(hoy):=20vista=20previa=20de=20ma=C3=B1ana?= =?UTF-8?q?=20en=20el=20kiosko=20del=20ni=C3=B1o?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .../recordalexia/service/TodayService.java | 68 +++++++++++++++++++ .../recordalexia/web/ChildController.java | 6 ++ .../asepeyo/recordalexia/web/TodayFlowIT.java | 35 ++++++++++ frontend/src/app/core/api.service.ts | 5 ++ frontend/src/app/core/i18n.service.ts | 4 ++ .../src/app/features/home/home.component.html | 46 +++++++++---- .../src/app/features/home/home.component.scss | 30 ++++++++ .../src/app/features/home/home.component.ts | 27 +++++++- 8 files changed, 205 insertions(+), 16 deletions(-) diff --git a/backend/src/main/java/es/asepeyo/recordalexia/service/TodayService.java b/backend/src/main/java/es/asepeyo/recordalexia/service/TodayService.java index 8c2b050..a076b8d 100644 --- a/backend/src/main/java/es/asepeyo/recordalexia/service/TodayService.java +++ b/backend/src/main/java/es/asepeyo/recordalexia/service/TodayService.java @@ -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; } @@ -79,6 +89,64 @@ public class TodayService { 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 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 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 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) { return new TaskView(t.getId(), t.getLabelEs(), t.getLabelCa(), t.getIcon(), t.getColor(), t.isDone(), t.getCoinsReward(), t.getOrderIndex()); diff --git a/backend/src/main/java/es/asepeyo/recordalexia/web/ChildController.java b/backend/src/main/java/es/asepeyo/recordalexia/web/ChildController.java index a6d9426..93ca1d0 100644 --- a/backend/src/main/java/es/asepeyo/recordalexia/web/ChildController.java +++ b/backend/src/main/java/es/asepeyo/recordalexia/web/ChildController.java @@ -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); diff --git a/backend/src/test/java/es/asepeyo/recordalexia/web/TodayFlowIT.java b/backend/src/test/java/es/asepeyo/recordalexia/web/TodayFlowIT.java index b4e38d8..991d7cb 100644 --- a/backend/src/test/java/es/asepeyo/recordalexia/web/TodayFlowIT.java +++ b/backend/src/test/java/es/asepeyo/recordalexia/web/TodayFlowIT.java @@ -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); diff --git a/frontend/src/app/core/api.service.ts b/frontend/src/app/core/api.service.ts index ff67b93..bc23a5e 100644 --- a/frontend/src/app/core/api.service.ts +++ b/frontend/src/app/core/api.service.ts @@ -30,6 +30,11 @@ export class ApiService { return this.http.get(`${this.base}/children/${childId}/today`); } + /** Vista previa de MAÑANA (solo lectura): preparar la mochila la noche antes. */ + getTomorrow(childId: number): Observable { + return this.http.get(`${this.base}/children/${childId}/tomorrow`); + } + /** Marca/desmarca una tarea y devuelve el saldo y progreso actualizados. */ toggleTask(taskId: number): Observable { return this.http.post(`${this.base}/tasks/${taskId}/toggle`, {}); diff --git a/frontend/src/app/core/i18n.service.ts b/frontend/src/app/core/i18n.service.ts index f272eea..2d22fcc 100644 --- a/frontend/src/app/core/i18n.service.ts +++ b/frontend/src/app/core/i18n.service.ts @@ -23,7 +23,11 @@ export class I18nService { 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!' }, diff --git a/frontend/src/app/features/home/home.component.html b/frontend/src/app/features/home/home.component.html index f2cbb50..6e405d0 100644 --- a/frontend/src/app/features/home/home.component.html +++ b/frontend/src/app/features/home/home.component.html @@ -11,31 +11,47 @@ + + +
+ + @if (viewingTomorrow()) { +
👀 {{ i18n.t('tomorrowBanner') }}
+ } + @if (t.progress.total > 0) { - -
- - -
+ @if (!viewingTomorrow()) { + +
+ + +
- - + + + } - - @if (mode() === 'BOARD') { + + @if (mode() === 'BOARD' || viewingTomorrow()) {
{{ t.child.mascot }} -

{{ i18n.t('emptyDayTitle') }}

+

+ {{ i18n.t(viewingTomorrow() ? 'emptyTomorrowTitle' : 'emptyDayTitle') }} +

{{ i18n.t('emptyDaySub') }} 🎉

} diff --git a/frontend/src/app/features/home/home.component.scss b/frontend/src/app/features/home/home.component.scss index 84f636a..119c3b6 100644 --- a/frontend/src/app/features/home/home.component.scss +++ b/frontend/src/app/features/home/home.component.scss @@ -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; diff --git a/frontend/src/app/features/home/home.component.ts b/frontend/src/app/features/home/home.component.ts index 5fc06a6..f145bd5 100644 --- a/frontend/src/app/features/home/home.component.ts +++ b/frontend/src/app/features/home/home.component.ts @@ -59,6 +59,8 @@ export class HomeComponent implements OnInit { protected readonly coins = signal([]); 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) {