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,25 @@
import { TestBed } from '@angular/core/testing';
import { provideRouter } from '@angular/router';
import { AppComponent } from './app.component';
import { FontPreferenceService } from './core/font-preference.service';
describe('AppComponent', () => {
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [AppComponent],
providers: [provideRouter([])],
}).compileComponents();
});
it('should create the app', () => {
const fixture = TestBed.createComponent(AppComponent);
expect(fixture.componentInstance).toBeTruthy();
});
it('debe aplicar OpenDyslexic por defecto al arrancar', () => {
TestBed.createComponent(AppComponent); // fuerza la inicialización del servicio
const fontPreference = TestBed.inject(FontPreferenceService);
expect(fontPreference.enabled()).toBe(true);
expect(document.documentElement.getAttribute('data-dyslexia-font')).toBe('on');
});
});

View File

@@ -0,0 +1,15 @@
import { Component, inject } from '@angular/core';
import { RouterOutlet } from '@angular/router';
import { FontPreferenceService } from './core/font-preference.service';
/** Componente raíz: monta el router. La navegación arranca en Perfiles. */
@Component({
selector: 'app-root',
imports: [RouterOutlet],
template: '<router-outlet />',
})
export class AppComponent {
// Inyectar el servicio fuerza su inicialización: aplica la preferencia de
// tipografía (OpenDyslexic por defecto) sobre <html> al arrancar la app.
private readonly fontPreference = inject(FontPreferenceService);
}

View File

@@ -0,0 +1,15 @@
import { ApplicationConfig, provideZoneChangeDetection } from '@angular/core';
import { provideHttpClient, withFetch, withInterceptors } from '@angular/common/http';
import { provideRouter } from '@angular/router';
import { routes } from './app.routes';
import { parentSessionInterceptor } from './core/parent-session.interceptor';
export const appConfig: ApplicationConfig = {
providers: [
provideZoneChangeDetection({ eventCoalescing: true }),
provideRouter(routes),
// Cliente HTTP (fetch) con el interceptor de sesión de padres.
provideHttpClient(withFetch(), withInterceptors([parentSessionInterceptor])),
],
};

View File

@@ -0,0 +1,20 @@
import { Routes } from '@angular/router';
import { ProfileSelectComponent } from './features/profiles/profile-select.component';
import { HomeComponent } from './features/home/home.component';
import { StoreComponent } from './features/store/store.component';
import { KeypadComponent } from './features/parents/keypad.component';
import { ParentsComponent } from './features/parents/parents.component';
import { parentGuard } from './core/parent.guard';
export const routes: Routes = [
// Selección de perfil: la pantalla de entrada del kiosko.
{ path: '', component: ProfileSelectComponent },
// Día de hoy del niño (Tablero / Foco).
{ path: 'home/:childId', component: HomeComponent },
// Tienda de recompensas.
{ path: 'store/:childId', component: StoreComponent },
// PIN de padres y panel protegido por sesión.
{ path: 'pin', component: KeypadComponent },
{ path: 'parents', component: ParentsComponent, canActivate: [parentGuard] },
{ path: '**', redirectTo: '' },
];

View File

@@ -0,0 +1,60 @@
import { HttpClient } from '@angular/common/http';
import { Injectable, inject } from '@angular/core';
import { Observable } from 'rxjs';
import {
ChildSummary,
RedeemResult,
RewardView,
SettingsRequest,
TodayResponse,
ToggleResult,
} from './models';
/**
* Cliente HTTP tipado contra la API REST del backend. Las rutas son relativas
* ("/api/..."); en producción las sirve Nginx (mismo origen) y en desarrollo el
* proxy de ng serve.
*/
@Injectable({ providedIn: 'root' })
export class ApiService {
private readonly http = inject(HttpClient);
private readonly base = '/api';
/** Perfiles de niños (pantalla de selección). */
getChildren(): Observable<ChildSummary[]> {
return this.http.get<ChildSummary[]>(`${this.base}/children`);
}
/** Día de hoy del niño: tareas, eventos, progreso, monedero y temporizador. */
getToday(childId: number): Observable<TodayResponse> {
return this.http.get<TodayResponse>(`${this.base}/children/${childId}/today`);
}
/** 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`, {});
}
/** Actualiza ajustes del niño (modo de vista, sonido, TTS, idioma, hora salida). */
updateSettings(childId: number, settings: SettingsRequest): Observable<void> {
return this.http.put<void>(`${this.base}/children/${childId}/settings`, settings);
}
/** Monedero del niño: saldo (y, opcionalmente, historial). */
getWallet(childId: number): Observable<{ coins: number }> {
return this.http.get<{ coins: number }>(`${this.base}/children/${childId}/wallet`);
}
/** Tienda: premios visibles para el niño (Fase 5). */
getRewards(childId: number): Observable<RewardView[]> {
return this.http.get<RewardView[]>(`${this.base}/children/${childId}/rewards`);
}
/** Canje de premio (Fase 5). */
redeem(childId: number, rewardId: number): Observable<RedeemResult> {
return this.http.post<RedeemResult>(
`${this.base}/rewards/${rewardId}/redeem?childId=${childId}`,
{},
);
}
}

View File

@@ -0,0 +1,80 @@
import { DOCUMENT } from '@angular/common';
import { Injectable, inject, signal } from '@angular/core';
/**
* Gestiona la preferencia de tipografía OpenDyslexic.
*
* Es la "costura" de accesibilidad: aplica (o quita) el atributo
* `data-dyslexia-font` en el elemento <html>, que es el interruptor que el
* fichero de tokens (_theme.scss) usa para alternar entre OpenDyslexic y las
* tipografías de marca del handoff (Fredoka/Nunito).
*
* Decisión de producto (Fase 1): OpenDyslexic activada POR DEFECTO y aplicada a
* TODO el texto. Es una preferencia por niño; de momento se persiste en
* localStorage. En la Fase 5 esta preferencia pasará a leerse/escribirse contra
* el backend (ajustes por niño), sustituyendo el almacenamiento local.
*/
@Injectable({ providedIn: 'root' })
export class FontPreferenceService {
/** Clave de persistencia temporal hasta el cableado con el backend. */
private static readonly STORAGE_KEY = 'recordalexia.dyslexiaFont';
private readonly document = inject(DOCUMENT);
/** Estado reactivo: ¿está activada OpenDyslexic? Por defecto, sí. */
private readonly enabledSignal = signal<boolean>(this.readInitialState());
/** Señal de solo lectura para que la consuma la UI. */
readonly enabled = this.enabledSignal.asReadonly();
constructor() {
// Sincroniza el DOM con el estado inicial al arrancar la app.
this.applyToDom(this.enabledSignal());
}
/** Activa o desactiva OpenDyslexic y propaga el cambio al DOM y a la persistencia. */
setEnabled(enabled: boolean): void {
this.enabledSignal.set(enabled);
this.applyToDom(enabled);
this.persist(enabled);
}
/** Alterna el estado actual. */
toggle(): void {
this.setEnabled(!this.enabledSignal());
}
/** Lee el estado inicial de localStorage; si no hay nada guardado, ACTIVA por defecto. */
private readInitialState(): boolean {
const stored = this.safeGetItem(FontPreferenceService.STORAGE_KEY);
return stored === null ? true : stored === 'true';
}
/** Refleja la preferencia en <html data-dyslexia-font="on|off">. */
private applyToDom(enabled: boolean): void {
this.document.documentElement.setAttribute(
'data-dyslexia-font',
enabled ? 'on' : 'off',
);
}
/** Guarda la preferencia, tolerando entornos sin localStorage. */
private persist(enabled: boolean): void {
try {
this.document.defaultView?.localStorage.setItem(
FontPreferenceService.STORAGE_KEY,
String(enabled),
);
} catch {
// localStorage no disponible (modo kiosko restringido): se ignora.
}
}
private safeGetItem(key: string): string | null {
try {
return this.document.defaultView?.localStorage.getItem(key) ?? null;
} catch {
return null;
}
}
}

