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,68 @@
import { Component, EventEmitter, Input, Output, inject } from '@angular/core';
import { I18nService } from '../../core/i18n.service';
import { TaskView } from '../../core/models';
import { TaskCardComponent, ToggleEvent } from './task-card.component';
/** Vista TABLERO: dos columnas (cole / tarde) con las tareas del día. */
@Component({
selector: 'app-board-view',
imports: [TaskCardComponent],
template: `
<div class="board">
<section class="col">
<h2 class="col__head">🎒 {{ i18n.t('school') }}</h2>
@if (morning.length) {
@for (task of morning; track task.id) {
<app-task-card [task]="task" [ttsEnabled]="ttsEnabled" (toggle)="toggle.emit($event)" />
}
} @else {
<div class="empty">🏖️ {{ i18n.t('noSchool') }}</div>
}
</section>
<section class="col">
<h2 class="col__head">🌙 {{ i18n.t('afternoon') }}</h2>
@for (task of afternoon; track task.id) {
<app-task-card [task]="task" [ttsEnabled]="ttsEnabled" (toggle)="toggle.emit($event)" />
}
</section>
</div>
`,
styles: [
`
.board {
display: grid;
grid-template-columns: 1fr 1fr;
gap: var(--space-5);
}
@media (max-width: 760px) {
.board { grid-template-columns: 1fr; }
}
.col { display: flex; flex-direction: column; gap: var(--space-3); }
.col__head {
margin: 0 0 var(--space-2);
font-size: 1.4rem;
color: var(--text-1);
}
.empty {
padding: var(--space-6) var(--space-4);
text-align: center;
font-family: var(--font-display);
font-weight: 700;
font-size: 1.3rem;
color: var(--text-2);
background: var(--surface);
border: 2px dashed var(--border-2);
border-radius: var(--radius-card);
}
`,
],
})
export class BoardViewComponent {
@Input() morning: TaskView[] = [];
@Input() afternoon: TaskView[] = [];
@Input() ttsEnabled = true;
@Output() toggle = new EventEmitter<ToggleEvent>();
protected readonly i18n = inject(I18nService);
}

View File

@@ -0,0 +1,104 @@
import { Component, EventEmitter, Input, Output, inject } from '@angular/core';
import { I18nService } from '../../core/i18n.service';
interface Confetti {
left: number;
delay: number;
color: string;
emoji: string;
}
/** Overlay celebratorio al completar TODO el día: confeti, mascota, monedas y botón. */
@Component({
selector: 'app-celebration-overlay',
imports: [],
template: `
<div class="cel">
@for (c of confetti; track $index) {
<span
class="cel__confetti"
[style.left.%]="c.left"
[style.animation-delay.s]="c.delay"
[style.color]="c.color"
>{{ c.emoji }}</span
>
}
<div class="cel__card">
<div class="cel__mascot">{{ mascot }}🎉</div>
<h2 class="cel__title">{{ i18n.t('allDone') }}</h2>
<p class="cel__coins">+{{ coinsDay }} 🪙</p>
<button type="button" class="cel__btn" (click)="close.emit()">
{{ i18n.t('great') }} 👍
</button>
</div>
</div>
`,
styles: [
`
.cel {
position: fixed;
inset: 0;
z-index: 80;
display: flex;
align-items: center;
justify-content: center;
background: rgba(35, 49, 66, 0.45);
backdrop-filter: blur(3px);
overflow: hidden;
}
.cel__confetti {
position: absolute;
top: -20vh;
font-size: 26px;
animation: confFall 2.4s linear infinite;
}
.cel__card {
position: relative;
background: var(--surface);
border-radius: var(--radius-card);
padding: 36px 40px;
text-align: center;
box-shadow: var(--shadow-pop);
animation: celebPop 0.4s ease both;
}
.cel__mascot { font-size: 72px; animation: floatY 2.5s ease-in-out infinite; }
.cel__title { margin: 12px 0; font-size: 2rem; color: var(--text-strong); }
.cel__coins {
margin: 0 0 20px;
font-family: var(--font-display);
font-weight: 700;
font-size: 1.6rem;
color: var(--coin-text);
}
.cel__btn {
font-family: var(--font-display);
font-weight: 700;
font-size: 1.2rem;
border: 0;
border-radius: 18px;
padding: 14px 28px;
min-height: var(--touch-nav);
background: var(--accent-green);
color: #fff;
cursor: pointer;
}
`,
],
})
export class CelebrationOverlayComponent {
@Input() coinsDay = 0;
@Input() mascot = '🦊';
@Output() close = new EventEmitter<void>();
protected readonly i18n = inject(I18nService);
// Confeti generado una vez al crear el overlay.
protected readonly confetti: Confetti[] = Array.from({ length: 28 }, () => ({
left: Math.random() * 100,
delay: Math.random() * 2,
color: ['#F2A65A', '#5B8DEF', '#A78BD0', '#7FBF6B', '#5BC0BE', '#F4C95D', '#EC8FA4'][
Math.floor(Math.random() * 7)
],
emoji: ['🎉', '⭐', '🪙', '✨'][Math.floor(Math.random() * 4)],
}));
}

