Files
recordalexia/frontend/src/app/features/home/home.component.ts
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

220 lines
7.2 KiB
TypeScript

import { Component, OnInit, ViewChild, computed, inject, signal } from '@angular/core';
import { ActivatedRoute, Router } from '@angular/router';
import { ApiService } from '../../core/api.service';
import { FontPreferenceService } from '../../core/font-preference.service';
import { I18nService } from '../../core/i18n.service';
import { SoundService } from '../../core/sound.service';
import { TodayResponse, ViewMode } from '../../core/models';
import { BoardViewComponent } from './board-view.component';
import { FocusViewComponent } from './focus-view.component';
import { WalletComponent } from './wallet.component';
import { MorningTimerComponent } from './morning-timer.component';
import { ProgressBarComponent } from './progress-bar.component';
import { EventBannerComponent } from './event-banner.component';
import { CelebrationOverlayComponent } from './celebration-overlay.component';
import { ToggleEvent } from './task-card.component';
/** Moneda voladora: parte del check de la tarea y vuela al monedero. */
interface FlyingCoin {
id: number;
x: number;
y: number;
dx: number;
dy: number;
flying: boolean;
}
/**
* Pantalla "HOY". Orquesta la carga del día, el conmutado Tablero/Foco (persistido),
* el marcado de tareas con su feedback (monedas voladoras, rebote del monedero,
* sonido) y la celebración al completar el día.
*/
@Component({
selector: 'app-home',
imports: [
BoardViewComponent,
FocusViewComponent,
WalletComponent,
MorningTimerComponent,
ProgressBarComponent,
EventBannerComponent,
CelebrationOverlayComponent,
],
templateUrl: './home.component.html',
styleUrl: './home.component.scss',
})
export class HomeComponent implements OnInit {
private readonly api = inject(ApiService);
private readonly route = inject(ActivatedRoute);
private readonly router = inject(Router);
private readonly sound = inject(SoundService);
private readonly fontPreference = inject(FontPreferenceService);
protected readonly i18n = inject(I18nService);
@ViewChild('wallet') private wallet?: WalletComponent;
protected readonly today = signal<TodayResponse | null>(null);
protected readonly loading = signal(true);
protected readonly mode = signal<ViewMode>('BOARD');
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;
/** Todas las tareas en orden (mañana y luego tarde) para el modo Foco. */
protected readonly allTasks = computed(() => {
const t = this.today();
return t ? [...t.morning, ...t.afternoon] : [];
});
/** 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(date);
});
ngOnInit(): void {
this.childId = Number(this.route.snapshot.paramMap.get('childId'));
this.load();
}
private load(): void {
this.api.getToday(this.childId).subscribe({
next: (data) => {
this.today.set(data);
this.mode.set(data.child.viewMode);
this.i18n.setLang(data.child.language);
// Aplica la preferencia de tipografía de ESTE niño.
this.fontPreference.apply(data.child.dyslexiaFont);
this.loading.set(false);
},
error: () => this.loading.set(false),
});
}
/** Cambia entre Tablero y Foco y persiste la preferencia del niño. */
setMode(mode: ViewMode): void {
if (mode === this.mode()) {
return;
}
this.mode.set(mode);
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) {
return;
}
// Actualiza estado del día de forma inmutable.
const apply = (list: typeof current.morning) =>
list.map((task) => (task.id === ev.taskId ? { ...task, done: result.done } : task));
const updated: TodayResponse = {
...current,
morning: apply(current.morning),
afternoon: apply(current.afternoon),
progress: result.progress,
wallet: { coins: result.newBalance },
};
this.today.set(updated);
// 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();
}
}
// Celebración al completar TODO el día.
if (result.progress.total > 0 && result.progress.totalDone === result.progress.total) {
this.lastEarned.set(result.coinsEarned);
this.celebrating.set(true);
}
});
}
/** Lanza monedas desde (x,y) hacia el monedero. */
private flyCoins(x: number, y: number, earned: number): void {
const rect = this.wallet?.getRect();
if (!rect) {
return;
}
const targetX = rect.left + rect.width / 2;
const targetY = rect.top + rect.height / 2;
const n = Math.min(Math.max(1, Math.round(earned / 5)), 6);
const nuevas: FlyingCoin[] = [];
for (let k = 0; k < n; k++) {
const jitterX = (k - n / 2) * 14;
const startX = x + jitterX;
const startY = y;
nuevas.push({
id: this.coinSeq++,
x: startX,
y: startY,
dx: targetX - startX,
dy: targetY - startY,
flying: false,
});
}
this.coins.update((c) => [...c, ...nuevas]);
// En el siguiente frame, activa la transición (vuelo).
requestAnimationFrame(() =>
requestAnimationFrame(() =>
this.coins.update((c) =>
c.map((coin) => (nuevas.some((nc) => nc.id === coin.id) ? { ...coin, flying: true } : coin)),
),
),
);
// Retira las monedas tras la animación.
const ids = new Set(nuevas.map((c) => c.id));
setTimeout(() => this.coins.update((c) => c.filter((coin) => !ids.has(coin.id))), 800);
}
goProfiles(): void {
this.router.navigate(['/']);
}
goStore(): void {
this.router.navigate(['/store', this.childId]);
}
}