View File

@@ -0,0 +1,74 @@
import { Injectable, signal } from '@angular/core';
import { Language } from './models';
/**
* Internacionalización ligera ES/CA. Mantiene el idioma activo (signal) y ofrece:
* - label(es, ca): elige la variante correcta de un texto bilingüe del backend.
* - t(key): textos fijos de la UI.
* El idioma es una preferencia del niño; se sincroniza con su ajuste al entrar.
*/
@Injectable({ providedIn: 'root' })
export class I18nService {
readonly lang = signal<Language>('ES');
private readonly strings: Record<string, { ES: string; CA: string }> = {
whoEntersToday: { ES: '¿QUIÉN ENTRA HOY?', CA: 'QUI ENTRA AVUI?' },
parents: { ES: 'Padres', CA: 'Pares' },
hello: { ES: 'Hola', CA: 'Hola' },
leaveIn: { ES: 'SALIMOS EN', CA: 'SORTIM EN' },
min: { ES: 'min', CA: 'min' },
school: { ES: 'COLE', CA: 'COLE' },
afternoon: { ES: 'TARDE', CA: 'TARDA' },
ready: { ES: 'listo', CA: 'fet' },
noSchool: { ES: 'HOY NO HAY COLE', CA: 'AVUI NO HI HA COLE' },
allDone: { ES: '¡TODO LISTO!', CA: 'TOT FET!' },
great: { ES: '¡GENIAL!', CA: 'GENIAL!' },
done: { ES: '¡HECHO!', CA: 'FET!' },
left: { ES: 'Quedan', CA: 'Queden' },
next: { ES: 'Después', CA: 'Després' },
exam: { ES: 'Examen', CA: 'Examen' },
homework: { ES: 'Deberes', CA: 'Deures' },
board: { ES: 'Tablero', CA: 'Tauler' },
focus: { ES: 'Foco', CA: 'Focus' },
store: { ES: 'Tienda', CA: 'Botiga' },
// Tienda
redeem: { ES: 'Canjear', CA: 'Bescanviar' },
missing: { ES: 'Te faltan', CA: "T'en falten" },
redeemed: { ES: '¡Canjeado!', CA: 'Bescanviat!' },
// PIN
enterPin: { ES: 'Introduce el PIN', CA: 'Introdueix el PIN' },
wrongPin: { ES: 'PIN incorrecto', CA: 'PIN incorrecte' },
// Panel de padres
logout: { ES: 'Salir', CA: 'Sortir' },
tabSchedule: { ES: 'Horario', CA: 'Horari' },
tabMaterials: { ES: 'Materiales', CA: 'Materials' },
tabEvents: { ES: 'Eventos', CA: 'Esdeveniments' },
tabRoutines: { ES: 'Rutinas', CA: 'Rutines' },
tabRewards: { ES: 'Recompensas', CA: 'Recompenses' },
add: { ES: 'Añadir', CA: 'Afegir' },
del: { ES: 'Borrar', CA: 'Esborrar' },
save: { ES: 'Guardar', CA: 'Desar' },
child: { ES: 'Niño', CA: 'Nen' },
perTask: { ES: 'Por tarea', CA: 'Per tasca' },
perBlock: { ES: 'Por bloque', CA: 'Per bloc' },
perDay: { ES: 'Por día', CA: 'Per dia' },
sound: { ES: 'Sonido', CA: 'So' },
readAloud: { ES: 'Lectura en voz alta', CA: 'Lectura en veu alta' },
none: { ES: 'No hay nada todavía', CA: 'Encara no hi ha res' },
};
setLang(lang: Language): void {
this.lang.set(lang);
}
/** Devuelve la variante del texto bilingüe según el idioma activo. */
label(es: string, ca: string): string {
return this.lang() === 'CA' ? ca : es;
}
/** Texto fijo de UI por clave. */
t(key: string): string {
const entry = this.strings[key];
return entry ? entry[this.lang()] : key;
}
}

View File

@@ -0,0 +1,33 @@
import { DOCUMENT } from '@angular/common';
import { Injectable, inject } from '@angular/core';
/**
* Utilidades del modo kiosko: pantalla completa para la tablet junto a la puerta.
* (La orientación horizontal se asume por el montaje físico de la tablet.)
*/
@Injectable({ providedIn: 'root' })
export class KioskService {
private readonly document = inject(DOCUMENT);
get isFullscreen(): boolean {
return this.document.fullscreenElement !== null;
}
/** Pide pantalla completa (debe llamarse desde un gesto del usuario). */
async enterFullscreen(): Promise<void> {
const el = this.document.documentElement;
if (el.requestFullscreen && !this.isFullscreen) {
try {
await el.requestFullscreen();
} catch {
// Algunos navegadores la deniegan sin gesto; se ignora silenciosamente.
}
}
}
async exitFullscreen(): Promise<void> {
if (this.isFullscreen && this.document.exitFullscreen) {
await this.document.exitFullscreen();
}
}
}

View File

@@ -0,0 +1,256 @@
// Modelos TypeScript alineados con los DTOs del backend (es.asepeyo.recordalexia.web.dto).
// Mantener en sincronía con el backend, en especial TodayResponse.
export type ViewMode = 'BOARD' | 'FOCUS';
export type Language = 'ES' | 'CA';
/** Resumen de perfil para la pantalla de selección. */
export interface ChildSummary {
id: number;
name: string;
mascot: string;
accentColor: string;
age: number;
coins: number;
viewMode: ViewMode;
language: Language;
}
/** Datos del niño embebidos en /today. */
export interface ChildInfo {
id: number;
name: string;
mascot: string;
accentColor: string;
viewMode: ViewMode;
language: Language;
soundEnabled: boolean;
ttsEnabled: boolean;
}
/** Tarea del día (mañana o tarde). Lleva texto ES y CA. */
export interface TaskView {
id: number;
labelEs: string;
labelCa: string;
icon: string;
color: string;
done: boolean;
coinsReward: number;
orderIndex: number;
}
/** Evento del día (examen/deberes) para el banner. */
export interface EventView {
id: number;
type: 'EXAM' | 'HOMEWORK';
titleEs: string;
titleCa: string;
icon: string;
color: string;
}
export interface ProgressView {
morningDone: number;
morningTotal: number;
afternoonDone: number;
afternoonTotal: number;
totalDone: number;
total: number;
}
export interface WalletInfo {
coins: number;
}
export interface TimerInfo {
departureTime: string | null;
minutesUntilDeparture: number | null;
}
/** Payload completo de GET /api/children/{id}/today. */
export interface TodayResponse {
child: ChildInfo;
morning: TaskView[];
afternoon: TaskView[];
specialEvents: EventView[];
progress: ProgressView;
wallet: WalletInfo;
timer: TimerInfo;
}
/** Resultado de marcar/desmarcar una tarea. */
export interface ToggleResult {
taskId: number;
done: boolean;
coinsEarned: number;
newBalance: number;
progress: ProgressView;
}
/** Ajustes editables del niño (todos opcionales). */
export interface SettingsRequest {
viewMode?: ViewMode;
soundEnabled?: boolean;
ttsEnabled?: boolean;
language?: Language;
departureTime?: string;
}
/** Premio visible en la tienda (Fase 5). */
export interface RewardView {
id: number;
labelEs: string;
labelCa: string;
icon: string;
color: string;
cost: number;
affordable: boolean;
missing: number;
}
export interface RedeemResult {
rewardId: number;
cost: number;
newBalance: number;
}
// ----- Panel de padres: vistas de lectura -----
export interface RewardAdminView {
id: number;
labelEs: string;
labelCa: string;
icon: string;
color: string;
cost: number;
active: boolean;
}
export interface MaterialView {
id: number;
labelEs: string;
labelCa: string;
icon: string;
color: string;
category: string | null;
}
export interface ActivityView {
id: number;
labelEs: string;
labelCa: string;
icon: string;
color: string;
materialIds: number[];
}
export interface WeeklyEntryView {
id: number;
childId: number;
dayOfWeek: string;
activityId: number;
activityLabelEs: string;
icon: string;
color: string;
orderIndex: number;
coinsReward: number | null;
}
export interface RoutineView {
id: number;
childId: number;
dayOfWeek: string;
labelEs: string;
labelCa: string;
icon: string;
color: string;
orderIndex: number;
coinsReward: number | null;
}
export interface EventAdminView {
id: number;
childId: number;
date: string;
type: 'EXAM' | 'HOMEWORK';
titleEs: string;
titleCa: string;
icon: string;
color: string;
}
export interface GamificationView {
coinsPerTask: number;
coinsPerBlock: number;
coinsPerDay: number;
}
// ----- Panel de padres: peticiones -----
export interface ChildRequest {
name?: string;
mascot?: string;
accentColor?: string;
age?: number;
departureTime?: string;
coins?: number;
}
export interface GamificationRequest {
coinsPerTask?: number;
coinsPerBlock?: number;
coinsPerDay?: number;
}
export interface RewardRequest {
labelEs: string;
labelCa: string;
icon: string;
color: string;
cost: number;
active?: boolean;
}
export interface MaterialRequest {
labelEs: string;
labelCa: string;
icon: string;
color: string;
category?: string;
}
export interface ActivityRequest {
labelEs: string;
labelCa: string;
icon: string;
color: string;
materialIds: number[];
}
export interface WeeklyEntryRequest {
childId: number;
dayOfWeek: string;
activityId: number;
orderIndex?: number;
coinsReward?: number;
}
export interface AfternoonRoutineRequest {
childId: number;
dayOfWeek: string;
labelEs: string;
labelCa: string;
icon: string;
color: string;
orderIndex?: number;
coinsReward?: number;
}
export interface SpecialEventRequest {
childId: number;
date: string;
type: 'EXAM' | 'HOMEWORK';
titleEs: string;
titleCa: string;
icon: string;
color: string;
}

