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.
This commit is contained in:
Jaume Garriga Maestre
2026-07-12 22:37:39 +02:00
parent 4eccee9b05
commit e5385f7ddd
8 changed files with 205 additions and 16 deletions

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

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

View File

@@ -11,31 +11,47 @@
<app-morning-timer [minutes]="t.timer.minutesUntilDeparture" />
<!-- Ver mañana / volver a hoy (la vista de mañana es solo lectura) -->
<button type="button" class="daybtn" (click)="toggleDay()">
@if (viewingTomorrow()) {
‹ {{ i18n.t('today') }}
} @else {
{{ i18n.t('tomorrow') }} ›
}
</button>
<div class="home__wallet">
<button type="button" class="iconbtn" (click)="goStore()" aria-label="Tienda">🎁</button>
<app-wallet #wallet [coins]="t.wallet.coins" />
</div>
</header>
<!-- Aviso de vista previa: que el niño sepa que está mirando mañana -->
@if (viewingTomorrow()) {
<div class="tomorrow">👀 {{ i18n.t('tomorrowBanner') }}</div>
}
<!-- Eventos del día -->
<app-event-banner [events]="t.specialEvents" [ttsEnabled]="t.child.ttsEnabled" />
@if (t.progress.total > 0) {
<!-- 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>
@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>
<!-- Progreso global -->
<app-progress-bar [done]="t.progress.totalDone" [total]="t.progress.total" />
<!-- Progreso global -->
<app-progress-bar [done]="t.progress.totalDone" [total]="t.progress.total" />
}
<!-- Tareas: tablero o foco -->
@if (mode() === 'BOARD') {
<!-- Tareas: tablero o foco (la vista de mañana siempre en tablero, solo lectura) -->
@if (mode() === 'BOARD' || viewingTomorrow()) {
<app-board-view
[morning]="t.morning"
[afternoon]="t.afternoon"
@@ -49,7 +65,9 @@
<!-- 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('emptyDayTitle') }}</p>
<p class="freeday__title">
{{ i18n.t(viewingTomorrow() ? 'emptyTomorrowTitle' : 'emptyDayTitle') }}
</p>
<p class="freeday__sub">{{ i18n.t('emptyDaySub') }} 🎉</p>
</div>
}

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;

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