View File

@@ -0,0 +1,54 @@
import { Component, Input, inject } from '@angular/core';
import { I18nService } from '../../core/i18n.service';
import { TtsService } from '../../core/tts.service';
import { EventView } from '../../core/models';
/** Banner de eventos del día (examen 📋 / deberes 📎) con lectura en voz alta. */
@Component({
selector: 'app-event-banner',
imports: [],
template: `
@for (ev of events; track ev.id) {
<div class="evt" [style.--c]="ev.color">
<span class="evt__icon">{{ ev.icon }}</span>
<span class="evt__text">{{ i18n.label(ev.titleEs, ev.titleCa) }}</span>
@if (ttsEnabled && tts.supported) {
<button type="button" class="evt__tts" (click)="speak(ev)" aria-label="Leer en voz alta">🔊</button>
}
</div>
}
`,
styles: [
`
.evt {
display: flex;
align-items: center;
gap: 12px;
padding: 12px 16px;
border-radius: var(--radius-sm);
background: color-mix(in srgb, var(--c) 16%, #fff);
border: 2px solid color-mix(in srgb, var(--c) 45%, #fff);
margin-bottom: 10px;
}
.evt__icon { font-size: 28px; }
.evt__text {
flex: 1;
font-family: var(--font-display);
font-weight: 700;
color: var(--text-strong);
}
.evt__tts { all: unset; cursor: pointer; font-size: 24px; }
`,
],
})
export class EventBannerComponent {
@Input() events: EventView[] = [];
@Input() ttsEnabled = true;
protected readonly i18n = inject(I18nService);
protected readonly tts = inject(TtsService);
speak(ev: EventView): void {
this.tts.speak(this.i18n.label(ev.titleEs, ev.titleCa), this.i18n.lang());
}
}

View File