View File

@@ -0,0 +1,125 @@
import { HttpClient } from '@angular/common/http';
import { Injectable, inject } from '@angular/core';
import { Observable } from 'rxjs';
import {
ActivityRequest,
ActivityView,
AfternoonRoutineRequest,
ChildRequest,
ChildSummary,
EventAdminView,
GamificationRequest,
GamificationView,
MaterialRequest,
MaterialView,
RewardAdminView,
RewardRequest,
RoutineView,
SettingsRequest,
SpecialEventRequest,
WeeklyEntryRequest,
WeeklyEntryView,
} from './models';
/**
* Cliente del panel de padres (/api/parents/**). El interceptor añade la cabecera
* de sesión automáticamente. Cubre el CRUD de niños, catálogo, horario, rutinas,
* eventos, premios y la gamificación.
*/
@Injectable({ providedIn: 'root' })
export class ParentApiService {
private readonly http = inject(HttpClient);
private readonly base = '/api/parents';
// --- Niños y gamificación ---
listChildren(): Observable<ChildSummary[]> {
return this.http.get<ChildSummary[]>(`${this.base}/children`);
}
createChild(req: ChildRequest): Observable<ChildSummary> {
return this.http.post<ChildSummary>(`${this.base}/children`, req);
}
updateChild(id: number, req: ChildRequest): Observable<void> {
return this.http.put<void>(`${this.base}/children/${id}`, req);
}
deleteChild(id: number): Observable<void> {
return this.http.delete<void>(`${this.base}/children/${id}`);
}
getGamification(id: number): Observable<GamificationView> {
return this.http.get<GamificationView>(`${this.base}/children/${id}/gamification`);
}
updateGamification(id: number, req: GamificationRequest): Observable<void> {
return this.http.put<void>(`${this.base}/children/${id}/gamification`, req);
}
// Ajustes (sonido/TTS/idioma): endpoint público del niño, no del panel.
updateSettings(id: number, req: SettingsRequest): Observable<void> {
return this.http.put<void>(`/api/children/${id}/settings`, req);
}
// --- Premios ---
listRewards(): Observable<RewardAdminView[]> {
return this.http.get<RewardAdminView[]>(`${this.base}/rewards`);
}
createReward(req: RewardRequest): Observable<RewardAdminView> {
return this.http.post<RewardAdminView>(`${this.base}/rewards`, req);
}
updateReward(id: number, req: RewardRequest): Observable<void> {
return this.http.put<void>(`${this.base}/rewards/${id}`, req);
}
deleteReward(id: number): Observable<void> {
return this.http.delete<void>(`${this.base}/rewards/${id}`);
}
// --- Catálogo: materiales y actividades ---
listMaterials(): Observable<MaterialView[]> {
return this.http.get<MaterialView[]>(`${this.base}/catalog/materials`);
}
createMaterial(req: MaterialRequest): Observable<MaterialView> {
return this.http.post<MaterialView>(`${this.base}/catalog/materials`, req);
}
deleteMaterial(id: number): Observable<void> {
return this.http.delete<void>(`${this.base}/catalog/materials/${id}`);
}
listActivities(): Observable<ActivityView[]> {
return this.http.get<ActivityView[]>(`${this.base}/catalog/activities`);
}
createActivity(req: ActivityRequest): Observable<ActivityView> {
return this.http.post<ActivityView>(`${this.base}/catalog/activities`, req);
}
deleteActivity(id: number): Observable<void> {
return this.http.delete<void>(`${this.base}/catalog/activities/${id}`);
}
// --- Horario y rutinas ---
listWeekly(childId: number): Observable<WeeklyEntryView[]> {
return this.http.get<WeeklyEntryView[]>(`${this.base}/schedule/weekly?childId=${childId}`);
}
createWeekly(req: WeeklyEntryRequest): Observable<WeeklyEntryView> {
return this.http.post<WeeklyEntryView>(`${this.base}/schedule/weekly`, req);
}
deleteWeekly(id: number): Observable<void> {
return this.http.delete<void>(`${this.base}/schedule/weekly/${id}`);
}
listRoutines(childId: number): Observable<RoutineView[]> {
return this.http.get<RoutineView[]>(`${this.base}/schedule/routines?childId=${childId}`);
}
createRoutine(req: AfternoonRoutineRequest): Observable<RoutineView> {
return this.http.post<RoutineView>(`${this.base}/schedule/routines`, req);
}
deleteRoutine(id: number): Observable<void> {
return this.http.delete<void>(`${this.base}/schedule/routines/${id}`);
}
reorderRoutines(orderedIds: number[]): Observable<void> {
return this.http.put<void>(`${this.base}/schedule/routines/reorder`, { orderedIds });
}
// --- Eventos ---
listEvents(childId: number): Observable<EventAdminView[]> {
return this.http.get<EventAdminView[]>(`${this.base}/events?childId=${childId}`);
}
createEvent(req: SpecialEventRequest): Observable<EventAdminView> {
return this.http.post<EventAdminView>(`${this.base}/events`, req);
}
deleteEvent(id: number): Observable<void> {
return this.http.delete<void>(`${this.base}/events/${id}`);
}
}

View File

@@ -0,0 +1,16 @@
import { HttpInterceptorFn } from '@angular/common/http';
import { inject } from '@angular/core';
import { ParentSessionService } from './parent-session.service';
/**
* Añade la cabecera X-Parent-Session a las peticiones del panel de padres
* (/api/parents/**), salvo al propio login. El resto de la API (kiosko) no la lleva.
*/
export const parentSessionInterceptor: HttpInterceptorFn = (req, next) => {
const session = inject(ParentSessionService);
const isParentApi = req.url.includes('/api/parents/') && !req.url.endsWith('/parents/login');
if (isParentApi && session.sessionId) {
return next(req.clone({ setHeaders: { 'X-Parent-Session': session.sessionId } }));
}
return next(req);
};

