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,132 @@
import { Component, OnInit, inject, signal } from '@angular/core';
import { ActivatedRoute, Router } from '@angular/router';
import { forkJoin } from 'rxjs';
import { ApiService } from '../../core/api.service';
import { I18nService } from '../../core/i18n.service';
import { SoundService } from '../../core/sound.service';
import { RewardView } from '../../core/models';
/** Tienda de recompensas: el niño canjea monedas por premios. */
@Component({
selector: 'app-store',
imports: [],
template: `
<main class="store">
<header class="store__top">
<button type="button" class="iconbtn" (click)="back()" aria-label="Volver"></button>
<h1 class="store__title">🎁 {{ i18n.t('store') }}</h1>
<span class="wallet">🪙 {{ coins() }}</span>
</header>
@if (loading()) {
<p class="store__msg">Cargando…</p>
} @else {
<div class="grid">
@for (r of rewards(); track r.id) {
<div class="reward" [style.--c]="r.color">
<span class="reward__icon">{{ r.icon }}</span>
<span class="reward__name">{{ i18n.label(r.labelEs, r.labelCa) }}</span>
<span class="reward__cost">🪙 {{ r.cost }}</span>
@if (coins() >= r.cost) {
<button type="button" class="reward__btn" (click)="redeem(r)">{{ i18n.t('redeem') }}</button>
} @else {
<span class="reward__missing">{{ i18n.t('missing') }} {{ r.cost - coins() }} 🪙</span>
}
</div>
}
</div>
}
@if (toast()) {
<div class="toast">{{ i18n.t('redeemed') }} {{ toast() }} 🎉</div>
}
</main>
`,
styles: [
`
.store { max-width: 900px; margin: 0 auto; padding: var(--space-5) var(--space-4); }
.store__top { display: flex; align-items: center; gap: var(--space-4); margin-bottom: var(--space-5); }
.store__title { flex: 1; margin: 0; font-size: 1.8rem; }
.store__msg { text-align: center; color: var(--text-1); }
.wallet {
font-family: var(--font-display); font-weight: 700; font-size: 1.4rem;
background: var(--coin-bg); color: var(--coin-text); padding: 9px 18px; border-radius: var(--radius-pill);
}
.iconbtn {
all: unset; cursor: pointer; width: var(--touch-nav); height: var(--touch-nav); border-radius: 50%;
background: var(--surface); box-shadow: var(--shadow-btn); display: flex; align-items: center;
justify-content: center; font-size: 28px; color: var(--text-2);
}
.grid { display: grid; grid-template-columns: repeat(3, 1fr); gap: var(--space-4); }
@media (max-width: 720px) { .grid { grid-template-columns: repeat(2, 1fr); } }
.reward {
display: flex; flex-direction: column; align-items: center; gap: var(--space-2);
background: var(--surface); border: 3px solid color-mix(in srgb, var(--c) 30%, #fff);
border-radius: var(--radius-card); padding: var(--space-5) var(--space-4); box-shadow: var(--shadow-card);
animation: slideUp 0.35s ease both;
}
.reward__icon {
width: 72px; height: 72px; border-radius: var(--radius-tile); display: flex; align-items: center;
justify-content: center; font-size: 42px; background: color-mix(in srgb, var(--c) 16%, #fff);
}
.reward__name {
font-family: var(--font-display); font-weight: 700; text-align: center; color: var(--text-strong);
}
.reward__cost { font-family: var(--font-display); font-weight: 700; color: var(--coin-text); }
.reward__btn {
font-family: var(--font-display); font-weight: 700; border: 0; border-radius: 16px;
padding: 10px 22px; min-height: 48px; background: var(--accent-green); color: #fff; cursor: pointer;
}
.reward__btn:active { transform: scale(0.96); }
.reward__missing { color: var(--text-3); font-size: 0.9rem; text-align: center; }
.toast {
position: fixed; bottom: 30px; left: 50%; transform: translateX(-50%);
background: var(--text-strong); color: #fff; padding: 14px 26px; border-radius: var(--radius-pill);
font-family: var(--font-display); font-weight: 700; box-shadow: var(--shadow-pop);
animation: slideUp 0.3s ease;
}
`,
],
})
export class StoreComponent 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);
protected readonly rewards = signal<RewardView[]>([]);
protected readonly coins = signal(0);
protected readonly loading = signal(true);
protected readonly toast = signal<string | null>(null);
private childId!: number;
ngOnInit(): void {
this.childId = Number(this.route.snapshot.paramMap.get('childId'));
forkJoin({
rewards: this.api.getRewards(this.childId),
wallet: this.api.getWallet(this.childId),
}).subscribe({
next: ({ rewards, wallet }) => {
this.rewards.set(rewards);
this.coins.set(wallet.coins);
this.loading.set(false);
},
error: () => this.loading.set(false),
});
}
redeem(reward: RewardView): void {
this.api.redeem(this.childId, reward.id).subscribe((result) => {
this.coins.set(result.newBalance);
this.sound.playReward();
this.toast.set(this.i18n.label(reward.labelEs, reward.labelCa));
setTimeout(() => this.toast.set(null), 2200);
});
}
back(): void {
this.router.navigate(['/home', this.childId]);
}
}