@@ -0,0 +1,145 @@
import {
Component,
ElementRef,
EventEmitter,
Input,
Output,
ViewChild,
computed,
inject,
signal,
} from '@angular/core';
import { I18nService } from '../../core/i18n.service';
import { TtsService } from '../../core/tts.service';
import { TaskView } from '../../core/models';
import { ToggleEvent } from './task-card.component';
/** Vista FOCO: una sola tarea a pantalla completa, clave para reducir carga en TDAH. */
@Component({
selector: 'app-focus-view',
imports: [],
template: `
@if (current(); as task) {
<div class="focus">
<div class="focus__nav">
<button type="button" class="navbtn" [disabled]="index() === 0" (click)="prev()">‹</button>
<div class="focus__stage">
<span class="hero" [class.hero--done]="task.done" [style.--c]="task.color">{{ task.icon }}</span>
<h2 class="focus__label">{{ i18n.label(task.labelEs, task.labelCa) }}</h2>
</div>
<button type="button" class="navbtn" [disabled]="index() >= tasks.length - 1" (click)="next()">›</button>
</div>
<div class="dots">
@for (t of tasks; track t.id; let i = $index) {
<span class="dot" [class.dot--done]="t.done" [class.dot--current]="i === index()"></span>
}
</div>
<div class="focus__actions">
<button #doneBtn type="button" class="bigbtn" [class.bigbtn--done]="task.done" (click)="emitToggle()">
{{ task.done ? '✓' : i18n.t('done') }}
</button>
@if (ttsEnabled && tts.supported) {
<button type="button" class="speakbtn" (click)="speak(task)" aria-label="Leer en voz alta">🔊</button>
}
</div>
<p class="focus__foot">
{{ i18n.t('left') }} {{ remaining() }} · {{ i18n.t('next') }}: {{ nextLabel() }}
</p>
</div>
}
`,
styles: [
`
.focus { display: flex; flex-direction: column; align-items: center; gap: var(--space-5); padding: var(--space-5) 0; }
.focus__nav { display: flex; align-items: center; gap: var(--space-5); }
.focus__stage { display: flex; flex-direction: column; align-items: center; gap: var(--space-4); min-width: 260px; }
.hero {
width: var(--hero-size);
height: var(--hero-size);
border-radius: 44px;
display: flex;
align-items: center;
justify-content: center;
font-size: 120px;
background: color-mix(in srgb, var(--c) 16%, #fff);
border: 4px solid color-mix(in srgb, var(--c) 35%, #fff);
animation: floatY 3.5s ease-in-out infinite;
}
.hero--done { background: color-mix(in srgb, var(--c) 22%, #fff); border-color: var(--c); animation: pop 0.4s ease; }
.focus__label { margin: 0; font-size: 2rem; text-transform: uppercase; text-align: center; color: var(--text-strong); }
.navbtn {
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: 38px; color: var(--text-4);
}
.navbtn:disabled { opacity: 0.3; cursor: default; }
.dots { display: flex; gap: 10px; }
.dot { width: 14px; height: 14px; border-radius: 50%; background: var(--border-2); transition: background 0.2s, transform 0.2s; }
.dot--done { background: var(--accent-green); }
.dot--current { transform: scale(1.4); box-shadow: 0 0 0 3px var(--surface-softer); }
.focus__actions { display: flex; align-items: center; gap: var(--space-4); }
.bigbtn {
font-family: var(--font-display); font-weight: 700; font-size: 1.6rem; color: #fff;
border: 0; border-radius: 24px; padding: 18px 48px; min-height: 72px; cursor: pointer;
background: var(--accent-blue); transition: transform 0.12s;
}
.bigbtn--done { background: var(--accent-green); }
.bigbtn:active { transform: scale(0.97); }
.speakbtn { all: unset; cursor: pointer; font-size: 34px; }
.focus__foot { margin: 0; color: var(--text-2); font-family: var(--font-display); font-weight: 600; }
`,
],
})
export class FocusViewComponent {
@Input() set tasksInput(value: TaskView[]) {
this.tasks = value;
if (this.index() >= value.length) {
this.index.set(Math.max(0, value.length - 1));
}
}
@Input() ttsEnabled = true;
@Output() toggle = new EventEmitter<ToggleEvent>();
@ViewChild('doneBtn') private doneBtn?: ElementRef<HTMLElement>;
protected tasks: TaskView[] = [];
protected readonly index = signal(0);
protected readonly i18n = inject(I18nService);
protected readonly tts = inject(TtsService);
protected readonly current = computed(() => this.tasks[this.index()] ?? null);
protected readonly remaining = computed(() => this.tasks.filter((t) => !t.done).length);
protected readonly nextLabel = computed(() => {
const nextTask = this.tasks[this.index() + 1];
return nextTask ? this.i18n.label(nextTask.labelEs, nextTask.labelCa) : '—';
});
prev(): void {
this.index.update((i) => Math.max(0, i - 1));
}
next(): void {
this.index.update((i) => Math.min(this.tasks.length - 1, i + 1));
}
emitToggle(): void {
const task = this.current();
if (!task) {
return;
}
const rect = this.doneBtn?.nativeElement.getBoundingClientRect();
this.toggle.emit({
taskId: task.id,
x: rect ? rect.left + rect.width / 2 : window.innerWidth / 2,
y: rect ? rect.top : window.innerHeight / 2,
});
}
speak(task: TaskView): void {
this.tts.speak(this.i18n.label(task.labelEs, task.labelCa), this.i18n.lang());
}
}