View File

@@ -0,0 +1,57 @@
import { HttpClient } from '@angular/common/http';
import { Injectable, computed, inject, signal } from '@angular/core';
import { Observable, tap } from 'rxjs';
/**
* Sesión del panel de padres. Guarda el identificador opaco devuelto por el login
* (cabecera X-Parent-Session) y lo mantiene en sessionStorage para sobrevivir a
* recargas mientras dura la pestaña. No es una credencial: es un ticket temporal.
*/
@Injectable({ providedIn: 'root' })
export class ParentSessionService {
private static readonly KEY = 'recordalexia.parentSession';
private readonly http = inject(HttpClient);
private readonly currentId = signal<string | null>(this.read());
readonly isAuthenticated = computed(() => this.currentId() !== null);
/** Identificador actual para la cabecera (o null si no hay sesión). */
get sessionId(): string | null {
return this.currentId();
}
/** Valida el PIN; si es correcto guarda la sesión. */
login(pin: string): Observable<{ session: string }> {
return this.http.post<{ session: string }>('/api/parents/login', { pin }).pipe(
tap((res) => {
this.currentId.set(res.session);
this.write(res.session);
}),
);
}
logout(): void {
this.currentId.set(null);
try {
sessionStorage.removeItem(ParentSessionService.KEY);
} catch {
// sessionStorage no disponible: nada que limpiar.
}
}
private read(): string | null {
try {
return sessionStorage.getItem(ParentSessionService.KEY);
} catch {
return null;
}
}
private write(value: string): void {
try {
sessionStorage.setItem(ParentSessionService.KEY, value);
} catch {
// Ignorar si no hay sessionStorage (modo kiosko restringido).
}
}
}

View File

@@ -0,0 +1,10 @@
import { inject } from '@angular/core';
import { CanActivateFn, Router } from '@angular/router';
import { ParentSessionService } from './parent-session.service';
/** Protege el panel de padres: sin sesión, redirige al teclado del PIN. */
export const parentGuard: CanActivateFn = () => {
const session = inject(ParentSessionService);
const router = inject(Router);
return session.isAuthenticated() ? true : router.createUrlTree(['/pin']);
};

View File

@@ -0,0 +1,50 @@
import { Injectable } from '@angular/core';
/**
* Sonido de recompensa sintetizado con WebAudio (sin ficheros de audio). Un par
* de notas alegres al ganar monedas. Silencioso si WebAudio no está disponible.
*/
@Injectable({ providedIn: 'root' })
export class SoundService {
private audioCtx: AudioContext | null = null;
private ensureContext(): AudioContext | null {
if (typeof window === 'undefined') {
return null;
}
const Ctx = window.AudioContext ?? (window as unknown as { webkitAudioContext?: typeof AudioContext }).webkitAudioContext;
if (!Ctx) {
return null;
}
if (!this.audioCtx) {
this.audioCtx = new Ctx();
}
return this.audioCtx;
}
/** Pequeño arpegio ascendente alegre (ding-ding). */
playReward(): void {
const ctx = this.ensureContext();
if (!ctx) {
return;
}
if (ctx.state === 'suspended') {
ctx.resume();
}
const now = ctx.currentTime;
// Dos notas (mi, sol) cortas y suaves.
[659.25, 783.99].forEach((freq, i) => {
const osc = ctx.createOscillator();
const gain = ctx.createGain();
osc.type = 'sine';
osc.frequency.value = freq;
const start = now + i * 0.12;
gain.gain.setValueAtTime(0.0001, start);
gain.gain.exponentialRampToValueAtTime(0.18, start + 0.02);
gain.gain.exponentialRampToValueAtTime(0.0001, start + 0.25);
osc.connect(gain).connect(ctx.destination);
osc.start(start);
osc.stop(start + 0.28);
});
}
}

View File

@@ -0,0 +1,32 @@
import { Injectable } from '@angular/core';
import { Language } from './models';
/**
* Lectura en voz alta (TTS) con la Web Speech API del navegador. Apoyo a la
* lectura para el niño. Degrada con elegancia si el navegador no la soporta.
*/
@Injectable({ providedIn: 'root' })
export class TtsService {
private get synth(): SpeechSynthesis | null {
return typeof window !== 'undefined' && 'speechSynthesis' in window
? window.speechSynthesis
: null;
}
get supported(): boolean {
return this.synth !== null;
}
/** Lee un texto en el idioma indicado (es-ES / ca-ES). */
speak(text: string, lang: Language = 'ES'): void {
const synth = this.synth;
if (!synth) {
return;
}
synth.cancel(); // corta lo anterior para no solapar
const utterance = new SpeechSynthesisUtterance(text);
utterance.lang = lang === 'CA' ? 'ca-ES' : 'es-ES';
utterance.rate = 0.95; // un punto más lento, para niños
synth.speak(utterance);
}
}

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

View File

@@ -0,0 +1,85 @@
import { Component, Input, inject, signal } from '@angular/core';
import { FormsModule } from '@angular/forms';
import { ParentApiService } from '../../core/parent-api.service';
import { I18nService } from '../../core/i18n.service';
import { EventAdminView } from '../../core/models';
/** Pestaña Eventos: exámenes y deberes con fecha, por niño. */
@Component({
selector: 'app-events-tab',
imports: [FormsModule],
template: `
<div class="adm-card">
<p class="adm-label">Nuevo evento</p>
<div class="adm-row">
<select class="adm-input" [(ngModel)]="type">
<option value="EXAM">📋 {{ i18n.t('exam') }}</option>
<option value="HOMEWORK">📎 {{ i18n.t('homework') }}</option>
</select>
<input class="adm-input" type="date" [(ngModel)]="date" />
<input class="adm-input" [(ngModel)]="titleEs" placeholder="Título (ES)" />
<input class="adm-input" [(ngModel)]="titleCa" placeholder="Títol (CA)" />
<button class="adm-btn" [disabled]="!date || !titleEs || !titleCa" (click)="add()">+ {{ i18n.t('add') }}</button>
</div>
</div>
<div class="adm-card">
<div class="adm-list">
@for (e of events(); track e.id) {
<div class="adm-item">
<span>{{ e.type === 'EXAM' ? '📋' : '📎' }}</span>
<span class="adm-item__grow">
<strong>{{ i18n.label(e.titleEs, e.titleCa) }}</strong> · {{ e.date }}
</span>
<button class="adm-del" (click)="remove(e)">✕</button>
</div>
} @empty {
<p class="adm-empty">{{ i18n.t('none') }}</p>
}
</div>
</div>
`,
})
export class EventsTabComponent {
@Input({ required: true }) set childId(value: number) {
this._childId = value;
this.reload();
}
private _childId!: number;
private readonly api = inject(ParentApiService);
protected readonly i18n = inject(I18nService);
protected readonly events = signal<EventAdminView[]>([]);
protected type: 'EXAM' | 'HOMEWORK' = 'EXAM';
protected date = '';
protected titleEs = '';
protected titleCa = '';
private reload(): void {
this.api.listEvents(this._childId).subscribe((l) => this.events.set(l));
}
add(): void {
const icon = this.type === 'EXAM' ? '📋' : '📎';
const color = this.type === 'EXAM' ? '#EC8FA4' : '#5B8DEF';
this.api
.createEvent({
childId: this._childId,
date: this.date,
type: this.type,
titleEs: this.titleEs,
titleCa: this.titleCa,
icon,
color,
})
.subscribe(() => {
this.titleEs = this.titleCa = this.date = '';
this.reload();
});
}
remove(e: EventAdminView): void {
this.api.deleteEvent(e.id).subscribe(() => this.reload());
}
}

