feat: app completa recordaLexia (fases 1-5)

App web familiar de rutinas visuales para niños con TDAH: muestra cada día el
material del cole y las rutinas de tarde, con gamificación por monedas y tienda
de recompensas. Multi-niño y bilingüe ES/CA. Uso doméstico/homelab.

Backend (Spring Boot 3.5 / Java 21 / Gradle):
- Dominio por capas, PostgreSQL + Liquibase, datos semilla.
- API REST con DTOs: /today, toggle con monedas y bonos de bloque/día, monedero,
  tienda/canje, ajustes y CRUD del panel de padres.
- Seguridad ligera por PIN (BCrypt + sesion en memoria), sin Keycloak.
- Tests JUnit: generacion del dia, monedas/bonos con reversion, canje, seguridad.

Frontend (Angular 19, standalone + signals):
- Perfiles, Home (Tablero y Foco), Tienda y panel de padres (5 pestañas).
- Tipografia OpenDyslexic conmutable (accesibilidad), i18n ES/CA, TTS y sonido.
- Tokens de diseño fieles al handoff (paleta, animaciones, monedas voladoras).

Empaquetado:
- Docker multi-stage + docker-compose (PostgreSQL + backend + Nginx).
- Decisiones de arquitectura documentadas en docs/adr.
This commit is contained in:
Jaume Garriga Maestre
2026-06-21 10:48:57 +02:00
commit 52e559a159
160 changed files with 29022 additions and 0 deletions

View File

@@ -0,0 +1,190 @@
import { Component, OnInit, ViewChild, computed, inject, signal } from '@angular/core';
import { ActivatedRoute, Router } from '@angular/router';
import { ApiService } from '../../core/api.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);
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);
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 de hoy formateada en el idioma activo. */
protected readonly dateLabel = computed(() => {
const locale = this.i18n.lang() === 'CA' ? 'ca-ES' : 'es-ES';
return new Intl.DateTimeFormat(locale, {
weekday: 'long',
day: 'numeric',
month: 'long',
}).format(new 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);
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();
}
/** Marca/desmarca una tarea y aplica el feedback (monedas, sonido, celebración). */
onToggle(ev: ToggleEvent): void {
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).
if (result.coinsEarned > 0) {
this.flyCoins(ev.x, ev.y, result.coinsEarned);
this.wallet?.bump();
if (current.child.soundEnabled) {
this.sound.playReward();
}
}
// 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]);
}
}