View File

@@ -0,0 +1,73 @@
@if (today(); as t) {
<main class="home">
<!-- Cabecera -->
<header class="home__top">
<button type="button" class="iconbtn" (click)="goProfiles()" aria-label="Volver a perfiles">‹</button>
<div class="home__greet">
<p class="home__hello">{{ i18n.t('hello') }}, {{ t.child.name }} {{ t.child.mascot }}</p>
<p class="home__date">{{ dateLabel() }}</p>
</div>
<app-morning-timer [minutes]="t.timer.minutesUntilDeparture" />
<div class="home__wallet">
<button type="button" class="iconbtn" (click)="goStore()" aria-label="Tienda">🎁</button>
<app-wallet #wallet [coins]="t.wallet.coins" />
</div>
</header>
<!-- 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>
<!-- Eventos del día -->
<app-event-banner [events]="t.specialEvents" [ttsEnabled]="t.child.ttsEnabled" />
<!-- Progreso global -->
<app-progress-bar [done]="t.progress.totalDone" [total]="t.progress.total" />
<!-- Tareas: tablero o foco -->
@if (mode() === 'BOARD') {
<app-board-view
[morning]="t.morning"
[afternoon]="t.afternoon"
[ttsEnabled]="t.child.ttsEnabled"
(toggle)="onToggle($event)"
/>
} @else {
<app-focus-view [tasksInput]="allTasks()" [ttsEnabled]="t.child.ttsEnabled" (toggle)="onToggle($event)" />
}
</main>
<!-- Monedas voladoras (capa superpuesta) -->
@for (coin of coins(); track coin.id) {
<span
class="flycoin"
[style.left.px]="coin.x"
[style.top.px]="coin.y"
[style.transform]="coin.flying ? 'translate(' + coin.dx + 'px,' + coin.dy + 'px) scale(.4)' : 'translate(0,0) scale(1)'"
[style.opacity]="coin.flying ? 0 : 1"
>🪙</span
>
}
<!-- Celebración al completar el día -->
@if (celebrating()) {
<app-celebration-overlay
[coinsDay]="lastEarned()"
[mascot]="t.child.mascot"
(close)="celebrating.set(false)"
/>
}
} @else if (loading()) {
<p class="home__msg">Cargando el día…</p>
} @else {
<p class="home__msg">No se pudo cargar el día. ¿Está arrancado el backend?</p>
}

View File

@@ -0,0 +1,100 @@
:host {
display: block;
}
.home {
max-width: 1000px;
margin: 0 auto;
padding: var(--space-5) var(--space-4);
display: flex;
flex-direction: column;
gap: var(--space-5);
&__msg {
text-align: center;
padding: var(--space-6);
color: var(--text-1);
}
&__top {
display: flex;
align-items: center;
gap: var(--space-4);
flex-wrap: wrap;
}
&__greet {
flex: 1;
min-width: 180px;
}
&__hello {
margin: 0;
font-family: var(--font-display);
font-weight: 700;
font-size: 1.6rem;
color: var(--text-strong);
}
&__date {
margin: 2px 0 0;
color: var(--text-2);
text-transform: capitalize;
}
&__wallet {
display: flex;
align-items: center;
gap: var(--space-3);
}
}
.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);
flex: none;
}
// Conmutador Tablero / Foco.
.modes {
display: inline-flex;
align-self: center;
background: var(--surface-softer);
border-radius: var(--radius-pill);
padding: 4px;
gap: 4px;
&__btn {
all: unset;
cursor: pointer;
padding: 10px 22px;
border-radius: var(--radius-pill);
font-family: var(--font-display);
font-weight: 700;
color: var(--text-2);
transition: background 0.2s, color 0.2s;
}
&__btn--on {
background: var(--surface);
color: var(--text-strong);
box-shadow: var(--shadow-card);
}
}
// Moneda voladora: parte de la tarea y viaja al monedero.
.flycoin {
position: fixed;
z-index: 60;
font-size: 40px;
pointer-events: none;
will-change: transform, opacity;
transition: transform 0.72s cubic-bezier(0.4, 0, 0.5, 1), opacity 0.72s;
}

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]);
}
}