View File

@@ -0,0 +1,137 @@
import { Component, inject, signal } from '@angular/core';
import { Router } from '@angular/router';
import { I18nService } from '../../core/i18n.service';
import { ParentSessionService } from '../../core/parent-session.service';
/** Teclado numérico 3×4 para el PIN de padres (4 dígitos, shake al fallar). */
@Component({
selector: 'app-keypad',
imports: [],
template: `
<main class="key-screen">
<button type="button" class="key-screen__back" (click)="cancel()"></button>
<p class="key-screen__title">{{ i18n.t('enterPin') }} 🔒</p>
<div class="dots" [class.dots--error]="error()">
@for (i of [0, 1, 2, 3]; track i) {
<span class="dot" [class.dot--filled]="entry().length > i"></span>
}
</div>
@if (error()) {
<p class="err">{{ i18n.t('wrongPin') }}</p>
}
<div class="pad">
@for (k of keys; track k.label) {
<button type="button" class="key" [class.key--fn]="k.fn" (click)="press(k)">{{ k.label }}</button>
}
</div>
</main>
`,
styles: [
`
.key-screen {
min-height: 100vh;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: var(--space-5);
position: relative;
}
.key-screen__back {
all: unset;
cursor: pointer;
position: absolute;
top: 24px;
left: 24px;
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);
}
.key-screen__title { margin: 0; font-family: var(--font-display); font-weight: 700; font-size: 1.4rem; }
.dots { display: flex; gap: 18px; }
.dots--error { animation: shake 0.4s ease; }
.dot { width: 20px; height: 20px; border-radius: 50%; border: 3px solid var(--border-3); }
.dot--filled { background: var(--accent-blue); border-color: var(--accent-blue); }
.err { margin: 0; color: var(--accent-pink); font-weight: 700; }
.pad { display: grid; grid-template-columns: repeat(3, var(--touch-pin)); gap: 14px; }
.key {
all: unset;
cursor: pointer;
width: var(--touch-pin);
height: var(--touch-pin);
border-radius: 24px;
background: var(--surface);
box-shadow: var(--shadow-card);
display: flex;
align-items: center;
justify-content: center;
font-family: var(--font-display);
font-weight: 700;
font-size: 1.8rem;
color: var(--text-strong);
transition: transform 0.1s;
}
.key:active { transform: scale(0.94); }
.key--fn { background: var(--surface-softer); font-size: 1.4rem; }
`,
],
})
export class KeypadComponent {
private readonly session = inject(ParentSessionService);
private readonly router = inject(Router);
protected readonly i18n = inject(I18nService);
protected readonly entry = signal('');
protected readonly error = signal(false);
protected readonly keys = [
...[1, 2, 3, 4, 5, 6, 7, 8, 9].map((n) => ({ label: String(n), fn: false })),
{ label: '⌫', fn: true },
{ label: '0', fn: false },
{ label: 'C', fn: true },
];
press(k: { label: string; fn: boolean }): void {
this.error.set(false);
if (k.label === '⌫') {
this.entry.update((e) => e.slice(0, -1));
return;
}
if (k.label === 'C') {
this.entry.set('');
return;
}
if (this.entry().length >= 4) {
return;
}
const next = this.entry() + k.label;
this.entry.set(next);
if (next.length === 4) {
this.submit(next);
}
}
private submit(code: string): void {
this.session.login(code).subscribe({
next: () => this.router.navigate(['/parents']),
error: () => {
this.error.set(true);
this.entry.set('');
},
});
}
cancel(): void {
this.router.navigate(['/']);
}
}

View File

@@ -0,0 +1,151 @@
import { Component, inject, signal } from '@angular/core';
import { FormsModule } from '@angular/forms';
import { ParentApiService } from '../../core/parent-api.service';
import { I18nService } from '../../core/i18n.service';
import { ActivityView, MaterialView } from '../../core/models';
/** Pestaña Materiales: catálogo de materiales y actividades (con su material). */
@Component({
selector: 'app-materials-tab',
imports: [FormsModule],
template: `
<!-- Alta de material -->
<div class="adm-card">
<p class="adm-label">Nuevo material</p>
<div class="adm-row">
<input class="adm-input adm-input--sm" [(ngModel)]="mIcon" placeholder="🎒" maxlength="4" />
<input class="adm-input" [(ngModel)]="mEs" placeholder="Nombre (ES)" />
<input class="adm-input" [(ngModel)]="mCa" placeholder="Nom (CA)" />
<input class="adm-input adm-input--sm" type="color" [(ngModel)]="mColor" />
<button class="adm-btn" [disabled]="!mEs || !mCa || !mIcon" (click)="addMaterial()">+ {{ i18n.t('add') }}</button>
</div>
<div class="adm-list" style="margin-top:12px">
@for (m of materials(); track m.id) {
<div class="adm-item">
<span>{{ m.icon }}</span>
<span class="adm-item__grow">{{ i18n.label(m.labelEs, m.labelCa) }}</span>
<button class="adm-del" (click)="delMaterial(m)">✕</button>
</div>
} @empty {
<p class="adm-empty">{{ i18n.t('none') }}</p>
}
</div>
</div>
<!-- Alta de actividad con su material -->
<div class="adm-card">
<p class="adm-label">Nueva actividad</p>
<div class="adm-row">
<input class="adm-input adm-input--sm" [(ngModel)]="aIcon" placeholder="🤸" maxlength="4" />
<input class="adm-input" [(ngModel)]="aEs" placeholder="Actividad (ES)" />
<input class="adm-input" [(ngModel)]="aCa" placeholder="Activitat (CA)" />
<input class="adm-input adm-input--sm" type="color" [(ngModel)]="aColor" />
</div>
<p class="adm-label" style="margin-top:12px">Material que conlleva:</p>
<div class="adm-row">
@for (m of materials(); track m.id) {
<label class="adm-chip">
<input type="checkbox" [checked]="selectedMaterials.has(m.id)" (change)="toggleMaterial(m.id)" />
{{ m.icon }} {{ i18n.label(m.labelEs, m.labelCa) }}
</label>
}
</div>
<div class="adm-row" style="margin-top:12px">
<button class="adm-btn" [disabled]="!aEs || !aCa || !aIcon" (click)="addActivity()">
+ {{ i18n.t('add') }}
</button>
</div>
</div>
<!-- Actividades existentes -->
<div class="adm-card">
<p class="adm-label">Actividades</p>
<div class="adm-list">
@for (a of activities(); track a.id) {
<div class="adm-item">
<span>{{ a.icon }}</span>
<span class="adm-item__grow">
<strong>{{ i18n.label(a.labelEs, a.labelCa) }}</strong>
— {{ materialNames(a) }}
</span>
<button class="adm-del" (click)="delActivity(a)">✕</button>
</div>
} @empty {
<p class="adm-empty">{{ i18n.t('none') }}</p>
}
</div>
</div>
`,
})
export class MaterialsTabComponent {
private readonly api = inject(ParentApiService);
protected readonly i18n = inject(I18nService);
protected readonly materials = signal<MaterialView[]>([]);
protected readonly activities = signal<ActivityView[]>([]);
protected mIcon = '';
protected mEs = '';
protected mCa = '';
protected mColor = '#5b8def';
protected aIcon = '';
protected aEs = '';
protected aCa = '';
protected aColor = '#7fbf6b';
protected selectedMaterials = new Set<number>();
constructor() {
this.reload();
}
private reload(): void {
this.api.listMaterials().subscribe((l) => this.materials.set(l));
this.api.listActivities().subscribe((l) => this.activities.set(l));
}
materialNames(a: ActivityView): string {
const byId = new Map(this.materials().map((m) => [m.id, this.i18n.label(m.labelEs, m.labelCa)]));
return a.materialIds.map((id) => byId.get(id) ?? '·').join(', ') || '—';
}
toggleMaterial(id: number): void {
if (this.selectedMaterials.has(id)) {
this.selectedMaterials.delete(id);
} else {
this.selectedMaterials.add(id);
}
}
addMaterial(): void {
this.api
.createMaterial({ labelEs: this.mEs, labelCa: this.mCa, icon: this.mIcon, color: this.mColor })
.subscribe(() => {
this.mEs = this.mCa = this.mIcon = '';
this.reload();
});
}
delMaterial(m: MaterialView): void {
this.api.deleteMaterial(m.id).subscribe(() => this.reload());
}
addActivity(): void {
this.api
.createActivity({
labelEs: this.aEs,
labelCa: this.aCa,
icon: this.aIcon,
color: this.aColor,
materialIds: [...this.selectedMaterials],
})
.subscribe(() => {
this.aEs = this.aCa = this.aIcon = '';
this.selectedMaterials.clear();
this.reload();
});
}
delActivity(a: ActivityView): void {
this.api.deleteActivity(a.id).subscribe(() => this.reload());
}
}

