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