View File

@@ -0,0 +1,41 @@
import { Component, Input, inject } from '@angular/core';
import { I18nService } from '../../core/i18n.service';
/** Temporizador de salida de la mañana: "SALIMOS EN {min} min" con anillo glow. */
@Component({
selector: 'app-morning-timer',
imports: [],
template: `
@if (minutes !== null) {
<div class="timer">
<span class="timer__ring">⏰</span>
<span class="timer__text">{{ i18n.t('leaveIn') }} {{ minutes }} {{ i18n.t('min') }}</span>
</div>
}
`,
styles: [
`
.timer {
display: inline-flex;
align-items: center;
gap: 8px;
padding: 8px 16px;
border-radius: var(--radius-pill);
background: color-mix(in srgb, var(--accent-orange) 14%, #fff);
color: var(--text-strong);
font-family: var(--font-display);
font-weight: 700;
}
.timer__ring {
font-size: 22px;
border-radius: 50%;
animation: ringGlow 2.2s ease-in-out infinite;
}
`,
],
})
export class MorningTimerComponent {
@Input() minutes: number | null = null;
protected readonly i18n = inject(I18nService);
}

View File

@@ -0,0 +1,49 @@
import { Component, Input, inject } from '@angular/core';
import { I18nService } from '../../core/i18n.service';
/** Barra de progreso global del día: "{hechas}/{total} listo ✨". */
@Component({
selector: 'app-progress-bar',
imports: [],
template: `
<div class="prog">
<div class="prog__track">
<div class="prog__fill" [style.width.%]="pct"></div>
</div>
<p class="prog__label">{{ done }}/{{ total }} {{ i18n.t('ready') }} ✨</p>
</div>
`,
styles: [
`
.prog { display: flex; flex-direction: column; gap: 6px; }
.prog__track {
height: 16px;
border-radius: var(--radius-pill);
background: var(--surface-softer);
overflow: hidden;
}
.prog__fill {
height: 100%;
border-radius: var(--radius-pill);
background: linear-gradient(90deg, var(--accent-green), var(--accent-teal));
transition: width 0.5s ease;
}
.prog__label {
margin: 0;
font-family: var(--font-display);
font-weight: 600;
color: var(--text-1);
}
`,
],
})
export class ProgressBarComponent {
@Input() done = 0;
@Input() total = 0;
protected readonly i18n = inject(I18nService);
get pct(): number {
return this.total > 0 ? Math.round((this.done / this.total) * 100) : 0;
}
}

View File