View File

@@ -0,0 +1,114 @@
import { Component, inject, signal } from '@angular/core';
import { FormsModule } from '@angular/forms';
import { Router } from '@angular/router';
import { ParentApiService } from '../../core/parent-api.service';
import { ParentSessionService } from '../../core/parent-session.service';
import { I18nService } from '../../core/i18n.service';
import { ChildSummary } from '../../core/models';
import { ScheduleTabComponent } from './schedule-tab.component';
import { MaterialsTabComponent } from './materials-tab.component';
import { EventsTabComponent } from './events-tab.component';
import { RoutinesTabComponent } from './routines-tab.component';
import { RewardsTabComponent } from './rewards-tab.component';
type Tab = 'schedule' | 'materials' | 'events' | 'routines' | 'rewards';
/** Panel de padres: barra de pestañas, selector de niño y salida. */
@Component({
selector: 'app-parents',
imports: [
FormsModule,
ScheduleTabComponent,
MaterialsTabComponent,
EventsTabComponent,
RoutinesTabComponent,
RewardsTabComponent,
],
template: `
<header class="ptop">
<div class="ptabs">
<button class="ptab" [class.ptab--on]="tab() === 'schedule'" (click)="tab.set('schedule')">📅 {{ i18n.t('tabSchedule') }}</button>
<button class="ptab" [class.ptab--on]="tab() === 'materials'" (click)="tab.set('materials')">🎒 {{ i18n.t('tabMaterials') }}</button>
<button class="ptab" [class.ptab--on]="tab() === 'events'" (click)="tab.set('events')">📋 {{ i18n.t('tabEvents') }}</button>
<button class="ptab" [class.ptab--on]="tab() === 'routines'" (click)="tab.set('routines')">🌙 {{ i18n.t('tabRoutines') }}</button>
<button class="ptab" [class.ptab--on]="tab() === 'rewards'" (click)="tab.set('rewards')">🪙 {{ i18n.t('tabRewards') }}</button>
</div>
<button class="plogout" (click)="logout()">🔓 {{ i18n.t('logout') }}</button>
</header>
<main class="pbody">
@if (tab() !== 'materials') {
<div class="pchild">
<label class="adm-label">{{ i18n.t('child') }}:</label>
<select class="adm-input" [(ngModel)]="selectedId">
@for (c of children(); track c.id) {
<option [ngValue]="c.id">{{ c.mascot }} {{ c.name }}</option>
}
</select>
</div>
}
@switch (tab()) {
@case ('schedule') { <app-schedule-tab [childId]="selectedId" /> }
@case ('materials') { <app-materials-tab /> }
@case ('events') { <app-events-tab [childId]="selectedId" /> }
@case ('routines') { <app-routines-tab [childId]="selectedId" /> }
@case ('rewards') { <app-rewards-tab [childId]="selectedId" /> }
}
</main>
`,
styles: [
`
.ptop {
position: sticky;
top: 0;
z-index: 10;
display: flex;
align-items: center;
gap: var(--space-3);
flex-wrap: wrap;
padding: var(--space-3) var(--space-4);
background: rgba(255, 255, 255, 0.85);
backdrop-filter: blur(8px);
border-bottom: 1px solid var(--border-1);
}
.ptabs { display: flex; flex-wrap: wrap; gap: 6px; flex: 1; }
.ptab {
all: unset; cursor: pointer; padding: 10px 16px; border-radius: var(--radius-pill);
font-family: var(--font-display); font-weight: 700; color: var(--text-2); font-size: 0.95rem;
}
.ptab--on { background: var(--accent-blue); color: #fff; }
.plogout {
all: unset; cursor: pointer; padding: 10px 16px; border-radius: var(--radius-pill);
font-family: var(--font-display); font-weight: 700; color: var(--accent-pink);
background: color-mix(in srgb, var(--accent-pink) 14%, #fff);
}
.pbody { max-width: 900px; margin: 0 auto; padding: var(--space-5) var(--space-4); }
.pchild { display: flex; align-items: center; gap: var(--space-3); margin-bottom: var(--space-4); }
`,
],
})
export class ParentsComponent {
private readonly parentApi = inject(ParentApiService);
private readonly session = inject(ParentSessionService);
private readonly router = inject(Router);
protected readonly i18n = inject(I18nService);
protected readonly children = signal<ChildSummary[]>([]);
protected readonly tab = signal<Tab>('schedule');
protected selectedId = 0;
constructor() {
this.parentApi.listChildren().subscribe((list) => {
this.children.set(list);
if (list.length && !this.selectedId) {
this.selectedId = list[0].id;
}
});
}
logout(): void {
this.session.logout();
this.router.navigate(['/']);
}
}

View File

@@ -0,0 +1,150 @@
import { Component, Input, inject, signal } from '@angular/core';
import { FormsModule } from '@angular/forms';
import { ApiService } from '../../core/api.service';
import { ParentApiService } from '../../core/parent-api.service';
import { I18nService } from '../../core/i18n.service';
import { RewardAdminView } from '../../core/models';
/** Pestaña Recompensas: catálogo de premios + gamificación y ajustes del niño. */
@Component({
selector: 'app-rewards-tab',
imports: [FormsModule],
template: `
<!-- Gamificación + ajustes del niño -->
<div class="adm-card">
<p class="adm-label">Gamificación (monedas)</p>
<div class="adm-row">
<label class="adm-label">{{ i18n.t('perTask') }}</label>
<input class="adm-input adm-input--sm" type="number" min="0" [(ngModel)]="perTask" />
<label class="adm-label">{{ i18n.t('perBlock') }}</label>
<input class="adm-input adm-input--sm" type="number" min="0" [(ngModel)]="perBlock" />
<label class="adm-label">{{ i18n.t('perDay') }}</label>
<input class="adm-input adm-input--sm" type="number" min="0" [(ngModel)]="perDay" />
<button class="adm-btn" (click)="saveGamification()">{{ i18n.t('save') }}</button>
</div>
<div class="adm-row" style="margin-top:12px">
<label class="adm-chip">
<input type="checkbox" [(ngModel)]="soundEnabled" (change)="saveSettings()" /> 🔊 {{ i18n.t('sound') }}
</label>
<label class="adm-chip">
<input type="checkbox" [(ngModel)]="ttsEnabled" (change)="saveSettings()" /> 🗣️ {{ i18n.t('readAloud') }}
</label>
</div>
</div>
<!-- Alta de premio -->
<div class="adm-card">
<p class="adm-label">Nuevo premio</p>
<div class="adm-row">
<input class="adm-input adm-input--sm" [(ngModel)]="icon" placeholder="🎮" maxlength="4" />
<input class="adm-input" [(ngModel)]="labelEs" placeholder="Premio (ES)" />
<input class="adm-input" [(ngModel)]="labelCa" placeholder="Premi (CA)" />
<input class="adm-input adm-input--sm" type="number" min="1" [(ngModel)]="cost" placeholder="🪙" />
<input class="adm-input adm-input--sm" type="color" [(ngModel)]="color" />
<button class="adm-btn" [disabled]="!labelEs || !labelCa || !icon || !cost" (click)="add()">
+ {{ i18n.t('add') }}
</button>
</div>
</div>
<!-- Catálogo de premios -->
<div class="adm-card">
<p class="adm-label">Catálogo</p>
<div class="adm-list">
@for (r of rewards(); track r.id) {
<div class="adm-item">
<span>{{ r.icon }}</span>
<span class="adm-item__grow">{{ i18n.label(r.labelEs, r.labelCa) }}</span>
<span class="adm-chip">🪙 {{ r.cost }}</span>
<button class="adm-del" (click)="remove(r)">✕</button>
</div>
} @empty {
<p class="adm-empty">{{ i18n.t('none') }}</p>
}
</div>
</div>
`,
})
export class RewardsTabComponent {
@Input({ required: true }) set childId(value: number) {
this._childId = value;
// Precarga la gamificación actual del niño.
this.parentApi.getGamification(value).subscribe((g) => {
this.perTask = g.coinsPerTask;
this.perBlock = g.coinsPerBlock;
this.perDay = g.coinsPerDay;
});
// Lee sonido/TTS actuales del niño desde /today (la lista no los trae).
this.api.getToday(value).subscribe((t) => {
this.soundEnabled = t.child.soundEnabled;
this.ttsEnabled = t.child.ttsEnabled;
});
}
private _childId!: number;
private readonly parentApi = inject(ParentApiService);
private readonly api = inject(ApiService);
protected readonly i18n = inject(I18nService);
protected readonly rewards = signal<RewardAdminView[]>([]);
// Gamificación: se precargan los valores actuales del niño en el setter de childId.
protected perTask = 5;
protected perBlock = 10;
protected perDay = 20;
protected soundEnabled = true;
protected ttsEnabled = true;
protected icon = '';
protected labelEs = '';
protected labelCa = '';
protected cost: number | null = null;
protected color = '#5b8def';
constructor() {
this.reload();
}
private reload(): void {
this.parentApi.listRewards().subscribe((l) => this.rewards.set(l));
}
saveGamification(): void {
this.parentApi
.updateGamification(this._childId, {
coinsPerTask: this.perTask,
coinsPerBlock: this.perBlock,
coinsPerDay: this.perDay,
})
.subscribe();
}
saveSettings(): void {
this.parentApi
.updateSettings(this._childId, { soundEnabled: this.soundEnabled, ttsEnabled: this.ttsEnabled })
.subscribe();
}
add(): void {
if (!this.cost) {
return;
}
this.parentApi
.createReward({
labelEs: this.labelEs,
labelCa: this.labelCa,
icon: this.icon,
color: this.color,
cost: this.cost,
})
.subscribe(() => {
this.labelEs = this.labelCa = this.icon = '';
this.cost = null;
this.reload();
});
}
remove(r: RewardAdminView): void {
this.parentApi.deleteReward(r.id).subscribe(() => this.reload());
}
}

View File

@@ -0,0 +1,122 @@
import { Component, Input, computed, inject, signal } from '@angular/core';
import { FormsModule } from '@angular/forms';
import { ParentApiService } from '../../core/parent-api.service';
import { I18nService } from '../../core/i18n.service';
import { RoutineView } from '../../core/models';
const DAYS: { key: string; es: string; ca: string }[] = [
{ key: 'MONDAY', es: 'Lunes', ca: 'Dilluns' },
{ key: 'TUESDAY', es: 'Martes', ca: 'Dimarts' },
{ key: 'WEDNESDAY', es: 'Miércoles', ca: 'Dimecres' },
{ key: 'THURSDAY', es: 'Jueves', ca: 'Dijous' },
{ key: 'FRIDAY', es: 'Viernes', ca: 'Divendres' },
];
/** Pestaña Rutinas de tarde por día de la semana, de un niño. */
@Component({
selector: 'app-routines-tab',
imports: [FormsModule],
template: `
<div class="adm-card">
<div class="adm-row">
<span class="adm-label">{{ i18n.t('tabRoutines') }}:</span>
@for (d of days; track d.key) {
<button
class="adm-chip"
[style.background]="d.key === day() ? 'var(--accent-purple)' : ''"
[style.color]="d.key === day() ? '#fff' : ''"
(click)="day.set(d.key)"
>
{{ i18n.label(d.es, d.ca) }}
</button>
}
</div>
</div>
<div class="adm-card">
<p class="adm-label">Nueva rutina</p>
<div class="adm-row">
<input class="adm-input adm-input--sm" [(ngModel)]="icon" placeholder="🎹" maxlength="4" />
<input class="adm-input" [(ngModel)]="labelEs" placeholder="Rutina (ES)" />
<input class="adm-input" [(ngModel)]="labelCa" placeholder="Rutina (CA)" />
<input class="adm-input adm-input--sm" type="color" [(ngModel)]="color" />
<button class="adm-btn" [disabled]="!labelEs || !labelCa || !icon" (click)="add()">+ {{ i18n.t('add') }}</button>
</div>
</div>
<div class="adm-card">
<div class="adm-list">
@for (r of routinesForDay(); track r.id; let i = $index; let last = $last) {
<div class="adm-item">
<span>{{ r.icon }}</span>
<span class="adm-item__grow">{{ i18n.label(r.labelEs, r.labelCa) }}</span>
<button class="adm-del" [disabled]="i === 0" (click)="move(i, -1)" aria-label="Subir">▲</button>
<button class="adm-del" [disabled]="last" (click)="move(i, 1)" aria-label="Bajar">▼</button>
<button class="adm-del" (click)="remove(r)">✕</button>
</div>
} @empty {
<p class="adm-empty">{{ i18n.t('none') }}</p>
}
</div>
</div>
`,
})
export class RoutinesTabComponent {
@Input({ required: true }) set childId(value: number) {
this._childId = value;
this.reload();
}
private _childId!: number;
private readonly api = inject(ParentApiService);
protected readonly i18n = inject(I18nService);
protected readonly days = DAYS;
protected readonly day = signal('MONDAY');
protected readonly routines = signal<RoutineView[]>([]);
protected readonly routinesForDay = computed(() =>
this.routines().filter((r) => r.dayOfWeek === this.day()),
);
protected icon = '';
protected labelEs = '';
protected labelCa = '';
protected color = '#a78bd0';
private reload(): void {
this.api.listRoutines(this._childId).subscribe((l) => this.routines.set(l));
}
add(): void {
const order = this.routinesForDay().length;
this.api
.createRoutine({
childId: this._childId,
dayOfWeek: this.day(),
labelEs: this.labelEs,
labelCa: this.labelCa,
icon: this.icon,
color: this.color,
orderIndex: order,
})
.subscribe(() => {
this.labelEs = this.labelCa = this.icon = '';
this.reload();
});
}
/** Mueve una rutina del día arriba (-1) o abajo (+1) y persiste el nuevo orden. */
move(index: number, delta: number): void {
const list = [...this.routinesForDay()];
const target = index + delta;
if (target < 0 || target >= list.length) {
return;
}
[list[index], list[target]] = [list[target], list[index]];
this.api.reorderRoutines(list.map((r) => r.id)).subscribe(() => this.reload());
}
remove(r: RoutineView): void {
this.api.deleteRoutine(r.id).subscribe(() => this.reload());
}
}

View File