@@ -0,0 +1,131 @@
import { Component, ElementRef, EventEmitter, Input, Output, ViewChild, inject } from '@angular/core';
import { I18nService } from '../../core/i18n.service';
import { TtsService } from '../../core/tts.service';
import { TaskView } from '../../core/models';
/** Coordenadas (centro del check) desde donde sale la moneda voladora. */
export interface ToggleEvent {
taskId: number;
x: number;
y: number;
}
/**
* Tarjeta de tarea del Tablero: icono en tile, etiqueta en MAYÚSCULAS, botón de
* lectura (TTS) y check grande. Réplica fiel del handoff (borde 3px, radio 26px,
* tinte del color al completar, animaciones pop/checkPop).
*/
@Component({
selector: 'app-task-card',
imports: [],
template: `
<div
class="card"
[class.card--done]="task.done"
[style.--c]="task.color"
(click)="emitToggle()"
>
<span class="card__tile">{{ task.icon }}</span>
<span class="card__label">{{ i18n.label(task.labelEs, task.labelCa) }}</span>
@if (ttsEnabled && tts.supported) {
<button type="button" class="card__tts" (click)="speak($event)" aria-label="Leer en voz alta">
🔊
</button>
}
<span #check class="card__check">{{ task.done ? '✓' : '' }}</span>
</div>
`,
styles: [
`
.card {
display: flex;
align-items: center;
gap: var(--space-4);
min-height: var(--card-min-height);
padding: 14px 16px;
background: var(--surface);
border: var(--card-border-width) solid var(--card-border-idle);
border-radius: var(--radius-card);
box-shadow: var(--shadow-card);
cursor: pointer;
transition: transform 0.15s, border-color 0.25s, background 0.25s;
}
.card:active { transform: scale(0.99); }
.card__tile {
width: var(--tile-size);
height: var(--tile-size);
border-radius: var(--radius-tile);
display: flex;
align-items: center;
justify-content: center;
font-size: 38px;
flex: none;
background: color-mix(in srgb, var(--c) 16%, #fff);
}
.card__label {
flex: 1;
font-family: var(--font-display);
font-weight: 700;
font-size: 1.15rem;
text-transform: uppercase;
color: var(--text-strong);
}
.card__tts {
all: unset;
cursor: pointer;
font-size: 26px;
padding: 6px;
border-radius: 50%;
flex: none;
}
.card__check {
width: var(--touch-check);
height: var(--touch-check);
border-radius: 50%;
border: 3px solid var(--border-2);
display: flex;
align-items: center;
justify-content: center;
font-size: 28px;
color: #fff;
flex: none;
}
.card--done {
background: color-mix(in srgb, var(--c) 14%, #fff);
border-color: var(--c);
animation: pop 0.35s ease;
}
.card--done .card__check {
background: var(--c);
border-color: var(--c);
animation: checkPop 0.35s ease;
}
`,
],
})
export class TaskCardComponent {
@Input({ required: true }) task!: TaskView;
@Input() ttsEnabled = true;
@Output() toggle = new EventEmitter<ToggleEvent>();
@ViewChild('check') private check!: ElementRef<HTMLElement>;
protected readonly i18n = inject(I18nService);
protected readonly tts = inject(TtsService);
emitToggle(): void {
const rect = this.check.nativeElement.getBoundingClientRect();
this.toggle.emit({
taskId: this.task.id,
x: rect.left + rect.width / 2,
y: rect.top + rect.height / 2,
});
}
/** Lee la etiqueta en voz alta sin propagar el toque al check. */
speak(event: Event): void {
event.stopPropagation();
this.tts.speak(this.i18n.label(this.task.labelEs, this.task.labelCa), this.i18n.lang());
}
}

View File

@@ -0,0 +1,49 @@
import { Component, ElementRef, Input, inject, signal } from '@angular/core';
/** Monedero: pill con 🪙 y el saldo. Hace walletBump cuando se le pide (al ganar). */
@Component({
selector: 'app-wallet',
imports: [],
template: `
<span class="wallet" [class.wallet--bump]="bumping()">
<span class="wallet__coin">🪙</span>
<span class="wallet__amount">{{ coins }}</span>
</span>
`,
styles: [
`
.wallet {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 9px 18px;
border-radius: var(--radius-pill);
background: var(--coin-bg);
color: var(--coin-text);
font-family: var(--font-display);
font-weight: 700;
font-size: 1.4rem;
}
.wallet--bump { animation: walletBump 0.45s ease; }
`,
],
})
export class WalletComponent {
@Input({ required: true }) coins = 0;
protected readonly bumping = signal(false);
private readonly el = inject(ElementRef<HTMLElement>);
/** Rectángulo del monedero: destino de las monedas voladoras. */
getRect(): DOMRect {
return (this.el.nativeElement as HTMLElement).getBoundingClientRect();
}
/** Dispara la animación de "rebote" del monedero. */
bump(): void {
this.bumping.set(false);
// Reinicia la animación en el siguiente frame.
requestAnimationFrame(() => this.bumping.set(true));
setTimeout(() => this.bumping.set(false), 500);
}
}