@@ -0,0 +1,99 @@
import { Component, Input, inject, signal } from '@angular/core';
import { FormsModule } from '@angular/forms';
import { ParentApiService } from '../../core/parent-api.service';
import { I18nService } from '../../core/i18n.service';
import { ActivityView, WeeklyEntryView } from '../../core/models';
const DAYS: { key: string; es: string; ca: string }[] = [
{ key: 'MONDAY', es: 'Lunes', ca: 'Dilluns' },
{ key: 'TUESDAY', es: 'Martes', ca: 'Dimarts' },
{ key: 'WEDNESDAY', es: 'Miércoles', ca: 'Dimecres' },
{ key: 'THURSDAY', es: 'Jueves', ca: 'Dijous' },
{ key: 'FRIDAY', es: 'Viernes', ca: 'Divendres' },
];
/** Pestaña Horario: actividades del cole por día de la semana (L-V) de un niño. */
@Component({
selector: 'app-schedule-tab',
imports: [FormsModule],
template: `
<div class="adm-card">
<div class="adm-row">
<select class="adm-input" [(ngModel)]="newDay">
@for (d of days; track d.key) {
<option [value]="d.key">{{ i18n.label(d.es, d.ca) }}</option>
}
</select>
<select class="adm-input" [(ngModel)]="newActivityId">
@for (a of activities(); track a.id) {
<option [ngValue]="a.id">{{ a.icon }} {{ i18n.label(a.labelEs, a.labelCa) }}</option>
}
</select>
<button class="adm-btn" [disabled]="!newActivityId" (click)="add()">+ {{ i18n.t('add') }}</button>
</div>
</div>
@for (d of days; track d.key) {
<div class="adm-card">
<p class="adm-label">{{ i18n.label(d.es, d.ca) }}</p>
<div class="adm-list">
@for (e of entriesFor(d.key); track e.id) {
<div class="adm-item">
<span>{{ e.icon }}</span>
<span class="adm-item__grow">{{ e.activityLabelEs }}</span>
<button class="adm-del" (click)="remove(e)" aria-label="Borrar">✕</button>
</div>
} @empty {
<p class="adm-empty">—</p>
}
</div>
</div>
}
`,
})
export class ScheduleTabComponent {
@Input({ required: true }) set childId(value: number) {
this._childId = value;
this.reload();
}
private _childId!: number;
private readonly api = inject(ParentApiService);
protected readonly i18n = inject(I18nService);
protected readonly days = DAYS;
protected readonly activities = signal<ActivityView[]>([]);
protected readonly entries = signal<WeeklyEntryView[]>([]);
protected newDay = 'MONDAY';
protected newActivityId: number | null = null;
constructor() {
this.api.listActivities().subscribe((list) => {
this.activities.set(list);
if (list.length) {
this.newActivityId = list[0].id;
}
});
}
private reload(): void {
this.api.listWeekly(this._childId).subscribe((list) => this.entries.set(list));
}
entriesFor(day: string): WeeklyEntryView[] {
return this.entries().filter((e) => e.dayOfWeek === day);
}
add(): void {
if (!this.newActivityId) {
return;
}
this.api
.createWeekly({ childId: this._childId, dayOfWeek: this.newDay, activityId: this.newActivityId })
.subscribe(() => this.reload());
}
remove(e: WeeklyEntryView): void {
this.api.deleteWeekly(e.id).subscribe(() => this.reload());
}
}

View File

@@ -0,0 +1,24 @@
<main class="profiles">
<h1 class="profiles__title">{{ i18n.t('whoEntersToday') }}</h1>
@if (loading()) {
<p class="profiles__msg">Cargando…</p>
} @else if (error()) {
<p class="profiles__msg">No se pudo conectar con el servidor. ¿Está arrancado el backend?</p>
} @else {
<div class="profiles__grid">
@for (child of children(); track child.id) {
<button type="button" class="kid" [style.--c]="child.accentColor" (click)="enter(child)">
<span class="kid__mascot">{{ child.mascot }}</span>
<span class="kid__name">{{ child.name }}</span>
<span class="kid__coins">🪙 {{ child.coins }}</span>
<span class="kid__age">{{ child.age }} años</span>
</button>
}
</div>
}
<button type="button" class="profiles__parents" (click)="openParents()">
⚙️ {{ i18n.t('parents') }}
</button>
</main>

View File

@@ -0,0 +1,92 @@
:host {
display: block;
}
.profiles {
min-height: 100vh;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: var(--space-6);
padding: var(--space-6) var(--space-4);
&__title {
margin: 0;
font-size: 2.4rem;
text-align: center;
color: var(--text-strong);
}
&__msg {
color: var(--text-1);
text-align: center;
}
&__grid {
display: flex;
flex-wrap: wrap;
gap: var(--space-5);
justify-content: center;
}
&__parents {
all: unset;
cursor: pointer;
color: var(--text-3);
font-family: var(--font-display);
font-weight: 600;
padding: 10px 18px;
border-radius: var(--radius-pill);
}
&__parents:hover { color: var(--text-1); background: var(--surface-soft); }
}
// Tarjeta de niño: grande, con su color de acento, animación de entrada.
.kid {
all: unset;
cursor: pointer;
box-sizing: border-box;
width: 200px;
display: flex;
flex-direction: column;
align-items: center;
gap: var(--space-2);
padding: var(--space-5);
background: var(--surface);
border: var(--card-border-width) solid color-mix(in srgb, var(--c) 35%, #fff);
border-radius: var(--radius-card);
box-shadow: var(--shadow-card);
transition: transform 0.15s, box-shadow 0.2s;
animation: slideUp 0.4s ease both;
&:hover {
transform: translateY(-4px);
box-shadow: var(--shadow-soft);
}
&:active { transform: scale(0.98); }
&__mascot {
font-size: 84px;
line-height: 1;
animation: floatY 3s ease-in-out infinite;
}
&__name {
font-family: var(--font-display);
font-weight: 700;
font-size: 1.5rem;
color: var(--text-strong);
}
&__coins {
font-family: var(--font-display);
font-weight: 700;
color: var(--coin-text);
background: var(--coin-bg);
padding: 4px 14px;
border-radius: var(--radius-pill);
}
&__age {
font-size: 0.9rem;
color: var(--text-3);
}
}

View File

@@ -0,0 +1,55 @@
import { Component, OnInit, inject, signal } from '@angular/core';
import { Router } from '@angular/router';
import { ApiService } from '../../core/api.service';
import { I18nService } from '../../core/i18n.service';
import { KioskService } from '../../core/kiosk.service';
import { ChildSummary } from '../../core/models';
/**
* Pantalla de entrada del kiosko: "¿QUIÉN ENTRA HOY?". Tarjetas grandes por niño
* con mascota, nombre y monedero. Al elegir, entra a su día (Home).
*
* Nota: el selector de edad ± del prototipo era un control de demo. La edad es un
* dato que gestionan los padres, así que aquí se muestra como chip de solo lectura;
* su edición vive en el panel de padres (Fase 5).
*/
@Component({
selector: 'app-profile-select',
imports: [],
templateUrl: './profile-select.component.html',
styleUrl: './profile-select.component.scss',
})
export class ProfileSelectComponent implements OnInit {
private readonly api = inject(ApiService);
private readonly router = inject(Router);
private readonly kiosk = inject(KioskService);
protected readonly i18n = inject(I18nService);
protected readonly children = signal<ChildSummary[]>([]);
protected readonly loading = signal(true);
protected readonly error = signal(false);
ngOnInit(): void {
this.api.getChildren().subscribe({
next: (list) => {
this.children.set(list);
this.loading.set(false);
},
error: () => {
this.error.set(true);
this.loading.set(false);
},
});
}
/** Entra al día del niño. Aprovecha el gesto para pedir pantalla completa. */
enter(child: ChildSummary): void {
this.i18n.setLang(child.language);
this.kiosk.enterFullscreen();
this.router.navigate(['/home', child.id]);
}
openParents(): void {
this.router.navigate(['/parents']);
}
}

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