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

13
.env.example Normal file
View File

@@ -0,0 +1,13 @@
# Plantilla de variables de entorno de recordaLexia.
# Copia este fichero a .env y rellena los valores reales. El .env NO se versiona.
#
# cp .env.example .env
#
# Base de datos PostgreSQL (usada por el contenedor postgres y por el backend).
DB_NAME=recordalexia
DB_USER=recordalexia
# Pon aquí una contraseña propia. NO uses esta de ejemplo en producción.
DB_PASSWORD=cambia-esta-clave
# Puerto en el host donde se publica el frontend (Nginx). La tablet apunta aquí.
WEB_PORT=8088

25
.gitignore vendored Normal file
View File

@@ -0,0 +1,25 @@
# --- Entorno / secretos ---
# El .env real (con credenciales) NUNCA se versiona. Solo .env.example.
.env
# --- Sistema operativo ---
.DS_Store
# --- Handoff comprimido (la fuente viva está descomprimida en su carpeta) ---
*.zip
# --- Backend (Gradle) ---
backend/.gradle/
backend/build/
# --- Frontend (Angular / Node) ---
frontend/node_modules/
frontend/dist/
frontend/.angular/
# --- IDE ---
.idea/
*.iml
# Artefactos de Playwright (capturas de verificación)
.playwright-mcp/

66
CLAUDE.md Normal file
View File

@@ -0,0 +1,66 @@
# recordaLexia
App web familiar para niños con TDAH: muestra cada mañana, en una tablet en modo
kiosko, qué material llevar al cole y qué rutinas hacer por la tarde, con
gamificación por monedas y tienda de recompensas. Multi-niño y bilingüe ES/CA.
## Stack
- Frontend: Angular 19 (standalone components + signals), TypeScript estricto, i18n ES/CA.
- Backend: Java 21, Spring Boot 3.x, Gradle 8, Spring Web + Data JPA + Security.
- Datos: PostgreSQL con Liquibase.
- Empaquetado: Docker (multi-stage) + docker-compose (postgres + backend + Nginx).
- Contexto doméstico/homelab. Ejecución LOCAL. Sin nube ni infraestructura corporativa.
## Estructura
Monorepo:
- `frontend/` — Angular 19.
- `backend/` — Spring Boot 3 + Gradle.
- `docs/` — specs de referencia (prompts de Design, backend y director). Son contrato.
- `artifacts/` — diseño de referencia exportado de Claude Design (HTML).
- `app-de-rutinas-visuales-para-tdah/` — handoff original de Claude Design (solo lectura).
- `docker-compose.yml` en la raíz.
## Comandos principales
- Backend: `./gradlew bootRun` (local), `./gradlew test`, `./gradlew build`.
- Frontend: `npm start` (ng serve), `npm test`, `npm run build`.
- Stack completo: `docker-compose up --build`.
## Convenciones
- Idioma de trabajo y de los comentarios de código: español de España. Comenta
siempre la lógica de negocio.
- Toda la UI es bilingüe ES/CA. Los textos visibles (materiales, actividades,
rutinas, premios, eventos) se almacenan con `labelEs` y `labelCa`.
- Iconografía = emojis (decisión del diseño). El campo `icon` es un string emoji.
No introducir librerías de iconos en la v1.
- Design tokens fijos (del handoff): tipografías Fredoka (títulos/labels) y Nunito
(texto); paleta de acento `#F2A65A`, `#5B8DEF`, `#A78BD0`, `#7FBF6B`, `#5BC0BE`,
`#F4C95D`, `#EC8FA4`; texto `#2A3142`; monedas pill `#FFF6E0`/`#C7912B`.
Centralizar en un único fichero de tokens y no esparcir colores por el código.
- Backend por capas (controller / service / repository / domain), dominio aislado
de JPA donde sea razonable. Exponer DTOs, nunca entidades.
- Frontend: componentes pequeños y reutilizables; estado con signals; HTTP tipado
contra DTOs alineados con el backend (en especial `GET /api/children/{id}/today`).
- Zona horaria fija Europe/Madrid para decidir qué es "hoy".
- Tests: lógica de generación del día, marcado/monedas (incl. bonos) y canje.
## Restricciones
- NO usar Keycloak, Gravitee, Camunda, Kubernetes ni Jenkins en esta fase. La auth
es ligera (kiosko sin fricción para el niño + PIN configurable para padres).
- NO portar el "dock" inferior de demo del prototipo (botones A/B, idioma,
reiniciar, vacío) a producción: esas funciones son preferencias reales del niño.
- NO borrar histórico de tareas ni de canjes al pasar el día (se conserva).
- NO hardcodear el PIN ni los parámetros de gamificación: son configurables.
- NO incrustar el HTML del handoff; reconstruir los componentes en Angular.
- Conservar el modo Foco (una tarea a la vez): es clave para TDAH.
## Referencias
El detalle completo de UX, dominio y plan por fases está en `docs/`:
- `docs/prompt-claude-code-recordalexia-director.md` (director, conduce el trabajo).
- `docs/prompt-claude-code-backend-rutinas-tdah.md` (dominio y API).
- `docs/prompt-claude-design-rutinas-tdah.md` (UX y pantallas).

140
README.md Normal file
View File

@@ -0,0 +1,140 @@
# recordaLexia
App web familiar para niños con **TDAH**. Cada mañana, en una tablet en modo kiosko
junto a la puerta, muestra qué material llevar al cole y qué rutinas hacer por la
tarde. El niño marca cada tarea, gana monedas y las canjea en una tienda de premios.
Multi-niño y bilingüe (español / catalán).
## Características
- Pantalla "HOY" con dos modos: **Tablero** (cole + tarde a la vista) y **Foco**
(una tarea a la vez), pensado para reducir la carga cognitiva.
- Avisos de exámenes y deberes, temporizador de salida y lectura en voz alta (TTS).
- Gamificación: monedas por tarea / bloque / día y tienda de recompensas.
- Panel de padres protegido por PIN: horario semanal, materiales, eventos, rutinas
de tarde y recompensas.
## Stack
- **Frontend:** Angular 19 (standalone + signals), TypeScript, i18n ES/CA.
- **Backend:** Java 21, Spring Boot 3.x, Gradle 8.
- **Datos:** PostgreSQL + Liquibase.
- **Empaquetado:** Docker + docker-compose (postgres + backend + Nginx).
## Estructura del repositorio
```
recordaLexia/
├── CLAUDE.md # Convenciones para Claude Code
├── README.md # Este fichero
├── docker-compose.yml # Stack: postgres + backend + frontend
├── .env.example # Plantilla de variables (copiar a .env)
├── frontend/ # Angular 19 (standalone + signals)
├── backend/ # Spring Boot 3.5 + Gradle (wrapper)
├── docs/ # Specs de referencia (contrato)
│ ├── adr/ # Decisiones de arquitectura (ADR)
│ ├── prompt-claude-code-recordalexia-director.md
│ ├── prompt-claude-code-backend-rutinas-tdah.md
│ └── prompt-claude-design-rutinas-tdah.md
├── artifacts/ # Diseño de referencia (HTML autónomo)
└── app-de-rutinas-visuales-para-tdah/ # Handoff de Claude Design (solo lectura)
├── project/
│ ├── Rutinas TDAH.dc.html
│ └── support.js
└── README.md
```
> El handoff de `app-de-rutinas-visuales-para-tdah/` es la fuente de verdad visual; no
> se edita, se usa como referencia para reconstruir la UI en Angular.
## Requisitos
- Java 21 (JDK). No hace falta instalar Gradle: el backend trae el *wrapper*.
- Node 20 LTS recomendado (Angular 19 soporta 18.19+/20/22; **Node 24 funciona
pero Angular lo marca como no soportado** — fija 20 LTS en el homelab).
- Docker y Docker Compose.
- No hace falta instalar PostgreSQL: lo levanta docker-compose.
## Configuración previa
Copia la plantilla de variables y pon tus valores (credenciales de BD, puerto web):
```bash
cp .env.example .env
```
El `.env` real **no se versiona**. Las credenciales nunca van en el código.
## Cómo arrancar
### Todo con Docker (recomendado para probar)
```bash
docker-compose up --build
```
Levanta PostgreSQL, el backend y el frontend tras Nginx. La app queda accesible en
el puerto que defina `docker-compose.yml` (ver su salida).
### Desarrollo (servicios por separado)
```bash
# Base de datos
docker-compose up -d postgres
# Backend (necesita las credenciales de BD en el entorno)
export $(grep -v '^#' .env | xargs)
export SPRING_DATASOURCE_USERNAME=$DB_USER SPRING_DATASOURCE_PASSWORD=$DB_PASSWORD
cd backend && ./gradlew bootRun
# Frontend
cd frontend && npm install && npm start
```
## Uso en la tablet (modo kiosko)
Monta la tablet en horizontal y abre el frontend en el navegador a pantalla
completa (Chrome admite modo kiosko). Apunta a la URL del frontend en la red local
del homelab. El niño selecciona su perfil y ve directamente las tareas del día.
## Tipografía accesible (OpenDyslexic)
La app usa **OpenDyslexic** como tipografía por defecto en todo el texto, pensada
para mejorar la legibilidad. Es una **preferencia conmutable por niño** (activada
de serie); al desactivarla, la UI cae a las tipografías de marca del handoff
(Fredoka/Nunito). Las tres familias se empaquetan en local (sin CDN), así que el
kiosko funciona sin internet. Detalle de la decisión en
[`docs/adr/0002-tipografia-opendyslexic.md`](docs/adr/0002-tipografia-opendyslexic.md).
## Backend: API y datos de ejemplo
Al arrancar con la base de datos vacía se siembran los datos del prototipo (niños
Nora 🦊, Leo 🐢 y Mía 🦉, su horario, rutinas, premios y eventos). El **PIN de
padres por defecto es `1234`** (configurable; no se puede cambiar el resto del
panel sin él).
Endpoints principales (kiosko del niño, acceso libre):
- `GET /api/children` — perfiles.
- `GET /api/children/{id}/today` — material de mañana, rutinas de tarde, eventos,
progreso, monedero y temporizador.
- `POST /api/tasks/{taskId}/toggle` — marca/desmarca y ajusta monedas (con bonos).
- `GET /api/children/{id}/wallet` — saldo e historial.
- `GET /api/children/{id}/rewards` — tienda.
- `POST /api/rewards/{rewardId}/redeem?childId=` — canje.
- `PUT /api/children/{id}/settings` — modo de vista, sonido, TTS, idioma, hora salida.
Panel de padres (requiere sesión; `POST /api/parents/login` con el PIN devuelve un
identificador que se envía en la cabecera `X-Parent-Session`): CRUD de niños,
catálogo, horario, rutinas, eventos, premios y ajuste de gamificación.
## Despliegue en el homelab
Construye las imágenes y levanta el `docker-compose` en el VPS o la Raspberry Pi.
Recuerda persistir el volumen de PostgreSQL y exponer el frontend tras tu proxy
(Nginx Proxy Manager / Cloudflare tunnel).
## Documentación
El detalle de UX, dominio, API y plan por fases está en `docs/`. El fichero
director (`docs/prompt-claude-code-recordalexia-director.md`) conduce el desarrollo.

View File

@@ -0,0 +1,22 @@
# CODING AGENTS: READ THIS FIRST
This is a **handoff bundle** from Claude Design (claude.ai/design).
A user mocked up designs in HTML/CSS/JS using an AI design tool, then exported this bundle so a coding agent can implement the designs for real.
## What you should do — IMPORTANT
**Read `app-de-rutinas-visuales-para-tdah/project/Rutinas TDAH.dc.html` in full.** The user had this file open when they triggered the handoff, so it's almost certainly the primary design they want built. Read it top to bottom — don't skim. Then **follow its imports**: open every file it pulls in (shared components, CSS, scripts) so you understand how the pieces fit together before you start implementing.
**If anything is ambiguous, ask the user to confirm before you start implementing.** It's much cheaper to clarify scope up front than to build the wrong thing.
## About the design files
The design medium is **HTML/CSS/JS** — these are prototypes, not production code. Your job is to **recreate them pixel-perfectly** in whatever technology makes sense for the target codebase (React, Vue, native, whatever fits). Match the visual output; don't copy the prototype's internal structure unless it happens to fit.
**Don't render these files in a browser or take screenshots unless the user asks you to.** Everything you need — dimensions, colors, layout rules — is spelled out in the source. Read the HTML and CSS directly; a screenshot won't tell you anything they don't.
## Bundle contents
- `app-de-rutinas-visuales-para-tdah/README.md` — this file
- `app-de-rutinas-visuales-para-tdah/project/` — the `App de rutinas visuales para TDAH` project files (HTML prototypes, assets, components)

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.3 KiB

View File

@@ -0,0 +1,630 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<script src="./support.js"></script>
</head>
<body>
<x-dc>
<helmet>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Fredoka:wght@400;500;600;700&family=Nunito:wght@400;600;700;800;900&display=swap" rel="stylesheet">
<style>
* { box-sizing: border-box; -webkit-tap-highlight-color: transparent; }
html, body { margin:0; padding:0; height:100%; background:#EAF1F4; font-family:'Nunito',sans-serif; overflow:hidden; }
@keyframes pop { 0%{transform:scale(1)} 40%{transform:scale(1.06)} 100%{transform:scale(1)} }
@keyframes checkPop { 0%{transform:scale(0)} 60%{transform:scale(1.25)} 100%{transform:scale(1)} }
@keyframes floatY { 0%,100%{transform:translateY(0)} 50%{transform:translateY(-8px)} }
@keyframes floatYb { 0%,100%{transform:translateY(0)} 50%{transform:translateY(-14px)} }
@keyframes walletBump { 0%{transform:scale(1)} 35%{transform:scale(1.22) rotate(-4deg)} 100%{transform:scale(1)} }
@keyframes confFall { 0%{transform:translateY(-20vh) rotate(0)} 100%{transform:translateY(110vh) rotate(720deg)} }
@keyframes shake { 0%,100%{transform:translateX(0)} 20%{transform:translateX(-8px)} 40%{transform:translateX(8px)} 60%{transform:translateX(-6px)} 80%{transform:translateX(6px)} }
@keyframes celebPop { 0%{transform:scale(.6);opacity:0} 60%{transform:scale(1.05)} 100%{transform:scale(1);opacity:1} }
@keyframes ringGlow { 0%,100%{box-shadow:0 0 0 0 rgba(242,166,90,.35)} 50%{box-shadow:0 0 0 14px rgba(242,166,90,0)} }
@keyframes slideUp { 0%{transform:translateY(30px);opacity:0} 100%{transform:translateY(0);opacity:1} }
::-webkit-scrollbar{ width:10px;height:10px } ::-webkit-scrollbar-thumb{ background:#cfd9e0;border-radius:8px }
</style>
</helmet>
<div style="position:fixed;inset:0;font-family:'Nunito',sans-serif;color:#2A3142;overflow:hidden;background:radial-gradient(1100px 700px at 12% -10%, #FBF4E9 0%, transparent 55%), radial-gradient(1000px 700px at 110% 120%, #E2F0EC 0%, transparent 55%), #EFF4F6;">
<!-- soft organic blobs -->
<div style="position:absolute;top:-120px;left:-100px;width:420px;height:420px;border-radius:48% 52% 60% 40%/55% 45% 60% 45%;background:#FCEBD3;opacity:.55;filter:blur(2px);"></div>
<div style="position:absolute;bottom:-140px;right:-90px;width:460px;height:460px;border-radius:60% 40% 45% 55%/50% 55% 45% 50%;background:#D7ECE5;opacity:.6;filter:blur(2px);"></div>
<!-- ============ PROFILES ============ -->
<sc-if value="{{ isProfiles }}" hint-placeholder-val="{{ true }}">
<div style="position:relative;height:100%;display:flex;flex-direction:column;align-items:center;justify-content:center;gap:48px;padding:40px;">
<div style="text-align:center;animation:slideUp .5s ease both;">
<div style="font-size:60px;animation:floatY 3.5s ease-in-out infinite;">🌳</div>
<div style="font-family:'Fredoka',sans-serif;font-weight:700;font-size:46px;letter-spacing:.5px;margin-top:6px;">{{ L.who }}</div>
</div>
<div style="display:flex;gap:36px;flex-wrap:wrap;justify-content:center;">
<sc-for list="{{ profiles }}" as="p" hint-placeholder-count="3">
<div style="{{ p.cardStyle }}">
<button onClick="{{ p.select }}" style="all:unset;cursor:pointer;display:flex;flex-direction:column;align-items:center;gap:14px;">
<div style="{{ p.ringStyle }}"><span style="font-size:78px;line-height:1;">{{ p.mascot }}</span></div>
<div style="font-family:'Fredoka',sans-serif;font-weight:600;font-size:30px;letter-spacing:.5px;">{{ p.name }}</div>
<div style="display:flex;align-items:center;gap:7px;background:#FFF6E0;color:#C7912B;border-radius:999px;padding:6px 16px;font-weight:800;font-size:18px;">🪙 {{ p.coins }}</div>
</button>
<div style="display:flex;align-items:center;gap:10px;margin-top:14px;background:#F4F7F9;border-radius:999px;padding:6px 8px;">
<button onClick="{{ p.ageDown }}" style="all:unset;cursor:pointer;width:34px;height:34px;border-radius:50%;background:#fff;display:flex;align-items:center;justify-content:center;font-weight:900;color:#7A879B;box-shadow:0 2px 5px rgba(0,0,0,.06);"></button>
<span style="font-weight:800;font-size:15px;color:#5A6B82;min-width:64px;text-align:center;">{{ p.ageLabel }}</span>
<button onClick="{{ p.ageUp }}" style="all:unset;cursor:pointer;width:34px;height:34px;border-radius:50%;background:#fff;display:flex;align-items:center;justify-content:center;font-weight:900;color:#7A879B;box-shadow:0 2px 5px rgba(0,0,0,.06);">+</button>
</div>
</div>
</sc-for>
</div>
<button onClick="{{ goPin }}" style="all:unset;cursor:pointer;display:flex;align-items:center;gap:9px;color:#8C99AB;font-weight:700;font-size:16px;background:rgba(255,255,255,.6);padding:10px 18px;border-radius:999px;">⚙️ {{ L.padres }}</button>
</div>
</sc-if>
<!-- ============ HOME ============ -->
<sc-if value="{{ isHome }}" hint-placeholder-val="{{ true }}">
<div style="position:relative;height:100%;display:flex;flex-direction:column;padding:22px 30px 88px 30px;">
<!-- header -->
<div style="display:flex;align-items:center;gap:20px;flex:none;">
<div style="flex:1;">
<div style="font-family:'Nunito';font-weight:800;font-size:17px;color:#8C99AB;letter-spacing:.5px;">{{ L.hola }}, {{ profile.name }}! 👋</div>
<div style="font-family:'Fredoka',sans-serif;font-weight:700;font-size:42px;line-height:1;margin-top:2px;">{{ dayName }} <span style="color:#9FB0BD;font-weight:500;font-size:30px;">{{ dayDate }}</span></div>
</div>
<!-- timer -->
<div style="display:flex;align-items:center;gap:14px;background:#fff;border-radius:24px;padding:12px 20px 12px 14px;box-shadow:0 8px 22px rgba(40,60,100,.07);animation:ringGlow 2.6s ease-in-out infinite;">
<div style="position:relative;width:74px;height:74px;border-radius:50%;background:{{ timerRing }};display:flex;align-items:center;justify-content:center;">
<div style="width:56px;height:56px;border-radius:50%;background:#fff;display:flex;align-items:center;justify-content:center;font-size:30px;">🐦</div>
</div>
<div style="line-height:1.05;">
<div style="font-weight:800;font-size:13px;color:#9FB0BD;letter-spacing:.5px;">{{ L.salimos }}</div>
<div style="font-family:'Fredoka',sans-serif;font-weight:700;font-size:30px;color:#E08A3C;">{{ timerMin }} <span style="font-size:16px;color:#C99B6A;">{{ L.min }}</span></div>
</div>
</div>
<!-- wallet -->
<div ref="{{ setWallet }}" style="{{ walletStyle }}">
<span style="font-size:26px;">🪙</span>
<span style="font-family:'Fredoka',sans-serif;font-weight:700;font-size:30px;">{{ coins }}</span>
</div>
<button onClick="{{ goStore }}" style="all:unset;cursor:pointer;width:58px;height:58px;border-radius:20px;background:#fff;box-shadow:0 8px 22px rgba(40,60,100,.07);display:flex;align-items:center;justify-content:center;font-size:28px;">🎁</button>
</div>
<!-- global progress -->
<div style="display:flex;align-items:center;gap:16px;margin:16px 0 14px;flex:none;">
<div style="flex:1;height:18px;background:#E2EAEE;border-radius:999px;overflow:hidden;">
<div style="height:100%;width:{{ globalPct }};background:linear-gradient(90deg,#7FBF6B,#5BC0BE);border-radius:999px;transition:width .5s cubic-bezier(.2,.8,.2,1);"></div>
</div>
<div style="font-family:'Fredoka',sans-serif;font-weight:600;font-size:20px;color:#5A6B82;white-space:nowrap;">{{ globalDone }}/{{ globalTotal }} {{ L.listo }} ✨</div>
</div>
<!-- exam / homework alert (in-flow banner) -->
<sc-if value="{{ hasEvents }}" hint-placeholder-val="{{ false }}">
<div style="display:flex;gap:12px;margin-bottom:14px;flex:none;">
<sc-for list="{{ events }}" as="e" hint-placeholder-count="2">
<div onClick="{{ e.speak }}" style="cursor:pointer;display:flex;align-items:center;gap:12px;background:{{ e.bg }};border:3px solid {{ e.border }};border-radius:18px;padding:10px 18px;box-shadow:0 6px 16px rgba(212,140,40,.12);">
<span style="font-size:30px;">{{ e.icon }}</span>
<div style="line-height:1.05;">
<div style="font-weight:900;font-size:11px;letter-spacing:1px;color:{{ e.border }};">{{ e.kind }}</div>
<div style="font-family:'Fredoka',sans-serif;font-weight:600;font-size:19px;color:#5a4326;">{{ e.title }}</div>
</div>
</div>
</sc-for>
</div>
</sc-if>
<!-- EMPTY STATE -->
<sc-if value="{{ showEmpty }}" hint-placeholder-val="{{ false }}">
<div style="flex:1;display:flex;flex-direction:column;align-items:center;justify-content:center;gap:18px;text-align:center;">
<div style="font-size:96px;animation:floatYb 3s ease-in-out infinite;">🏖️</div>
<div style="font-family:'Fredoka',sans-serif;font-weight:700;font-size:40px;">{{ L.vacioT }}</div>
<div style="font-size:24px;color:#7A879B;font-weight:700;">{{ L.vacioS }}</div>
</div>
</sc-if>
<!-- VARIANT A : board -->
<sc-if value="{{ showA }}" hint-placeholder-val="{{ false }}">
<div style="flex:1;display:flex;gap:22px;min-height:0;">
<!-- COLE -->
<div style="flex:1.05;display:flex;flex-direction:column;min-height:0;">
<div style="display:flex;align-items:center;gap:10px;margin-bottom:12px;">
<span style="font-size:30px;">🎒</span>
<span style="font-family:'Fredoka',sans-serif;font-weight:700;font-size:25px;">{{ L.cole }}</span>
<span style="margin-left:auto;font-weight:800;color:#7A879B;font-size:16px;background:#fff;padding:5px 13px;border-radius:999px;">{{ coleDone }}/{{ coleTotal }}</span>
</div>
<div style="flex:1;overflow:auto;display:grid;grid-template-columns:1fr 1fr;gap:14px;align-content:start;padding-right:4px;">
<sc-for list="{{ coleItems }}" as="item" hint-placeholder-count="6">
<div ref="{{ item.setRef }}" onClick="{{ item.toggle }}" style="{{ item.cardStyle }}">
<div style="{{ item.tileStyle }}">{{ item.icon }}</div>
<div style="flex:1;min-width:0;"><div style="{{ item.labelStyle }}">{{ item.label }}</div></div>
<button onClick="{{ item.speak }}" style="all:unset;cursor:pointer;width:34px;height:34px;border-radius:50%;background:#F2F6F8;display:flex;align-items:center;justify-content:center;font-size:16px;flex:none;">🔊</button>
<div style="{{ item.checkStyle }}">{{ item.check }}</div>
</div>
</sc-for>
</div>
</div>
<!-- TARDE -->
<div style="flex:1;display:flex;flex-direction:column;min-height:0;">
<div style="display:flex;align-items:center;gap:10px;margin-bottom:12px;">
<span style="font-size:30px;">🌙</span>
<span style="font-family:'Fredoka',sans-serif;font-weight:700;font-size:25px;">{{ L.tarde }}</span>
<span style="margin-left:auto;font-weight:800;color:#7A879B;font-size:16px;background:#fff;padding:5px 13px;border-radius:999px;">{{ tardeDone }}/{{ tardeTotal }}</span>
</div>
<div style="flex:1;overflow:auto;display:flex;flex-direction:column;gap:14px;padding-right:4px;">
<sc-for list="{{ tardeItems }}" as="item" hint-placeholder-count="5">
<div ref="{{ item.setRef }}" onClick="{{ item.toggle }}" style="{{ item.cardStyle }}">
<div style="{{ item.tileStyle }}">{{ item.icon }}</div>
<div style="flex:1;min-width:0;"><div style="{{ item.labelStyle }}">{{ item.label }}</div></div>
<button onClick="{{ item.speak }}" style="all:unset;cursor:pointer;width:34px;height:34px;border-radius:50%;background:#F2F6F8;display:flex;align-items:center;justify-content:center;font-size:16px;flex:none;">🔊</button>
<div style="{{ item.checkStyle }}">{{ item.check }}</div>
</div>
</sc-for>
</div>
</div>
</div>
</sc-if>
<!-- VARIANT B : focus -->
<sc-if value="{{ showB }}" hint-placeholder-val="{{ false }}">
<div style="flex:1;display:flex;flex-direction:column;align-items:center;justify-content:center;min-height:0;gap:22px;">
<div style="display:flex;align-items:center;gap:10px;background:{{ focus.tint }};color:{{ focus.color }};padding:9px 22px;border-radius:999px;font-family:'Fredoka',sans-serif;font-weight:700;font-size:22px;">{{ focus.blockIcon }} {{ focus.blockLabel }}</div>
<div style="display:flex;align-items:center;gap:38px;">
<button onClick="{{ bPrev }}" style="{{ bPrevStyle }}"></button>
<div style="display:flex;flex-direction:column;align-items:center;gap:20px;animation:celebPop .35s ease both;">
<div style="{{ focus.heroTile }}">{{ focus.icon }}</div>
<div style="font-family:'Fredoka',sans-serif;font-weight:700;font-size:46px;text-align:center;text-wrap:balance;max-width:560px;line-height:1.05;">{{ focus.label }}</div>
<div style="display:flex;gap:16px;align-items:center;">
<button onClick="{{ focus.speak }}" style="all:unset;cursor:pointer;width:62px;height:62px;border-radius:50%;background:#fff;box-shadow:0 6px 18px rgba(40,60,100,.1);display:flex;align-items:center;justify-content:center;font-size:28px;">🔊</button>
<button onClick="{{ bDone }}" style="{{ bDoneStyle }}">{{ focus.doneLabel }}</button>
</div>
</div>
<button onClick="{{ bNext }}" style="{{ bNextStyle }}"></button>
</div>
<div style="display:flex;gap:10px;margin-top:8px;">
<sc-for list="{{ dots }}" as="d" hint-placeholder-count="11">
<div style="{{ d.style }}"></div>
</sc-for>
</div>
<div style="font-weight:800;color:#7A879B;font-size:19px;">{{ L.quedan }} {{ remaining }} · {{ L.despues }}: {{ nextLabel }}</div>
</div>
</sc-if>
</div>
</sc-if>
<!-- ============ STORE ============ -->
<sc-if value="{{ isStore }}" hint-placeholder-val="{{ false }}">
<div style="position:relative;height:100%;display:flex;flex-direction:column;padding:24px 36px 36px;">
<div style="display:flex;align-items:center;gap:18px;margin-bottom:22px;flex:none;">
<button onClick="{{ goHome }}" style="all:unset;cursor:pointer;width:54px;height:54px;border-radius:18px;background:#fff;box-shadow:0 6px 16px rgba(40,60,100,.08);display:flex;align-items:center;justify-content:center;font-size:24px;"></button>
<div style="font-family:'Fredoka',sans-serif;font-weight:700;font-size:38px;flex:1;">🎁 {{ L.tienda }}</div>
<div style="display:flex;align-items:center;gap:8px;background:#FFF6E0;color:#C7912B;border-radius:999px;padding:10px 22px;font-family:'Fredoka';font-weight:700;font-size:30px;">🪙 {{ coins }}</div>
</div>
<div style="flex:1;overflow:auto;display:grid;grid-template-columns:repeat(3,1fr);gap:20px;align-content:start;">
<sc-for list="{{ rewards }}" as="r" hint-placeholder-count="6">
<div style="{{ r.cardStyle }}">
<div style="{{ r.tileStyle }}">{{ r.icon }}</div>
<div style="font-family:'Fredoka',sans-serif;font-weight:600;font-size:22px;text-align:center;line-height:1.1;text-wrap:balance;min-height:52px;display:flex;align-items:center;">{{ r.name }}</div>
<div style="display:flex;align-items:center;gap:6px;background:#FFF6E0;color:#C7912B;border-radius:999px;padding:5px 16px;font-family:'Fredoka';font-weight:700;font-size:22px;">🪙 {{ r.cost }}</div>
<button onClick="{{ r.redeem }}" style="{{ r.btnStyle }}">{{ r.btnLabel }}</button>
</div>
</sc-for>
</div>
<sc-if value="{{ toast.show }}" hint-placeholder-val="{{ false }}">
<div style="position:absolute;bottom:34px;left:50%;transform:translateX(-50%);background:#2A3142;color:#fff;padding:16px 28px;border-radius:999px;font-weight:800;font-size:21px;box-shadow:0 12px 30px rgba(0,0,0,.2);animation:slideUp .3s ease both;">{{ toast.msg }}</div>
</sc-if>
</div>
</sc-if>
<!-- ============ PIN ============ -->
<sc-if value="{{ isPin }}" hint-placeholder-val="{{ false }}">
<div style="position:relative;height:100%;display:flex;flex-direction:column;align-items:center;justify-content:center;gap:30px;">
<button onClick="{{ goProfiles }}" style="all:unset;cursor:pointer;position:absolute;top:26px;left:30px;color:#8C99AB;font-weight:800;font-size:18px;"> {{ L.volver }}</button>
<div style="font-size:50px;">🔒</div>
<div style="font-family:'Fredoka',sans-serif;font-weight:600;font-size:28px;">{{ L.pin }}</div>
<div style="{{ pinDotsStyle }}">
<sc-for list="{{ pinDots }}" as="d" hint-placeholder-count="4">
<div style="{{ d.style }}"></div>
</sc-for>
</div>
<div style="display:grid;grid-template-columns:repeat(3,84px);gap:16px;">
<sc-for list="{{ keys }}" as="k" hint-placeholder-count="12">
<button onClick="{{ k.press }}" style="{{ k.style }}">{{ k.label }}</button>
</sc-for>
</div>
<div style="color:#9FB0BD;font-weight:700;font-size:15px;">PIN demo: 1 2 3 4</div>
</div>
</sc-if>
<!-- ============ PARENTS ============ -->
<sc-if value="{{ isParents }}" hint-placeholder-val="{{ false }}">
<div style="position:relative;height:100%;display:flex;flex-direction:column;background:#F4F7F9;">
<div style="display:flex;align-items:center;gap:16px;padding:18px 30px;background:#fff;border-bottom:1px solid #E6ECF0;flex:none;">
<div style="font-family:'Fredoka',sans-serif;font-weight:700;font-size:26px;flex:none;">👪 {{ L.padres }}</div>
<div style="flex:1;display:flex;gap:8px;justify-content:center;">
<sc-for list="{{ tabs }}" as="t" hint-placeholder-count="5">
<button onClick="{{ t.select }}" style="{{ t.style }}">{{ t.icon }} {{ t.label }}</button>
</sc-for>
</div>
<button onClick="{{ goHome }}" style="all:unset;cursor:pointer;background:#2A3142;color:#fff;font-weight:800;font-size:15px;padding:11px 20px;border-radius:14px;flex:none;">🔓 {{ L.salir }}</button>
</div>
<div style="flex:1;overflow:auto;padding:26px 30px;">
<!-- HORARIO -->
<sc-if value="{{ tabHorario }}" hint-placeholder-val="{{ false }}">
<div style="font-family:'Fredoka';font-weight:600;font-size:22px;margin-bottom:14px;">Horario semanal · material de cada día</div>
<div style="display:grid;grid-template-columns:repeat(5,1fr);gap:16px;">
<sc-for list="{{ schedule }}" as="day" hint-placeholder-count="5">
<div style="background:#fff;border-radius:20px;padding:16px;box-shadow:0 4px 14px rgba(40,60,100,.05);min-height:230px;">
<div style="font-family:'Fredoka';font-weight:700;font-size:18px;margin-bottom:12px;text-align:center;color:#5A6B82;">{{ day.name }}</div>
<div style="display:flex;flex-direction:column;gap:9px;">
<sc-for list="{{ day.acts }}" as="a" hint-placeholder-count="2">
<div style="display:flex;align-items:center;gap:9px;background:{{ a.bg }};border-radius:12px;padding:8px 11px;">
<span style="font-size:20px;">{{ a.icon }}</span>
<span style="font-weight:800;font-size:14px;color:{{ a.color }};">{{ a.name }}</span>
</div>
</sc-for>
<button style="all:unset;cursor:pointer;text-align:center;border:2px dashed #D3DCE3;border-radius:12px;padding:7px;color:#9FB0BD;font-weight:800;font-size:13px;">+ Añadir</button>
</div>
</div>
</sc-for>
</div>
</sc-if>
<!-- MATERIALES -->
<sc-if value="{{ tabMateriales }}" hint-placeholder-val="{{ false }}">
<div style="font-family:'Fredoka';font-weight:600;font-size:22px;margin-bottom:14px;">Actividades y su material</div>
<div style="display:grid;grid-template-columns:repeat(2,1fr);gap:18px;">
<sc-for list="{{ activities }}" as="act" hint-placeholder-count="4">
<div style="background:#fff;border-radius:20px;padding:18px;box-shadow:0 4px 14px rgba(40,60,100,.05);border-left:8px solid {{ act.color }};">
<div style="display:flex;align-items:center;gap:10px;margin-bottom:14px;">
<span style="font-size:26px;">{{ act.icon }}</span>
<span style="font-family:'Fredoka';font-weight:700;font-size:21px;">{{ act.name }}</span>
</div>
<div style="display:flex;flex-wrap:wrap;gap:9px;">
<sc-for list="{{ act.items }}" as="m" hint-placeholder-count="3">
<div style="display:flex;align-items:center;gap:7px;background:#F4F7F9;border-radius:999px;padding:7px 14px;font-weight:800;font-size:14px;color:#5A6B82;">{{ m.i }} {{ m.n }}</div>
</sc-for>
<button style="all:unset;cursor:pointer;background:#F4F7F9;border:2px dashed #D3DCE3;border-radius:999px;padding:6px 14px;color:#9FB0BD;font-weight:800;font-size:14px;">+ material</button>
</div>
</div>
</sc-for>
<button style="all:unset;cursor:pointer;display:flex;align-items:center;justify-content:center;gap:8px;background:#fff;border:3px dashed #D3DCE3;border-radius:20px;min-height:120px;color:#9FB0BD;font-family:'Fredoka';font-weight:600;font-size:20px;">+ Nueva actividad</button>
</div>
</sc-if>
<!-- EVENTOS -->
<sc-if value="{{ tabEventos }}" hint-placeholder-val="{{ false }}">
<div style="font-family:'Fredoka';font-weight:600;font-size:22px;margin-bottom:14px;">Exámenes y deberes</div>
<div style="display:flex;flex-direction:column;gap:12px;max-width:680px;">
<sc-for list="{{ pEvents }}" as="e" hint-placeholder-count="3">
<div style="display:flex;align-items:center;gap:16px;background:#fff;border-radius:18px;padding:16px 20px;box-shadow:0 4px 14px rgba(40,60,100,.05);">
<div style="width:48px;height:48px;border-radius:14px;background:{{ e.bg }};display:flex;align-items:center;justify-content:center;font-size:26px;">{{ e.icon }}</div>
<div style="flex:1;">
<div style="font-weight:900;font-size:12px;letter-spacing:1px;color:{{ e.color }};">{{ e.kind }}</div>
<div style="font-family:'Fredoka';font-weight:600;font-size:20px;">{{ e.title }}</div>
</div>
<div style="font-weight:800;color:#7A879B;font-size:16px;">📅 {{ e.date }}</div>
</div>
</sc-for>
<button style="all:unset;cursor:pointer;display:flex;align-items:center;justify-content:center;gap:8px;background:#fff;border:3px dashed #D3DCE3;border-radius:18px;padding:18px;color:#9FB0BD;font-family:'Fredoka';font-weight:600;font-size:19px;">+ Añadir examen o deberes</button>
</div>
</sc-if>
<!-- RUTINAS -->
<sc-if value="{{ tabRutinas }}" hint-placeholder-val="{{ false }}">
<div style="font-family:'Fredoka';font-weight:600;font-size:22px;margin-bottom:14px;">Rutinas de la tarde (por día)</div>
<div style="display:flex;gap:8px;margin-bottom:18px;">
<sc-for list="{{ weekChips }}" as="w" hint-placeholder-count="5">
<button onClick="{{ w.select }}" style="{{ w.style }}">{{ w.label }}</button>
</sc-for>
</div>
<div style="display:flex;flex-direction:column;gap:11px;max-width:620px;">
<sc-for list="{{ routineList }}" as="r" hint-placeholder-count="5">
<div style="display:flex;align-items:center;gap:14px;background:#fff;border-radius:16px;padding:13px 18px;box-shadow:0 4px 14px rgba(40,60,100,.05);">
<span style="font-size:26px;">{{ r.icon }}</span>
<span style="flex:1;font-family:'Fredoka';font-weight:600;font-size:19px;">{{ r.name }}</span>
<span style="cursor:grab;color:#C3CDD6;font-size:20px;"></span>
</div>
</sc-for>
<button style="all:unset;cursor:pointer;display:flex;align-items:center;justify-content:center;gap:8px;background:#fff;border:3px dashed #D3DCE3;border-radius:16px;padding:14px;color:#9FB0BD;font-family:'Fredoka';font-weight:600;font-size:18px;">+ Añadir rutina</button>
</div>
</sc-if>
<!-- GAMIFICACIÓN -->
<sc-if value="{{ tabJuego }}" hint-placeholder-val="{{ false }}">
<div style="font-family:'Fredoka';font-weight:600;font-size:22px;margin-bottom:18px;">Recompensas y ajustes</div>
<div style="display:flex;flex-direction:column;gap:14px;max-width:560px;">
<sc-for list="{{ steppers }}" as="s" hint-placeholder-count="3">
<div style="display:flex;align-items:center;gap:16px;background:#fff;border-radius:18px;padding:16px 22px;box-shadow:0 4px 14px rgba(40,60,100,.05);">
<span style="font-size:30px;">{{ s.icon }}</span>
<div style="flex:1;"><div style="font-family:'Fredoka';font-weight:600;font-size:19px;">{{ s.label }}</div><div style="font-size:14px;color:#9FB0BD;font-weight:700;">{{ s.hint }}</div></div>
<div style="display:flex;align-items:center;gap:12px;">
<button onClick="{{ s.dec }}" style="all:unset;cursor:pointer;width:42px;height:42px;border-radius:50%;background:#F4F7F9;display:flex;align-items:center;justify-content:center;font-weight:900;font-size:22px;color:#7A879B;"></button>
<span style="font-family:'Fredoka';font-weight:700;font-size:26px;min-width:56px;text-align:center;">🪙 {{ s.value }}</span>
<button onClick="{{ s.inc }}" style="all:unset;cursor:pointer;width:42px;height:42px;border-radius:50%;background:#F4F7F9;display:flex;align-items:center;justify-content:center;font-weight:900;font-size:22px;color:#7A879B;">+</button>
</div>
</div>
</sc-for>
<sc-for list="{{ toggles }}" as="tg" hint-placeholder-count="2">
<div style="display:flex;align-items:center;gap:16px;background:#fff;border-radius:18px;padding:16px 22px;box-shadow:0 4px 14px rgba(40,60,100,.05);">
<span style="font-size:30px;">{{ tg.icon }}</span>
<div style="flex:1;font-family:'Fredoka';font-weight:600;font-size:19px;">{{ tg.label }}</div>
<button onClick="{{ tg.toggle }}" style="{{ tg.trackStyle }}"><span style="{{ tg.knobStyle }}"></span></button>
</div>
</sc-for>
</div>
</sc-if>
</div>
</div>
</sc-if>
<!-- ============ BOTTOM DEMO DOCK (prototype controls) ============ -->
<sc-if value="{{ showDock }}" hint-placeholder-val="{{ true }}">
<div style="position:fixed;bottom:0;left:0;right:0;z-index:40;display:flex;align-items:center;justify-content:center;gap:8px;padding:8px;background:rgba(255,255,255,.78);backdrop-filter:blur(8px);border-top:1px solid rgba(0,0,0,.05);">
<button onClick="{{ goProfiles }}" style="{{ dockBtn }}">👤 {{ L.perfiles }}</button>
<button onClick="{{ goPin }}" style="{{ dockBtn }}">⚙️ {{ L.padres }}</button>
<button onClick="{{ goStore }}" style="{{ dockBtn }}">🎁 {{ L.tienda }}</button>
<span style="width:1px;height:24px;background:#DDE4E9;"></span>
<button onClick="{{ setVarA }}" style="{{ varAStyle }}">A · Tablero</button>
<button onClick="{{ setVarB }}" style="{{ varBStyle }}">B · Foco</button>
<span style="width:1px;height:24px;background:#DDE4E9;"></span>
<button onClick="{{ toggleEmpty }}" style="{{ dockBtn }}">{{ emptyLabel }}</button>
<button onClick="{{ reset }}" style="{{ dockBtn }}">↺ {{ L.reiniciar }}</button>
<span style="width:1px;height:24px;background:#DDE4E9;"></span>
<button onClick="{{ toggleLang }}" style="{{ langBtn }}">{{ langLabel }}</button>
</div>
</sc-if>
<!-- flying coins layer -->
<div style="position:fixed;inset:0;pointer-events:none;z-index:60;">{{ flyingCoinEls }}</div>
<!-- CELEBRATION -->
<sc-if value="{{ celebrate }}" hint-placeholder-val="{{ false }}">
<div style="position:fixed;inset:0;z-index:80;display:flex;align-items:center;justify-content:center;background:rgba(35,49,66,.45);backdrop-filter:blur(3px);">
<div style="position:absolute;inset:0;overflow:hidden;pointer-events:none;">{{ confettiEls }}</div>
<div style="position:relative;background:#fff;border-radius:36px;padding:46px 64px;text-align:center;box-shadow:0 30px 80px rgba(0,0,0,.3);animation:celebPop .45s cubic-bezier(.2,.8,.2,1) both;">
<div style="font-size:104px;animation:floatYb 2.2s ease-in-out infinite;">🦊🎉</div>
<div style="font-family:'Fredoka',sans-serif;font-weight:700;font-size:54px;color:#2A3142;margin-top:8px;">{{ L.todoListo }}</div>
<div style="font-size:24px;color:#7A879B;font-weight:700;margin-top:6px;">{{ L.biengrande }}</div>
<div style="display:inline-flex;align-items:center;gap:9px;background:#FFF6E0;color:#C7912B;border-radius:999px;padding:12px 26px;font-family:'Fredoka';font-weight:700;font-size:28px;margin-top:22px;">🪙 +{{ coinsPerDay }}</div>
<div style="margin-top:26px;">
<button onClick="{{ dismissCeleb }}" style="all:unset;cursor:pointer;background:linear-gradient(135deg,#7FBF6B,#5BC0BE);color:#fff;font-family:'Fredoka';font-weight:700;font-size:26px;padding:16px 44px;border-radius:999px;box-shadow:0 10px 26px rgba(91,192,190,.4);">{{ L.genial }} 👍</button>
</div>
</div>
</div>
</sc-if>
</div>
</x-dc>
<script type="text/x-dc" data-dc-script data-props="{&quot;$preview&quot;:{&quot;width&quot;:1280,&quot;height&quot;:800}}">
class Component extends DCLogic {
D = {
es:{ who:'¿QUIÉN ENTRA HOY?', cole:'PARA EL COLE', tarde:'ESTA TARDE', salimos:'SALIMOS EN', min:'min', todoListo:'¡TODO LISTO!', biengrande:'¡Lo has hecho genial!', listo:'listo', quedan:'Quedan', despues:'Después', genial:'¡GENIAL!', tienda:'TIENDA DE PREMIOS', canjear:'CANJEAR', faltan:'Te faltan', monedas:'monedas', padres:'Padres', salir:'Salir', pin:'Introduce el PIN', vacioT:'HOY NO HAY COLE', vacioS:'¡Disfruta de la tarde! 🎉', volver:'Volver', perfiles:'Perfiles', reiniciar:'Reiniciar', hola:'Hola', diaVacio:'Día vacío', diaNormal:'Día normal', hecho:'¡HECHO!', anios:'años' },
ca:{ who:'QUI ENTRA AVUI?', cole:"PER A L'ESCOLA", tarde:'AQUESTA TARDA', salimos:'SORTIM EN', min:'min', todoListo:'TOT FET!', biengrande:'Ho has fet genial!', listo:'fet', quedan:'En queden', despues:'Després', genial:'GENIAL!', tienda:'BOTIGA DE PREMIS', canjear:'BESCANVIAR', faltan:'Et falten', monedas:'monedes', padres:'Pares', salir:'Sortir', pin:'Introdueix el PIN', vacioT:'AVUI NO HI HA ESCOLA', vacioS:'Gaudeix de la tarda! 🎉', volver:'Tornar', perfiles:'Perfils', reiniciar:'Reiniciar', hola:'Hola', diaVacio:'Dia buit', diaNormal:'Dia normal', hecho:'FET!', anios:'anys' }
};
profilesData = [
{ id:'nora', name:'NORA', mascot:'🦊', color:'#F2A65A', coins:42 },
{ id:'leo', name:'LEO', mascot:'🐢', color:'#5BC0BE', coins:28 },
{ id:'mia', name:'MÍA', mascot:'🦉', color:'#A78BD0', coins:55 },
];
coleData = [
{ id:'estuche', icon:'✏️', label:'ESTUCHE', labelCa:'ESTOIG', color:'#F2A65A' },
{ id:'mates', icon:'📘', label:'LIBRO DE MATES', labelCa:'LLIBRE DE MATES', color:'#5B8DEF' },
{ id:'flauta', icon:'🎵', label:'FLAUTA', labelCa:'FLAUTA', color:'#A78BD0' },
{ id:'ropa', icon:'👕', label:'ROPA DE GIMNASIA', labelCa:'ROBA D\u2019EDUCACIÓ FÍSICA', color:'#7FBF6B' },
{ id:'zapas', icon:'👟', label:'ZAPATILLAS', labelCa:'SABATILLES', color:'#7FBF6B' },
{ id:'almuerzo', icon:'🍎', label:'ALMUERZO', labelCa:'ESMORZAR', color:'#F4C95D' },
];
tardeData = [
{ id:'mochila', icon:'🎒', label:'DESHACER LA MOCHILA', labelCa:'BUIDAR LA MOTXILLA', color:'#5BC0BE' },
{ id:'merienda', icon:'🥪', label:'MERENDAR', labelCa:'BERENAR', color:'#F2A65A' },
{ id:'deberes', icon:'📝', label:'HACER LOS DEBERES', labelCa:'FER ELS DEURES', color:'#5B8DEF' },
{ id:'piano', icon:'🎹', label:'PRACTICAR PIANO', labelCa:'PRACTICAR PIANO', color:'#A78BD0' },
{ id:'mesa', icon:'🍽️', label:'RECOGER LA MESA', labelCa:'PARAR TAULA', color:'#7FBF6B' },
];
rewardsData = [
{ icon:'🎮', name:'30 MIN DE TABLET', nameCa:'30 MIN DE TAULETA', cost:20, color:'#5B8DEF' },
{ icon:'🍿', name:'PELI EN FAMILIA', nameCa:'PEL·LÍCULA', cost:50, color:'#A78BD0' },
{ icon:'🛝', name:'TARDE EN EL PARQUE', nameCa:'TARDA AL PARC', cost:40, color:'#7FBF6B' },
{ icon:'🍕', name:'ELIJO LA CENA', nameCa:'TRIO EL SOPAR', cost:30, color:'#F2A65A' },
{ icon:'🌙', name:'30 MIN MÁS DESPIERTO', nameCa:'30 MIN MÉS DESPERT', cost:60, color:'#5BC0BE' },
{ icon:'🦖', name:'SORPRESA DINO', nameCa:'SORPRESA DINO', cost:80, color:'#EC8FA4' },
];
activitiesData = [
{ name:'Gimnasia', icon:'🤸', color:'#7FBF6B', items:[{i:'👕',n:'Equipación'},{i:'👟',n:'Zapatillas'},{i:'🧺',n:'Toalla'},{i:'💧',n:'Agua'}] },
{ name:'Música', icon:'🎵', color:'#A78BD0', items:[{i:'🎵',n:'Flauta'},{i:'📒',n:'Libreta'}] },
{ name:'Matemáticas', icon:'📘', color:'#5B8DEF', items:[{i:'📘',n:'Libro'},{i:'📐',n:'Regla'},{i:'✏️',n:'Estuche'}] },
{ name:'Lengua', icon:'📖', color:'#EC8FA4', items:[{i:'📖',n:'Lectura'},{i:'📓',n:'Cuaderno'}] },
];
state = {
screen:'profiles', lang:'es', variant:'A', profileId:'nora', coins:42,
done:{}, focusIndex:0, celebrate:false, confetti:[], flyingCoins:[], walletPulse:false,
emptyDemo:false, pinInput:'', parentsTab:'horario', routineDay:1,
coinsPerTask:5, coinsPerBlock:10, coinsPerDay:20, sndOn:true, ttsOn:true,
ages:{ nora:7, leo:9, mia:6 }, timer:18*60,
};
componentDidMount(){ this._t = setInterval(()=>{ this.setState(s=>({ timer: s.timer>0 ? s.timer-1 : 0 })); }, 1000); }
componentWillUnmount(){ clearInterval(this._t); }
L(){ return this.D[this.state.lang]; }
allTasks(){ return this.coleData.concat(this.tardeData); }
cardRefs = {};
setCardRef = (id)=> (el)=>{ if(el) this.cardRefs[id]=el; };
speak(text){ if(!this.state.ttsOn) return; try{ const u=new SpeechSynthesisUtterance(text); u.lang=this.state.lang==='ca'?'ca-ES':'es-ES'; u.rate=0.9; u.pitch=1.1; speechSynthesis.cancel(); speechSynthesis.speak(u);}catch(e){} }
ding(){ if(!this.state.sndOn) return; try{ const a=new (window.AudioContext||window.webkitAudioContext)(); const o=a.createOscillator(), g=a.createGain(); o.connect(g); g.connect(a.destination); o.type='sine'; o.frequency.setValueAtTime(660,a.currentTime); o.frequency.setValueAtTime(990,a.currentTime+0.09); g.gain.setValueAtTime(0.12,a.currentTime); g.gain.exponentialRampToValueAtTime(0.001,a.currentTime+0.32); o.start(); o.stop(a.currentTime+0.33);}catch(e){} }
toggleTask(id){
const done={...this.state.done}; const was=done[id]; done[id]=!was;
this.setState({done}, ()=>{ if(!was){ this.launchCoin(id); this.ding(); this.maybeCelebrate(); } });
}
launchCoin(id){
const card=this.cardRefs[id], wal=this.walletEl;
if(!card||!wal){ this.addCoins(); return; }
const c=card.getBoundingClientRect(), w=wal.getBoundingClientRect();
const x=c.left+30, y=c.top+30;
const dx=(w.left+w.width/2)-x, dy=(w.top+w.height/2)-y;
const key='c'+Date.now()+Math.random();
this.setState(s=>({flyingCoins:[...s.flyingCoins,{key,x,y,dx,dy}]}));
setTimeout(()=>{ this.setState(s=>({flyingCoins:s.flyingCoins.filter(f=>f.key!==key)})); this.addCoins(); }, 760);
}
addCoins(){ this.setState(s=>({coins:s.coins+s.coinsPerTask, walletPulse:!s.walletPulse})); }
maybeCelebrate(){
const total=this.allTasks().length;
const d=this.allTasks().filter(t=>this.state.done[t.id]).length;
if(d===total && total>0 && !this.state.celebrate){
setTimeout(()=>this.setState(s=>({celebrate:true, confetti:this.makeConfetti(), coins:s.coins+s.coinsPerDay})), 450);
}
}
makeConfetti(){ const cs=['#5B8DEF','#5BC0BE','#F2A65A','#A78BD0','#7FBF6B','#EC8FA4','#F4C95D']; return Array.from({length:100},(_,i)=>({key:i,left:Math.random()*100,delay:Math.random()*0.7,dur:2.1+Math.random()*1.8,color:cs[i%cs.length],size:7+Math.random()*11})); }
dismissCeleb(){ this.setState({celebrate:false, confetti:[]}); }
// variant B
focusTask(){ const t=this.allTasks(); let i=Math.min(this.state.focusIndex, t.length-1); return t[i]; }
bDone(){ const t=this.focusTask(); if(!t) return; if(!this.state.done[t.id]) this.toggleTask(t.id); const all=this.allTasks(); let n=this.state.focusIndex+1; while(n<all.length && this.state.done[all[n].id]) n++; if(n<all.length) this.setState({focusIndex:n}); }
bNav(d){ const all=this.allTasks(); let i=this.state.focusIndex+d; if(i<0)i=0; if(i>all.length-1)i=all.length-1; this.setState({focusIndex:i}); }
selectProfile(p){ this.setState({screen:'home', profileId:p.id, coins:p.coins, done:{}, focusIndex:0, celebrate:false, emptyDemo:false}); }
changeAge(id,d){ this.setState(s=>{ const ages={...s.ages}; ages[id]=Math.max(4,Math.min(12,(ages[id]||7)+d)); return {ages}; }); }
reset(){ this.setState({done:{}, focusIndex:0, celebrate:false, confetti:[], emptyDemo:false}); }
pressPin(d){ if(d==='del'){ this.setState(s=>({pinInput:s.pinInput.slice(0,-1)})); return; } const v=this.state.pinInput+d; if(v.length>=4){ if(v==='1234'){ this.setState({screen:'parents', pinInput:''}); } else { this.setState({pinInput:''}); } } else { this.setState({pinInput:v}); } }
redeem(r){ if(this.state.coins>=r.cost){ this.setState(s=>({coins:s.coins-r.cost}), ()=>this.flash('🎉 ¡'+(this.state.lang==='ca'?'Bescanviat':'Canjeado')+'!')); } else { this.flash('🙂 '+this.L().faltan+' '+(r.cost-this.state.coins)); } }
flash(msg){ clearTimeout(this._tt); this.setState({toast:{show:true,msg}}); this._tt=setTimeout(()=>this.setState({toast:{show:false,msg}}),1700); }
step(key,d,min,max){ this.setState(s=>({[key]:Math.max(min,Math.min(max,s[key]+d))})); }
renderVals(){
const s=this.state, L=this.L(), lang=s.lang;
const profile=this.profilesData.find(p=>p.id===s.profileId)||this.profilesData[0];
const lbl=(it)=> lang==='ca'? it.labelCa : it.label;
const tint=(hex)=> hex+'24';
const baseCard=(it)=>{ const done=!!s.done[it.id];
const card='display:flex;align-items:center;gap:16px;background:'+(done?tint(it.color):'#fff')+';border:3px solid '+(done?it.color:'#EEF2F6')+';border-radius:26px;padding:14px 16px;cursor:pointer;box-shadow:0 6px 16px rgba(40,60,100,.06);transition:transform .15s,border-color .25s,background .25s;min-height:92px;'+(done?'animation:pop .35s ease;':'');
const tile='width:66px;height:66px;border-radius:20px;display:flex;align-items:center;justify-content:center;font-size:38px;flex:none;background:'+tint(it.color)+';'+(done?'opacity:.85;':'');
const label='font-family:\'Fredoka\',sans-serif;font-weight:600;font-size:21px;letter-spacing:.2px;color:'+(done?'#7A879B':'#2A3142')+';line-height:1.05;text-transform:uppercase;';
const check='width:60px;height:60px;border-radius:50%;flex:none;display:flex;align-items:center;justify-content:center;font-size:30px;color:#fff;font-weight:900;border:3px solid '+(done?it.color:'#DCE3EA')+';background:'+(done?it.color:'#fff')+';'+(done?'animation:checkPop .35s ease;':'');
return {...it, done, label:lbl(it), cardStyle:card, tileStyle:tile, labelStyle:label, checkStyle:check, check:done?'✓':'',
setRef:this.setCardRef(it.id), toggle:()=>this.toggleTask(it.id), speak:(e)=>{e&&e.stopPropagation&&e.stopPropagation();this.speak(lbl(it));}};
};
const coleItems=this.coleData.map(baseCard);
const tardeItems=this.tardeData.map(baseCard);
const coleDone=coleItems.filter(i=>i.done).length, tardeDone=tardeItems.filter(i=>i.done).length;
const gTotal=coleItems.length+tardeItems.length, gDone=coleDone+tardeDone;
// focus B
const all=this.allTasks(); const fi=Math.min(s.focusIndex,all.length-1); const ft=all[fi];
const isCole=fi<this.coleData.length; const fdone=!!s.done[ft.id];
const heroTile='width:200px;height:200px;border-radius:46px;display:flex;align-items:center;justify-content:center;font-size:120px;background:'+tint(ft.color)+';box-shadow:0 16px 40px '+ft.color+'33;animation:floatYb 3s ease-in-out infinite;';
const navBtn=(en)=>'all:unset;cursor:pointer;width:64px;height:64px;border-radius:50%;background:#fff;box-shadow:0 6px 16px rgba(40,60,100,.08);display:flex;align-items:center;justify-content:center;font-size:34px;color:#9FB0BD;'+(en?'':'opacity:.3;pointer-events:none;');
const bDoneStyle='all:unset;cursor:pointer;background:'+(fdone?'#7FBF6B':'linear-gradient(135deg,'+ft.color+',#5BC0BE)')+';color:#fff;font-family:\'Fredoka\';font-weight:700;font-size:30px;padding:18px 46px;border-radius:999px;box-shadow:0 12px 28px '+ft.color+'55;';
const remaining=all.filter(t=>!s.done[t.id]).length;
const nextT=all.slice(fi+1).find(t=>!s.done[t.id]) || all.find(t=>!s.done[t.id]);
const dots=all.map((t,i)=>({ style:'width:14px;height:14px;border-radius:50%;background:'+(s.done[t.id]?'#7FBF6B':(i===fi?'#F2A65A':'#D3DCE3'))+';transition:.3s;'+(i===fi?'transform:scale(1.4);':'') }));
// events
const evRaw=[ {icon:'📋',kind:'EXAMEN',kindCa:'EXAMEN',title:lang==='ca'?'Examen de Llengua':'Examen de Lengua',border:'#E08A3C',bg:'#FFF1DE'},
{icon:'📎',kind:'DEBERES',kindCa:'DEURES',title:lang==='ca'?'Fitxa de mates':'Ficha de mates',border:'#5B8DEF',bg:'#E9F1FF'} ];
const events=evRaw.map(e=>({...e, kind:lang==='ca'?e.kindCa:e.kind, speak:()=>this.speak((lang==='ca'?e.kindCa:e.kind)+'. '+e.title)}));
// rewards
const rewards=this.rewardsData.map(r=>{ const can=s.coins>=r.cost; const name=lang==='ca'?r.nameCa:r.name;
return {...r, name, cardStyle:'display:flex;flex-direction:column;align-items:center;gap:12px;background:#fff;border-radius:26px;padding:20px 16px 18px;box-shadow:0 8px 22px rgba(40,60,100,.06);border-top:6px solid '+r.color+';',
tileStyle:'width:88px;height:88px;border-radius:26px;display:flex;align-items:center;justify-content:center;font-size:52px;background:'+tint(r.color)+';',
btnStyle:'all:unset;cursor:'+(can?'pointer':'default')+';background:'+(can?r.color:'#EEF2F6')+';color:'+(can?'#fff':'#9FB0BD')+';font-family:\'Fredoka\';font-weight:700;font-size:18px;padding:11px 26px;border-radius:999px;white-space:nowrap;',
btnLabel:can?L.canjear:(L.faltan+' '+(r.cost-s.coins)),
redeem:()=>this.redeem(r)};
});
// pin
const pinDots=[0,1,2,3].map(i=>({ style:'width:20px;height:20px;border-radius:50%;background:'+(i<s.pinInput.length?'#5BC0BE':'#D3DCE3')+';' }));
const keyDef=['1','2','3','4','5','6','7','8','9','','0','del'];
const keys=keyDef.map(k=>({ label:k==='del'?'⌫':k, press:k===''?(()=>{}):(()=>this.pressPin(k)),
style:'all:'+(k===''?'unset':'unset')+';cursor:'+(k===''?'default':'pointer')+';width:84px;height:84px;border-radius:24px;background:'+(k===''?'transparent':'#fff')+';'+(k===''?'':'box-shadow:0 5px 14px rgba(40,60,100,.07);')+'display:flex;align-items:center;justify-content:center;font-family:\'Fredoka\';font-weight:600;font-size:34px;color:#2A3142;' }));
// parents tabs
const tabDefs=[ {id:'horario',icon:'📅',label:lang==='ca'?'Horari':'Horario'}, {id:'materiales',icon:'🎒',label:lang==='ca'?'Materials':'Materiales'}, {id:'eventos',icon:'📋',label:lang==='ca'?'Esdeveniments':'Eventos'}, {id:'rutinas',icon:'🌙',label:lang==='ca'?'Rutines':'Rutinas'}, {id:'juego',icon:'🪙',label:lang==='ca'?'Recompenses':'Recompensas'} ];
const tabs=tabDefs.map(t=>({...t, select:()=>this.setState({parentsTab:t.id}), style:'all:unset;cursor:pointer;padding:10px 18px;border-radius:14px;font-family:\'Fredoka\';font-weight:600;font-size:16px;background:'+(s.parentsTab===t.id?'#2A3142':'#F4F7F9')+';color:'+(s.parentsTab===t.id?'#fff':'#5A6B82')+';' }));
const actColor={Gimnasia:'#7FBF6B',Música:'#A78BD0',Matemáticas:'#5B8DEF',Lengua:'#EC8FA4',Plástica:'#F2A65A'};
const actIcon={Gimnasia:'🤸',Música:'🎵',Matemáticas:'📘',Lengua:'📖',Plástica:'🎨'};
const sched=[['Matemáticas','Lengua'],['Matemáticas','Música','Gimnasia'],['Lengua','Plástica'],['Matemáticas','Música'],['Gimnasia','Lengua']];
const dayNames=lang==='ca'?['DILLUNS','DIMARTS','DIMECRES','DIJOUS','DIVENDRES']:['LUNES','MARTES','MIÉRCOLES','JUEVES','VIERNES'];
const schedule=sched.map((acts,i)=>({ name:dayNames[i], acts:acts.map(a=>({name:a,icon:actIcon[a]||'📗',color:actColor[a]||'#5A6B82',bg:(actColor[a]||'#5A6B82')+'1f'})) }));
const activities=this.activitiesData.map(a=>({...a}));
const pEvents=[ {icon:'📋',kind:'EXAMEN',color:'#E08A3C',bg:'#FFF1DE',title:lang==='ca'?'Examen de Llengua':'Examen de Lengua',date:'Mar 17'},
{icon:'📎',kind:'DEBERES',color:'#5B8DEF',bg:'#E9F1FF',title:lang==='ca'?'Fitxa de mates':'Ficha de mates',date:'Mar 17'},
{icon:'📋',kind:'EXAMEN',color:'#E08A3C',bg:'#FFF1DE',title:lang==='ca'?'Examen de Mates':'Examen de Mates',date:'Jue 19'} ];
const weekChips=dayNames.map((d,i)=>({ label:d.slice(0,3), select:()=>this.setState({routineDay:i}), style:'all:unset;cursor:pointer;padding:9px 16px;border-radius:12px;font-weight:800;font-size:14px;background:'+(s.routineDay===i?'#5BC0BE':'#fff')+';color:'+(s.routineDay===i?'#fff':'#5A6B82')+';box-shadow:0 3px 10px rgba(40,60,100,.05);' }));
const baseRoutines=[ {icon:'🎒',name:lang==='ca'?'Buidar la motxilla':'Deshacer la mochila'},{icon:'🥪',name:lang==='ca'?'Berenar':'Merendar'},{icon:'📝',name:lang==='ca'?'Fer els deures':'Hacer los deberes'},{icon:'🍽️',name:lang==='ca'?'Parar taula':'Recoger la mesa'} ];
const routineList=(s.routineDay===1||s.routineDay===3)? baseRoutines.concat([{icon:'🎹',name:lang==='ca'?'Practicar piano':'Practicar piano'}]) : baseRoutines;
const steppers=[ {icon:'✅',label:lang==='ca'?'Monedes per tasca':'Monedas por tarea',hint:lang==='ca'?'En completar cada tasca':'Al completar cada tarea',value:s.coinsPerTask,inc:()=>this.step('coinsPerTask',1,1,20),dec:()=>this.step('coinsPerTask',-1,1,20)},
{icon:'🎯',label:lang==='ca'?'Monedes per bloc':'Monedas por bloque',hint:lang==='ca'?'En acabar cole o tarda':'Al terminar cole o tarde',value:s.coinsPerBlock,inc:()=>this.step('coinsPerBlock',1,0,50),dec:()=>this.step('coinsPerBlock',-1,0,50)},
{icon:'🏆',label:lang==='ca'?'Bonus dia complet':'Bonus día completo',hint:lang==='ca'?'En acabar-ho tot':'Al completar todo el día',value:s.coinsPerDay,inc:()=>this.step('coinsPerDay',5,0,100),dec:()=>this.step('coinsPerDay',-5,0,100)} ];
const tgl=(on)=>'all:unset;cursor:pointer;width:62px;height:34px;border-radius:999px;background:'+(on?'#7FBF6B':'#D3DCE3')+';display:flex;align-items:center;padding:3px;transition:.2s;';
const knob=(on)=>'width:28px;height:28px;border-radius:50%;background:#fff;box-shadow:0 2px 4px rgba(0,0,0,.2);transform:translateX('+(on?'28px':'0')+');transition:.2s;';
const toggles=[ {icon:'🔊',label:lang==='ca'?'So en marcar':'Sonido al marcar',trackStyle:tgl(s.sndOn),knobStyle:knob(s.sndOn),toggle:()=>this.setState(p=>({sndOn:!p.sndOn}))},
{icon:'🗣️',label:lang==='ca'?'Lectura en veu alta':'Lectura en voz alta',trackStyle:tgl(s.ttsOn),knobStyle:knob(s.ttsOn),toggle:()=>this.setState(p=>({ttsOn:!p.ttsOn}))} ];
// timer ring
const frac=s.timer/(20*60); const deg=Math.round(frac*360);
const timerRing='conic-gradient(#F2A65A '+deg+'deg, #FFE6C7 0deg)';
const timerMin=Math.ceil(s.timer/60);
// flying coins
const flyingCoinEls=s.flyingCoins.map(f=>React.createElement('div',{key:f.key, style:{position:'fixed',left:f.x+'px',top:f.y+'px',fontSize:'40px',zIndex:60,['--dx']:f.dx+'px',['--dy']:f.dy+'px',animation:'none',transform:'translate('+f.dx+'px,'+f.dy+'px) scale(.4)',transition:'transform .72s cubic-bezier(.4,0,.5,1), opacity .72s',opacity:0,willChange:'transform'}, ref:(el)=>{ if(el){ el.style.transform='translate(0,0) scale(1)'; el.style.opacity='1'; requestAnimationFrame(()=>requestAnimationFrame(()=>{ el.style.transform='translate('+f.dx+'px,'+f.dy+'px) scale(.4)'; el.style.opacity='0'; })); } }}, '🪙'));
const confettiEls=s.confetti.map(c=>React.createElement('div',{key:c.key, style:{position:'absolute',top:'-5vh',left:c.left+'%',width:c.size+'px',height:(c.size*0.6)+'px',background:c.color,borderRadius:'2px',animation:'confFall '+c.dur+'s linear '+c.delay+'s infinite'}}));
const dockBtn='all:unset;cursor:pointer;padding:7px 13px;border-radius:11px;background:#fff;color:#5A6B82;font-weight:800;font-size:13px;box-shadow:0 2px 6px rgba(40,60,100,.06);';
const segBtn=(on)=>'all:unset;cursor:pointer;padding:7px 13px;border-radius:11px;background:'+(on?'#2A3142':'#fff')+';color:'+(on?'#fff':'#5A6B82')+';font-weight:800;font-size:13px;box-shadow:0 2px 6px rgba(40,60,100,.06);';
return {
L,
isProfiles:s.screen==='profiles', isHome:s.screen==='home', isStore:s.screen==='store', isPin:s.screen==='pin', isParents:s.screen==='parents',
showDock:s.screen!=='pin',
profiles:this.profilesData.map(p=>({...p, ageLabel:(s.ages[p.id]||7)+' '+L.anios,
cardStyle:'background:#fff;border-radius:30px;padding:26px 30px;box-shadow:0 12px 30px rgba(40,60,100,.08);display:flex;flex-direction:column;align-items:center;animation:slideUp .5s ease both;',
ringStyle:'width:140px;height:140px;border-radius:50%;background:'+p.color+'26;display:flex;align-items:center;justify-content:center;animation:floatY 3.5s ease-in-out infinite;',
select:()=>this.selectProfile(p), ageUp:()=>this.changeAge(p.id,1), ageDown:()=>this.changeAge(p.id,-1) })),
profile, coins:s.coins,
walletStyle:'display:flex;align-items:center;gap:8px;background:#FFF6E0;color:#C7912B;border-radius:20px;padding:10px 20px;'+(s.walletPulse!==undefined?'':'')+(true?'animation:'+(s._wp?'':'')+'walletBump .5s ease;':''),
setWallet:(el)=>{this.walletEl=el;},
dayName: lang==='ca'?'DIMARTS':'MARTES', dayDate: lang==='ca'?'17 de juny':'17 de junio',
timerRing, timerMin,
globalDone:gDone, globalTotal:gTotal, globalPct:(gTotal?Math.round(gDone/gTotal*100):0)+'%',
coleItems, tardeItems, coleDone, coleTotal:coleItems.length, tardeDone, tardeTotal:tardeItems.length,
showEmpty:s.emptyDemo, showA:!s.emptyDemo && s.variant==='A', showB:!s.emptyDemo && s.variant==='B',
hasEvents:!s.emptyDemo, events,
focus:{...ft, label:lbl(ft), icon:ft.icon, color:ft.color, tint:tint(ft.color), heroTile,
blockIcon:isCole?'🎒':'🌙', blockLabel:isCole?L.cole:L.tarde, doneLabel:fdone?'✓ '+L.listo.toUpperCase():L.hecho,
speak:()=>this.speak(lbl(ft))},
bDone:()=>this.bDone(), bPrev:()=>this.bNav(-1), bNext:()=>this.bNav(1),
bPrevStyle:navBtn(fi>0), bNextStyle:navBtn(fi<all.length-1), bDoneStyle,
dots, remaining, nextLabel: nextT?lbl(nextT):'🎉',
rewards, toast:s.toast||{show:false,msg:''},
pinDots, pinDotsStyle:'display:flex;gap:16px;'+(s.pinInput.length===0&&false?'animation:shake .4s;':''), keys,
tabs, tabHorario:s.parentsTab==='horario', tabMateriales:s.parentsTab==='materiales', tabEventos:s.parentsTab==='eventos', tabRutinas:s.parentsTab==='rutinas', tabJuego:s.parentsTab==='juego',
schedule, activities, pEvents, weekChips, routineList, steppers, toggles,
celebrate:s.celebrate, confettiEls, flyingCoinEls, coinsPerDay:s.coinsPerDay, dismissCeleb:()=>this.dismissCeleb(),
goHome:()=>this.setState({screen:'home'}), goStore:()=>this.setState({screen:'store'}), goProfiles:()=>this.setState({screen:'profiles'}), goPin:()=>this.setState({screen:'pin',pinInput:''}),
setVarA:()=>this.setState({variant:'A'}), setVarB:()=>this.setState({variant:'B'}),
varAStyle:segBtn(s.variant==='A'), varBStyle:segBtn(s.variant==='B'),
toggleEmpty:()=>this.setState(p=>({emptyDemo:!p.emptyDemo})), emptyLabel:s.emptyDemo?('📋 '+L.diaNormal):('🏖️ '+L.diaVacio),
reset:()=>this.reset(), dockBtn,
toggleLang:()=>this.setState(p=>({lang:p.lang==='es'?'ca':'es'})), langBtn:dockBtn, langLabel:s.lang==='es'?'🌐 CA':'🌐 ES',
};
}
}
</script>
</body>
</html>

File diff suppressed because it is too large Load Diff

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.3 KiB

View File

@@ -0,0 +1,630 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<script src="./support.js"></script>
</head>
<body>
<x-dc>
<helmet>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Fredoka:wght@400;500;600;700&family=Nunito:wght@400;600;700;800;900&display=swap" rel="stylesheet">
<style>
* { box-sizing: border-box; -webkit-tap-highlight-color: transparent; }
html, body { margin:0; padding:0; height:100%; background:#EAF1F4; font-family:'Nunito',sans-serif; overflow:hidden; }
@keyframes pop { 0%{transform:scale(1)} 40%{transform:scale(1.06)} 100%{transform:scale(1)} }
@keyframes checkPop { 0%{transform:scale(0)} 60%{transform:scale(1.25)} 100%{transform:scale(1)} }
@keyframes floatY { 0%,100%{transform:translateY(0)} 50%{transform:translateY(-8px)} }
@keyframes floatYb { 0%,100%{transform:translateY(0)} 50%{transform:translateY(-14px)} }
@keyframes walletBump { 0%{transform:scale(1)} 35%{transform:scale(1.22) rotate(-4deg)} 100%{transform:scale(1)} }
@keyframes confFall { 0%{transform:translateY(-20vh) rotate(0)} 100%{transform:translateY(110vh) rotate(720deg)} }
@keyframes shake { 0%,100%{transform:translateX(0)} 20%{transform:translateX(-8px)} 40%{transform:translateX(8px)} 60%{transform:translateX(-6px)} 80%{transform:translateX(6px)} }
@keyframes celebPop { 0%{transform:scale(.6);opacity:0} 60%{transform:scale(1.05)} 100%{transform:scale(1);opacity:1} }
@keyframes ringGlow { 0%,100%{box-shadow:0 0 0 0 rgba(242,166,90,.35)} 50%{box-shadow:0 0 0 14px rgba(242,166,90,0)} }
@keyframes slideUp { 0%{transform:translateY(30px);opacity:0} 100%{transform:translateY(0);opacity:1} }
::-webkit-scrollbar{ width:10px;height:10px } ::-webkit-scrollbar-thumb{ background:#cfd9e0;border-radius:8px }
</style>
</helmet>
<div style="position:fixed;inset:0;font-family:'Nunito',sans-serif;color:#2A3142;overflow:hidden;background:radial-gradient(1100px 700px at 12% -10%, #FBF4E9 0%, transparent 55%), radial-gradient(1000px 700px at 110% 120%, #E2F0EC 0%, transparent 55%), #EFF4F6;">
<!-- soft organic blobs -->
<div style="position:absolute;top:-120px;left:-100px;width:420px;height:420px;border-radius:48% 52% 60% 40%/55% 45% 60% 45%;background:#FCEBD3;opacity:.55;filter:blur(2px);"></div>
<div style="position:absolute;bottom:-140px;right:-90px;width:460px;height:460px;border-radius:60% 40% 45% 55%/50% 55% 45% 50%;background:#D7ECE5;opacity:.6;filter:blur(2px);"></div>
<!-- ============ PROFILES ============ -->
<sc-if value="{{ isProfiles }}" hint-placeholder-val="{{ true }}">
<div style="position:relative;height:100%;display:flex;flex-direction:column;align-items:center;justify-content:center;gap:48px;padding:40px;">
<div style="text-align:center;animation:slideUp .5s ease both;">
<div style="font-size:60px;animation:floatY 3.5s ease-in-out infinite;">🌳</div>
<div style="font-family:'Fredoka',sans-serif;font-weight:700;font-size:46px;letter-spacing:.5px;margin-top:6px;">{{ L.who }}</div>
</div>
<div style="display:flex;gap:36px;flex-wrap:wrap;justify-content:center;">
<sc-for list="{{ profiles }}" as="p" hint-placeholder-count="3">
<div style="{{ p.cardStyle }}">
<button onClick="{{ p.select }}" style="all:unset;cursor:pointer;display:flex;flex-direction:column;align-items:center;gap:14px;">
<div style="{{ p.ringStyle }}"><span style="font-size:78px;line-height:1;">{{ p.mascot }}</span></div>
<div style="font-family:'Fredoka',sans-serif;font-weight:600;font-size:30px;letter-spacing:.5px;">{{ p.name }}</div>
<div style="display:flex;align-items:center;gap:7px;background:#FFF6E0;color:#C7912B;border-radius:999px;padding:6px 16px;font-weight:800;font-size:18px;">🪙 {{ p.coins }}</div>
</button>
<div style="display:flex;align-items:center;gap:10px;margin-top:14px;background:#F4F7F9;border-radius:999px;padding:6px 8px;">
<button onClick="{{ p.ageDown }}" style="all:unset;cursor:pointer;width:34px;height:34px;border-radius:50%;background:#fff;display:flex;align-items:center;justify-content:center;font-weight:900;color:#7A879B;box-shadow:0 2px 5px rgba(0,0,0,.06);"></button>
<span style="font-weight:800;font-size:15px;color:#5A6B82;min-width:64px;text-align:center;">{{ p.ageLabel }}</span>
<button onClick="{{ p.ageUp }}" style="all:unset;cursor:pointer;width:34px;height:34px;border-radius:50%;background:#fff;display:flex;align-items:center;justify-content:center;font-weight:900;color:#7A879B;box-shadow:0 2px 5px rgba(0,0,0,.06);">+</button>
</div>
</div>
</sc-for>
</div>
<button onClick="{{ goPin }}" style="all:unset;cursor:pointer;display:flex;align-items:center;gap:9px;color:#8C99AB;font-weight:700;font-size:16px;background:rgba(255,255,255,.6);padding:10px 18px;border-radius:999px;">⚙️ {{ L.padres }}</button>
</div>
</sc-if>
<!-- ============ HOME ============ -->
<sc-if value="{{ isHome }}" hint-placeholder-val="{{ true }}">
<div style="position:relative;height:100%;display:flex;flex-direction:column;padding:22px 30px 88px 30px;">
<!-- header -->
<div style="display:flex;align-items:center;gap:20px;flex:none;">
<div style="flex:1;">
<div style="font-family:'Nunito';font-weight:800;font-size:17px;color:#8C99AB;letter-spacing:.5px;">{{ L.hola }}, {{ profile.name }}! 👋</div>
<div style="font-family:'Fredoka',sans-serif;font-weight:700;font-size:42px;line-height:1;margin-top:2px;">{{ dayName }} <span style="color:#9FB0BD;font-weight:500;font-size:30px;">{{ dayDate }}</span></div>
</div>
<!-- timer -->
<div style="display:flex;align-items:center;gap:14px;background:#fff;border-radius:24px;padding:12px 20px 12px 14px;box-shadow:0 8px 22px rgba(40,60,100,.07);animation:ringGlow 2.6s ease-in-out infinite;">
<div style="position:relative;width:74px;height:74px;border-radius:50%;background:{{ timerRing }};display:flex;align-items:center;justify-content:center;">
<div style="width:56px;height:56px;border-radius:50%;background:#fff;display:flex;align-items:center;justify-content:center;font-size:30px;">🐦</div>
</div>
<div style="line-height:1.05;">
<div style="font-weight:800;font-size:13px;color:#9FB0BD;letter-spacing:.5px;">{{ L.salimos }}</div>
<div style="font-family:'Fredoka',sans-serif;font-weight:700;font-size:30px;color:#E08A3C;">{{ timerMin }} <span style="font-size:16px;color:#C99B6A;">{{ L.min }}</span></div>
</div>
</div>
<!-- wallet -->
<div ref="{{ setWallet }}" style="{{ walletStyle }}">
<span style="font-size:26px;">🪙</span>
<span style="font-family:'Fredoka',sans-serif;font-weight:700;font-size:30px;">{{ coins }}</span>
</div>
<button onClick="{{ goStore }}" style="all:unset;cursor:pointer;width:58px;height:58px;border-radius:20px;background:#fff;box-shadow:0 8px 22px rgba(40,60,100,.07);display:flex;align-items:center;justify-content:center;font-size:28px;">🎁</button>
</div>
<!-- global progress -->
<div style="display:flex;align-items:center;gap:16px;margin:16px 0 14px;flex:none;">
<div style="flex:1;height:18px;background:#E2EAEE;border-radius:999px;overflow:hidden;">
<div style="height:100%;width:{{ globalPct }};background:linear-gradient(90deg,#7FBF6B,#5BC0BE);border-radius:999px;transition:width .5s cubic-bezier(.2,.8,.2,1);"></div>
</div>
<div style="font-family:'Fredoka',sans-serif;font-weight:600;font-size:20px;color:#5A6B82;white-space:nowrap;">{{ globalDone }}/{{ globalTotal }} {{ L.listo }} ✨</div>
</div>
<!-- exam / homework alert (in-flow banner) -->
<sc-if value="{{ hasEvents }}" hint-placeholder-val="{{ false }}">
<div style="display:flex;gap:12px;margin-bottom:14px;flex:none;">
<sc-for list="{{ events }}" as="e" hint-placeholder-count="2">
<div onClick="{{ e.speak }}" style="cursor:pointer;display:flex;align-items:center;gap:12px;background:{{ e.bg }};border:3px solid {{ e.border }};border-radius:18px;padding:10px 18px;box-shadow:0 6px 16px rgba(212,140,40,.12);">
<span style="font-size:30px;">{{ e.icon }}</span>
<div style="line-height:1.05;">
<div style="font-weight:900;font-size:11px;letter-spacing:1px;color:{{ e.border }};">{{ e.kind }}</div>
<div style="font-family:'Fredoka',sans-serif;font-weight:600;font-size:19px;color:#5a4326;">{{ e.title }}</div>
</div>
</div>
</sc-for>
</div>
</sc-if>
<!-- EMPTY STATE -->
<sc-if value="{{ showEmpty }}" hint-placeholder-val="{{ false }}">
<div style="flex:1;display:flex;flex-direction:column;align-items:center;justify-content:center;gap:18px;text-align:center;">
<div style="font-size:96px;animation:floatYb 3s ease-in-out infinite;">🏖️</div>
<div style="font-family:'Fredoka',sans-serif;font-weight:700;font-size:40px;">{{ L.vacioT }}</div>
<div style="font-size:24px;color:#7A879B;font-weight:700;">{{ L.vacioS }}</div>
</div>
</sc-if>
<!-- VARIANT A : board -->
<sc-if value="{{ showA }}" hint-placeholder-val="{{ false }}">
<div style="flex:1;display:flex;gap:22px;min-height:0;">
<!-- COLE -->
<div style="flex:1.05;display:flex;flex-direction:column;min-height:0;">
<div style="display:flex;align-items:center;gap:10px;margin-bottom:12px;">
<span style="font-size:30px;">🎒</span>
<span style="font-family:'Fredoka',sans-serif;font-weight:700;font-size:25px;">{{ L.cole }}</span>
<span style="margin-left:auto;font-weight:800;color:#7A879B;font-size:16px;background:#fff;padding:5px 13px;border-radius:999px;">{{ coleDone }}/{{ coleTotal }}</span>
</div>
<div style="flex:1;overflow:auto;display:grid;grid-template-columns:1fr 1fr;gap:14px;align-content:start;padding-right:4px;">
<sc-for list="{{ coleItems }}" as="item" hint-placeholder-count="6">
<div ref="{{ item.setRef }}" onClick="{{ item.toggle }}" style="{{ item.cardStyle }}">
<div style="{{ item.tileStyle }}">{{ item.icon }}</div>
<div style="flex:1;min-width:0;"><div style="{{ item.labelStyle }}">{{ item.label }}</div></div>
<button onClick="{{ item.speak }}" style="all:unset;cursor:pointer;width:34px;height:34px;border-radius:50%;background:#F2F6F8;display:flex;align-items:center;justify-content:center;font-size:16px;flex:none;">🔊</button>
<div style="{{ item.checkStyle }}">{{ item.check }}</div>
</div>
</sc-for>
</div>
</div>
<!-- TARDE -->
<div style="flex:1;display:flex;flex-direction:column;min-height:0;">
<div style="display:flex;align-items:center;gap:10px;margin-bottom:12px;">
<span style="font-size:30px;">🌙</span>
<span style="font-family:'Fredoka',sans-serif;font-weight:700;font-size:25px;">{{ L.tarde }}</span>
<span style="margin-left:auto;font-weight:800;color:#7A879B;font-size:16px;background:#fff;padding:5px 13px;border-radius:999px;">{{ tardeDone }}/{{ tardeTotal }}</span>
</div>
<div style="flex:1;overflow:auto;display:flex;flex-direction:column;gap:14px;padding-right:4px;">
<sc-for list="{{ tardeItems }}" as="item" hint-placeholder-count="5">
<div ref="{{ item.setRef }}" onClick="{{ item.toggle }}" style="{{ item.cardStyle }}">
<div style="{{ item.tileStyle }}">{{ item.icon }}</div>
<div style="flex:1;min-width:0;"><div style="{{ item.labelStyle }}">{{ item.label }}</div></div>
<button onClick="{{ item.speak }}" style="all:unset;cursor:pointer;width:34px;height:34px;border-radius:50%;background:#F2F6F8;display:flex;align-items:center;justify-content:center;font-size:16px;flex:none;">🔊</button>
<div style="{{ item.checkStyle }}">{{ item.check }}</div>
</div>
</sc-for>
</div>
</div>
</div>
</sc-if>
<!-- VARIANT B : focus -->
<sc-if value="{{ showB }}" hint-placeholder-val="{{ false }}">
<div style="flex:1;display:flex;flex-direction:column;align-items:center;justify-content:center;min-height:0;gap:22px;">
<div style="display:flex;align-items:center;gap:10px;background:{{ focus.tint }};color:{{ focus.color }};padding:9px 22px;border-radius:999px;font-family:'Fredoka',sans-serif;font-weight:700;font-size:22px;">{{ focus.blockIcon }} {{ focus.blockLabel }}</div>
<div style="display:flex;align-items:center;gap:38px;">
<button onClick="{{ bPrev }}" style="{{ bPrevStyle }}"></button>
<div style="display:flex;flex-direction:column;align-items:center;gap:20px;animation:celebPop .35s ease both;">
<div style="{{ focus.heroTile }}">{{ focus.icon }}</div>
<div style="font-family:'Fredoka',sans-serif;font-weight:700;font-size:46px;text-align:center;text-wrap:balance;max-width:560px;line-height:1.05;">{{ focus.label }}</div>
<div style="display:flex;gap:16px;align-items:center;">
<button onClick="{{ focus.speak }}" style="all:unset;cursor:pointer;width:62px;height:62px;border-radius:50%;background:#fff;box-shadow:0 6px 18px rgba(40,60,100,.1);display:flex;align-items:center;justify-content:center;font-size:28px;">🔊</button>
<button onClick="{{ bDone }}" style="{{ bDoneStyle }}">{{ focus.doneLabel }}</button>
</div>
</div>
<button onClick="{{ bNext }}" style="{{ bNextStyle }}"></button>
</div>
<div style="display:flex;gap:10px;margin-top:8px;">
<sc-for list="{{ dots }}" as="d" hint-placeholder-count="11">
<div style="{{ d.style }}"></div>
</sc-for>
</div>
<div style="font-weight:800;color:#7A879B;font-size:19px;">{{ L.quedan }} {{ remaining }} · {{ L.despues }}: {{ nextLabel }}</div>
</div>
</sc-if>
</div>
</sc-if>
<!-- ============ STORE ============ -->
<sc-if value="{{ isStore }}" hint-placeholder-val="{{ false }}">
<div style="position:relative;height:100%;display:flex;flex-direction:column;padding:24px 36px 36px;">
<div style="display:flex;align-items:center;gap:18px;margin-bottom:22px;flex:none;">
<button onClick="{{ goHome }}" style="all:unset;cursor:pointer;width:54px;height:54px;border-radius:18px;background:#fff;box-shadow:0 6px 16px rgba(40,60,100,.08);display:flex;align-items:center;justify-content:center;font-size:24px;"></button>
<div style="font-family:'Fredoka',sans-serif;font-weight:700;font-size:38px;flex:1;">🎁 {{ L.tienda }}</div>
<div style="display:flex;align-items:center;gap:8px;background:#FFF6E0;color:#C7912B;border-radius:999px;padding:10px 22px;font-family:'Fredoka';font-weight:700;font-size:30px;">🪙 {{ coins }}</div>
</div>
<div style="flex:1;overflow:auto;display:grid;grid-template-columns:repeat(3,1fr);gap:20px;align-content:start;">
<sc-for list="{{ rewards }}" as="r" hint-placeholder-count="6">
<div style="{{ r.cardStyle }}">
<div style="{{ r.tileStyle }}">{{ r.icon }}</div>
<div style="font-family:'Fredoka',sans-serif;font-weight:600;font-size:22px;text-align:center;line-height:1.1;text-wrap:balance;min-height:52px;display:flex;align-items:center;">{{ r.name }}</div>
<div style="display:flex;align-items:center;gap:6px;background:#FFF6E0;color:#C7912B;border-radius:999px;padding:5px 16px;font-family:'Fredoka';font-weight:700;font-size:22px;">🪙 {{ r.cost }}</div>
<button onClick="{{ r.redeem }}" style="{{ r.btnStyle }}">{{ r.btnLabel }}</button>
</div>
</sc-for>
</div>
<sc-if value="{{ toast.show }}" hint-placeholder-val="{{ false }}">
<div style="position:absolute;bottom:34px;left:50%;transform:translateX(-50%);background:#2A3142;color:#fff;padding:16px 28px;border-radius:999px;font-weight:800;font-size:21px;box-shadow:0 12px 30px rgba(0,0,0,.2);animation:slideUp .3s ease both;">{{ toast.msg }}</div>
</sc-if>
</div>
</sc-if>
<!-- ============ PIN ============ -->
<sc-if value="{{ isPin }}" hint-placeholder-val="{{ false }}">
<div style="position:relative;height:100%;display:flex;flex-direction:column;align-items:center;justify-content:center;gap:30px;">
<button onClick="{{ goProfiles }}" style="all:unset;cursor:pointer;position:absolute;top:26px;left:30px;color:#8C99AB;font-weight:800;font-size:18px;"> {{ L.volver }}</button>
<div style="font-size:50px;">🔒</div>
<div style="font-family:'Fredoka',sans-serif;font-weight:600;font-size:28px;">{{ L.pin }}</div>
<div style="{{ pinDotsStyle }}">
<sc-for list="{{ pinDots }}" as="d" hint-placeholder-count="4">
<div style="{{ d.style }}"></div>
</sc-for>
</div>
<div style="display:grid;grid-template-columns:repeat(3,84px);gap:16px;">
<sc-for list="{{ keys }}" as="k" hint-placeholder-count="12">
<button onClick="{{ k.press }}" style="{{ k.style }}">{{ k.label }}</button>
</sc-for>
</div>
<div style="color:#9FB0BD;font-weight:700;font-size:15px;">PIN demo: 1 2 3 4</div>
</div>
</sc-if>
<!-- ============ PARENTS ============ -->
<sc-if value="{{ isParents }}" hint-placeholder-val="{{ false }}">
<div style="position:relative;height:100%;display:flex;flex-direction:column;background:#F4F7F9;">
<div style="display:flex;align-items:center;gap:16px;padding:18px 30px;background:#fff;border-bottom:1px solid #E6ECF0;flex:none;">
<div style="font-family:'Fredoka',sans-serif;font-weight:700;font-size:26px;flex:none;">👪 {{ L.padres }}</div>
<div style="flex:1;display:flex;gap:8px;justify-content:center;">
<sc-for list="{{ tabs }}" as="t" hint-placeholder-count="5">
<button onClick="{{ t.select }}" style="{{ t.style }}">{{ t.icon }} {{ t.label }}</button>
</sc-for>
</div>
<button onClick="{{ goHome }}" style="all:unset;cursor:pointer;background:#2A3142;color:#fff;font-weight:800;font-size:15px;padding:11px 20px;border-radius:14px;flex:none;">🔓 {{ L.salir }}</button>
</div>
<div style="flex:1;overflow:auto;padding:26px 30px;">
<!-- HORARIO -->
<sc-if value="{{ tabHorario }}" hint-placeholder-val="{{ false }}">
<div style="font-family:'Fredoka';font-weight:600;font-size:22px;margin-bottom:14px;">Horario semanal · material de cada día</div>
<div style="display:grid;grid-template-columns:repeat(5,1fr);gap:16px;">
<sc-for list="{{ schedule }}" as="day" hint-placeholder-count="5">
<div style="background:#fff;border-radius:20px;padding:16px;box-shadow:0 4px 14px rgba(40,60,100,.05);min-height:230px;">
<div style="font-family:'Fredoka';font-weight:700;font-size:18px;margin-bottom:12px;text-align:center;color:#5A6B82;">{{ day.name }}</div>
<div style="display:flex;flex-direction:column;gap:9px;">
<sc-for list="{{ day.acts }}" as="a" hint-placeholder-count="2">
<div style="display:flex;align-items:center;gap:9px;background:{{ a.bg }};border-radius:12px;padding:8px 11px;">
<span style="font-size:20px;">{{ a.icon }}</span>
<span style="font-weight:800;font-size:14px;color:{{ a.color }};">{{ a.name }}</span>
</div>
</sc-for>
<button style="all:unset;cursor:pointer;text-align:center;border:2px dashed #D3DCE3;border-radius:12px;padding:7px;color:#9FB0BD;font-weight:800;font-size:13px;">+ Añadir</button>
</div>
</div>
</sc-for>
</div>
</sc-if>
<!-- MATERIALES -->
<sc-if value="{{ tabMateriales }}" hint-placeholder-val="{{ false }}">
<div style="font-family:'Fredoka';font-weight:600;font-size:22px;margin-bottom:14px;">Actividades y su material</div>
<div style="display:grid;grid-template-columns:repeat(2,1fr);gap:18px;">
<sc-for list="{{ activities }}" as="act" hint-placeholder-count="4">
<div style="background:#fff;border-radius:20px;padding:18px;box-shadow:0 4px 14px rgba(40,60,100,.05);border-left:8px solid {{ act.color }};">
<div style="display:flex;align-items:center;gap:10px;margin-bottom:14px;">
<span style="font-size:26px;">{{ act.icon }}</span>
<span style="font-family:'Fredoka';font-weight:700;font-size:21px;">{{ act.name }}</span>
</div>
<div style="display:flex;flex-wrap:wrap;gap:9px;">
<sc-for list="{{ act.items }}" as="m" hint-placeholder-count="3">
<div style="display:flex;align-items:center;gap:7px;background:#F4F7F9;border-radius:999px;padding:7px 14px;font-weight:800;font-size:14px;color:#5A6B82;">{{ m.i }} {{ m.n }}</div>
</sc-for>
<button style="all:unset;cursor:pointer;background:#F4F7F9;border:2px dashed #D3DCE3;border-radius:999px;padding:6px 14px;color:#9FB0BD;font-weight:800;font-size:14px;">+ material</button>
</div>
</div>
</sc-for>
<button style="all:unset;cursor:pointer;display:flex;align-items:center;justify-content:center;gap:8px;background:#fff;border:3px dashed #D3DCE3;border-radius:20px;min-height:120px;color:#9FB0BD;font-family:'Fredoka';font-weight:600;font-size:20px;">+ Nueva actividad</button>
</div>
</sc-if>
<!-- EVENTOS -->
<sc-if value="{{ tabEventos }}" hint-placeholder-val="{{ false }}">
<div style="font-family:'Fredoka';font-weight:600;font-size:22px;margin-bottom:14px;">Exámenes y deberes</div>
<div style="display:flex;flex-direction:column;gap:12px;max-width:680px;">
<sc-for list="{{ pEvents }}" as="e" hint-placeholder-count="3">
<div style="display:flex;align-items:center;gap:16px;background:#fff;border-radius:18px;padding:16px 20px;box-shadow:0 4px 14px rgba(40,60,100,.05);">
<div style="width:48px;height:48px;border-radius:14px;background:{{ e.bg }};display:flex;align-items:center;justify-content:center;font-size:26px;">{{ e.icon }}</div>
<div style="flex:1;">
<div style="font-weight:900;font-size:12px;letter-spacing:1px;color:{{ e.color }};">{{ e.kind }}</div>
<div style="font-family:'Fredoka';font-weight:600;font-size:20px;">{{ e.title }}</div>
</div>
<div style="font-weight:800;color:#7A879B;font-size:16px;">📅 {{ e.date }}</div>
</div>
</sc-for>
<button style="all:unset;cursor:pointer;display:flex;align-items:center;justify-content:center;gap:8px;background:#fff;border:3px dashed #D3DCE3;border-radius:18px;padding:18px;color:#9FB0BD;font-family:'Fredoka';font-weight:600;font-size:19px;">+ Añadir examen o deberes</button>
</div>
</sc-if>
<!-- RUTINAS -->
<sc-if value="{{ tabRutinas }}" hint-placeholder-val="{{ false }}">
<div style="font-family:'Fredoka';font-weight:600;font-size:22px;margin-bottom:14px;">Rutinas de la tarde (por día)</div>
<div style="display:flex;gap:8px;margin-bottom:18px;">
<sc-for list="{{ weekChips }}" as="w" hint-placeholder-count="5">
<button onClick="{{ w.select }}" style="{{ w.style }}">{{ w.label }}</button>
</sc-for>
</div>
<div style="display:flex;flex-direction:column;gap:11px;max-width:620px;">
<sc-for list="{{ routineList }}" as="r" hint-placeholder-count="5">
<div style="display:flex;align-items:center;gap:14px;background:#fff;border-radius:16px;padding:13px 18px;box-shadow:0 4px 14px rgba(40,60,100,.05);">
<span style="font-size:26px;">{{ r.icon }}</span>
<span style="flex:1;font-family:'Fredoka';font-weight:600;font-size:19px;">{{ r.name }}</span>
<span style="cursor:grab;color:#C3CDD6;font-size:20px;"></span>
</div>
</sc-for>
<button style="all:unset;cursor:pointer;display:flex;align-items:center;justify-content:center;gap:8px;background:#fff;border:3px dashed #D3DCE3;border-radius:16px;padding:14px;color:#9FB0BD;font-family:'Fredoka';font-weight:600;font-size:18px;">+ Añadir rutina</button>
</div>
</sc-if>
<!-- GAMIFICACIÓN -->
<sc-if value="{{ tabJuego }}" hint-placeholder-val="{{ false }}">
<div style="font-family:'Fredoka';font-weight:600;font-size:22px;margin-bottom:18px;">Recompensas y ajustes</div>
<div style="display:flex;flex-direction:column;gap:14px;max-width:560px;">
<sc-for list="{{ steppers }}" as="s" hint-placeholder-count="3">
<div style="display:flex;align-items:center;gap:16px;background:#fff;border-radius:18px;padding:16px 22px;box-shadow:0 4px 14px rgba(40,60,100,.05);">
<span style="font-size:30px;">{{ s.icon }}</span>
<div style="flex:1;"><div style="font-family:'Fredoka';font-weight:600;font-size:19px;">{{ s.label }}</div><div style="font-size:14px;color:#9FB0BD;font-weight:700;">{{ s.hint }}</div></div>
<div style="display:flex;align-items:center;gap:12px;">
<button onClick="{{ s.dec }}" style="all:unset;cursor:pointer;width:42px;height:42px;border-radius:50%;background:#F4F7F9;display:flex;align-items:center;justify-content:center;font-weight:900;font-size:22px;color:#7A879B;"></button>
<span style="font-family:'Fredoka';font-weight:700;font-size:26px;min-width:56px;text-align:center;">🪙 {{ s.value }}</span>
<button onClick="{{ s.inc }}" style="all:unset;cursor:pointer;width:42px;height:42px;border-radius:50%;background:#F4F7F9;display:flex;align-items:center;justify-content:center;font-weight:900;font-size:22px;color:#7A879B;">+</button>
</div>
</div>
</sc-for>
<sc-for list="{{ toggles }}" as="tg" hint-placeholder-count="2">
<div style="display:flex;align-items:center;gap:16px;background:#fff;border-radius:18px;padding:16px 22px;box-shadow:0 4px 14px rgba(40,60,100,.05);">
<span style="font-size:30px;">{{ tg.icon }}</span>
<div style="flex:1;font-family:'Fredoka';font-weight:600;font-size:19px;">{{ tg.label }}</div>
<button onClick="{{ tg.toggle }}" style="{{ tg.trackStyle }}"><span style="{{ tg.knobStyle }}"></span></button>
</div>
</sc-for>
</div>
</sc-if>
</div>
</div>
</sc-if>
<!-- ============ BOTTOM DEMO DOCK (prototype controls) ============ -->
<sc-if value="{{ showDock }}" hint-placeholder-val="{{ true }}">
<div style="position:fixed;bottom:0;left:0;right:0;z-index:40;display:flex;align-items:center;justify-content:center;gap:8px;padding:8px;background:rgba(255,255,255,.78);backdrop-filter:blur(8px);border-top:1px solid rgba(0,0,0,.05);">
<button onClick="{{ goProfiles }}" style="{{ dockBtn }}">👤 {{ L.perfiles }}</button>
<button onClick="{{ goPin }}" style="{{ dockBtn }}">⚙️ {{ L.padres }}</button>
<button onClick="{{ goStore }}" style="{{ dockBtn }}">🎁 {{ L.tienda }}</button>
<span style="width:1px;height:24px;background:#DDE4E9;"></span>
<button onClick="{{ setVarA }}" style="{{ varAStyle }}">A · Tablero</button>
<button onClick="{{ setVarB }}" style="{{ varBStyle }}">B · Foco</button>
<span style="width:1px;height:24px;background:#DDE4E9;"></span>
<button onClick="{{ toggleEmpty }}" style="{{ dockBtn }}">{{ emptyLabel }}</button>
<button onClick="{{ reset }}" style="{{ dockBtn }}">↺ {{ L.reiniciar }}</button>
<span style="width:1px;height:24px;background:#DDE4E9;"></span>
<button onClick="{{ toggleLang }}" style="{{ langBtn }}">{{ langLabel }}</button>
</div>
</sc-if>
<!-- flying coins layer -->
<div style="position:fixed;inset:0;pointer-events:none;z-index:60;">{{ flyingCoinEls }}</div>
<!-- CELEBRATION -->
<sc-if value="{{ celebrate }}" hint-placeholder-val="{{ false }}">
<div style="position:fixed;inset:0;z-index:80;display:flex;align-items:center;justify-content:center;background:rgba(35,49,66,.45);backdrop-filter:blur(3px);">
<div style="position:absolute;inset:0;overflow:hidden;pointer-events:none;">{{ confettiEls }}</div>
<div style="position:relative;background:#fff;border-radius:36px;padding:46px 64px;text-align:center;box-shadow:0 30px 80px rgba(0,0,0,.3);animation:celebPop .45s cubic-bezier(.2,.8,.2,1) both;">
<div style="font-size:104px;animation:floatYb 2.2s ease-in-out infinite;">🦊🎉</div>
<div style="font-family:'Fredoka',sans-serif;font-weight:700;font-size:54px;color:#2A3142;margin-top:8px;">{{ L.todoListo }}</div>
<div style="font-size:24px;color:#7A879B;font-weight:700;margin-top:6px;">{{ L.biengrande }}</div>
<div style="display:inline-flex;align-items:center;gap:9px;background:#FFF6E0;color:#C7912B;border-radius:999px;padding:12px 26px;font-family:'Fredoka';font-weight:700;font-size:28px;margin-top:22px;">🪙 +{{ coinsPerDay }}</div>
<div style="margin-top:26px;">
<button onClick="{{ dismissCeleb }}" style="all:unset;cursor:pointer;background:linear-gradient(135deg,#7FBF6B,#5BC0BE);color:#fff;font-family:'Fredoka';font-weight:700;font-size:26px;padding:16px 44px;border-radius:999px;box-shadow:0 10px 26px rgba(91,192,190,.4);">{{ L.genial }} 👍</button>
</div>
</div>
</div>
</sc-if>
</div>
</x-dc>
<script type="text/x-dc" data-dc-script data-props="{&quot;$preview&quot;:{&quot;width&quot;:1280,&quot;height&quot;:800}}">
class Component extends DCLogic {
D = {
es:{ who:'¿QUIÉN ENTRA HOY?', cole:'PARA EL COLE', tarde:'ESTA TARDE', salimos:'SALIMOS EN', min:'min', todoListo:'¡TODO LISTO!', biengrande:'¡Lo has hecho genial!', listo:'listo', quedan:'Quedan', despues:'Después', genial:'¡GENIAL!', tienda:'TIENDA DE PREMIOS', canjear:'CANJEAR', faltan:'Te faltan', monedas:'monedas', padres:'Padres', salir:'Salir', pin:'Introduce el PIN', vacioT:'HOY NO HAY COLE', vacioS:'¡Disfruta de la tarde! 🎉', volver:'Volver', perfiles:'Perfiles', reiniciar:'Reiniciar', hola:'Hola', diaVacio:'Día vacío', diaNormal:'Día normal', hecho:'¡HECHO!', anios:'años' },
ca:{ who:'QUI ENTRA AVUI?', cole:"PER A L'ESCOLA", tarde:'AQUESTA TARDA', salimos:'SORTIM EN', min:'min', todoListo:'TOT FET!', biengrande:'Ho has fet genial!', listo:'fet', quedan:'En queden', despues:'Després', genial:'GENIAL!', tienda:'BOTIGA DE PREMIS', canjear:'BESCANVIAR', faltan:'Et falten', monedas:'monedes', padres:'Pares', salir:'Sortir', pin:'Introdueix el PIN', vacioT:'AVUI NO HI HA ESCOLA', vacioS:'Gaudeix de la tarda! 🎉', volver:'Tornar', perfiles:'Perfils', reiniciar:'Reiniciar', hola:'Hola', diaVacio:'Dia buit', diaNormal:'Dia normal', hecho:'FET!', anios:'anys' }
};
profilesData = [
{ id:'nora', name:'NORA', mascot:'🦊', color:'#F2A65A', coins:42 },
{ id:'leo', name:'LEO', mascot:'🐢', color:'#5BC0BE', coins:28 },
{ id:'mia', name:'MÍA', mascot:'🦉', color:'#A78BD0', coins:55 },
];
coleData = [
{ id:'estuche', icon:'✏️', label:'ESTUCHE', labelCa:'ESTOIG', color:'#F2A65A' },
{ id:'mates', icon:'📘', label:'LIBRO DE MATES', labelCa:'LLIBRE DE MATES', color:'#5B8DEF' },
{ id:'flauta', icon:'🎵', label:'FLAUTA', labelCa:'FLAUTA', color:'#A78BD0' },
{ id:'ropa', icon:'👕', label:'ROPA DE GIMNASIA', labelCa:'ROBA D\u2019EDUCACIÓ FÍSICA', color:'#7FBF6B' },
{ id:'zapas', icon:'👟', label:'ZAPATILLAS', labelCa:'SABATILLES', color:'#7FBF6B' },
{ id:'almuerzo', icon:'🍎', label:'ALMUERZO', labelCa:'ESMORZAR', color:'#F4C95D' },
];
tardeData = [
{ id:'mochila', icon:'🎒', label:'DESHACER LA MOCHILA', labelCa:'BUIDAR LA MOTXILLA', color:'#5BC0BE' },
{ id:'merienda', icon:'🥪', label:'MERENDAR', labelCa:'BERENAR', color:'#F2A65A' },
{ id:'deberes', icon:'📝', label:'HACER LOS DEBERES', labelCa:'FER ELS DEURES', color:'#5B8DEF' },
{ id:'piano', icon:'🎹', label:'PRACTICAR PIANO', labelCa:'PRACTICAR PIANO', color:'#A78BD0' },
{ id:'mesa', icon:'🍽️', label:'RECOGER LA MESA', labelCa:'PARAR TAULA', color:'#7FBF6B' },
];
rewardsData = [
{ icon:'🎮', name:'30 MIN DE TABLET', nameCa:'30 MIN DE TAULETA', cost:20, color:'#5B8DEF' },
{ icon:'🍿', name:'PELI EN FAMILIA', nameCa:'PEL·LÍCULA', cost:50, color:'#A78BD0' },
{ icon:'🛝', name:'TARDE EN EL PARQUE', nameCa:'TARDA AL PARC', cost:40, color:'#7FBF6B' },
{ icon:'🍕', name:'ELIJO LA CENA', nameCa:'TRIO EL SOPAR', cost:30, color:'#F2A65A' },
{ icon:'🌙', name:'30 MIN MÁS DESPIERTO', nameCa:'30 MIN MÉS DESPERT', cost:60, color:'#5BC0BE' },
{ icon:'🦖', name:'SORPRESA DINO', nameCa:'SORPRESA DINO', cost:80, color:'#EC8FA4' },
];
activitiesData = [
{ name:'Gimnasia', icon:'🤸', color:'#7FBF6B', items:[{i:'👕',n:'Equipación'},{i:'👟',n:'Zapatillas'},{i:'🧺',n:'Toalla'},{i:'💧',n:'Agua'}] },
{ name:'Música', icon:'🎵', color:'#A78BD0', items:[{i:'🎵',n:'Flauta'},{i:'📒',n:'Libreta'}] },
{ name:'Matemáticas', icon:'📘', color:'#5B8DEF', items:[{i:'📘',n:'Libro'},{i:'📐',n:'Regla'},{i:'✏️',n:'Estuche'}] },
{ name:'Lengua', icon:'📖', color:'#EC8FA4', items:[{i:'📖',n:'Lectura'},{i:'📓',n:'Cuaderno'}] },
];
state = {
screen:'profiles', lang:'es', variant:'A', profileId:'nora', coins:42,
done:{}, focusIndex:0, celebrate:false, confetti:[], flyingCoins:[], walletPulse:false,
emptyDemo:false, pinInput:'', parentsTab:'horario', routineDay:1,
coinsPerTask:5, coinsPerBlock:10, coinsPerDay:20, sndOn:true, ttsOn:true,
ages:{ nora:7, leo:9, mia:6 }, timer:18*60,
};
componentDidMount(){ this._t = setInterval(()=>{ this.setState(s=>({ timer: s.timer>0 ? s.timer-1 : 0 })); }, 1000); }
componentWillUnmount(){ clearInterval(this._t); }
L(){ return this.D[this.state.lang]; }
allTasks(){ return this.coleData.concat(this.tardeData); }
cardRefs = {};
setCardRef = (id)=> (el)=>{ if(el) this.cardRefs[id]=el; };
speak(text){ if(!this.state.ttsOn) return; try{ const u=new SpeechSynthesisUtterance(text); u.lang=this.state.lang==='ca'?'ca-ES':'es-ES'; u.rate=0.9; u.pitch=1.1; speechSynthesis.cancel(); speechSynthesis.speak(u);}catch(e){} }
ding(){ if(!this.state.sndOn) return; try{ const a=new (window.AudioContext||window.webkitAudioContext)(); const o=a.createOscillator(), g=a.createGain(); o.connect(g); g.connect(a.destination); o.type='sine'; o.frequency.setValueAtTime(660,a.currentTime); o.frequency.setValueAtTime(990,a.currentTime+0.09); g.gain.setValueAtTime(0.12,a.currentTime); g.gain.exponentialRampToValueAtTime(0.001,a.currentTime+0.32); o.start(); o.stop(a.currentTime+0.33);}catch(e){} }
toggleTask(id){
const done={...this.state.done}; const was=done[id]; done[id]=!was;
this.setState({done}, ()=>{ if(!was){ this.launchCoin(id); this.ding(); this.maybeCelebrate(); } });
}
launchCoin(id){
const card=this.cardRefs[id], wal=this.walletEl;
if(!card||!wal){ this.addCoins(); return; }
const c=card.getBoundingClientRect(), w=wal.getBoundingClientRect();
const x=c.left+30, y=c.top+30;
const dx=(w.left+w.width/2)-x, dy=(w.top+w.height/2)-y;
const key='c'+Date.now()+Math.random();
this.setState(s=>({flyingCoins:[...s.flyingCoins,{key,x,y,dx,dy}]}));
setTimeout(()=>{ this.setState(s=>({flyingCoins:s.flyingCoins.filter(f=>f.key!==key)})); this.addCoins(); }, 760);
}
addCoins(){ this.setState(s=>({coins:s.coins+s.coinsPerTask, walletPulse:!s.walletPulse})); }
maybeCelebrate(){
const total=this.allTasks().length;
const d=this.allTasks().filter(t=>this.state.done[t.id]).length;
if(d===total && total>0 && !this.state.celebrate){
setTimeout(()=>this.setState(s=>({celebrate:true, confetti:this.makeConfetti(), coins:s.coins+s.coinsPerDay})), 450);
}
}
makeConfetti(){ const cs=['#5B8DEF','#5BC0BE','#F2A65A','#A78BD0','#7FBF6B','#EC8FA4','#F4C95D']; return Array.from({length:100},(_,i)=>({key:i,left:Math.random()*100,delay:Math.random()*0.7,dur:2.1+Math.random()*1.8,color:cs[i%cs.length],size:7+Math.random()*11})); }
dismissCeleb(){ this.setState({celebrate:false, confetti:[]}); }
// variant B
focusTask(){ const t=this.allTasks(); let i=Math.min(this.state.focusIndex, t.length-1); return t[i]; }
bDone(){ const t=this.focusTask(); if(!t) return; if(!this.state.done[t.id]) this.toggleTask(t.id); const all=this.allTasks(); let n=this.state.focusIndex+1; while(n<all.length && this.state.done[all[n].id]) n++; if(n<all.length) this.setState({focusIndex:n}); }
bNav(d){ const all=this.allTasks(); let i=this.state.focusIndex+d; if(i<0)i=0; if(i>all.length-1)i=all.length-1; this.setState({focusIndex:i}); }
selectProfile(p){ this.setState({screen:'home', profileId:p.id, coins:p.coins, done:{}, focusIndex:0, celebrate:false, emptyDemo:false}); }
changeAge(id,d){ this.setState(s=>{ const ages={...s.ages}; ages[id]=Math.max(4,Math.min(12,(ages[id]||7)+d)); return {ages}; }); }
reset(){ this.setState({done:{}, focusIndex:0, celebrate:false, confetti:[], emptyDemo:false}); }
pressPin(d){ if(d==='del'){ this.setState(s=>({pinInput:s.pinInput.slice(0,-1)})); return; } const v=this.state.pinInput+d; if(v.length>=4){ if(v==='1234'){ this.setState({screen:'parents', pinInput:''}); } else { this.setState({pinInput:''}); } } else { this.setState({pinInput:v}); } }
redeem(r){ if(this.state.coins>=r.cost){ this.setState(s=>({coins:s.coins-r.cost}), ()=>this.flash('🎉 ¡'+(this.state.lang==='ca'?'Bescanviat':'Canjeado')+'!')); } else { this.flash('🙂 '+this.L().faltan+' '+(r.cost-this.state.coins)); } }
flash(msg){ clearTimeout(this._tt); this.setState({toast:{show:true,msg}}); this._tt=setTimeout(()=>this.setState({toast:{show:false,msg}}),1700); }
step(key,d,min,max){ this.setState(s=>({[key]:Math.max(min,Math.min(max,s[key]+d))})); }
renderVals(){
const s=this.state, L=this.L(), lang=s.lang;
const profile=this.profilesData.find(p=>p.id===s.profileId)||this.profilesData[0];
const lbl=(it)=> lang==='ca'? it.labelCa : it.label;
const tint=(hex)=> hex+'24';
const baseCard=(it)=>{ const done=!!s.done[it.id];
const card='display:flex;align-items:center;gap:16px;background:'+(done?tint(it.color):'#fff')+';border:3px solid '+(done?it.color:'#EEF2F6')+';border-radius:26px;padding:14px 16px;cursor:pointer;box-shadow:0 6px 16px rgba(40,60,100,.06);transition:transform .15s,border-color .25s,background .25s;min-height:92px;'+(done?'animation:pop .35s ease;':'');
const tile='width:66px;height:66px;border-radius:20px;display:flex;align-items:center;justify-content:center;font-size:38px;flex:none;background:'+tint(it.color)+';'+(done?'opacity:.85;':'');
const label='font-family:\'Fredoka\',sans-serif;font-weight:600;font-size:21px;letter-spacing:.2px;color:'+(done?'#7A879B':'#2A3142')+';line-height:1.05;text-transform:uppercase;';
const check='width:60px;height:60px;border-radius:50%;flex:none;display:flex;align-items:center;justify-content:center;font-size:30px;color:#fff;font-weight:900;border:3px solid '+(done?it.color:'#DCE3EA')+';background:'+(done?it.color:'#fff')+';'+(done?'animation:checkPop .35s ease;':'');
return {...it, done, label:lbl(it), cardStyle:card, tileStyle:tile, labelStyle:label, checkStyle:check, check:done?'✓':'',
setRef:this.setCardRef(it.id), toggle:()=>this.toggleTask(it.id), speak:(e)=>{e&&e.stopPropagation&&e.stopPropagation();this.speak(lbl(it));}};
};
const coleItems=this.coleData.map(baseCard);
const tardeItems=this.tardeData.map(baseCard);
const coleDone=coleItems.filter(i=>i.done).length, tardeDone=tardeItems.filter(i=>i.done).length;
const gTotal=coleItems.length+tardeItems.length, gDone=coleDone+tardeDone;
// focus B
const all=this.allTasks(); const fi=Math.min(s.focusIndex,all.length-1); const ft=all[fi];
const isCole=fi<this.coleData.length; const fdone=!!s.done[ft.id];
const heroTile='width:200px;height:200px;border-radius:46px;display:flex;align-items:center;justify-content:center;font-size:120px;background:'+tint(ft.color)+';box-shadow:0 16px 40px '+ft.color+'33;animation:floatYb 3s ease-in-out infinite;';
const navBtn=(en)=>'all:unset;cursor:pointer;width:64px;height:64px;border-radius:50%;background:#fff;box-shadow:0 6px 16px rgba(40,60,100,.08);display:flex;align-items:center;justify-content:center;font-size:34px;color:#9FB0BD;'+(en?'':'opacity:.3;pointer-events:none;');
const bDoneStyle='all:unset;cursor:pointer;background:'+(fdone?'#7FBF6B':'linear-gradient(135deg,'+ft.color+',#5BC0BE)')+';color:#fff;font-family:\'Fredoka\';font-weight:700;font-size:30px;padding:18px 46px;border-radius:999px;box-shadow:0 12px 28px '+ft.color+'55;';
const remaining=all.filter(t=>!s.done[t.id]).length;
const nextT=all.slice(fi+1).find(t=>!s.done[t.id]) || all.find(t=>!s.done[t.id]);
const dots=all.map((t,i)=>({ style:'width:14px;height:14px;border-radius:50%;background:'+(s.done[t.id]?'#7FBF6B':(i===fi?'#F2A65A':'#D3DCE3'))+';transition:.3s;'+(i===fi?'transform:scale(1.4);':'') }));
// events
const evRaw=[ {icon:'📋',kind:'EXAMEN',kindCa:'EXAMEN',title:lang==='ca'?'Examen de Llengua':'Examen de Lengua',border:'#E08A3C',bg:'#FFF1DE'},
{icon:'📎',kind:'DEBERES',kindCa:'DEURES',title:lang==='ca'?'Fitxa de mates':'Ficha de mates',border:'#5B8DEF',bg:'#E9F1FF'} ];
const events=evRaw.map(e=>({...e, kind:lang==='ca'?e.kindCa:e.kind, speak:()=>this.speak((lang==='ca'?e.kindCa:e.kind)+'. '+e.title)}));
// rewards
const rewards=this.rewardsData.map(r=>{ const can=s.coins>=r.cost; const name=lang==='ca'?r.nameCa:r.name;
return {...r, name, cardStyle:'display:flex;flex-direction:column;align-items:center;gap:12px;background:#fff;border-radius:26px;padding:20px 16px 18px;box-shadow:0 8px 22px rgba(40,60,100,.06);border-top:6px solid '+r.color+';',
tileStyle:'width:88px;height:88px;border-radius:26px;display:flex;align-items:center;justify-content:center;font-size:52px;background:'+tint(r.color)+';',
btnStyle:'all:unset;cursor:'+(can?'pointer':'default')+';background:'+(can?r.color:'#EEF2F6')+';color:'+(can?'#fff':'#9FB0BD')+';font-family:\'Fredoka\';font-weight:700;font-size:18px;padding:11px 26px;border-radius:999px;white-space:nowrap;',
btnLabel:can?L.canjear:(L.faltan+' '+(r.cost-s.coins)),
redeem:()=>this.redeem(r)};
});
// pin
const pinDots=[0,1,2,3].map(i=>({ style:'width:20px;height:20px;border-radius:50%;background:'+(i<s.pinInput.length?'#5BC0BE':'#D3DCE3')+';' }));
const keyDef=['1','2','3','4','5','6','7','8','9','','0','del'];
const keys=keyDef.map(k=>({ label:k==='del'?'⌫':k, press:k===''?(()=>{}):(()=>this.pressPin(k)),
style:'all:'+(k===''?'unset':'unset')+';cursor:'+(k===''?'default':'pointer')+';width:84px;height:84px;border-radius:24px;background:'+(k===''?'transparent':'#fff')+';'+(k===''?'':'box-shadow:0 5px 14px rgba(40,60,100,.07);')+'display:flex;align-items:center;justify-content:center;font-family:\'Fredoka\';font-weight:600;font-size:34px;color:#2A3142;' }));
// parents tabs
const tabDefs=[ {id:'horario',icon:'📅',label:lang==='ca'?'Horari':'Horario'}, {id:'materiales',icon:'🎒',label:lang==='ca'?'Materials':'Materiales'}, {id:'eventos',icon:'📋',label:lang==='ca'?'Esdeveniments':'Eventos'}, {id:'rutinas',icon:'🌙',label:lang==='ca'?'Rutines':'Rutinas'}, {id:'juego',icon:'🪙',label:lang==='ca'?'Recompenses':'Recompensas'} ];
const tabs=tabDefs.map(t=>({...t, select:()=>this.setState({parentsTab:t.id}), style:'all:unset;cursor:pointer;padding:10px 18px;border-radius:14px;font-family:\'Fredoka\';font-weight:600;font-size:16px;background:'+(s.parentsTab===t.id?'#2A3142':'#F4F7F9')+';color:'+(s.parentsTab===t.id?'#fff':'#5A6B82')+';' }));
const actColor={Gimnasia:'#7FBF6B',Música:'#A78BD0',Matemáticas:'#5B8DEF',Lengua:'#EC8FA4',Plástica:'#F2A65A'};
const actIcon={Gimnasia:'🤸',Música:'🎵',Matemáticas:'📘',Lengua:'📖',Plástica:'🎨'};
const sched=[['Matemáticas','Lengua'],['Matemáticas','Música','Gimnasia'],['Lengua','Plástica'],['Matemáticas','Música'],['Gimnasia','Lengua']];
const dayNames=lang==='ca'?['DILLUNS','DIMARTS','DIMECRES','DIJOUS','DIVENDRES']:['LUNES','MARTES','MIÉRCOLES','JUEVES','VIERNES'];
const schedule=sched.map((acts,i)=>({ name:dayNames[i], acts:acts.map(a=>({name:a,icon:actIcon[a]||'📗',color:actColor[a]||'#5A6B82',bg:(actColor[a]||'#5A6B82')+'1f'})) }));
const activities=this.activitiesData.map(a=>({...a}));
const pEvents=[ {icon:'📋',kind:'EXAMEN',color:'#E08A3C',bg:'#FFF1DE',title:lang==='ca'?'Examen de Llengua':'Examen de Lengua',date:'Mar 17'},
{icon:'📎',kind:'DEBERES',color:'#5B8DEF',bg:'#E9F1FF',title:lang==='ca'?'Fitxa de mates':'Ficha de mates',date:'Mar 17'},
{icon:'📋',kind:'EXAMEN',color:'#E08A3C',bg:'#FFF1DE',title:lang==='ca'?'Examen de Mates':'Examen de Mates',date:'Jue 19'} ];
const weekChips=dayNames.map((d,i)=>({ label:d.slice(0,3), select:()=>this.setState({routineDay:i}), style:'all:unset;cursor:pointer;padding:9px 16px;border-radius:12px;font-weight:800;font-size:14px;background:'+(s.routineDay===i?'#5BC0BE':'#fff')+';color:'+(s.routineDay===i?'#fff':'#5A6B82')+';box-shadow:0 3px 10px rgba(40,60,100,.05);' }));
const baseRoutines=[ {icon:'🎒',name:lang==='ca'?'Buidar la motxilla':'Deshacer la mochila'},{icon:'🥪',name:lang==='ca'?'Berenar':'Merendar'},{icon:'📝',name:lang==='ca'?'Fer els deures':'Hacer los deberes'},{icon:'🍽️',name:lang==='ca'?'Parar taula':'Recoger la mesa'} ];
const routineList=(s.routineDay===1||s.routineDay===3)? baseRoutines.concat([{icon:'🎹',name:lang==='ca'?'Practicar piano':'Practicar piano'}]) : baseRoutines;
const steppers=[ {icon:'✅',label:lang==='ca'?'Monedes per tasca':'Monedas por tarea',hint:lang==='ca'?'En completar cada tasca':'Al completar cada tarea',value:s.coinsPerTask,inc:()=>this.step('coinsPerTask',1,1,20),dec:()=>this.step('coinsPerTask',-1,1,20)},
{icon:'🎯',label:lang==='ca'?'Monedes per bloc':'Monedas por bloque',hint:lang==='ca'?'En acabar cole o tarda':'Al terminar cole o tarde',value:s.coinsPerBlock,inc:()=>this.step('coinsPerBlock',1,0,50),dec:()=>this.step('coinsPerBlock',-1,0,50)},
{icon:'🏆',label:lang==='ca'?'Bonus dia complet':'Bonus día completo',hint:lang==='ca'?'En acabar-ho tot':'Al completar todo el día',value:s.coinsPerDay,inc:()=>this.step('coinsPerDay',5,0,100),dec:()=>this.step('coinsPerDay',-5,0,100)} ];
const tgl=(on)=>'all:unset;cursor:pointer;width:62px;height:34px;border-radius:999px;background:'+(on?'#7FBF6B':'#D3DCE3')+';display:flex;align-items:center;padding:3px;transition:.2s;';
const knob=(on)=>'width:28px;height:28px;border-radius:50%;background:#fff;box-shadow:0 2px 4px rgba(0,0,0,.2);transform:translateX('+(on?'28px':'0')+');transition:.2s;';
const toggles=[ {icon:'🔊',label:lang==='ca'?'So en marcar':'Sonido al marcar',trackStyle:tgl(s.sndOn),knobStyle:knob(s.sndOn),toggle:()=>this.setState(p=>({sndOn:!p.sndOn}))},
{icon:'🗣️',label:lang==='ca'?'Lectura en veu alta':'Lectura en voz alta',trackStyle:tgl(s.ttsOn),knobStyle:knob(s.ttsOn),toggle:()=>this.setState(p=>({ttsOn:!p.ttsOn}))} ];
// timer ring
const frac=s.timer/(20*60); const deg=Math.round(frac*360);
const timerRing='conic-gradient(#F2A65A '+deg+'deg, #FFE6C7 0deg)';
const timerMin=Math.ceil(s.timer/60);
// flying coins
const flyingCoinEls=s.flyingCoins.map(f=>React.createElement('div',{key:f.key, style:{position:'fixed',left:f.x+'px',top:f.y+'px',fontSize:'40px',zIndex:60,['--dx']:f.dx+'px',['--dy']:f.dy+'px',animation:'none',transform:'translate('+f.dx+'px,'+f.dy+'px) scale(.4)',transition:'transform .72s cubic-bezier(.4,0,.5,1), opacity .72s',opacity:0,willChange:'transform'}, ref:(el)=>{ if(el){ el.style.transform='translate(0,0) scale(1)'; el.style.opacity='1'; requestAnimationFrame(()=>requestAnimationFrame(()=>{ el.style.transform='translate('+f.dx+'px,'+f.dy+'px) scale(.4)'; el.style.opacity='0'; })); } }}, '🪙'));
const confettiEls=s.confetti.map(c=>React.createElement('div',{key:c.key, style:{position:'absolute',top:'-5vh',left:c.left+'%',width:c.size+'px',height:(c.size*0.6)+'px',background:c.color,borderRadius:'2px',animation:'confFall '+c.dur+'s linear '+c.delay+'s infinite'}}));
const dockBtn='all:unset;cursor:pointer;padding:7px 13px;border-radius:11px;background:#fff;color:#5A6B82;font-weight:800;font-size:13px;box-shadow:0 2px 6px rgba(40,60,100,.06);';
const segBtn=(on)=>'all:unset;cursor:pointer;padding:7px 13px;border-radius:11px;background:'+(on?'#2A3142':'#fff')+';color:'+(on?'#fff':'#5A6B82')+';font-weight:800;font-size:13px;box-shadow:0 2px 6px rgba(40,60,100,.06);';
return {
L,
isProfiles:s.screen==='profiles', isHome:s.screen==='home', isStore:s.screen==='store', isPin:s.screen==='pin', isParents:s.screen==='parents',
showDock:s.screen!=='pin',
profiles:this.profilesData.map(p=>({...p, ageLabel:(s.ages[p.id]||7)+' '+L.anios,
cardStyle:'background:#fff;border-radius:30px;padding:26px 30px;box-shadow:0 12px 30px rgba(40,60,100,.08);display:flex;flex-direction:column;align-items:center;animation:slideUp .5s ease both;',
ringStyle:'width:140px;height:140px;border-radius:50%;background:'+p.color+'26;display:flex;align-items:center;justify-content:center;animation:floatY 3.5s ease-in-out infinite;',
select:()=>this.selectProfile(p), ageUp:()=>this.changeAge(p.id,1), ageDown:()=>this.changeAge(p.id,-1) })),
profile, coins:s.coins,
walletStyle:'display:flex;align-items:center;gap:8px;background:#FFF6E0;color:#C7912B;border-radius:20px;padding:10px 20px;'+(s.walletPulse!==undefined?'':'')+(true?'animation:'+(s._wp?'':'')+'walletBump .5s ease;':''),
setWallet:(el)=>{this.walletEl=el;},
dayName: lang==='ca'?'DIMARTS':'MARTES', dayDate: lang==='ca'?'17 de juny':'17 de junio',
timerRing, timerMin,
globalDone:gDone, globalTotal:gTotal, globalPct:(gTotal?Math.round(gDone/gTotal*100):0)+'%',
coleItems, tardeItems, coleDone, coleTotal:coleItems.length, tardeDone, tardeTotal:tardeItems.length,
showEmpty:s.emptyDemo, showA:!s.emptyDemo && s.variant==='A', showB:!s.emptyDemo && s.variant==='B',
hasEvents:!s.emptyDemo, events,
focus:{...ft, label:lbl(ft), icon:ft.icon, color:ft.color, tint:tint(ft.color), heroTile,
blockIcon:isCole?'🎒':'🌙', blockLabel:isCole?L.cole:L.tarde, doneLabel:fdone?'✓ '+L.listo.toUpperCase():L.hecho,
speak:()=>this.speak(lbl(ft))},
bDone:()=>this.bDone(), bPrev:()=>this.bNav(-1), bNext:()=>this.bNav(1),
bPrevStyle:navBtn(fi>0), bNextStyle:navBtn(fi<all.length-1), bDoneStyle,
dots, remaining, nextLabel: nextT?lbl(nextT):'🎉',
rewards, toast:s.toast||{show:false,msg:''},
pinDots, pinDotsStyle:'display:flex;gap:16px;'+(s.pinInput.length===0&&false?'animation:shake .4s;':''), keys,
tabs, tabHorario:s.parentsTab==='horario', tabMateriales:s.parentsTab==='materiales', tabEventos:s.parentsTab==='eventos', tabRutinas:s.parentsTab==='rutinas', tabJuego:s.parentsTab==='juego',
schedule, activities, pEvents, weekChips, routineList, steppers, toggles,
celebrate:s.celebrate, confettiEls, flyingCoinEls, coinsPerDay:s.coinsPerDay, dismissCeleb:()=>this.dismissCeleb(),
goHome:()=>this.setState({screen:'home'}), goStore:()=>this.setState({screen:'store'}), goProfiles:()=>this.setState({screen:'profiles'}), goPin:()=>this.setState({screen:'pin',pinInput:''}),
setVarA:()=>this.setState({variant:'A'}), setVarB:()=>this.setState({variant:'B'}),
varAStyle:segBtn(s.variant==='A'), varBStyle:segBtn(s.variant==='B'),
toggleEmpty:()=>this.setState(p=>({emptyDemo:!p.emptyDemo})), emptyLabel:s.emptyDemo?('📋 '+L.diaNormal):('🏖️ '+L.diaVacio),
reset:()=>this.reset(), dockBtn,
toggleLang:()=>this.setState(p=>({lang:p.lang==='es'?'ca':'es'})), langBtn:dockBtn, langLabel:s.lang==='es'?'🌐 CA':'🌐 ES',
};
}
}
</script>
</body>
</html>

File diff suppressed because it is too large Load Diff

7
backend/.dockerignore Normal file
View File

@@ -0,0 +1,7 @@
# No enviar artefactos locales al contexto de build de Docker.
.gradle
build
*.log
.idea
.vscode
HELP.md

3
backend/.gitattributes vendored Normal file
View File

@@ -0,0 +1,3 @@
/gradlew text eol=lf
*.bat text eol=crlf
*.jar binary

37
backend/.gitignore vendored Normal file
View File

@@ -0,0 +1,37 @@
HELP.md
.gradle
build/
!gradle/wrapper/gradle-wrapper.jar
!**/src/main/**/build/
!**/src/test/**/build/
### STS ###
.apt_generated
.classpath
.factorypath
.project
.settings
.springBeans
.sts4-cache
bin/
!**/src/main/**/bin/
!**/src/test/**/bin/
### IntelliJ IDEA ###
.idea
*.iws
*.iml
*.ipr
out/
!**/src/main/**/out/
!**/src/test/**/out/
### NetBeans ###
/nbproject/private/
/nbbuild/
/dist/
/nbdist/
/.nb-gradle/
### VS Code ###
.vscode/

33
backend/Dockerfile Normal file
View File

@@ -0,0 +1,33 @@
# --- Etapa 1: build ---
# Compila el jar con el Gradle wrapper del proyecto (no requiere Gradle en host).
FROM eclipse-temurin:21-jdk AS build
WORKDIR /app
# Primero solo lo necesario para resolver dependencias y aprovechar la caché de capas.
COPY gradlew settings.gradle build.gradle ./
COPY gradle ./gradle
RUN chmod +x gradlew && ./gradlew --no-daemon dependencies > /dev/null 2>&1 || true
# Ahora el código fuente y el empaquetado.
COPY src ./src
RUN ./gradlew --no-daemon clean bootJar
# --- Etapa 2: runtime ---
# Imagen mínima con JRE 21. Usuario no-root y zona horaria del negocio.
FROM eclipse-temurin:21-jre AS runtime
WORKDIR /app
ENV TZ=Europe/Madrid
# Usuario sin privilegios.
RUN useradd --system --uid 10001 --create-home appuser
USER appuser
COPY --from=build /app/build/libs/*.jar app.jar
EXPOSE 8080
# Healthcheck contra el endpoint de Actuator (solo health expuesto).
HEALTHCHECK --interval=15s --timeout=3s --start-period=40s --retries=5 \
CMD wget -qO- http://localhost:8080/actuator/health | grep -q '"status":"UP"' || exit 1
ENTRYPOINT ["java", "-jar", "/app/app.jar"]

38
backend/build.gradle Normal file
View File

@@ -0,0 +1,38 @@
plugins {
id 'java'
id 'org.springframework.boot' version '3.5.15'
id 'io.spring.dependency-management' version '1.1.7'
}
group = 'es.asepeyo'
version = '0.0.1-SNAPSHOT'
java {
toolchain {
languageVersion = JavaLanguageVersion.of(21)
}
}
repositories {
mavenCentral()
}
dependencies {
implementation 'org.springframework.boot:spring-boot-starter-actuator'
implementation 'org.springframework.boot:spring-boot-starter-data-jpa'
implementation 'org.springframework.boot:spring-boot-starter-security'
implementation 'org.springframework.boot:spring-boot-starter-validation'
implementation 'org.springframework.boot:spring-boot-starter-web'
implementation 'org.liquibase:liquibase-core'
runtimeOnly 'org.postgresql:postgresql'
// H2 solo en test: permite que el contexto cargue sin un Postgres real en la
// Fase 1 (esqueleto). La infraestructura de test realista (Testcontainers)
// se introducirá en la Fase 2, junto con el dominio.
testRuntimeOnly 'com.h2database:h2'
testImplementation 'org.springframework.boot:spring-boot-starter-test'
testRuntimeOnly 'org.junit.platform:junit-platform-launcher'
}
tasks.named('test') {
useJUnitPlatform()
}

Binary file not shown.

View File

@@ -0,0 +1,7 @@
distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-8.14.5-bin.zip
networkTimeout=10000
validateDistributionUrl=true
zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists

251
backend/gradlew vendored Executable file
View File

@@ -0,0 +1,251 @@
#!/bin/sh
#
# Copyright © 2015-2021 the original authors.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# https://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
#
# SPDX-License-Identifier: Apache-2.0
#
##############################################################################
#
# Gradle start up script for POSIX generated by Gradle.
#
# Important for running:
#
# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is
# noncompliant, but you have some other compliant shell such as ksh or
# bash, then to run this script, type that shell name before the whole
# command line, like:
#
# ksh Gradle
#
# Busybox and similar reduced shells will NOT work, because this script
# requires all of these POSIX shell features:
# * functions;
# * expansions «$var», «${var}», «${var:-default}», «${var+SET}»,
# «${var#prefix}», «${var%suffix}», and «$( cmd )»;
# * compound commands having a testable exit status, especially «case»;
# * various built-in commands including «command», «set», and «ulimit».
#
# Important for patching:
#
# (2) This script targets any POSIX shell, so it avoids extensions provided
# by Bash, Ksh, etc; in particular arrays are avoided.
#
# The "traditional" practice of packing multiple parameters into a
# space-separated string is a well documented source of bugs and security
# problems, so this is (mostly) avoided, by progressively accumulating
# options in "$@", and eventually passing that to Java.
#
# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS,
# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly;
# see the in-line comments for details.
#
# There are tweaks for specific operating systems such as AIX, CygWin,
# Darwin, MinGW, and NonStop.
#
# (3) This script is generated from the Groovy template
# https://github.com/gradle/gradle/blob/HEAD/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt
# within the Gradle project.
#
# You can find Gradle at https://github.com/gradle/gradle/.
#
##############################################################################
# Attempt to set APP_HOME
# Resolve links: $0 may be a link
app_path=$0
# Need this for daisy-chained symlinks.
while
APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path
[ -h "$app_path" ]
do
ls=$( ls -ld "$app_path" )
link=${ls#*' -> '}
case $link in #(
/*) app_path=$link ;; #(
*) app_path=$APP_HOME$link ;;
esac
done
# This is normally unused
# shellcheck disable=SC2034
APP_BASE_NAME=${0##*/}
# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036)
APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s\n' "$PWD" ) || exit
# Use the maximum available, or set MAX_FD != -1 to use that value.
MAX_FD=maximum
warn () {
echo "$*"
} >&2
die () {
echo
echo "$*"
echo
exit 1
} >&2
# OS specific support (must be 'true' or 'false').
cygwin=false
msys=false
darwin=false
nonstop=false
case "$( uname )" in #(
CYGWIN* ) cygwin=true ;; #(
Darwin* ) darwin=true ;; #(
MSYS* | MINGW* ) msys=true ;; #(
NONSTOP* ) nonstop=true ;;
esac
CLASSPATH="\\\"\\\""
# Determine the Java command to use to start the JVM.
if [ -n "$JAVA_HOME" ] ; then
if [ -x "$JAVA_HOME/jre/sh/java" ] ; then
# IBM's JDK on AIX uses strange locations for the executables
JAVACMD=$JAVA_HOME/jre/sh/java
else
JAVACMD=$JAVA_HOME/bin/java
fi
if [ ! -x "$JAVACMD" ] ; then
die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME
Please set the JAVA_HOME variable in your environment to match the
location of your Java installation."
fi
else
JAVACMD=java
if ! command -v java >/dev/null 2>&1
then
die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
Please set the JAVA_HOME variable in your environment to match the
location of your Java installation."
fi
fi
# Increase the maximum file descriptors if we can.
if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then
case $MAX_FD in #(
max*)
# In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked.
# shellcheck disable=SC2039,SC3045
MAX_FD=$( ulimit -H -n ) ||
warn "Could not query maximum file descriptor limit"
esac
case $MAX_FD in #(
'' | soft) :;; #(
*)
# In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked.
# shellcheck disable=SC2039,SC3045
ulimit -n "$MAX_FD" ||
warn "Could not set maximum file descriptor limit to $MAX_FD"
esac
fi
# Collect all arguments for the java command, stacking in reverse order:
# * args from the command line
# * the main class name
# * -classpath
# * -D...appname settings
# * --module-path (only if needed)
# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables.
# For Cygwin or MSYS, switch paths to Windows format before running java
if "$cygwin" || "$msys" ; then
APP_HOME=$( cygpath --path --mixed "$APP_HOME" )
CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" )
JAVACMD=$( cygpath --unix "$JAVACMD" )
# Now convert the arguments - kludge to limit ourselves to /bin/sh
for arg do
if
case $arg in #(
-*) false ;; # don't mess with options #(
/?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath
[ -e "$t" ] ;; #(
*) false ;;
esac
then
arg=$( cygpath --path --ignore --mixed "$arg" )
fi
# Roll the args list around exactly as many times as the number of
# args, so each arg winds up back in the position where it started, but
# possibly modified.
#
# NB: a `for` loop captures its iteration list before it begins, so
# changing the positional parameters here affects neither the number of
# iterations, nor the values presented in `arg`.
shift # remove old arg
set -- "$@" "$arg" # push replacement arg
done
fi
# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"'
# Collect all arguments for the java command:
# * DEFAULT_JVM_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments,
# and any embedded shellness will be escaped.
# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be
# treated as '${Hostname}' itself on the command line.
set -- \
"-Dorg.gradle.appname=$APP_BASE_NAME" \
-classpath "$CLASSPATH" \
-jar "$APP_HOME/gradle/wrapper/gradle-wrapper.jar" \
"$@"
# Stop when "xargs" is not available.
if ! command -v xargs >/dev/null 2>&1
then
die "xargs is not available"
fi
# Use "xargs" to parse quoted args.
#
# With -n1 it outputs one arg per line, with the quotes and backslashes removed.
#
# In Bash we could simply go:
#
# readarray ARGS < <( xargs -n1 <<<"$var" ) &&
# set -- "${ARGS[@]}" "$@"
#
# but POSIX shell has neither arrays nor command substitution, so instead we
# post-process each arg (as a line of input to sed) to backslash-escape any
# character that might be a shell metacharacter, then use eval to reverse
# that process (while maintaining the separation between arguments), and wrap
# the whole thing up as a single "set" statement.
#
# This will of course break if any of these variables contains a newline or
# an unmatched quote.
#
eval "set -- $(
printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" |
xargs -n1 |
sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' |
tr '\n' ' '
)" '"$@"'
exec "$JAVACMD" "$@"

94
backend/gradlew.bat vendored Normal file
View File

@@ -0,0 +1,94 @@
@rem
@rem Copyright 2015 the original author or authors.
@rem
@rem Licensed under the Apache License, Version 2.0 (the "License");
@rem you may not use this file except in compliance with the License.
@rem You may obtain a copy of the License at
@rem
@rem https://www.apache.org/licenses/LICENSE-2.0
@rem
@rem Unless required by applicable law or agreed to in writing, software
@rem distributed under the License is distributed on an "AS IS" BASIS,
@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
@rem See the License for the specific language governing permissions and
@rem limitations under the License.
@rem
@rem SPDX-License-Identifier: Apache-2.0
@rem
@if "%DEBUG%"=="" @echo off
@rem ##########################################################################
@rem
@rem Gradle startup script for Windows
@rem
@rem ##########################################################################
@rem Set local scope for the variables with windows NT shell
if "%OS%"=="Windows_NT" setlocal
set DIRNAME=%~dp0
if "%DIRNAME%"=="" set DIRNAME=.
@rem This is normally unused
set APP_BASE_NAME=%~n0
set APP_HOME=%DIRNAME%
@rem Resolve any "." and ".." in APP_HOME to make it shorter.
for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi
@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m"
@rem Find java.exe
if defined JAVA_HOME goto findJavaFromJavaHome
set JAVA_EXE=java.exe
%JAVA_EXE% -version >NUL 2>&1
if %ERRORLEVEL% equ 0 goto execute
echo. 1>&2
echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2
echo. 1>&2
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
echo location of your Java installation. 1>&2
goto fail
:findJavaFromJavaHome
set JAVA_HOME=%JAVA_HOME:"=%
set JAVA_EXE=%JAVA_HOME%/bin/java.exe
if exist "%JAVA_EXE%" goto execute
echo. 1>&2
echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2
echo. 1>&2
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
echo location of your Java installation. 1>&2
goto fail
:execute
@rem Setup the command line
set CLASSPATH=
@rem Execute Gradle
"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %*
:end
@rem End local scope for the variables with windows NT shell
if %ERRORLEVEL% equ 0 goto mainEnd
:fail
rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of
rem the _cmd.exe /c_ return code!
set EXIT_CODE=%ERRORLEVEL%
if %EXIT_CODE% equ 0 set EXIT_CODE=1
if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE%
exit /b %EXIT_CODE%
:mainEnd
if "%OS%"=="Windows_NT" endlocal
:omega

1
backend/settings.gradle Normal file
View File

@@ -0,0 +1 @@
rootProject.name = 'recordalexia'

View File

@@ -0,0 +1,13 @@
package es.asepeyo.recordalexia;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
@SpringBootApplication
public class RecordalexiaApplication {
public static void main(String[] args) {
SpringApplication.run(RecordalexiaApplication.class, args);
}
}

View File

@@ -0,0 +1,209 @@
package es.asepeyo.recordalexia.bootstrap;
import es.asepeyo.recordalexia.domain.Activity;
import es.asepeyo.recordalexia.domain.AfternoonRoutine;
import es.asepeyo.recordalexia.domain.Child;
import es.asepeyo.recordalexia.domain.EventType;
import es.asepeyo.recordalexia.domain.MaterialItem;
import es.asepeyo.recordalexia.domain.ParentUser;
import es.asepeyo.recordalexia.domain.Reward;
import es.asepeyo.recordalexia.domain.SpecialEvent;
import es.asepeyo.recordalexia.domain.WeeklyTemplateEntry;
import es.asepeyo.recordalexia.repository.ActivityRepository;
import es.asepeyo.recordalexia.repository.AfternoonRoutineRepository;
import es.asepeyo.recordalexia.repository.ChildRepository;
import es.asepeyo.recordalexia.repository.MaterialItemRepository;
import es.asepeyo.recordalexia.repository.ParentUserRepository;
import es.asepeyo.recordalexia.repository.RewardRepository;
import es.asepeyo.recordalexia.repository.SpecialEventRepository;
import es.asepeyo.recordalexia.repository.WeeklyTemplateEntryRepository;
import java.time.Clock;
import java.time.DayOfWeek;
import java.time.LocalDate;
import java.time.LocalTime;
import java.util.List;
import org.springframework.boot.ApplicationArguments;
import org.springframework.boot.ApplicationRunner;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.stereotype.Component;
import org.springframework.transaction.annotation.Transactional;
/**
* Siembra los datos de ejemplo del prototipo (niños, material, actividades, horario,
* rutinas, eventos, premios y el PIN de padres por defecto). Solo actúa si la base
* de datos está vacía, así que es seguro arrancar varias veces.
*
* Se desactiva con recordalexia.seed.enabled=false (lo hacen los tests, que montan
* sus propios datos deterministas).
*/
@Component
@ConditionalOnProperty(name = "recordalexia.seed.enabled", havingValue = "true", matchIfMissing = true)
public class DataSeeder implements ApplicationRunner {
/** PIN de padres por defecto; configurable (el panel permite cambiarlo). */
private static final String DEFAULT_PIN = "1234";
private final ChildRepository childRepository;
private final ParentUserRepository parentUserRepository;
private final MaterialItemRepository materialRepository;
private final ActivityRepository activityRepository;
private final WeeklyTemplateEntryRepository templateRepository;
private final AfternoonRoutineRepository routineRepository;
private final SpecialEventRepository eventRepository;
private final RewardRepository rewardRepository;
private final PasswordEncoder passwordEncoder;
private final Clock clock;
public DataSeeder(ChildRepository childRepository, ParentUserRepository parentUserRepository,
MaterialItemRepository materialRepository, ActivityRepository activityRepository,
WeeklyTemplateEntryRepository templateRepository,
AfternoonRoutineRepository routineRepository,
SpecialEventRepository eventRepository, RewardRepository rewardRepository,
PasswordEncoder passwordEncoder, Clock clock) {
this.childRepository = childRepository;
this.parentUserRepository = parentUserRepository;
this.materialRepository = materialRepository;
this.activityRepository = activityRepository;
this.templateRepository = templateRepository;
this.routineRepository = routineRepository;
this.eventRepository = eventRepository;
this.rewardRepository = rewardRepository;
this.passwordEncoder = passwordEncoder;
this.clock = clock;
}
@Override
@Transactional
public void run(ApplicationArguments args) {
if (childRepository.count() > 0) {
return; // Ya sembrado: no duplicar.
}
seedParent();
var materials = seedMaterials();
var activities = seedActivities(materials);
seedRewards();
// Tres niños del prototipo, cada uno con su horario y rutinas.
Child nora = childRepository.save(child("Nora", "🦊", "#F2A65A", 7, 42, LocalTime.of(8, 30)));
Child leo = childRepository.save(child("Leo", "🐢", "#5BC0BE", 9, 28, LocalTime.of(8, 30)));
Child mia = childRepository.save(child("Mía", "🦉", "#A78BD0", 6, 55, LocalTime.of(8, 15)));
for (Child c : List.of(nora, leo, mia)) {
seedWeeklyMornings(c, activities);
seedAfternoonRoutines(c);
}
// Un par de eventos para Nora alrededor de hoy, para poder probar /today.
LocalDate today = LocalDate.now(clock);
eventRepository.save(new SpecialEvent(nora, today, EventType.EXAM,
"Examen de Lengua", "Examen de Llengua", "📋", "#EC8FA4"));
eventRepository.save(new SpecialEvent(nora, today.plusDays(1), EventType.HOMEWORK,
"Ficha de mates", "Fitxa de mates", "📎", "#5B8DEF"));
}
private void seedParent() {
parentUserRepository.save(new ParentUser(passwordEncoder.encode(DEFAULT_PIN)));
}
private MaterialsCatalog seedMaterials() {
return new MaterialsCatalog(
material("Estuche", "Estoig", "✏️", "#F4C95D", "general"),
material("Libro de mates", "Llibre de mates", "📘", "#5B8DEF", "matematicas"),
material("Regla", "Regle", "📏", "#5B8DEF", "matematicas"),
material("Flauta", "Flauta", "🎵", "#A78BD0", "musica"),
material("Libreta", "Llibreta", "📓", "#A78BD0", "musica"),
material("Ropa de gimnasia", "Roba d'EF", "👕", "#7FBF6B", "gimnasia"),
material("Zapatillas", "Sabatilles", "👟", "#7FBF6B", "gimnasia"),
material("Toalla", "Tovallola", "🧖", "#5BC0BE", "gimnasia"),
material("Agua", "Aigua", "💧", "#5BC0BE", "gimnasia"),
material("Lectura", "Lectura", "📖", "#F2A65A", "lengua"),
material("Cuaderno", "Quadern", "📒", "#F2A65A", "lengua"),
material("Almuerzo", "Esmorzar", "🍎", "#EC8FA4", "general"));
}
private MaterialItem material(String es, String ca, String icon, String color, String category) {
return materialRepository.save(new MaterialItem(es, ca, icon, color, category));
}
private ActivitiesCatalog seedActivities(MaterialsCatalog m) {
Activity gimnasia = activity("Gimnasia", "Gimnàstica", "🤸", "#7FBF6B",
m.ropaGimnasia, m.zapatillas, m.toalla, m.agua);
Activity musica = activity("Música", "Música", "🎵", "#A78BD0", m.flauta, m.libreta);
Activity mates = activity("Matemáticas", "Matemàtiques", "📘", "#5B8DEF",
m.libroMates, m.regla, m.estuche);
Activity lengua = activity("Lengua", "Llengua", "📖", "#F2A65A", m.lectura, m.cuaderno);
return new ActivitiesCatalog(gimnasia, musica, mates, lengua);
}
private Activity activity(String es, String ca, String icon, String color, MaterialItem... mats) {
Activity activity = new Activity(es, ca, icon, color);
for (MaterialItem mat : mats) {
activity.addMaterial(mat);
}
return activityRepository.save(activity);
}
private void seedRewards() {
rewardRepository.saveAll(List.of(
new Reward("30 min de tablet", "30 min de tauleta", "🎮", "#5B8DEF", 20),
new Reward("Elijo la cena", "Trio el sopar", "🍕", "#F2A65A", 30),
new Reward("Tarde en el parque", "Tarda al parc", "🛝", "#7FBF6B", 40),
new Reward("Peli en familia", "Pel·lícula en família", "🍿", "#A78BD0", 50),
new Reward("30 min más despierto", "30 min més despert", "🌙", "#5BC0BE", 60),
new Reward("Sorpresa dino", "Sorpresa dino", "🦖", "#EC8FA4", 80)));
}
private Child child(String name, String mascot, String color, int age, int coins, LocalTime departure) {
Child c = new Child();
c.setName(name);
c.setMascot(mascot);
c.setAccentColor(color);
c.setAge(age);
c.setCoins(coins);
c.setDepartureTime(departure);
return c;
}
/** Plantilla de mañana de lunes a viernes (cada día, una actividad del cole). */
private void seedWeeklyMornings(Child child, ActivitiesCatalog a) {
addWeekly(child, DayOfWeek.MONDAY, a.matematicas);
addWeekly(child, DayOfWeek.TUESDAY, a.gimnasia);
addWeekly(child, DayOfWeek.WEDNESDAY, a.musica);
addWeekly(child, DayOfWeek.THURSDAY, a.lengua);
addWeekly(child, DayOfWeek.FRIDAY, a.gimnasia);
}
private void addWeekly(Child child, DayOfWeek day, Activity activity) {
templateRepository.save(new WeeklyTemplateEntry(child, day, activity, 0));
}
/** Rutinas de tarde, iguales de lunes a viernes. */
private void seedAfternoonRoutines(Child child) {
for (DayOfWeek day : List.of(DayOfWeek.MONDAY, DayOfWeek.TUESDAY, DayOfWeek.WEDNESDAY,
DayOfWeek.THURSDAY, DayOfWeek.FRIDAY)) {
routineRepository.save(new AfternoonRoutine(child, day, "Deshacer la mochila",
"Buidar la motxilla", "🎒", "#F2A65A", 0));
routineRepository.save(new AfternoonRoutine(child, day, "Merendar",
"Berenar", "🥪", "#F4C95D", 1));
routineRepository.save(new AfternoonRoutine(child, day, "Hacer los deberes",
"Fer els deures", "📝", "#5B8DEF", 2));
routineRepository.save(new AfternoonRoutine(child, day, "Practicar piano",
"Practicar piano", "🎹", "#A78BD0", 3));
routineRepository.save(new AfternoonRoutine(child, day, "Recoger la mesa",
"Parar taula", "🍽️", "#7FBF6B", 4));
}
}
// Pequeños contenedores para pasar el catálogo sembrado con nombres claros.
private record MaterialsCatalog(
MaterialItem estuche, MaterialItem libroMates, MaterialItem regla, MaterialItem flauta,
MaterialItem libreta, MaterialItem ropaGimnasia, MaterialItem zapatillas, MaterialItem toalla,
MaterialItem agua, MaterialItem lectura, MaterialItem cuaderno, MaterialItem almuerzo) {
}
private record ActivitiesCatalog(Activity gimnasia, Activity musica, Activity matematicas,
Activity lengua) {
}
}

View File

@@ -0,0 +1,22 @@
package es.asepeyo.recordalexia.config;
import java.time.Clock;
import java.time.ZoneId;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
/**
* Reloj de la aplicación fijado a Europe/Madrid. Inyectar este Clock (en vez de
* usar LocalDate.now() directamente) permite que el negocio decida qué es "hoy"
* de forma consistente y que los tests controlen la fecha.
*/
@Configuration
public class TimeConfig {
public static final ZoneId ZONE_MADRID = ZoneId.of("Europe/Madrid");
@Bean
public Clock clock() {
return Clock.system(ZONE_MADRID);
}
}

View File

@@ -0,0 +1,98 @@
package es.asepeyo.recordalexia.domain;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.FetchType;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.JoinColumn;
import jakarta.persistence.JoinTable;
import jakarta.persistence.ManyToMany;
import jakarta.persistence.Table;
import java.util.LinkedHashSet;
import java.util.Set;
/**
* Actividad del cole (Gimnasia, Música...). Cada actividad arrastra el material
* necesario mediante una relación N:M con {@link MaterialItem}.
*/
@Entity
@Table(name = "activity")
public class Activity {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(name = "label_es")
private String labelEs;
@Column(name = "label_ca")
private String labelCa;
private String icon;
private String color;
// N:M: el material que hay que llevar cuando toca esta actividad.
@ManyToMany(fetch = FetchType.LAZY)
@JoinTable(
name = "activity_material",
joinColumns = @JoinColumn(name = "activity_id"),
inverseJoinColumns = @JoinColumn(name = "material_item_id"))
private Set<MaterialItem> materials = new LinkedHashSet<>();
protected Activity() {
}
public Activity(String labelEs, String labelCa, String icon, String color) {
this.labelEs = labelEs;
this.labelCa = labelCa;
this.icon = icon;
this.color = color;
}
public void addMaterial(MaterialItem material) {
this.materials.add(material);
}
public Long getId() {
return id;
}
public String getLabelEs() {
return labelEs;
}
public void setLabelEs(String labelEs) {
this.labelEs = labelEs;
}
public String getLabelCa() {
return labelCa;
}
public void setLabelCa(String labelCa) {
this.labelCa = labelCa;
}
public String getIcon() {
return icon;
}
public void setIcon(String icon) {
this.icon = icon;
}
public String getColor() {
return color;
}
public void setColor(String color) {
this.color = color;
}
public Set<MaterialItem> getMaterials() {
return materials;
}
}

View File

@@ -0,0 +1,130 @@
package es.asepeyo.recordalexia.domain;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.EnumType;
import jakarta.persistence.Enumerated;
import jakarta.persistence.FetchType;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.JoinColumn;
import jakarta.persistence.ManyToOne;
import jakarta.persistence.Table;
import java.time.DayOfWeek;
/** Rutina de tarde recurrente de un niño para un día de la semana (reordenable). */
@Entity
@Table(name = "afternoon_routine")
public class AfternoonRoutine {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@ManyToOne(fetch = FetchType.LAZY, optional = false)
@JoinColumn(name = "child_id")
private Child child;
@Enumerated(EnumType.STRING)
@Column(name = "day_of_week")
private DayOfWeek dayOfWeek;
@Column(name = "label_es")
private String labelEs;
@Column(name = "label_ca")
private String labelCa;
private String icon;
private String color;
@Column(name = "order_index")
private int orderIndex;
/** Monedas de la rutina; si es null se usa coinsPerTask del niño. */
@Column(name = "coins_reward")
private Integer coinsReward;
protected AfternoonRoutine() {
}
public AfternoonRoutine(Child child, DayOfWeek dayOfWeek, String labelEs, String labelCa,
String icon, String color, int orderIndex) {
this.child = child;
this.dayOfWeek = dayOfWeek;
this.labelEs = labelEs;
this.labelCa = labelCa;
this.icon = icon;
this.color = color;
this.orderIndex = orderIndex;
}
public Long getId() {
return id;
}
public Child getChild() {
return child;
}
public void setChild(Child child) {
this.child = child;
}
public DayOfWeek getDayOfWeek() {
return dayOfWeek;
}
public void setDayOfWeek(DayOfWeek dayOfWeek) {
this.dayOfWeek = dayOfWeek;
}
public String getLabelEs() {
return labelEs;
}
public void setLabelEs(String labelEs) {
this.labelEs = labelEs;
}
public String getLabelCa() {
return labelCa;
}
public void setLabelCa(String labelCa) {
this.labelCa = labelCa;
}
public String getIcon() {
return icon;
}
public void setIcon(String icon) {
this.icon = icon;
}
public String getColor() {
return color;
}
public void setColor(String color) {
this.color = color;
}
public int getOrderIndex() {
return orderIndex;
}
public void setOrderIndex(int orderIndex) {
this.orderIndex = orderIndex;
}
public Integer getCoinsReward() {
return coinsReward;
}
public void setCoinsReward(Integer coinsReward) {
this.coinsReward = coinsReward;
}
}

View File

@@ -0,0 +1,210 @@
package es.asepeyo.recordalexia.domain;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.EnumType;
import jakarta.persistence.Enumerated;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.Table;
import java.time.LocalTime;
/**
* Niño/a que usa la app. Centraliza su saldo de monedas, sus ajustes (modo de
* vista, sonido, TTS, idioma, hora de salida) y sus parámetros de gamificación.
*/
@Entity
@Table(name = "child")
public class Child {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private String name;
private String mascot;
@Column(name = "accent_color")
private String accentColor;
private int age;
/** Saldo actual de monedas. Nunca se modifica directamente desde fuera. */
private int coins;
/** Hora de salida de la mañana; alimenta el temporizador del frontend. */
@Column(name = "departure_time")
private LocalTime departureTime;
@Enumerated(EnumType.STRING)
@Column(name = "view_mode")
private ViewMode viewMode = ViewMode.BOARD;
@Column(name = "sound_enabled")
private boolean soundEnabled = true;
@Column(name = "tts_enabled")
private boolean ttsEnabled = true;
@Enumerated(EnumType.STRING)
private Language language = Language.ES;
// --- Parámetros de gamificación (configurables por niño) ---
@Column(name = "coins_per_task")
private int coinsPerTask = 5;
@Column(name = "coins_per_block")
private int coinsPerBlock = 10;
@Column(name = "coins_per_day")
private int coinsPerDay = 20;
public Child() {
// Constructor vacío: usado por JPA y por la creación desde el servicio/seeder.
}
// --- Comportamiento de dominio sobre el monedero ---
/** Suma monedas al saldo (ganancia). El importe debe ser >= 0. */
public void addCoins(int amount) {
if (amount < 0) {
throw new IllegalArgumentException("El importe a sumar no puede ser negativo");
}
this.coins += amount;
}
/** ¿Tiene saldo suficiente para gastar este coste? */
public boolean canAfford(int cost) {
return this.coins >= cost;
}
/** Descuenta monedas del saldo (gasto/canje). Valida que haya saldo. */
public void spend(int cost) {
if (cost < 0) {
throw new IllegalArgumentException("El coste no puede ser negativo");
}
if (!canAfford(cost)) {
throw new IllegalStateException("Saldo insuficiente");
}
this.coins -= cost;
}
/** Resta monedas previamente ganadas (al desmarcar una tarea). No baja de 0. */
public void removeCoins(int amount) {
if (amount < 0) {
throw new IllegalArgumentException("El importe a restar no puede ser negativo");
}
this.coins = Math.max(0, this.coins - amount);
}
// --- Getters / setters ---
public Long getId() {
return id;
}
public String getName() {
return name;
}
public void setName(String name) {
this.name = name;
}
public String getMascot() {
return mascot;
}
public void setMascot(String mascot) {
this.mascot = mascot;
}
public String getAccentColor() {
return accentColor;
}
public void setAccentColor(String accentColor) {
this.accentColor = accentColor;
}
public int getAge() {
return age;
}
public void setAge(int age) {
this.age = age;
}
public int getCoins() {
return coins;
}
public void setCoins(int coins) {
this.coins = coins;
}
public LocalTime getDepartureTime() {
return departureTime;
}
public void setDepartureTime(LocalTime departureTime) {
this.departureTime = departureTime;
}
public ViewMode getViewMode() {
return viewMode;
}
public void setViewMode(ViewMode viewMode) {
this.viewMode = viewMode;
}
public boolean isSoundEnabled() {
return soundEnabled;
}
public void setSoundEnabled(boolean soundEnabled) {
this.soundEnabled = soundEnabled;
}
public boolean isTtsEnabled() {
return ttsEnabled;
}
public void setTtsEnabled(boolean ttsEnabled) {
this.ttsEnabled = ttsEnabled;
}
public Language getLanguage() {
return language;
}
public void setLanguage(Language language) {
this.language = language;
}
public int getCoinsPerTask() {
return coinsPerTask;
}
public void setCoinsPerTask(int coinsPerTask) {
this.coinsPerTask = coinsPerTask;
}
public int getCoinsPerBlock() {
return coinsPerBlock;
}
public void setCoinsPerBlock(int coinsPerBlock) {
this.coinsPerBlock = coinsPerBlock;
}
public int getCoinsPerDay() {
return coinsPerDay;
}
public void setCoinsPerDay(int coinsPerDay) {
this.coinsPerDay = coinsPerDay;
}
}

View File

@@ -0,0 +1,74 @@
package es.asepeyo.recordalexia.domain;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.FetchType;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.JoinColumn;
import jakarta.persistence.ManyToOne;
import jakarta.persistence.Table;
import java.time.Instant;
import java.time.LocalDate;
/**
* Movimiento de monedas: positivo al ganar (tarea, bono), negativo al canjear.
* Forma el historial del monedero; nunca se borra.
*/
@Entity
@Table(name = "coin_transaction")
public class CoinTransaction {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@ManyToOne(fetch = FetchType.LAZY, optional = false)
@JoinColumn(name = "child_id")
private Child child;
@Column(name = "tx_date")
private LocalDate txDate;
private int amount;
private String reason;
@Column(name = "created_at")
private Instant createdAt;
protected CoinTransaction() {
}
public CoinTransaction(Child child, LocalDate txDate, int amount, String reason) {
this.child = child;
this.txDate = txDate;
this.amount = amount;
this.reason = reason;
this.createdAt = Instant.now();
}
public Long getId() {
return id;
}
public Child getChild() {
return child;
}
public LocalDate getTxDate() {
return txDate;
}
public int getAmount() {
return amount;
}
public String getReason() {
return reason;
}
public Instant getCreatedAt() {
return createdAt;
}
}

View File

@@ -0,0 +1,148 @@
package es.asepeyo.recordalexia.domain;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.EnumType;
import jakarta.persistence.Enumerated;
import jakarta.persistence.FetchType;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.JoinColumn;
import jakarta.persistence.ManyToOne;
import jakarta.persistence.Table;
import java.time.Instant;
import java.time.LocalDate;
/**
* Instancia de una tarea para un día concreto. Es la unidad que el niño marca.
* Se genera a partir de la plantilla semanal, las rutinas de tarde y los eventos.
*/
@Entity
@Table(name = "daily_task")
public class DailyTask {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@ManyToOne(fetch = FetchType.LAZY, optional = false)
@JoinColumn(name = "child_id")
private Child child;
@Column(name = "task_date")
private LocalDate taskDate;
@Enumerated(EnumType.STRING)
private Slot slot;
@Column(name = "label_es")
private String labelEs;
@Column(name = "label_ca")
private String labelCa;
private String icon;
private String color;
@Enumerated(EnumType.STRING)
private TaskStatus status = TaskStatus.PENDING;
@Column(name = "coins_reward")
private int coinsReward;
@Column(name = "completed_at")
private Instant completedAt;
@Enumerated(EnumType.STRING)
private TaskOrigin origin;
@Column(name = "order_index")
private int orderIndex;
protected DailyTask() {
}
public DailyTask(Child child, LocalDate taskDate, Slot slot, String labelEs, String labelCa,
String icon, String color, int coinsReward, TaskOrigin origin, int orderIndex) {
this.child = child;
this.taskDate = taskDate;
this.slot = slot;
this.labelEs = labelEs;
this.labelCa = labelCa;
this.icon = icon;
this.color = color;
this.coinsReward = coinsReward;
this.origin = origin;
this.orderIndex = orderIndex;
}
/** ¿Está ya completada? */
public boolean isDone() {
return status == TaskStatus.DONE;
}
/** Marca la tarea como completada y registra el instante. Idempotente. */
public void markDone(Instant when) {
this.status = TaskStatus.DONE;
this.completedAt = when;
}
/** Vuelve a dejar la tarea pendiente (al desmarcar). */
public void markPending() {
this.status = TaskStatus.PENDING;
this.completedAt = null;
}
public Long getId() {
return id;
}
public Child getChild() {
return child;
}
public LocalDate getTaskDate() {
return taskDate;
}
public Slot getSlot() {
return slot;
}
public String getLabelEs() {
return labelEs;
}
public String getLabelCa() {
return labelCa;
}
public String getIcon() {
return icon;
}
public String getColor() {
return color;
}
public TaskStatus getStatus() {
return status;
}
public int getCoinsReward() {
return coinsReward;
}
public Instant getCompletedAt() {
return completedAt;
}
public TaskOrigin getOrigin() {
return origin;
}
public int getOrderIndex() {
return orderIndex;
}
}

View File

@@ -0,0 +1,7 @@
package es.asepeyo.recordalexia.domain;
/** Tipo de evento especial mostrado en el banner del día. */
public enum EventType {
EXAM,
HOMEWORK
}

View File

@@ -0,0 +1,7 @@
package es.asepeyo.recordalexia.domain;
/** Idioma activo de la UI para el niño. */
public enum Language {
ES,
CA
}

View File

@@ -0,0 +1,83 @@
package es.asepeyo.recordalexia.domain;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.Table;
/** Material concreto del cole (estuche, flauta...). Texto bilingüe + emoji + color. */
@Entity
@Table(name = "material_item")
public class MaterialItem {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(name = "label_es")
private String labelEs;
@Column(name = "label_ca")
private String labelCa;
private String icon;
private String color;
private String category;
protected MaterialItem() {
}
public MaterialItem(String labelEs, String labelCa, String icon, String color, String category) {
this.labelEs = labelEs;
this.labelCa = labelCa;
this.icon = icon;
this.color = color;
this.category = category;
}
public Long getId() {
return id;
}
public String getLabelEs() {
return labelEs;
}
public void setLabelEs(String labelEs) {
this.labelEs = labelEs;
}
public String getLabelCa() {
return labelCa;
}
public void setLabelCa(String labelCa) {
this.labelCa = labelCa;
}
public String getIcon() {
return icon;
}
public void setIcon(String icon) {
this.icon = icon;
}
public String getColor() {
return color;
}
public void setColor(String color) {
this.color = color;
}
public String getCategory() {
return category;
}
public void setCategory(String category) {
this.category = category;
}
}

View File

@@ -0,0 +1,53 @@
package es.asepeyo.recordalexia.domain;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.Table;
import java.time.Instant;
/**
* Credenciales del panel de padres. Guarda únicamente el HASH del PIN (BCrypt),
* nunca el PIN en claro. El PIN es configurable; no se hardcodea.
*/
@Entity
@Table(name = "parent_user")
public class ParentUser {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(name = "pin_hash")
private String pinHash;
@Column(name = "updated_at")
private Instant updatedAt;
protected ParentUser() {
}
public ParentUser(String pinHash) {
this.pinHash = pinHash;
this.updatedAt = Instant.now();
}
public Long getId() {
return id;
}
public String getPinHash() {
return pinHash;
}
public void setPinHash(String pinHash) {
this.pinHash = pinHash;
this.updatedAt = Instant.now();
}
public Instant getUpdatedAt() {
return updatedAt;
}
}

View File

@@ -0,0 +1,92 @@
package es.asepeyo.recordalexia.domain;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.Table;
/** Premio canjeable en la tienda. Compartido por todos los niños. */
@Entity
@Table(name = "reward")
public class Reward {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(name = "label_es")
private String labelEs;
@Column(name = "label_ca")
private String labelCa;
private String icon;
private String color;
private int cost;
private boolean active = true;
protected Reward() {
}
public Reward(String labelEs, String labelCa, String icon, String color, int cost) {
this.labelEs = labelEs;
this.labelCa = labelCa;
this.icon = icon;
this.color = color;
this.cost = cost;
}
public Long getId() {
return id;
}
public String getLabelEs() {
return labelEs;
}
public void setLabelEs(String labelEs) {
this.labelEs = labelEs;
}
public String getLabelCa() {
return labelCa;
}
public void setLabelCa(String labelCa) {
this.labelCa = labelCa;
}
public String getIcon() {
return icon;
}
public void setIcon(String icon) {
this.icon = icon;
}
public String getColor() {
return color;
}
public void setColor(String color) {
this.color = color;
}
public int getCost() {
return cost;
}
public void setCost(int cost) {
this.cost = cost;
}
public boolean isActive() {
return active;
}
public void setActive(boolean active) {
this.active = active;
}
}

View File

@@ -0,0 +1,74 @@
package es.asepeyo.recordalexia.domain;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.FetchType;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.JoinColumn;
import jakarta.persistence.ManyToOne;
import jakarta.persistence.Table;
import java.time.Instant;
import java.time.LocalDate;
/** Canje de un premio por parte de un niño. Histórico, no se borra. */
@Entity
@Table(name = "reward_redemption")
public class RewardRedemption {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@ManyToOne(fetch = FetchType.LAZY, optional = false)
@JoinColumn(name = "child_id")
private Child child;
@ManyToOne(fetch = FetchType.LAZY, optional = false)
@JoinColumn(name = "reward_id")
private Reward reward;
@Column(name = "redeemed_date")
private LocalDate redeemedDate;
private int cost;
@Column(name = "created_at")
private Instant createdAt;
protected RewardRedemption() {
}
public RewardRedemption(Child child, Reward reward, LocalDate redeemedDate, int cost) {
this.child = child;
this.reward = reward;
this.redeemedDate = redeemedDate;
this.cost = cost;
this.createdAt = Instant.now();
}
public Long getId() {
return id;
}
public Child getChild() {
return child;
}
public Reward getReward() {
return reward;
}
public LocalDate getRedeemedDate() {
return redeemedDate;
}
public int getCost() {
return cost;
}
public Instant getCreatedAt() {
return createdAt;
}
}

View File

@@ -0,0 +1,7 @@
package es.asepeyo.recordalexia.domain;
/** Bloque del día: mañana (cole) o tarde (rutinas). */
public enum Slot {
MORNING,
AFTERNOON
}

View File

@@ -0,0 +1,117 @@
package es.asepeyo.recordalexia.domain;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.EnumType;
import jakarta.persistence.Enumerated;
import jakarta.persistence.FetchType;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.JoinColumn;
import jakarta.persistence.ManyToOne;
import jakarta.persistence.Table;
import java.time.LocalDate;
/** Evento puntual (examen o deberes) de un niño en una fecha concreta. */
@Entity
@Table(name = "special_event")
public class SpecialEvent {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@ManyToOne(fetch = FetchType.LAZY, optional = false)
@JoinColumn(name = "child_id")
private Child child;
@Column(name = "event_date")
private LocalDate eventDate;
@Enumerated(EnumType.STRING)
private EventType type;
@Column(name = "title_es")
private String titleEs;
@Column(name = "title_ca")
private String titleCa;
private String icon;
private String color;
protected SpecialEvent() {
}
public SpecialEvent(Child child, LocalDate eventDate, EventType type, String titleEs,
String titleCa, String icon, String color) {
this.child = child;
this.eventDate = eventDate;
this.type = type;
this.titleEs = titleEs;
this.titleCa = titleCa;
this.icon = icon;
this.color = color;
}
public Long getId() {
return id;
}
public Child getChild() {
return child;
}
public void setChild(Child child) {
this.child = child;
}
public LocalDate getEventDate() {
return eventDate;
}
public void setEventDate(LocalDate eventDate) {
this.eventDate = eventDate;
}
public EventType getType() {
return type;
}
public void setType(EventType type) {
this.type = type;
}
public String getTitleEs() {
return titleEs;
}
public void setTitleEs(String titleEs) {
this.titleEs = titleEs;
}
public String getTitleCa() {
return titleCa;
}
public void setTitleCa(String titleCa) {
this.titleCa = titleCa;
}
public String getIcon() {
return icon;
}
public void setIcon(String icon) {
this.icon = icon;
}
public String getColor() {
return color;
}
public void setColor(String color) {
this.color = color;
}
}

View File

@@ -0,0 +1,8 @@
package es.asepeyo.recordalexia.domain;
/** Origen de una tarea del día: de qué fuente de plantilla se generó. */
public enum TaskOrigin {
TEMPLATE,
ROUTINE,
EVENT
}

View File

@@ -0,0 +1,7 @@
package es.asepeyo.recordalexia.domain;
/** Estado de una tarea del día. */
public enum TaskStatus {
PENDING,
DONE
}

View File

@@ -0,0 +1,7 @@
package es.asepeyo.recordalexia.domain;
/** Modo de presentación del día: tablero (todo a la vista) o foco (una tarea). */
public enum ViewMode {
BOARD,
FOCUS
}

View File

@@ -0,0 +1,100 @@
package es.asepeyo.recordalexia.domain;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.EnumType;
import jakarta.persistence.Enumerated;
import jakarta.persistence.FetchType;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.JoinColumn;
import jakarta.persistence.ManyToOne;
import jakarta.persistence.Table;
import java.time.DayOfWeek;
/**
* Entrada de plantilla semanal de MAÑANA: qué actividad del cole tiene un niño un
* día concreto de la semana. La tarde se modela aparte con {@link AfternoonRoutine}.
*/
@Entity
@Table(name = "weekly_template_entry")
public class WeeklyTemplateEntry {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@ManyToOne(fetch = FetchType.LAZY, optional = false)
@JoinColumn(name = "child_id")
private Child child;
@Enumerated(EnumType.STRING)
@Column(name = "day_of_week")
private DayOfWeek dayOfWeek;
@ManyToOne(fetch = FetchType.LAZY, optional = false)
@JoinColumn(name = "activity_id")
private Activity activity;
@Column(name = "order_index")
private int orderIndex;
/** Monedas de la tarea; si es null se usa coinsPerTask del niño. */
@Column(name = "coins_reward")
private Integer coinsReward;
protected WeeklyTemplateEntry() {
}
public WeeklyTemplateEntry(Child child, DayOfWeek dayOfWeek, Activity activity, int orderIndex) {
this.child = child;
this.dayOfWeek = dayOfWeek;
this.activity = activity;
this.orderIndex = orderIndex;
}
public Long getId() {
return id;
}
public Child getChild() {
return child;
}
public void setChild(Child child) {
this.child = child;
}
public DayOfWeek getDayOfWeek() {
return dayOfWeek;
}
public void setDayOfWeek(DayOfWeek dayOfWeek) {
this.dayOfWeek = dayOfWeek;
}
public Activity getActivity() {
return activity;
}
public void setActivity(Activity activity) {
this.activity = activity;
}
public int getOrderIndex() {
return orderIndex;
}
public void setOrderIndex(int orderIndex) {
this.orderIndex = orderIndex;
}
public Integer getCoinsReward() {
return coinsReward;
}
public void setCoinsReward(Integer coinsReward) {
this.coinsReward = coinsReward;
}
}

View File

@@ -0,0 +1,32 @@
package es.asepeyo.recordalexia.exception;
import java.util.Map;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.RestControllerAdvice;
/** Traduce las excepciones de dominio a respuestas HTTP claras (sin filtrar internals). */
@RestControllerAdvice
public class GlobalExceptionHandler {
@ExceptionHandler(NotFoundException.class)
public ResponseEntity<Map<String, Object>> handleNotFound(NotFoundException ex) {
return ResponseEntity.status(HttpStatus.NOT_FOUND)
.body(Map.of("error", "not_found", "message", ex.getMessage()));
}
@ExceptionHandler(InsufficientCoinsException.class)
public ResponseEntity<Map<String, Object>> handleInsufficientCoins(InsufficientCoinsException ex) {
// 409 Conflict: la petición es válida pero el estado (saldo) no la permite.
return ResponseEntity.status(HttpStatus.CONFLICT)
.body(Map.of("error", "insufficient_coins", "missing", ex.getMissing(),
"message", ex.getMessage()));
}
@ExceptionHandler(IllegalArgumentException.class)
public ResponseEntity<Map<String, Object>> handleBadRequest(IllegalArgumentException ex) {
return ResponseEntity.badRequest()
.body(Map.of("error", "bad_request", "message", ex.getMessage()));
}
}

View File

@@ -0,0 +1,19 @@
package es.asepeyo.recordalexia.exception;
/**
* Saldo insuficiente para canjear un premio. Lleva cuántas monedas faltan para
* que el frontend pueda mostrar el mensaje "te faltan N".
*/
public class InsufficientCoinsException extends RuntimeException {
private final int missing;
public InsufficientCoinsException(int missing) {
super("Saldo insuficiente: faltan " + missing + " monedas");
this.missing = missing;
}
public int getMissing() {
return missing;
}
}

View File

@@ -0,0 +1,9 @@
package es.asepeyo.recordalexia.exception;
/** Recurso no encontrado (niño, tarea, premio...). Se traduce a HTTP 404. */
public class NotFoundException extends RuntimeException {
public NotFoundException(String message) {
super(message);
}
}

View File

@@ -0,0 +1,7 @@
package es.asepeyo.recordalexia.repository;
import es.asepeyo.recordalexia.domain.Activity;
import org.springframework.data.jpa.repository.JpaRepository;
public interface ActivityRepository extends JpaRepository<Activity, Long> {
}

View File

@@ -0,0 +1,14 @@
package es.asepeyo.recordalexia.repository;
import es.asepeyo.recordalexia.domain.AfternoonRoutine;
import java.time.DayOfWeek;
import java.util.List;
import org.springframework.data.jpa.repository.JpaRepository;
public interface AfternoonRoutineRepository extends JpaRepository<AfternoonRoutine, Long> {
/** Rutinas de tarde de un niño para un día de la semana, ya ordenadas. */
List<AfternoonRoutine> findByChildIdAndDayOfWeekOrderByOrderIndexAsc(Long childId, DayOfWeek dayOfWeek);
List<AfternoonRoutine> findByChildIdOrderByDayOfWeekAscOrderIndexAsc(Long childId);
}

View File

@@ -0,0 +1,7 @@
package es.asepeyo.recordalexia.repository;
import es.asepeyo.recordalexia.domain.Child;
import org.springframework.data.jpa.repository.JpaRepository;
public interface ChildRepository extends JpaRepository<Child, Long> {
}

View File

@@ -0,0 +1,28 @@
package es.asepeyo.recordalexia.repository;
import es.asepeyo.recordalexia.domain.CoinTransaction;
import java.time.LocalDate;
import java.util.List;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;
public interface CoinTransactionRepository extends JpaRepository<CoinTransaction, Long> {
/** Historial del monedero, lo más reciente primero. */
List<CoinTransaction> findByChildIdOrderByCreatedAtDesc(Long childId);
/**
* Suma neta de monedas de un motivo concreto en una fecha. Sirve para saber si
* un bono (bloque/día) está actualmente activo: el otorgar suma +importe y el
* revertir resta -importe con el MISMO motivo, así que neto > 0 = activo.
*/
@Query("""
select coalesce(sum(t.amount), 0)
from CoinTransaction t
where t.child.id = :childId and t.txDate = :date and t.reason = :reason
""")
int sumAmount(@Param("childId") Long childId,
@Param("date") LocalDate date,
@Param("reason") String reason);
}

View File

@@ -0,0 +1,22 @@
package es.asepeyo.recordalexia.repository;
import es.asepeyo.recordalexia.domain.DailyTask;
import es.asepeyo.recordalexia.domain.Slot;
import es.asepeyo.recordalexia.domain.TaskStatus;
import java.time.LocalDate;
import java.util.List;
import org.springframework.data.jpa.repository.JpaRepository;
public interface DailyTaskRepository extends JpaRepository<DailyTask, Long> {
/** Tareas de un niño en una fecha, ordenadas por bloque y orden. */
List<DailyTask> findByChildIdAndTaskDateOrderBySlotAscOrderIndexAsc(Long childId, LocalDate taskDate);
/** ¿Ya se generó el día? Sirve para que la generación sea idempotente. */
boolean existsByChildIdAndTaskDate(Long childId, LocalDate taskDate);
/** Tareas de un bloque concreto (mañana/tarde) en una fecha. */
List<DailyTask> findByChildIdAndTaskDateAndSlot(Long childId, LocalDate taskDate, Slot slot);
long countByChildIdAndTaskDateAndStatus(Long childId, LocalDate taskDate, TaskStatus status);
}

View File

@@ -0,0 +1,7 @@
package es.asepeyo.recordalexia.repository;
import es.asepeyo.recordalexia.domain.MaterialItem;
import org.springframework.data.jpa.repository.JpaRepository;
public interface MaterialItemRepository extends JpaRepository<MaterialItem, Long> {
}

View File

@@ -0,0 +1,11 @@
package es.asepeyo.recordalexia.repository;
import es.asepeyo.recordalexia.domain.ParentUser;
import java.util.Optional;
import org.springframework.data.jpa.repository.JpaRepository;
public interface ParentUserRepository extends JpaRepository<ParentUser, Long> {
/** Solo hay un usuario de padres en el hogar; devuelve el primero. */
Optional<ParentUser> findFirstByOrderByIdAsc();
}

View File

@@ -0,0 +1,10 @@
package es.asepeyo.recordalexia.repository;
import es.asepeyo.recordalexia.domain.RewardRedemption;
import java.util.List;
import org.springframework.data.jpa.repository.JpaRepository;
public interface RewardRedemptionRepository extends JpaRepository<RewardRedemption, Long> {
List<RewardRedemption> findByChildIdOrderByCreatedAtDesc(Long childId);
}

View File

@@ -0,0 +1,11 @@
package es.asepeyo.recordalexia.repository;
import es.asepeyo.recordalexia.domain.Reward;
import java.util.List;
import org.springframework.data.jpa.repository.JpaRepository;
public interface RewardRepository extends JpaRepository<Reward, Long> {
/** Premios activos para mostrar en la tienda. */
List<Reward> findByActiveTrueOrderByCostAsc();
}

View File

@@ -0,0 +1,14 @@
package es.asepeyo.recordalexia.repository;
import es.asepeyo.recordalexia.domain.SpecialEvent;
import java.time.LocalDate;
import java.util.List;
import org.springframework.data.jpa.repository.JpaRepository;
public interface SpecialEventRepository extends JpaRepository<SpecialEvent, Long> {
/** Eventos de un niño en una fecha concreta (para generar el día). */
List<SpecialEvent> findByChildIdAndEventDate(Long childId, LocalDate eventDate);
List<SpecialEvent> findByChildIdOrderByEventDateAsc(Long childId);
}

View File

@@ -0,0 +1,14 @@
package es.asepeyo.recordalexia.repository;
import es.asepeyo.recordalexia.domain.WeeklyTemplateEntry;
import java.time.DayOfWeek;
import java.util.List;
import org.springframework.data.jpa.repository.JpaRepository;
public interface WeeklyTemplateEntryRepository extends JpaRepository<WeeklyTemplateEntry, Long> {
/** Entradas de mañana de un niño para un día de la semana, ya ordenadas. */
List<WeeklyTemplateEntry> findByChildIdAndDayOfWeekOrderByOrderIndexAsc(Long childId, DayOfWeek dayOfWeek);
List<WeeklyTemplateEntry> findByChildIdOrderByDayOfWeekAscOrderIndexAsc(Long childId);
}

View File

@@ -0,0 +1,42 @@
package es.asepeyo.recordalexia.security;
import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import java.io.IOException;
import java.util.List;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.stereotype.Component;
import org.springframework.web.filter.OncePerRequestFilter;
/**
* Filtro que reconoce la sesión de padres a partir de la cabecera X-Parent-Session.
* Si la sesión es válida, marca la petición como autenticada con rol PARENT; la
* autorización por ruta la decide {@link SecurityConfig}.
*/
@Component
public class ParentAuthFilter extends OncePerRequestFilter {
public static final String HEADER = "X-Parent-Session";
private final ParentSessionStore sessionStore;
public ParentAuthFilter(ParentSessionStore sessionStore) {
this.sessionStore = sessionStore;
}
@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response,
FilterChain filterChain) throws ServletException, IOException {
String sessionId = request.getHeader(HEADER);
if (sessionStore.isValid(sessionId)) {
var authentication = new UsernamePasswordAuthenticationToken(
"parent", null, List.of(new SimpleGrantedAuthority("ROLE_PARENT")));
SecurityContextHolder.getContext().setAuthentication(authentication);
}
filterChain.doFilter(request, response);
}
}

View File

@@ -0,0 +1,54 @@
package es.asepeyo.recordalexia.security;
import es.asepeyo.recordalexia.domain.ParentUser;
import es.asepeyo.recordalexia.repository.ParentUserRepository;
import java.util.Optional;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
/**
* Autenticación del panel de padres por PIN.
*
* Encapsula la verificación del PIN (hash BCrypt) y la apertura de sesión. Está
* aislada a propósito para poder sustituirla por un proveedor externo (Keycloak)
* sin afectar a controladores ni servicios de negocio.
*/
@Service
public class ParentAuthService {
private final ParentUserRepository parentUserRepository;
private final ParentSessionStore sessionStore;
private final PasswordEncoder passwordEncoder;
public ParentAuthService(ParentUserRepository parentUserRepository,
ParentSessionStore sessionStore,
PasswordEncoder passwordEncoder) {
this.parentUserRepository = parentUserRepository;
this.sessionStore = sessionStore;
this.passwordEncoder = passwordEncoder;
}
/**
* Valida el PIN y, si es correcto, abre sesión y devuelve su identificador.
* Devuelve vacío si el PIN no es válido (el controlador responde 401).
*/
@Transactional(readOnly = true)
public Optional<String> login(String pin) {
return parentUserRepository.findFirstByOrderByIdAsc()
.filter(parent -> passwordEncoder.matches(pin, parent.getPinHash()))
.map(parent -> sessionStore.issue());
}
/** Cambia el PIN si el actual es correcto. Devuelve true si se cambió. */
@Transactional
public boolean changePin(String currentPin, String newPin) {
Optional<ParentUser> parent = parentUserRepository.findFirstByOrderByIdAsc()
.filter(p -> passwordEncoder.matches(currentPin, p.getPinHash()));
if (parent.isEmpty()) {
return false;
}
parent.get().setPinHash(passwordEncoder.encode(newPin));
return true;
}
}

View File

@@ -0,0 +1,52 @@
package es.asepeyo.recordalexia.security;
import java.time.Duration;
import java.time.Instant;
import java.util.Map;
import java.util.UUID;
import java.util.concurrent.ConcurrentHashMap;
import org.springframework.stereotype.Component;
/**
* Almacén de sesiones del panel de padres, en memoria.
*
* Cada sesión se identifica por un valor opaco aleatorio (el "token" que viaja en
* la cabecera X-Parent-Token). Es deliberadamente simple (homelab, instancia
* única) y está encapsulado para que, si en el futuro se externaliza la auth
* (p. ej. Keycloak), se sustituya sin tocar el resto del código.
*/
@Component
public class ParentSessionStore {
/** Vigencia de la sesión de padres. */
private static final Duration TTL = Duration.ofHours(2);
private final Map<String, Instant> sessions = new ConcurrentHashMap<>();
/** Abre una sesión nueva y devuelve su identificador opaco. */
public String issue() {
String id = UUID.randomUUID().toString();
sessions.put(id, Instant.now().plus(TTL));
return id;
}
/** ¿La sesión existe y no ha caducado? Limpia las caducadas de paso. */
public boolean isValid(String id) {
if (id == null) {
return false;
}
Instant expiry = sessions.get(id);
if (expiry == null) {
return false;
}
if (expiry.isBefore(Instant.now())) {
sessions.remove(id);
return false;
}
return true;
}
public void revoke(String id) {
sessions.remove(id);
}
}

View File

@@ -0,0 +1,63 @@
package es.asepeyo.recordalexia.security;
import java.util.List;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.http.HttpMethod;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.http.SessionCreationPolicy;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.security.web.SecurityFilterChain;
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;
import org.springframework.web.cors.CorsConfiguration;
import org.springframework.web.cors.CorsConfigurationSource;
import org.springframework.web.cors.UrlBasedCorsConfigurationSource;
/**
* Seguridad ligera y doméstica:
* - Kiosko (niño): el resto de la API es de acceso libre (lectura, marcar, canjear).
* - Panel de padres: /api/parents/** exige sesión válida (cabecera X-Parent-Session),
* salvo el login que abre la sesión.
*
* Sin Keycloak/OAuth2 en esta fase; la auth queda encapsulada en el paquete security.
*/
@Configuration
public class SecurityConfig {
@Bean
public PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder();
}
@Bean
public SecurityFilterChain filterChain(HttpSecurity http, ParentAuthFilter parentAuthFilter)
throws Exception {
http
.csrf(csrf -> csrf.disable())
.cors(cors -> cors.configurationSource(corsConfigurationSource()))
.sessionManagement(sm -> sm.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
.authorizeHttpRequests(auth -> auth
// El login de padres es público: es lo que abre la sesión.
.requestMatchers(HttpMethod.POST, "/api/parents/login").permitAll()
.requestMatchers("/actuator/health").permitAll()
// El resto del panel de padres exige rol PARENT.
.requestMatchers("/api/parents/**").hasRole("PARENT")
// Todo lo demás (kiosko del niño) es de acceso libre.
.anyRequest().permitAll())
.addFilterBefore(parentAuthFilter, UsernamePasswordAuthenticationFilter.class);
return http.build();
}
/** CORS permisivo para desarrollo (ng serve en otro puerto). En prod va tras Nginx. */
@Bean
public CorsConfigurationSource corsConfigurationSource() {
CorsConfiguration config = new CorsConfiguration();
config.setAllowedOriginPatterns(List.of("*"));
config.setAllowedMethods(List.of("GET", "POST", "PUT", "DELETE", "PATCH", "OPTIONS"));
config.setAllowedHeaders(List.of("*"));
UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
source.registerCorsConfiguration("/**", config);
return source;
}
}

View File

@@ -0,0 +1,121 @@
package es.asepeyo.recordalexia.service;
import es.asepeyo.recordalexia.domain.Child;
import es.asepeyo.recordalexia.domain.Language;
import es.asepeyo.recordalexia.domain.ViewMode;
import es.asepeyo.recordalexia.exception.NotFoundException;
import es.asepeyo.recordalexia.repository.ChildRepository;
import es.asepeyo.recordalexia.web.dto.ChildDtos.ChildRequest;
import es.asepeyo.recordalexia.web.dto.ChildDtos.ChildSummary;
import es.asepeyo.recordalexia.web.dto.ChildDtos.SettingsRequest;
import java.time.LocalTime;
import java.util.List;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
/** Perfiles de niños: consulta, ajustes, parámetros de gamificación y CRUD. */
@Service
public class ChildService {
private final ChildRepository childRepository;
public ChildService(ChildRepository childRepository) {
this.childRepository = childRepository;
}
@Transactional(readOnly = true)
public Child get(Long childId) {
return requireChild(childId);
}
@Transactional(readOnly = true)
public List<ChildSummary> listChildren() {
return childRepository.findAll().stream()
.map(c -> new ChildSummary(c.getId(), c.getName(), c.getMascot(), c.getAccentColor(),
c.getAge(), c.getCoins(), c.getViewMode().name(), c.getLanguage().name()))
.toList();
}
/** Aplica solo los ajustes presentes (no nulos) en la petición. */
@Transactional
public void updateSettings(Long childId, SettingsRequest req) {
Child child = requireChild(childId);
if (req.viewMode() != null) {
child.setViewMode(ViewMode.valueOf(req.viewMode()));
}
if (req.soundEnabled() != null) {
child.setSoundEnabled(req.soundEnabled());
}
if (req.ttsEnabled() != null) {
child.setTtsEnabled(req.ttsEnabled());
}
if (req.language() != null) {
child.setLanguage(Language.valueOf(req.language()));
}
if (req.departureTime() != null) {
child.setDepartureTime(LocalTime.parse(req.departureTime()));
}
}
/** Actualiza los parámetros de gamificación de un niño (panel de padres). */
@Transactional
public void updateGamification(Long childId, Integer perTask, Integer perBlock, Integer perDay) {
Child child = requireChild(childId);
if (perTask != null) {
child.setCoinsPerTask(perTask);
}
if (perBlock != null) {
child.setCoinsPerBlock(perBlock);
}
if (perDay != null) {
child.setCoinsPerDay(perDay);
}
}
@Transactional
public Child create(ChildRequest req) {
Child child = new Child();
applyRequest(child, req);
return childRepository.save(child);
}
@Transactional
public void update(Long childId, ChildRequest req) {
Child child = requireChild(childId);
applyRequest(child, req);
}
@Transactional
public void delete(Long childId) {
if (!childRepository.existsById(childId)) {
throw new NotFoundException("No existe el niño con id " + childId);
}
childRepository.deleteById(childId);
}
private void applyRequest(Child child, ChildRequest req) {
if (req.name() != null) {
child.setName(req.name());
}
if (req.mascot() != null) {
child.setMascot(req.mascot());
}
if (req.accentColor() != null) {
child.setAccentColor(req.accentColor());
}
if (req.age() != null) {
child.setAge(req.age());
}
if (req.departureTime() != null) {
child.setDepartureTime(LocalTime.parse(req.departureTime()));
}
if (req.coins() != null) {
child.setCoins(req.coins());
}
}
private Child requireChild(Long childId) {
return childRepository.findById(childId)
.orElseThrow(() -> new NotFoundException("No existe el niño con id " + childId));
}
}

View File

@@ -0,0 +1,25 @@
package es.asepeyo.recordalexia.service;
import es.asepeyo.recordalexia.domain.Slot;
/** Motivos de los movimientos de monedas (texto estable guardado en BD). */
public final class CoinReason {
public static final String TASK = "TASK";
public static final String TASK_REVERT = "TASK_REVERT";
public static final String DAY_BONUS = "DAY_BONUS";
public static final String DAY_BONUS_REVERT = "DAY_BONUS_REVERT";
public static final String REDEEM = "REDEEM";
private CoinReason() {
}
/** Bono de bloque, distinguiendo mañana/tarde: "BLOCK_BONUS_MORNING". */
public static String blockBonus(Slot slot) {
return "BLOCK_BONUS_" + slot.name();
}
public static String blockBonusRevert(Slot slot) {
return "BLOCK_BONUS_REVERT_" + slot.name();
}
}

View File

@@ -0,0 +1,88 @@
package es.asepeyo.recordalexia.service;
import es.asepeyo.recordalexia.domain.AfternoonRoutine;
import es.asepeyo.recordalexia.domain.Child;
import es.asepeyo.recordalexia.domain.DailyTask;
import es.asepeyo.recordalexia.domain.Slot;
import es.asepeyo.recordalexia.domain.TaskOrigin;
import es.asepeyo.recordalexia.domain.WeeklyTemplateEntry;
import es.asepeyo.recordalexia.exception.NotFoundException;
import es.asepeyo.recordalexia.repository.AfternoonRoutineRepository;
import es.asepeyo.recordalexia.repository.ChildRepository;
import es.asepeyo.recordalexia.repository.DailyTaskRepository;
import es.asepeyo.recordalexia.repository.WeeklyTemplateEntryRepository;
import java.time.DayOfWeek;
import java.time.LocalDate;
import java.util.ArrayList;
import java.util.List;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
/**
* Genera las tareas de un día concreto para un niño a partir de su plantilla.
*
* Mañana: desde {@link WeeklyTemplateEntry} (actividades del cole del día).
* Tarde: desde {@link AfternoonRoutine} (rutinas del día).
* Los eventos (exámenes/deberes) NO se materializan como tareas marcables: se
* muestran en el banner informativo del día (ver TodayService).
*
* La operación es IDEMPOTENTE: si el día ya tiene tareas, no se vuelve a generar.
*/
@Service
public class DayGenerationService {
private final ChildRepository childRepository;
private final WeeklyTemplateEntryRepository templateRepository;
private final AfternoonRoutineRepository routineRepository;
private final DailyTaskRepository dailyTaskRepository;
public DayGenerationService(ChildRepository childRepository,
WeeklyTemplateEntryRepository templateRepository,
AfternoonRoutineRepository routineRepository,
DailyTaskRepository dailyTaskRepository) {
this.childRepository = childRepository;
this.templateRepository = templateRepository;
this.routineRepository = routineRepository;
this.dailyTaskRepository = dailyTaskRepository;
}
/**
* Asegura que existan las tareas del día indicado para el niño. Si ya existen,
* no hace nada (idempotente). Devuelve la lista de tareas resultante.
*/
@Transactional
public List<DailyTask> generateIfAbsent(Long childId, LocalDate date) {
if (dailyTaskRepository.existsByChildIdAndTaskDate(childId, date)) {
return dailyTaskRepository.findByChildIdAndTaskDateOrderBySlotAscOrderIndexAsc(childId, date);
}
Child child = childRepository.findById(childId)
.orElseThrow(() -> new NotFoundException("No existe el niño con id " + childId));
DayOfWeek dayOfWeek = date.getDayOfWeek();
List<DailyTask> nuevas = new ArrayList<>();
// --- Mañana: una tarea por cada actividad del cole de ese día ---
List<WeeklyTemplateEntry> entradas =
templateRepository.findByChildIdAndDayOfWeekOrderByOrderIndexAsc(childId, dayOfWeek);
for (WeeklyTemplateEntry entrada : entradas) {
var actividad = entrada.getActivity();
int monedas = entrada.getCoinsReward() != null ? entrada.getCoinsReward() : child.getCoinsPerTask();
nuevas.add(new DailyTask(child, date, Slot.MORNING,
actividad.getLabelEs(), actividad.getLabelCa(), actividad.getIcon(), actividad.getColor(),
monedas, TaskOrigin.TEMPLATE, entrada.getOrderIndex()));
}
// --- Tarde: una tarea por cada rutina del día ---
List<AfternoonRoutine> rutinas =
routineRepository.findByChildIdAndDayOfWeekOrderByOrderIndexAsc(childId, dayOfWeek);
for (AfternoonRoutine rutina : rutinas) {
int monedas = rutina.getCoinsReward() != null ? rutina.getCoinsReward() : child.getCoinsPerTask();
nuevas.add(new DailyTask(child, date, Slot.AFTERNOON,
rutina.getLabelEs(), rutina.getLabelCa(), rutina.getIcon(), rutina.getColor(),
monedas, TaskOrigin.ROUTINE, rutina.getOrderIndex()));
}
return dailyTaskRepository.saveAll(nuevas);
}
}

View File

@@ -0,0 +1,38 @@
package es.asepeyo.recordalexia.service;
import es.asepeyo.recordalexia.domain.DailyTask;
import es.asepeyo.recordalexia.domain.Slot;
import es.asepeyo.recordalexia.web.dto.TodayResponse.ProgressView;
import java.util.List;
/** Calcula el progreso del día (por bloque y global) a partir de las tareas. */
public final class ProgressCalculator {
private ProgressCalculator() {
}
public static ProgressView from(List<DailyTask> tasks) {
int morningTotal = 0;
int morningDone = 0;
int afternoonTotal = 0;
int afternoonDone = 0;
for (DailyTask t : tasks) {
boolean done = t.isDone();
if (t.getSlot() == Slot.MORNING) {
morningTotal++;
if (done) {
morningDone++;
}
} else if (t.getSlot() == Slot.AFTERNOON) {
afternoonTotal++;
if (done) {
afternoonDone++;
}
}
}
return new ProgressView(
morningDone, morningTotal,
afternoonDone, afternoonTotal,
morningDone + afternoonDone, morningTotal + afternoonTotal);
}
}

View File

@@ -0,0 +1,86 @@
package es.asepeyo.recordalexia.service;
import es.asepeyo.recordalexia.domain.Child;
import es.asepeyo.recordalexia.domain.CoinTransaction;
import es.asepeyo.recordalexia.domain.Reward;
import es.asepeyo.recordalexia.domain.RewardRedemption;
import es.asepeyo.recordalexia.exception.InsufficientCoinsException;
import es.asepeyo.recordalexia.exception.NotFoundException;
import es.asepeyo.recordalexia.repository.ChildRepository;
import es.asepeyo.recordalexia.repository.CoinTransactionRepository;
import es.asepeyo.recordalexia.repository.RewardRedemptionRepository;
import es.asepeyo.recordalexia.repository.RewardRepository;
import es.asepeyo.recordalexia.web.dto.StoreDtos.RedeemResult;
import es.asepeyo.recordalexia.web.dto.StoreDtos.RewardView;
import java.time.Clock;
import java.time.LocalDate;
import java.util.List;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
/** Tienda de premios: listado visible para el niño y canje con control de saldo. */
@Service
public class StoreService {
private final ChildRepository childRepository;
private final RewardRepository rewardRepository;
private final CoinTransactionRepository coinTransactionRepository;
private final RewardRedemptionRepository rewardRedemptionRepository;
private final Clock clock;
public StoreService(ChildRepository childRepository,
RewardRepository rewardRepository,
CoinTransactionRepository coinTransactionRepository,
RewardRedemptionRepository rewardRedemptionRepository,
Clock clock) {
this.childRepository = childRepository;
this.rewardRepository = rewardRepository;
this.coinTransactionRepository = coinTransactionRepository;
this.rewardRedemptionRepository = rewardRedemptionRepository;
this.clock = clock;
}
/** Premios activos, indicando si el niño puede permitírselos y cuánto le falta. */
@Transactional(readOnly = true)
public List<RewardView> listRewards(Long childId) {
Child child = requireChild(childId);
return rewardRepository.findByActiveTrueOrderByCostAsc().stream()
.map(r -> new RewardView(r.getId(), r.getLabelEs(), r.getLabelCa(), r.getIcon(),
r.getColor(), r.getCost(), child.canAfford(r.getCost()),
Math.max(0, r.getCost() - child.getCoins())))
.toList();
}
/**
* Canjea un premio: valida saldo, descuenta monedas, registra la transacción
* negativa y el canje. Si no llega, lanza {@link InsufficientCoinsException}.
*/
@Transactional
public RedeemResult redeem(Long childId, Long rewardId) {
Child child = requireChild(childId);
Reward reward = rewardRepository.findById(rewardId)
.orElseThrow(() -> new NotFoundException("No existe el premio con id " + rewardId));
if (!reward.isActive()) {
throw new NotFoundException("El premio " + rewardId + " no está disponible");
}
if (!child.canAfford(reward.getCost())) {
throw new InsufficientCoinsException(reward.getCost() - child.getCoins());
}
LocalDate today = LocalDate.now(clock);
child.spend(reward.getCost());
coinTransactionRepository.save(
new CoinTransaction(child, today, -reward.getCost(), CoinReason.REDEEM));
rewardRedemptionRepository.save(
new RewardRedemption(child, reward, today, reward.getCost()));
childRepository.save(child);
return new RedeemResult(reward.getId(), reward.getCost(), child.getCoins());
}
private Child requireChild(Long childId) {
return childRepository.findById(childId)
.orElseThrow(() -> new NotFoundException("No existe el niño con id " + childId));
}
}

View File

@@ -0,0 +1,124 @@
package es.asepeyo.recordalexia.service;
import es.asepeyo.recordalexia.domain.Child;
import es.asepeyo.recordalexia.domain.CoinTransaction;
import es.asepeyo.recordalexia.domain.DailyTask;
import es.asepeyo.recordalexia.domain.Slot;
import es.asepeyo.recordalexia.exception.NotFoundException;
import es.asepeyo.recordalexia.repository.ChildRepository;
import es.asepeyo.recordalexia.repository.CoinTransactionRepository;
import es.asepeyo.recordalexia.repository.DailyTaskRepository;
import es.asepeyo.recordalexia.web.dto.ToggleResult;
import java.time.Clock;
import java.time.Instant;
import java.time.LocalDate;
import java.util.List;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
/**
* Marcado/desmarcado de tareas y gestión de monedas.
*
* Al marcar una tarea como hecha se suman sus monedas; si al hacerlo se completa
* un bloque (mañana o tarde) o el día entero, se otorga el bono correspondiente.
* Al desmarcar, todo se revierte de forma coherente: monedas de la tarea y los
* bonos que dejen de corresponder.
*
* Los bonos se reconcilian con el estado real del día: el motivo de bono suma
* +importe al otorgarse y -importe al revertirse (mismo motivo), de modo que la
* suma neta > 0 indica que el bono está activo. Esto hace la operación robusta
* ante cualquier secuencia de marcar/desmarcar.
*/
@Service
public class TaskService {
private final DailyTaskRepository dailyTaskRepository;
private final ChildRepository childRepository;
private final CoinTransactionRepository coinTransactionRepository;
private final Clock clock;
public TaskService(DailyTaskRepository dailyTaskRepository,
ChildRepository childRepository,
CoinTransactionRepository coinTransactionRepository,
Clock clock) {
this.dailyTaskRepository = dailyTaskRepository;
this.childRepository = childRepository;
this.coinTransactionRepository = coinTransactionRepository;
this.clock = clock;
}
@Transactional
public ToggleResult toggle(Long taskId) {
DailyTask task = dailyTaskRepository.findById(taskId)
.orElseThrow(() -> new NotFoundException("No existe la tarea con id " + taskId));
Child child = task.getChild();
Long childId = child.getId();
LocalDate date = task.getTaskDate();
Slot slot = task.getSlot();
int balanceBefore = child.getCoins();
if (task.isDone()) {
// --- Desmarcar: revertir monedas de la tarea ---
task.markPending();
child.removeCoins(task.getCoinsReward());
recordTx(child, date, -task.getCoinsReward(), CoinReason.TASK);
} else {
// --- Marcar: sumar monedas de la tarea ---
task.markDone(Instant.now(clock));
child.addCoins(task.getCoinsReward());
recordTx(child, date, task.getCoinsReward(), CoinReason.TASK);
}
// La consulta de tareas refleja ya el cambio (autoflush antes de la query).
// Reconciliar el bono de bloque y el del día con el estado real.
reconcileBonus(child, date, CoinReason.blockBonus(slot), child.getCoinsPerBlock(),
isSlotComplete(childId, date, slot));
reconcileBonus(child, date, CoinReason.DAY_BONUS, child.getCoinsPerDay(),
isDayComplete(childId, date));
childRepository.save(child);
List<DailyTask> tasks =
dailyTaskRepository.findByChildIdAndTaskDateOrderBySlotAscOrderIndexAsc(childId, date);
int balanceAfter = child.getCoins();
return new ToggleResult(
task.getId(),
task.isDone(),
balanceAfter - balanceBefore,
balanceAfter,
ProgressCalculator.from(tasks));
}
/** Otorga o revierte un bono para que su estado case con {@code shouldBeActive}. */
private void reconcileBonus(Child child, LocalDate date, String reason, int amount,
boolean shouldBeActive) {
boolean currentlyActive =
coinTransactionRepository.sumAmount(child.getId(), date, reason) > 0;
if (shouldBeActive && !currentlyActive) {
child.addCoins(amount);
recordTx(child, date, amount, reason);
} else if (!shouldBeActive && currentlyActive) {
child.removeCoins(amount);
recordTx(child, date, -amount, reason);
}
}
/** Un bloque está completo si tiene tareas y todas están hechas. */
private boolean isSlotComplete(Long childId, LocalDate date, Slot slot) {
List<DailyTask> slotTasks =
dailyTaskRepository.findByChildIdAndTaskDateAndSlot(childId, date, slot);
return !slotTasks.isEmpty() && slotTasks.stream().allMatch(DailyTask::isDone);
}
/** El día está completo si tiene tareas y todas están hechas. */
private boolean isDayComplete(Long childId, LocalDate date) {
List<DailyTask> tasks =
dailyTaskRepository.findByChildIdAndTaskDateOrderBySlotAscOrderIndexAsc(childId, date);
return !tasks.isEmpty() && tasks.stream().allMatch(DailyTask::isDone);
}
private void recordTx(Child child, LocalDate date, int amount, String reason) {
coinTransactionRepository.save(new CoinTransaction(child, date, amount, reason));
}
}

View File

@@ -0,0 +1,101 @@
package es.asepeyo.recordalexia.service;
import es.asepeyo.recordalexia.domain.Child;
import es.asepeyo.recordalexia.domain.DailyTask;
import es.asepeyo.recordalexia.domain.Slot;
import es.asepeyo.recordalexia.domain.SpecialEvent;
import es.asepeyo.recordalexia.exception.NotFoundException;
import es.asepeyo.recordalexia.repository.ChildRepository;
import es.asepeyo.recordalexia.repository.SpecialEventRepository;
import es.asepeyo.recordalexia.web.dto.TodayResponse;
import es.asepeyo.recordalexia.web.dto.TodayResponse.ChildInfo;
import es.asepeyo.recordalexia.web.dto.TodayResponse.EventView;
import es.asepeyo.recordalexia.web.dto.TodayResponse.TaskView;
import es.asepeyo.recordalexia.web.dto.TodayResponse.TimerInfo;
import es.asepeyo.recordalexia.web.dto.TodayResponse.WalletInfo;
import java.time.Clock;
import java.time.Duration;
import java.time.LocalDate;
import java.time.LocalTime;
import java.time.format.DateTimeFormatter;
import java.util.List;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
/** Construye la vista del día de hoy para un niño (genera el día si hace falta). */
@Service
public class TodayService {
private static final DateTimeFormatter HHMM = DateTimeFormatter.ofPattern("HH:mm");
private final DayGenerationService dayGenerationService;
private final ChildRepository childRepository;
private final SpecialEventRepository specialEventRepository;
private final Clock clock;
public TodayService(DayGenerationService dayGenerationService,
ChildRepository childRepository,
SpecialEventRepository specialEventRepository,
Clock clock) {
this.dayGenerationService = dayGenerationService;
this.childRepository = childRepository;
this.specialEventRepository = specialEventRepository;
this.clock = clock;
}
@Transactional
public TodayResponse getToday(Long childId) {
Child child = childRepository.findById(childId)
.orElseThrow(() -> new NotFoundException("No existe el niño con id " + childId));
LocalDate today = LocalDate.now(clock);
List<DailyTask> tasks = dayGenerationService.generateIfAbsent(childId, today);
List<TaskView> morning = tasks.stream()
.filter(t -> t.getSlot() == Slot.MORNING)
.map(this::toTaskView)
.toList();
List<TaskView> afternoon = tasks.stream()
.filter(t -> t.getSlot() == Slot.AFTERNOON)
.map(this::toTaskView)
.toList();
List<EventView> events = specialEventRepository.findByChildIdAndEventDate(childId, today).stream()
.map(this::toEventView)
.toList();
return new TodayResponse(
toChildInfo(child),
morning,
afternoon,
events,
ProgressCalculator.from(tasks),
new WalletInfo(child.getCoins()),
buildTimer(child));
}
private TaskView toTaskView(DailyTask t) {
return new TaskView(t.getId(), t.getLabelEs(), t.getLabelCa(), t.getIcon(), t.getColor(),
t.isDone(), t.getCoinsReward(), t.getOrderIndex());
}
private EventView toEventView(SpecialEvent e) {
return new EventView(e.getId(), e.getType().name(), e.getTitleEs(), e.getTitleCa(),
e.getIcon(), e.getColor());
}
private ChildInfo toChildInfo(Child c) {
return new ChildInfo(c.getId(), c.getName(), c.getMascot(), c.getAccentColor(),
c.getViewMode().name(), c.getLanguage().name(), c.isSoundEnabled(), c.isTtsEnabled());
}
/** Temporizador de salida: minutos que faltan hasta departureTime (>= 0). */
private TimerInfo buildTimer(Child child) {
LocalTime departure = child.getDepartureTime();
if (departure == null) {
return new TimerInfo(null, null);
}
long minutes = Duration.between(LocalTime.now(clock), departure).toMinutes();
return new TimerInfo(departure.format(HHMM), (int) Math.max(0, minutes));
}
}

View File

@@ -0,0 +1,39 @@
package es.asepeyo.recordalexia.service;
import es.asepeyo.recordalexia.domain.Child;
import es.asepeyo.recordalexia.exception.NotFoundException;
import es.asepeyo.recordalexia.repository.ChildRepository;
import es.asepeyo.recordalexia.repository.CoinTransactionRepository;
import es.asepeyo.recordalexia.web.dto.WalletResponse;
import es.asepeyo.recordalexia.web.dto.WalletResponse.CoinTxView;
import java.util.List;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
/** Consulta del monedero: saldo actual e historial de movimientos. */
@Service
public class WalletService {
private final ChildRepository childRepository;
private final CoinTransactionRepository coinTransactionRepository;
public WalletService(ChildRepository childRepository,
CoinTransactionRepository coinTransactionRepository) {
this.childRepository = childRepository;
this.coinTransactionRepository = coinTransactionRepository;
}
@Transactional(readOnly = true)
public WalletResponse getWallet(Long childId) {
Child child = childRepository.findById(childId)
.orElseThrow(() -> new NotFoundException("No existe el niño con id " + childId));
List<CoinTxView> history = coinTransactionRepository
.findByChildIdOrderByCreatedAtDesc(childId).stream()
.map(tx -> new CoinTxView(tx.getId(), tx.getTxDate(), tx.getAmount(),
tx.getReason(), tx.getCreatedAt()))
.toList();
return new WalletResponse(child.getCoins(), history);
}
}

View File

@@ -0,0 +1,65 @@
package es.asepeyo.recordalexia.web;
import es.asepeyo.recordalexia.service.ChildService;
import es.asepeyo.recordalexia.service.StoreService;
import es.asepeyo.recordalexia.service.TodayService;
import es.asepeyo.recordalexia.service.WalletService;
import es.asepeyo.recordalexia.web.dto.ChildDtos.ChildSummary;
import es.asepeyo.recordalexia.web.dto.ChildDtos.SettingsRequest;
import es.asepeyo.recordalexia.web.dto.StoreDtos.RewardView;
import es.asepeyo.recordalexia.web.dto.TodayResponse;
import es.asepeyo.recordalexia.web.dto.WalletResponse;
import java.util.List;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PutMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
/** API del kiosko orientada al niño: perfiles, día de hoy, monedero, tienda, ajustes. */
@RestController
@RequestMapping("/api/children")
public class ChildController {
private final ChildService childService;
private final TodayService todayService;
private final WalletService walletService;
private final StoreService storeService;
public ChildController(ChildService childService, TodayService todayService,
WalletService walletService, StoreService storeService) {
this.childService = childService;
this.todayService = todayService;
this.walletService = walletService;
this.storeService = storeService;
}
@GetMapping
public List<ChildSummary> listChildren() {
return childService.listChildren();
}
@GetMapping("/{id}/today")
public TodayResponse today(@PathVariable Long id) {
return todayService.getToday(id);
}
@GetMapping("/{id}/wallet")
public WalletResponse wallet(@PathVariable Long id) {
return walletService.getWallet(id);
}
@GetMapping("/{id}/rewards")
public List<RewardView> rewards(@PathVariable Long id) {
return storeService.listRewards(id);
}
@PutMapping("/{id}/settings")
public ResponseEntity<Void> updateSettings(@PathVariable Long id,
@RequestBody SettingsRequest request) {
childService.updateSettings(id, request);
return ResponseEntity.noContent().build();
}
}

View File

@@ -0,0 +1,40 @@
package es.asepeyo.recordalexia.web;
import es.asepeyo.recordalexia.security.ParentAuthService;
import es.asepeyo.recordalexia.web.dto.ParentDtos.ChangePinRequest;
import es.asepeyo.recordalexia.web.dto.ParentDtos.LoginRequest;
import es.asepeyo.recordalexia.web.dto.ParentDtos.LoginResponse;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
/** Autenticación del panel de padres: abrir sesión con PIN y cambiar el PIN. */
@RestController
@RequestMapping("/api/parents")
public class ParentAuthController {
private final ParentAuthService parentAuthService;
public ParentAuthController(ParentAuthService parentAuthService) {
this.parentAuthService = parentAuthService;
}
/** Valida el PIN. 200 con la sesión si es correcto; 401 si no. */
@PostMapping("/login")
public ResponseEntity<LoginResponse> login(@RequestBody LoginRequest request) {
return parentAuthService.login(request.pin())
.map(session -> ResponseEntity.ok(new LoginResponse(session)))
.orElseGet(() -> ResponseEntity.status(HttpStatus.UNAUTHORIZED).build());
}
/** Cambia el PIN (requiere sesión de padres válida). */
@PostMapping("/change-pin")
public ResponseEntity<Void> changePin(@RequestBody ChangePinRequest request) {
boolean changed = parentAuthService.changePin(request.currentPin(), request.newPin());
return changed ? ResponseEntity.noContent().build()
: ResponseEntity.status(HttpStatus.UNAUTHORIZED).build();
}
}

View File

@@ -0,0 +1,106 @@
package es.asepeyo.recordalexia.web;
import es.asepeyo.recordalexia.domain.Activity;
import es.asepeyo.recordalexia.domain.MaterialItem;
import es.asepeyo.recordalexia.exception.NotFoundException;
import es.asepeyo.recordalexia.repository.ActivityRepository;
import es.asepeyo.recordalexia.repository.MaterialItemRepository;
import es.asepeyo.recordalexia.web.dto.ParentDtos.ActivityRequest;
import es.asepeyo.recordalexia.web.dto.ParentDtos.MaterialRequest;
import es.asepeyo.recordalexia.web.dto.ParentViews.ActivityView;
import es.asepeyo.recordalexia.web.dto.ParentViews.MaterialView;
import java.util.List;
import org.springframework.http.ResponseEntity;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.web.bind.annotation.DeleteMapping;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
/** CRUD de actividades del cole y su material (panel de padres). */
@RestController
@RequestMapping("/api/parents/catalog")
public class ParentCatalogController {
private final ActivityRepository activityRepository;
private final MaterialItemRepository materialRepository;
public ParentCatalogController(ActivityRepository activityRepository,
MaterialItemRepository materialRepository) {
this.activityRepository = activityRepository;
this.materialRepository = materialRepository;
}
// --- Materiales ---
@GetMapping("/materials")
public List<MaterialView> listMaterials() {
return materialRepository.findAll().stream()
.map(m -> new MaterialView(m.getId(), m.getLabelEs(), m.getLabelCa(), m.getIcon(),
m.getColor(), m.getCategory()))
.toList();
}
@PostMapping("/materials")
public ResponseEntity<MaterialView> createMaterial(@RequestBody MaterialRequest req) {
MaterialItem saved = materialRepository.save(
new MaterialItem(req.labelEs(), req.labelCa(), req.icon(), req.color(), req.category()));
return ResponseEntity.ok(new MaterialView(saved.getId(), saved.getLabelEs(),
saved.getLabelCa(), saved.getIcon(), saved.getColor(), saved.getCategory()));
}
@DeleteMapping("/materials/{id}")
public ResponseEntity<Void> deleteMaterial(@PathVariable Long id) {
if (!materialRepository.existsById(id)) {
throw new NotFoundException("No existe el material con id " + id);
}
materialRepository.deleteById(id);
return ResponseEntity.noContent().build();
}
// --- Actividades (con su material asociado) ---
@GetMapping("/activities")
@Transactional(readOnly = true)
public List<ActivityView> listActivities() {
return activityRepository.findAll().stream().map(this::toActivityView).toList();
}
@PostMapping("/activities")
@Transactional
public ResponseEntity<ActivityView> createActivity(@RequestBody ActivityRequest req) {
Activity activity = new Activity(req.labelEs(), req.labelCa(), req.icon(), req.color());
attachMaterials(activity, req.materialIds());
Activity saved = activityRepository.save(activity);
return ResponseEntity.ok(toActivityView(saved));
}
@DeleteMapping("/activities/{id}")
public ResponseEntity<Void> deleteActivity(@PathVariable Long id) {
if (!activityRepository.existsById(id)) {
throw new NotFoundException("No existe la actividad con id " + id);
}
activityRepository.deleteById(id);
return ResponseEntity.noContent().build();
}
private void attachMaterials(Activity activity, List<Long> materialIds) {
if (materialIds == null) {
return;
}
for (Long materialId : materialIds) {
MaterialItem material = materialRepository.findById(materialId)
.orElseThrow(() -> new NotFoundException("No existe el material con id " + materialId));
activity.addMaterial(material);
}
}
private ActivityView toActivityView(Activity a) {
List<Long> materialIds = a.getMaterials().stream().map(MaterialItem::getId).toList();
return new ActivityView(a.getId(), a.getLabelEs(), a.getLabelCa(), a.getIcon(),
a.getColor(), materialIds);
}
}

View File

@@ -0,0 +1,74 @@
package es.asepeyo.recordalexia.web;
import es.asepeyo.recordalexia.domain.Child;
import es.asepeyo.recordalexia.service.ChildService;
import es.asepeyo.recordalexia.web.dto.ChildDtos.ChildRequest;
import es.asepeyo.recordalexia.web.dto.ChildDtos.ChildSummary;
import es.asepeyo.recordalexia.web.dto.ParentDtos.GamificationRequest;
import es.asepeyo.recordalexia.web.dto.ParentViews.GamificationView;
import java.net.URI;
import java.util.List;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.DeleteMapping;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.PutMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
/** CRUD de niños y ajuste de gamificación desde el panel de padres. */
@RestController
@RequestMapping("/api/parents/children")
public class ParentChildController {
private final ChildService childService;
public ParentChildController(ChildService childService) {
this.childService = childService;
}
@GetMapping
public List<ChildSummary> list() {
return childService.listChildren();
}
@PostMapping
public ResponseEntity<ChildSummary> create(@RequestBody ChildRequest request) {
Child child = childService.create(request);
return ResponseEntity.created(URI.create("/api/parents/children/" + child.getId()))
.body(new ChildSummary(child.getId(), child.getName(), child.getMascot(),
child.getAccentColor(), child.getAge(), child.getCoins(),
child.getViewMode().name(), child.getLanguage().name()));
}
@PutMapping("/{id}")
public ResponseEntity<Void> update(@PathVariable Long id, @RequestBody ChildRequest request) {
childService.update(id, request);
return ResponseEntity.noContent().build();
}
@DeleteMapping("/{id}")
public ResponseEntity<Void> delete(@PathVariable Long id) {
childService.delete(id);
return ResponseEntity.noContent().build();
}
/** Valores actuales de gamificación del niño (para precargar el panel). */
@GetMapping("/{id}/gamification")
public GamificationView getGamification(@PathVariable Long id) {
Child child = childService.get(id);
return new GamificationView(child.getCoinsPerTask(), child.getCoinsPerBlock(),
child.getCoinsPerDay());
}
/** Parámetros de gamificación del niño (monedas por tarea / bloque / día). */
@PutMapping("/{id}/gamification")
public ResponseEntity<Void> updateGamification(@PathVariable Long id,
@RequestBody GamificationRequest request) {
childService.updateGamification(id, request.coinsPerTask(), request.coinsPerBlock(),
request.coinsPerDay());
return ResponseEntity.noContent().build();
}
}

View File

@@ -0,0 +1,72 @@
package es.asepeyo.recordalexia.web;
import es.asepeyo.recordalexia.domain.Child;
import es.asepeyo.recordalexia.domain.EventType;
import es.asepeyo.recordalexia.domain.SpecialEvent;
import es.asepeyo.recordalexia.exception.NotFoundException;
import es.asepeyo.recordalexia.repository.ChildRepository;
import es.asepeyo.recordalexia.repository.SpecialEventRepository;
import es.asepeyo.recordalexia.web.dto.ParentDtos.SpecialEventRequest;
import es.asepeyo.recordalexia.web.dto.ParentViews.EventAdminView;
import java.net.URI;
import java.time.LocalDate;
import java.util.List;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.DeleteMapping;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
/** CRUD de eventos especiales (exámenes/deberes) desde el panel de padres. */
@RestController
@RequestMapping("/api/parents/events")
public class ParentEventController {
private final SpecialEventRepository eventRepository;
private final ChildRepository childRepository;
public ParentEventController(SpecialEventRepository eventRepository,
ChildRepository childRepository) {
this.eventRepository = eventRepository;
this.childRepository = childRepository;
}
@GetMapping
public List<EventAdminView> list(@RequestParam Long childId) {
return eventRepository.findByChildIdOrderByEventDateAsc(childId).stream()
.map(this::toView).toList();
}
@PostMapping
public ResponseEntity<EventAdminView> create(@RequestBody SpecialEventRequest req) {
Child child = requireChild(req.childId());
SpecialEvent event = new SpecialEvent(child, LocalDate.parse(req.date()),
EventType.valueOf(req.type()), req.titleEs(), req.titleCa(), req.icon(), req.color());
SpecialEvent saved = eventRepository.save(event);
return ResponseEntity.created(URI.create("/api/parents/events/" + saved.getId()))
.body(toView(saved));
}
@DeleteMapping("/{id}")
public ResponseEntity<Void> delete(@PathVariable Long id) {
if (!eventRepository.existsById(id)) {
throw new NotFoundException("No existe el evento con id " + id);
}
eventRepository.deleteById(id);
return ResponseEntity.noContent().build();
}
private Child requireChild(Long childId) {
return childRepository.findById(childId)
.orElseThrow(() -> new NotFoundException("No existe el niño con id " + childId));
}
private EventAdminView toView(SpecialEvent e) {
return new EventAdminView(e.getId(), e.getChild().getId(), e.getEventDate(),
e.getType().name(), e.getTitleEs(), e.getTitleCa(), e.getIcon(), e.getColor());
}
}

View File

@@ -0,0 +1,87 @@
package es.asepeyo.recordalexia.web;
import es.asepeyo.recordalexia.domain.Reward;
import es.asepeyo.recordalexia.exception.NotFoundException;
import es.asepeyo.recordalexia.repository.RewardRepository;
import es.asepeyo.recordalexia.web.dto.ParentViews.RewardAdminView;
import es.asepeyo.recordalexia.web.dto.StoreDtos.RewardRequest;
import java.net.URI;
import java.util.List;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.DeleteMapping;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.PutMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
/** CRUD del catálogo de premios desde el panel de padres. */
@RestController
@RequestMapping("/api/parents/rewards")
public class ParentRewardController {
private final RewardRepository rewardRepository;
public ParentRewardController(RewardRepository rewardRepository) {
this.rewardRepository = rewardRepository;
}
@GetMapping
public List<RewardAdminView> list() {
return rewardRepository.findAll().stream().map(this::toView).toList();
}
@PostMapping
public ResponseEntity<RewardAdminView> create(@RequestBody RewardRequest req) {
Reward reward = new Reward(req.labelEs(), req.labelCa(), req.icon(), req.color(),
req.cost() != null ? req.cost() : 0);
if (req.active() != null) {
reward.setActive(req.active());
}
Reward saved = rewardRepository.save(reward);
return ResponseEntity.created(URI.create("/api/parents/rewards/" + saved.getId()))
.body(toView(saved));
}
@PutMapping("/{id}")
public ResponseEntity<Void> update(@PathVariable Long id, @RequestBody RewardRequest req) {
Reward reward = rewardRepository.findById(id)
.orElseThrow(() -> new NotFoundException("No existe el premio con id " + id));
if (req.labelEs() != null) {
reward.setLabelEs(req.labelEs());
}
if (req.labelCa() != null) {
reward.setLabelCa(req.labelCa());
}
if (req.icon() != null) {
reward.setIcon(req.icon());
}
if (req.color() != null) {
reward.setColor(req.color());
}
if (req.cost() != null) {
reward.setCost(req.cost());
}
if (req.active() != null) {
reward.setActive(req.active());
}
rewardRepository.save(reward);
return ResponseEntity.noContent().build();
}
@DeleteMapping("/{id}")
public ResponseEntity<Void> delete(@PathVariable Long id) {
if (!rewardRepository.existsById(id)) {
throw new NotFoundException("No existe el premio con id " + id);
}
rewardRepository.deleteById(id);
return ResponseEntity.noContent().build();
}
private RewardAdminView toView(Reward r) {
return new RewardAdminView(r.getId(), r.getLabelEs(), r.getLabelCa(), r.getIcon(),
r.getColor(), r.getCost(), r.isActive());
}
}

View File

@@ -0,0 +1,135 @@
package es.asepeyo.recordalexia.web;
import es.asepeyo.recordalexia.domain.Activity;
import es.asepeyo.recordalexia.domain.AfternoonRoutine;
import es.asepeyo.recordalexia.domain.Child;
import es.asepeyo.recordalexia.domain.WeeklyTemplateEntry;
import es.asepeyo.recordalexia.exception.NotFoundException;
import es.asepeyo.recordalexia.repository.ActivityRepository;
import es.asepeyo.recordalexia.repository.AfternoonRoutineRepository;
import es.asepeyo.recordalexia.repository.ChildRepository;
import es.asepeyo.recordalexia.repository.WeeklyTemplateEntryRepository;
import es.asepeyo.recordalexia.web.dto.ParentDtos.AfternoonRoutineRequest;
import es.asepeyo.recordalexia.web.dto.ParentDtos.RoutineReorderRequest;
import es.asepeyo.recordalexia.web.dto.ParentDtos.WeeklyEntryRequest;
import es.asepeyo.recordalexia.web.dto.ParentViews.RoutineView;
import es.asepeyo.recordalexia.web.dto.ParentViews.WeeklyEntryView;
import java.time.DayOfWeek;
import java.util.List;
import org.springframework.http.ResponseEntity;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.web.bind.annotation.DeleteMapping;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.PutMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
/** CRUD del horario semanal de mañana y de las rutinas de tarde (panel de padres). */
@RestController
@RequestMapping("/api/parents/schedule")
public class ParentScheduleController {
private final WeeklyTemplateEntryRepository templateRepository;
private final AfternoonRoutineRepository routineRepository;
private final ChildRepository childRepository;
private final ActivityRepository activityRepository;
public ParentScheduleController(WeeklyTemplateEntryRepository templateRepository,
AfternoonRoutineRepository routineRepository,
ChildRepository childRepository,
ActivityRepository activityRepository) {
this.templateRepository = templateRepository;
this.routineRepository = routineRepository;
this.childRepository = childRepository;
this.activityRepository = activityRepository;
}
// --- Plantilla de mañana ---
@GetMapping("/weekly")
@Transactional(readOnly = true)
public List<WeeklyEntryView> listWeekly(@RequestParam Long childId) {
return templateRepository.findByChildIdOrderByDayOfWeekAscOrderIndexAsc(childId).stream()
.map(this::toWeeklyView).toList();
}
@PostMapping("/weekly")
public ResponseEntity<WeeklyEntryView> createWeekly(@RequestBody WeeklyEntryRequest req) {
Child child = requireChild(req.childId());
Activity activity = activityRepository.findById(req.activityId())
.orElseThrow(() -> new NotFoundException("No existe la actividad con id " + req.activityId()));
WeeklyTemplateEntry entry = new WeeklyTemplateEntry(child, DayOfWeek.valueOf(req.dayOfWeek()),
activity, req.orderIndex() != null ? req.orderIndex() : 0);
entry.setCoinsReward(req.coinsReward());
return ResponseEntity.ok(toWeeklyView(templateRepository.save(entry)));
}
@DeleteMapping("/weekly/{id}")
public ResponseEntity<Void> deleteWeekly(@PathVariable Long id) {
if (!templateRepository.existsById(id)) {
throw new NotFoundException("No existe la entrada de horario con id " + id);
}
templateRepository.deleteById(id);
return ResponseEntity.noContent().build();
}
// --- Rutinas de tarde ---
@GetMapping("/routines")
public List<RoutineView> listRoutines(@RequestParam Long childId) {
return routineRepository.findByChildIdOrderByDayOfWeekAscOrderIndexAsc(childId).stream()
.map(this::toRoutineView).toList();
}
@PostMapping("/routines")
public ResponseEntity<RoutineView> createRoutine(@RequestBody AfternoonRoutineRequest req) {
Child child = requireChild(req.childId());
AfternoonRoutine routine = new AfternoonRoutine(child, DayOfWeek.valueOf(req.dayOfWeek()),
req.labelEs(), req.labelCa(), req.icon(), req.color(),
req.orderIndex() != null ? req.orderIndex() : 0);
routine.setCoinsReward(req.coinsReward());
return ResponseEntity.ok(toRoutineView(routineRepository.save(routine)));
}
/** Reordena las rutinas: asigna orderIndex según la posición en la lista recibida. */
@PutMapping("/routines/reorder")
@Transactional
public ResponseEntity<Void> reorderRoutines(@RequestBody RoutineReorderRequest req) {
List<Long> ids = req.orderedIds();
for (int i = 0; i < ids.size(); i++) {
int orderIndex = i;
routineRepository.findById(ids.get(i)).ifPresent(r -> r.setOrderIndex(orderIndex));
}
return ResponseEntity.noContent().build();
}
@DeleteMapping("/routines/{id}")
public ResponseEntity<Void> deleteRoutine(@PathVariable Long id) {
if (!routineRepository.existsById(id)) {
throw new NotFoundException("No existe la rutina con id " + id);
}
routineRepository.deleteById(id);
return ResponseEntity.noContent().build();
}
private Child requireChild(Long childId) {
return childRepository.findById(childId)
.orElseThrow(() -> new NotFoundException("No existe el niño con id " + childId));
}
private WeeklyEntryView toWeeklyView(WeeklyTemplateEntry e) {
Activity a = e.getActivity();
return new WeeklyEntryView(e.getId(), e.getChild().getId(), e.getDayOfWeek().name(),
a.getId(), a.getLabelEs(), a.getIcon(), a.getColor(), e.getOrderIndex(), e.getCoinsReward());
}
private RoutineView toRoutineView(AfternoonRoutine r) {
return new RoutineView(r.getId(), r.getChild().getId(), r.getDayOfWeek().name(),
r.getLabelEs(), r.getLabelCa(), r.getIcon(), r.getColor(), r.getOrderIndex(),
r.getCoinsReward());
}
}

View File

@@ -0,0 +1,27 @@
package es.asepeyo.recordalexia.web;
import es.asepeyo.recordalexia.service.StoreService;
import es.asepeyo.recordalexia.web.dto.StoreDtos.RedeemResult;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
/** Canje de premios de la tienda. */
@RestController
@RequestMapping("/api/rewards")
public class StoreController {
private final StoreService storeService;
public StoreController(StoreService storeService) {
this.storeService = storeService;
}
/** Canjea un premio para un niño (el niño se indica por query param childId). */
@PostMapping("/{rewardId}/redeem")
public RedeemResult redeem(@PathVariable Long rewardId,
@org.springframework.web.bind.annotation.RequestParam Long childId) {
return storeService.redeem(childId, rewardId);
}
}

View File

@@ -0,0 +1,25 @@
package es.asepeyo.recordalexia.web;
import es.asepeyo.recordalexia.service.TaskService;
import es.asepeyo.recordalexia.web.dto.ToggleResult;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
/** Marcado/desmarcado de tareas del día. */
@RestController
@RequestMapping("/api/tasks")
public class TaskController {
private final TaskService taskService;
public TaskController(TaskService taskService) {
this.taskService = taskService;
}
@PostMapping("/{taskId}/toggle")
public ToggleResult toggle(@PathVariable Long taskId) {
return taskService.toggle(taskId);
}
}

View File

@@ -0,0 +1,42 @@
package es.asepeyo.recordalexia.web.dto;
/** DTOs relacionados con el niño: resumen de perfil y actualización de ajustes. */
public final class ChildDtos {
private ChildDtos() {
}
/** Resumen para la pantalla de selección de perfil. */
public record ChildSummary(
Long id,
String name,
String mascot,
String accentColor,
int age,
int coins,
String viewMode,
String language) {
}
/**
* Ajustes editables del niño. Todos opcionales: solo se aplican los no nulos.
* departureTime en formato ISO "HH:mm".
*/
public record SettingsRequest(
String viewMode,
Boolean soundEnabled,
Boolean ttsEnabled,
String language,
String departureTime) {
}
/** Petición de alta/edición de un niño desde el panel de padres. */
public record ChildRequest(
String name,
String mascot,
String accentColor,
Integer age,
String departureTime,
Integer coins) {
}
}

View File

@@ -0,0 +1,53 @@
package es.asepeyo.recordalexia.web.dto;
import java.util.List;
/** DTOs del panel de padres: autenticación, gamificación y altas de configuración. */
public final class ParentDtos {
private ParentDtos() {
}
// --- Autenticación ---
public record LoginRequest(String pin) {
}
/** Identificador de sesión a enviar luego en la cabecera X-Parent-Session. */
public record LoginResponse(String session) {
}
public record ChangePinRequest(String currentPin, String newPin) {
}
// --- Gamificación (por niño) ---
public record GamificationRequest(Integer coinsPerTask, Integer coinsPerBlock, Integer coinsPerDay) {
}
// --- Catálogo ---
public record ActivityRequest(
String labelEs, String labelCa, String icon, String color, List<Long> materialIds) {
}
public record MaterialRequest(
String labelEs, String labelCa, String icon, String color, String category) {
}
// --- Horario / rutinas ---
public record WeeklyEntryRequest(
Long childId, String dayOfWeek, Long activityId, Integer orderIndex, Integer coinsReward) {
}
public record AfternoonRoutineRequest(
Long childId, String dayOfWeek, String labelEs, String labelCa, String icon, String color,
Integer orderIndex, Integer coinsReward) {
}
/** Reordenación de rutinas: lista de ids en el nuevo orden deseado. */
public record RoutineReorderRequest(List<Long> orderedIds) {
}
// --- Eventos ---
public record SpecialEventRequest(
Long childId, String date, String type, String titleEs, String titleCa, String icon, String color) {
}
}

View File

@@ -0,0 +1,42 @@
package es.asepeyo.recordalexia.web.dto;
import java.time.LocalDate;
import java.util.List;
/** Vistas de lectura del panel de padres (nunca exponen entidades JPA). */
public final class ParentViews {
private ParentViews() {
}
public record RewardAdminView(
Long id, String labelEs, String labelCa, String icon, String color, int cost, boolean active) {
}
public record MaterialView(
Long id, String labelEs, String labelCa, String icon, String color, String category) {
}
public record ActivityView(
Long id, String labelEs, String labelCa, String icon, String color, List<Long> materialIds) {
}
public record WeeklyEntryView(
Long id, Long childId, String dayOfWeek, Long activityId, String activityLabelEs,
String icon, String color, int orderIndex, Integer coinsReward) {
}
public record RoutineView(
Long id, Long childId, String dayOfWeek, String labelEs, String labelCa, String icon,
String color, int orderIndex, Integer coinsReward) {
}
public record EventAdminView(
Long id, Long childId, LocalDate date, String type, String titleEs, String titleCa,
String icon, String color) {
}
/** Valores actuales de gamificación de un niño (para precargar el panel). */
public record GamificationView(int coinsPerTask, int coinsPerBlock, int coinsPerDay) {
}
}

View File

@@ -0,0 +1,34 @@
package es.asepeyo.recordalexia.web.dto;
/** DTOs de la tienda de premios. */
public final class StoreDtos {
private StoreDtos() {
}
/** Premio tal como lo ve el niño, con si puede permitírselo y cuánto le falta. */
public record RewardView(
Long id,
String labelEs,
String labelCa,
String icon,
String color,
int cost,
boolean affordable,
int missing) {
}
/** Resultado de un canje: saldo nuevo y premio canjeado. */
public record RedeemResult(Long rewardId, int cost, int newBalance) {
}
/** Alta/edición de premio desde el panel de padres. */
public record RewardRequest(
String labelEs,
String labelCa,
String icon,
String color,
Integer cost,
Boolean active) {
}
}

View File

@@ -0,0 +1,65 @@
package es.asepeyo.recordalexia.web.dto;
import java.util.List;
/**
* Payload de GET /api/children/{id}/today. Lleva las tareas de mañana y tarde, los
* eventos del día, el progreso, el monedero y el temporizador de salida. Los textos
* van en ES y CA; el frontend elige según el idioma activo.
*/
public record TodayResponse(
ChildInfo child,
List<TaskView> morning,
List<TaskView> afternoon,
List<EventView> specialEvents,
ProgressView progress,
WalletInfo wallet,
TimerInfo timer) {
public record ChildInfo(
Long id,
String name,
String mascot,
String accentColor,
String viewMode,
String language,
boolean soundEnabled,
boolean ttsEnabled) {
}
public record TaskView(
Long id,
String labelEs,
String labelCa,
String icon,
String color,
boolean done,
int coinsReward,
int orderIndex) {
}
public record EventView(
Long id,
String type,
String titleEs,
String titleCa,
String icon,
String color) {
}
public record ProgressView(
int morningDone,
int morningTotal,
int afternoonDone,
int afternoonTotal,
int totalDone,
int total) {
}
public record WalletInfo(int coins) {
}
/** departureTime en formato ISO (HH:mm); minutesUntilDeparture puede ser null. */
public record TimerInfo(String departureTime, Integer minutesUntilDeparture) {
}
}

View File

@@ -0,0 +1,15 @@
package es.asepeyo.recordalexia.web.dto;
import es.asepeyo.recordalexia.web.dto.TodayResponse.ProgressView;
/**
* Resultado de marcar/desmarcar una tarea: estado nuevo, saldo, monedas ganadas en
* este toggle (incluidos bonos) y el progreso actualizado del día.
*/
public record ToggleResult(
Long taskId,
boolean done,
int coinsEarned,
int newBalance,
ProgressView progress) {
}

View File

@@ -0,0 +1,17 @@
package es.asepeyo.recordalexia.web.dto;
import java.time.Instant;
import java.time.LocalDate;
import java.util.List;
/** Saldo del monedero más el historial de movimientos. */
public record WalletResponse(int coins, List<CoinTxView> history) {
public record CoinTxView(
Long id,
LocalDate date,
int amount,
String reason,
Instant createdAt) {
}
}

View File

@@ -0,0 +1,54 @@
# Configuración base de recordaLexia (backend).
# Los valores dependientes del entorno se externalizan en variables de entorno
# (ver .env.example en la raíz).
#
# CREDENCIALES: no se declaran aquí. Spring Boot enlaza automáticamente, por
# binding relajado, las variables de entorno SPRING_DATASOURCE_USERNAME y
# SPRING_DATASOURCE_PASSWORD sobre spring.datasource.*. Así la credencial nunca
# vive en texto plano dentro del repositorio (norma del proyecto).
spring:
application:
name: recordalexia
# --- Origen de datos (PostgreSQL) ---
# En docker-compose, DB_HOST=postgres. En desarrollo local fuera de Docker,
# localhost. Usuario/clave llegan por SPRING_DATASOURCE_USERNAME/_PASSWORD.
datasource:
url: jdbc:postgresql://${DB_HOST:localhost}:${DB_PORT:5432}/${DB_NAME:recordalexia}
# --- JPA / Hibernate ---
# El esquema lo gobierna Liquibase, por eso ddl-auto = validate (Hibernate solo
# valida que el mapeo case con las tablas, nunca crea ni altera). open-in-view
# desactivado: el patrón por capas cierra la sesión en el service, no en la vista.
jpa:
hibernate:
ddl-auto: validate
open-in-view: false
properties:
hibernate:
jdbc:
# Zona horaria fija: el negocio decide "hoy" en Europe/Madrid.
time_zone: Europe/Madrid
# --- Liquibase ---
liquibase:
change-log: classpath:db/changelog/db.changelog-master.yaml
# --- Serialización JSON ---
jackson:
time-zone: Europe/Madrid
# --- Servidor ---
server:
port: ${SERVER_PORT:8080}
# --- Actuator: solo health, usado por el healthcheck de Docker ---
management:
endpoints:
web:
exposure:
include: health
endpoint:
health:
probes:
enabled: true

View File

@@ -0,0 +1 @@
# Los changesets de Liquibase de la Fase 2 (dominio) vivirán aquí.

View File

@@ -0,0 +1,261 @@
# Esquema inicial de recordaLexia (Fase 2).
# Todas las tablas del dominio. Los textos visibles llevan variante ES y CA.
databaseChangeLog:
- changeSet:
id: 001-create-child
author: recordalexia
changes:
- createTable:
tableName: child
columns:
- column: { name: id, type: BIGINT, autoIncrement: true, constraints: { primaryKey: true, nullable: false } }
- column: { name: name, type: VARCHAR(80), constraints: { nullable: false } }
- column: { name: mascot, type: VARCHAR(16), constraints: { nullable: false } }
- column: { name: accent_color, type: VARCHAR(9), constraints: { nullable: false } }
- column: { name: age, type: INT, constraints: { nullable: false } }
- column: { name: coins, type: INT, defaultValueNumeric: 0, constraints: { nullable: false } }
- column: { name: departure_time, type: TIME }
- column: { name: view_mode, type: VARCHAR(10), defaultValue: 'BOARD', constraints: { nullable: false } }
- column: { name: sound_enabled, type: BOOLEAN, defaultValueBoolean: true, constraints: { nullable: false } }
- column: { name: tts_enabled, type: BOOLEAN, defaultValueBoolean: true, constraints: { nullable: false } }
- column: { name: language, type: VARCHAR(2), defaultValue: 'ES', constraints: { nullable: false } }
- column: { name: coins_per_task, type: INT, defaultValueNumeric: 5, constraints: { nullable: false } }
- column: { name: coins_per_block, type: INT, defaultValueNumeric: 10, constraints: { nullable: false } }
- column: { name: coins_per_day, type: INT, defaultValueNumeric: 20, constraints: { nullable: false } }
- changeSet:
id: 002-create-parent-user
author: recordalexia
changes:
- createTable:
tableName: parent_user
columns:
- column: { name: id, type: BIGINT, autoIncrement: true, constraints: { primaryKey: true, nullable: false } }
- column: { name: pin_hash, type: VARCHAR(100), constraints: { nullable: false } }
- column: { name: updated_at, type: TIMESTAMP }
- changeSet:
id: 003-create-activity
author: recordalexia
changes:
- createTable:
tableName: activity
columns:
- column: { name: id, type: BIGINT, autoIncrement: true, constraints: { primaryKey: true, nullable: false } }
- column: { name: label_es, type: VARCHAR(120), constraints: { nullable: false } }
- column: { name: label_ca, type: VARCHAR(120), constraints: { nullable: false } }
- column: { name: icon, type: VARCHAR(16), constraints: { nullable: false } }
- column: { name: color, type: VARCHAR(9), constraints: { nullable: false } }
- changeSet:
id: 004-create-material-item
author: recordalexia
changes:
- createTable:
tableName: material_item
columns:
- column: { name: id, type: BIGINT, autoIncrement: true, constraints: { primaryKey: true, nullable: false } }
- column: { name: label_es, type: VARCHAR(120), constraints: { nullable: false } }
- column: { name: label_ca, type: VARCHAR(120), constraints: { nullable: false } }
- column: { name: icon, type: VARCHAR(16), constraints: { nullable: false } }
- column: { name: color, type: VARCHAR(9), constraints: { nullable: false } }
- column: { name: category, type: VARCHAR(40) }
- changeSet:
id: 005-create-activity-material
author: recordalexia
changes:
- createTable:
tableName: activity_material
columns:
- column: { name: activity_id, type: BIGINT, constraints: { nullable: false } }
- column: { name: material_item_id, type: BIGINT, constraints: { nullable: false } }
- addPrimaryKey:
tableName: activity_material
columnNames: activity_id, material_item_id
constraintName: pk_activity_material
- addForeignKeyConstraint:
baseTableName: activity_material
baseColumnNames: activity_id
referencedTableName: activity
referencedColumnNames: id
constraintName: fk_actmat_activity
onDelete: CASCADE
- addForeignKeyConstraint:
baseTableName: activity_material
baseColumnNames: material_item_id
referencedTableName: material_item
referencedColumnNames: id
constraintName: fk_actmat_material
onDelete: CASCADE
- changeSet:
id: 006-create-weekly-template-entry
author: recordalexia
changes:
- createTable:
tableName: weekly_template_entry
columns:
- column: { name: id, type: BIGINT, autoIncrement: true, constraints: { primaryKey: true, nullable: false } }
- column: { name: child_id, type: BIGINT, constraints: { nullable: false } }
- column: { name: day_of_week, type: VARCHAR(9), constraints: { nullable: false } }
- column: { name: activity_id, type: BIGINT, constraints: { nullable: false } }
- column: { name: order_index, type: INT, defaultValueNumeric: 0, constraints: { nullable: false } }
- column: { name: coins_reward, type: INT }
- addForeignKeyConstraint:
baseTableName: weekly_template_entry
baseColumnNames: child_id
referencedTableName: child
referencedColumnNames: id
constraintName: fk_wte_child
onDelete: CASCADE
- addForeignKeyConstraint:
baseTableName: weekly_template_entry
baseColumnNames: activity_id
referencedTableName: activity
referencedColumnNames: id
constraintName: fk_wte_activity
- changeSet:
id: 007-create-afternoon-routine
author: recordalexia
changes:
- createTable:
tableName: afternoon_routine
columns:
- column: { name: id, type: BIGINT, autoIncrement: true, constraints: { primaryKey: true, nullable: false } }
- column: { name: child_id, type: BIGINT, constraints: { nullable: false } }
- column: { name: day_of_week, type: VARCHAR(9), constraints: { nullable: false } }
- column: { name: label_es, type: VARCHAR(120), constraints: { nullable: false } }
- column: { name: label_ca, type: VARCHAR(120), constraints: { nullable: false } }
- column: { name: icon, type: VARCHAR(16), constraints: { nullable: false } }
- column: { name: color, type: VARCHAR(9), constraints: { nullable: false } }
- column: { name: order_index, type: INT, defaultValueNumeric: 0, constraints: { nullable: false } }
- column: { name: coins_reward, type: INT }
- addForeignKeyConstraint:
baseTableName: afternoon_routine
baseColumnNames: child_id
referencedTableName: child
referencedColumnNames: id
constraintName: fk_routine_child
onDelete: CASCADE
- changeSet:
id: 008-create-special-event
author: recordalexia
changes:
- createTable:
tableName: special_event
columns:
- column: { name: id, type: BIGINT, autoIncrement: true, constraints: { primaryKey: true, nullable: false } }
- column: { name: child_id, type: BIGINT, constraints: { nullable: false } }
- column: { name: event_date, type: DATE, constraints: { nullable: false } }
- column: { name: type, type: VARCHAR(10), constraints: { nullable: false } }
- column: { name: title_es, type: VARCHAR(160), constraints: { nullable: false } }
- column: { name: title_ca, type: VARCHAR(160), constraints: { nullable: false } }
- column: { name: icon, type: VARCHAR(16), constraints: { nullable: false } }
- column: { name: color, type: VARCHAR(9), constraints: { nullable: false } }
- addForeignKeyConstraint:
baseTableName: special_event
baseColumnNames: child_id
referencedTableName: child
referencedColumnNames: id
constraintName: fk_event_child
onDelete: CASCADE
- changeSet:
id: 009-create-daily-task
author: recordalexia
changes:
- createTable:
tableName: daily_task
columns:
- column: { name: id, type: BIGINT, autoIncrement: true, constraints: { primaryKey: true, nullable: false } }
- column: { name: child_id, type: BIGINT, constraints: { nullable: false } }
- column: { name: task_date, type: DATE, constraints: { nullable: false } }
- column: { name: slot, type: VARCHAR(10), constraints: { nullable: false } }
- column: { name: label_es, type: VARCHAR(160), constraints: { nullable: false } }
- column: { name: label_ca, type: VARCHAR(160), constraints: { nullable: false } }
- column: { name: icon, type: VARCHAR(16), constraints: { nullable: false } }
- column: { name: color, type: VARCHAR(9), constraints: { nullable: false } }
- column: { name: status, type: VARCHAR(10), defaultValue: 'PENDING', constraints: { nullable: false } }
- column: { name: coins_reward, type: INT, defaultValueNumeric: 0, constraints: { nullable: false } }
- column: { name: completed_at, type: TIMESTAMP }
- column: { name: origin, type: VARCHAR(20), constraints: { nullable: false } }
- column: { name: order_index, type: INT, defaultValueNumeric: 0, constraints: { nullable: false } }
- addForeignKeyConstraint:
baseTableName: daily_task
baseColumnNames: child_id
referencedTableName: child
referencedColumnNames: id
constraintName: fk_task_child
onDelete: CASCADE
- createIndex:
tableName: daily_task
indexName: idx_task_child_date
columns:
- column: { name: child_id }
- column: { name: task_date }
- changeSet:
id: 010-create-reward
author: recordalexia
changes:
- createTable:
tableName: reward
columns:
- column: { name: id, type: BIGINT, autoIncrement: true, constraints: { primaryKey: true, nullable: false } }
- column: { name: label_es, type: VARCHAR(120), constraints: { nullable: false } }
- column: { name: label_ca, type: VARCHAR(120), constraints: { nullable: false } }
- column: { name: icon, type: VARCHAR(16), constraints: { nullable: false } }
- column: { name: color, type: VARCHAR(9), constraints: { nullable: false } }
- column: { name: cost, type: INT, constraints: { nullable: false } }
- column: { name: active, type: BOOLEAN, defaultValueBoolean: true, constraints: { nullable: false } }
- changeSet:
id: 011-create-coin-transaction
author: recordalexia
changes:
- createTable:
tableName: coin_transaction
columns:
- column: { name: id, type: BIGINT, autoIncrement: true, constraints: { primaryKey: true, nullable: false } }
- column: { name: child_id, type: BIGINT, constraints: { nullable: false } }
- column: { name: tx_date, type: DATE, constraints: { nullable: false } }
- column: { name: amount, type: INT, constraints: { nullable: false } }
- column: { name: reason, type: VARCHAR(40), constraints: { nullable: false } }
- column: { name: created_at, type: TIMESTAMP, constraints: { nullable: false } }
- addForeignKeyConstraint:
baseTableName: coin_transaction
baseColumnNames: child_id
referencedTableName: child
referencedColumnNames: id
constraintName: fk_tx_child
onDelete: CASCADE
- changeSet:
id: 012-create-reward-redemption
author: recordalexia
changes:
- createTable:
tableName: reward_redemption
columns:
- column: { name: id, type: BIGINT, autoIncrement: true, constraints: { primaryKey: true, nullable: false } }
- column: { name: child_id, type: BIGINT, constraints: { nullable: false } }
- column: { name: reward_id, type: BIGINT, constraints: { nullable: false } }
- column: { name: redeemed_date, type: DATE, constraints: { nullable: false } }
- column: { name: cost, type: INT, constraints: { nullable: false } }
- column: { name: created_at, type: TIMESTAMP, constraints: { nullable: false } }
- addForeignKeyConstraint:
baseTableName: reward_redemption
baseColumnNames: child_id
referencedTableName: child
referencedColumnNames: id
constraintName: fk_redemption_child
onDelete: CASCADE
- addForeignKeyConstraint:
baseTableName: reward_redemption
baseColumnNames: reward_id
referencedTableName: reward
referencedColumnNames: id
constraintName: fk_redemption_reward

View File

@@ -0,0 +1,9 @@
# Changelog maestro de Liquibase.
# Incluye, en orden, todos los changesets ubicados en changes/.
# En la Fase 1 (esqueleto) no hay aún ningún changeset: el esquema de dominio
# (niños, materiales, actividades, eventos, rutinas, premios, monedero...) se
# añadirá en la Fase 2 como ficheros independientes dentro de changes/.
databaseChangeLog:
- includeAll:
path: changes/
relativeToChangelogFile: true

View File

@@ -0,0 +1,13 @@
package es.asepeyo.recordalexia;
import org.junit.jupiter.api.Test;
import org.springframework.boot.test.context.SpringBootTest;
@SpringBootTest
class RecordalexiaApplicationTests {
@Test
void contextLoads() {
}
}

View File

@@ -0,0 +1,41 @@
package es.asepeyo.recordalexia.bootstrap;
import static org.assertj.core.api.Assertions.assertThat;
import es.asepeyo.recordalexia.repository.ActivityRepository;
import es.asepeyo.recordalexia.repository.ChildRepository;
import es.asepeyo.recordalexia.repository.ParentUserRepository;
import es.asepeyo.recordalexia.repository.RewardRepository;
import es.asepeyo.recordalexia.repository.WeeklyTemplateEntryRepository;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.test.context.TestPropertySource;
/**
* Verifica que el sembrado del prototipo se ejecuta correctamente. Usa una BD H2
* propia y el seeder activado (al contrario que el resto de tests).
*/
@SpringBootTest
@TestPropertySource(properties = {
"recordalexia.seed.enabled=true",
"spring.datasource.url=jdbc:h2:mem:seedtest;MODE=PostgreSQL;DB_CLOSE_DELAY=-1"
})
class DataSeederIT {
@Autowired private ChildRepository childRepository;
@Autowired private ParentUserRepository parentUserRepository;
@Autowired private RewardRepository rewardRepository;
@Autowired private ActivityRepository activityRepository;
@Autowired private WeeklyTemplateEntryRepository templateRepository;
@Test
void siembraLosDatosDelPrototipo() {
assertThat(childRepository.count()).isEqualTo(3); // Nora, Leo, Mía
assertThat(parentUserRepository.findFirstByOrderByIdAsc()).isPresent();
assertThat(rewardRepository.count()).isEqualTo(6); // 6 premios
assertThat(activityRepository.count()).isEqualTo(4); // 4 actividades
// Cada niño tiene 5 entradas de mañana (L-V): 3 niños x 5 = 15.
assertThat(templateRepository.count()).isEqualTo(15);
}
}

View File

@@ -0,0 +1,66 @@
package es.asepeyo.recordalexia.service;
import static org.assertj.core.api.Assertions.assertThat;
import es.asepeyo.recordalexia.domain.Activity;
import es.asepeyo.recordalexia.domain.AfternoonRoutine;
import es.asepeyo.recordalexia.domain.Child;
import es.asepeyo.recordalexia.domain.DailyTask;
import es.asepeyo.recordalexia.domain.Slot;
import es.asepeyo.recordalexia.domain.WeeklyTemplateEntry;
import es.asepeyo.recordalexia.repository.ActivityRepository;
import es.asepeyo.recordalexia.repository.AfternoonRoutineRepository;
import es.asepeyo.recordalexia.repository.ChildRepository;
import es.asepeyo.recordalexia.repository.WeeklyTemplateEntryRepository;
import java.time.DayOfWeek;
import java.time.LocalDate;
import java.util.List;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.transaction.annotation.Transactional;
/** Verifica la generación del día: mañana desde plantilla, tarde desde rutinas, idempotente. */
@SpringBootTest
@Transactional
class DayGenerationServiceTest {
@Autowired private DayGenerationService dayGenerationService;
@Autowired private ChildRepository childRepository;
@Autowired private ActivityRepository activityRepository;
@Autowired private WeeklyTemplateEntryRepository templateRepository;
@Autowired private AfternoonRoutineRepository routineRepository;
@Test
void generaTareasDeMananaYTardeYesIdempotente() {
// Fecha fija; usamos su día de la semana para enganchar plantilla y rutina.
LocalDate fecha = LocalDate.of(2026, 6, 22);
DayOfWeek dia = fecha.getDayOfWeek();
Child nino = childRepository.save(nuevoNino());
Activity mates = activityRepository.save(new Activity("Mates", "Mates", "📘", "#5B8DEF"));
templateRepository.save(new WeeklyTemplateEntry(nino, dia, mates, 0));
routineRepository.save(new AfternoonRoutine(nino, dia, "Deberes", "Deures", "📝", "#F2A65A", 0));
List<DailyTask> primera = dayGenerationService.generateIfAbsent(nino.getId(), fecha);
assertThat(primera).hasSize(2);
assertThat(primera).filteredOn(t -> t.getSlot() == Slot.MORNING)
.singleElement().extracting(DailyTask::getLabelEs).isEqualTo("Mates");
assertThat(primera).filteredOn(t -> t.getSlot() == Slot.AFTERNOON)
.singleElement().extracting(DailyTask::getLabelEs).isEqualTo("Deberes");
// Segunda llamada: no debe duplicar (idempotente).
List<DailyTask> segunda = dayGenerationService.generateIfAbsent(nino.getId(), fecha);
assertThat(segunda).hasSize(2);
}
private Child nuevoNino() {
Child c = new Child();
c.setName("Nora");
c.setMascot("🦊");
c.setAccentColor("#F2A65A");
c.setAge(7);
return c;
}
}

View File

@@ -0,0 +1,63 @@
package es.asepeyo.recordalexia.service;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import es.asepeyo.recordalexia.domain.Child;
import es.asepeyo.recordalexia.domain.Reward;
import es.asepeyo.recordalexia.exception.InsufficientCoinsException;
import es.asepeyo.recordalexia.repository.ChildRepository;
import es.asepeyo.recordalexia.repository.RewardRedemptionRepository;
import es.asepeyo.recordalexia.repository.RewardRepository;
import es.asepeyo.recordalexia.web.dto.StoreDtos.RedeemResult;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.transaction.annotation.Transactional;
/** Verifica el canje de premios: descuento correcto y error por saldo insuficiente. */
@SpringBootTest
@Transactional
class StoreServiceTest {
@Autowired private StoreService storeService;
@Autowired private ChildRepository childRepository;
@Autowired private RewardRepository rewardRepository;
@Autowired private RewardRedemptionRepository redemptionRepository;
@Test
void canjeaPremioYdescuentaMonedas() {
Child nino = childRepository.save(ninoConSaldo(50));
Reward premio = rewardRepository.save(new Reward("Tablet", "Tauleta", "🎮", "#5B8DEF", 20));
RedeemResult resultado = storeService.redeem(nino.getId(), premio.getId());
assertThat(resultado.cost()).isEqualTo(20);
assertThat(resultado.newBalance()).isEqualTo(30);
assertThat(childRepository.findById(nino.getId()).orElseThrow().getCoins()).isEqualTo(30);
assertThat(redemptionRepository.findByChildIdOrderByCreatedAtDesc(nino.getId())).hasSize(1);
}
@Test
void rechazaCanjeSiNoHaySaldoSuficiente() {
Child nino = childRepository.save(ninoConSaldo(30));
Reward caro = rewardRepository.save(new Reward("Dino", "Dino", "🦖", "#EC8FA4", 80));
assertThatThrownBy(() -> storeService.redeem(nino.getId(), caro.getId()))
.isInstanceOf(InsufficientCoinsException.class)
.satisfies(ex -> assertThat(((InsufficientCoinsException) ex).getMissing()).isEqualTo(50));
// No se ha tocado el saldo.
assertThat(childRepository.findById(nino.getId()).orElseThrow().getCoins()).isEqualTo(30);
}
private Child ninoConSaldo(int saldo) {
Child c = new Child();
c.setName("Mía");
c.setMascot("🦉");
c.setAccentColor("#A78BD0");
c.setAge(6);
c.setCoins(saldo);
return c;
}
}

View File

@@ -0,0 +1,82 @@
package es.asepeyo.recordalexia.service;
import static org.assertj.core.api.Assertions.assertThat;
import es.asepeyo.recordalexia.domain.Child;
import es.asepeyo.recordalexia.domain.DailyTask;
import es.asepeyo.recordalexia.domain.Slot;
import es.asepeyo.recordalexia.domain.TaskOrigin;
import es.asepeyo.recordalexia.repository.ChildRepository;
import es.asepeyo.recordalexia.repository.DailyTaskRepository;
import java.time.LocalDate;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.transaction.annotation.Transactional;
/**
* Verifica el marcado/desmarcado de tareas, las monedas por tarea y los bonos de
* bloque y de día, incluida su reversión coherente al desmarcar.
*
* Niño con coinsPerTask=5, coinsPerBlock=10, coinsPerDay=20. Día con 2 tareas de
* mañana y 1 de tarde.
*/
@SpringBootTest
@Transactional
class TaskServiceTest {
private static final LocalDate DIA = LocalDate.of(2026, 6, 22);
@Autowired private TaskService taskService;
@Autowired private ChildRepository childRepository;
@Autowired private DailyTaskRepository dailyTaskRepository;
@Test
void monedasYbonosDeBloqueYdiaConReversion() {
Child nino = childRepository.save(nuevoNino());
DailyTask m1 = nuevaTarea(nino, Slot.MORNING, "Mates", 0);
DailyTask m2 = nuevaTarea(nino, Slot.MORNING, "Lengua", 1);
DailyTask t1 = nuevaTarea(nino, Slot.AFTERNOON, "Deberes", 0);
// 1ª tarea de mañana: +5 (bloque aún incompleto).
var r1 = taskService.toggle(m1.getId());
assertThat(r1.coinsEarned()).isEqualTo(5);
assertThat(r1.newBalance()).isEqualTo(5);
// 2ª tarea de mañana: +5 tarea +10 bono de bloque (mañana completa).
var r2 = taskService.toggle(m2.getId());
assertThat(r2.coinsEarned()).isEqualTo(15);
assertThat(r2.newBalance()).isEqualTo(20);
// Tarea de tarde: +5 tarea +10 bono de tarde +20 bono de día (todo hecho).
var r3 = taskService.toggle(t1.getId());
assertThat(r3.coinsEarned()).isEqualTo(35);
assertThat(r3.newBalance()).isEqualTo(55);
// Desmarcar la de tarde: -5 tarea, -10 bono de tarde, -20 bono de día.
var r4 = taskService.toggle(t1.getId());
assertThat(r4.coinsEarned()).isEqualTo(-35);
assertThat(r4.newBalance()).isEqualTo(20);
// El bono de mañana se conserva (la mañana sigue completa).
Child recargado = childRepository.findById(nino.getId()).orElseThrow();
assertThat(recargado.getCoins()).isEqualTo(20);
}
private Child nuevoNino() {
Child c = new Child();
c.setName("Leo");
c.setMascot("🐢");
c.setAccentColor("#5BC0BE");
c.setAge(9);
c.setCoinsPerTask(5);
c.setCoinsPerBlock(10);
c.setCoinsPerDay(20);
return c;
}
private DailyTask nuevaTarea(Child nino, Slot slot, String label, int orden) {
return dailyTaskRepository.save(new DailyTask(nino, DIA, slot, label, label, "", "#5B8DEF",
5, TaskOrigin.TEMPLATE, orden));
}
}

View File

@@ -0,0 +1,68 @@
package es.asepeyo.recordalexia.web;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import es.asepeyo.recordalexia.domain.ParentUser;
import es.asepeyo.recordalexia.repository.ParentUserRepository;
import es.asepeyo.recordalexia.security.ParentAuthFilter;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.http.MediaType;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.test.web.servlet.MockMvc;
import org.springframework.transaction.annotation.Transactional;
/** Verifica el login por PIN y que el panel de padres queda protegido. */
@SpringBootTest
@AutoConfigureMockMvc
@Transactional
class ParentAuthIT {
@Autowired private MockMvc mockMvc;
@Autowired private ObjectMapper objectMapper;
@Autowired private ParentUserRepository parentUserRepository;
@Autowired private PasswordEncoder passwordEncoder;
@BeforeEach
void setUp() {
parentUserRepository.save(new ParentUser(passwordEncoder.encode("1234")));
}
@Test
void loginCorrectoAbreSesionYpermiteAccederAlPanel() throws Exception {
// Login con PIN correcto -> 200 con identificador de sesión.
String body = mockMvc.perform(post("/api/parents/login")
.contentType(MediaType.APPLICATION_JSON)
.content("{\"pin\":\"1234\"}"))
.andExpect(status().isOk())
.andReturn().getResponse().getContentAsString();
JsonNode json = objectMapper.readTree(body);
String session = json.path("session").asText();
// Con la sesión, el panel responde.
mockMvc.perform(get("/api/parents/children").header(ParentAuthFilter.HEADER, session))
.andExpect(status().isOk());
}
@Test
void pinIncorrectoDevuelve401() throws Exception {
mockMvc.perform(post("/api/parents/login")
.contentType(MediaType.APPLICATION_JSON)
.content("{\"pin\":\"0000\"}"))
.andExpect(status().isUnauthorized());
}
@Test
void panelSinSesionEstaProhibido() throws Exception {
mockMvc.perform(get("/api/parents/children"))
.andExpect(status().isForbidden());
}
}

View File

@@ -0,0 +1,92 @@
package es.asepeyo.recordalexia.web;
import static org.assertj.core.api.Assertions.assertThat;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import es.asepeyo.recordalexia.domain.Activity;
import es.asepeyo.recordalexia.domain.AfternoonRoutine;
import es.asepeyo.recordalexia.domain.Child;
import es.asepeyo.recordalexia.domain.Reward;
import es.asepeyo.recordalexia.domain.WeeklyTemplateEntry;
import es.asepeyo.recordalexia.repository.ActivityRepository;
import es.asepeyo.recordalexia.repository.AfternoonRoutineRepository;
import es.asepeyo.recordalexia.repository.ChildRepository;
import es.asepeyo.recordalexia.repository.RewardRepository;
import es.asepeyo.recordalexia.repository.WeeklyTemplateEntryRepository;
import java.time.DayOfWeek;
import java.time.LocalDate;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.test.web.servlet.MockMvc;
/**
* Test de integración del flujo del kiosko a través de la API REST:
* ver el día de hoy, marcar una tarea (ganar monedas) y canjear un premio.
*/
@SpringBootTest
@AutoConfigureMockMvc
class TodayFlowIT {
@Autowired private MockMvc mockMvc;
@Autowired private ObjectMapper objectMapper;
@Autowired private ChildRepository childRepository;
@Autowired private ActivityRepository activityRepository;
@Autowired private WeeklyTemplateEntryRepository templateRepository;
@Autowired private AfternoonRoutineRepository routineRepository;
@Autowired private RewardRepository rewardRepository;
@Test
void flujoVerDiaMarcarTareaYcanjear() throws Exception {
// Datos para el día de hoy (engancha plantilla y rutina al día de la semana actual).
DayOfWeek hoy = LocalDate.now().getDayOfWeek();
Child nino = childRepository.save(nuevoNino());
Activity mates = activityRepository.save(new Activity("Mates", "Mates", "📘", "#5B8DEF"));
templateRepository.save(new WeeklyTemplateEntry(nino, hoy, mates, 0));
routineRepository.save(new AfternoonRoutine(nino, hoy, "Deberes", "Deures", "📝", "#F2A65A", 0));
Reward premio = rewardRepository.save(new Reward("Tablet", "Tauleta", "🎮", "#5B8DEF", 5));
// 1) Ver el día: una tarea de mañana y una de tarde.
String body = mockMvc.perform(get("/api/children/{id}/today", nino.getId()))
.andExpect(status().isOk())
.andExpect(jsonPath("$.morning.length()").value(1))
.andExpect(jsonPath("$.afternoon.length()").value(1))
.andExpect(jsonPath("$.wallet.coins").value(50))
.andReturn().getResponse().getContentAsString();
JsonNode json = objectMapper.readTree(body);
long taskId = json.path("morning").get(0).path("id").asLong();
// 2) Marcar la única tarea de mañana: completa el bloque mañana, así que
// gana +5 (tarea) +10 (bono de bloque) = 15. El día no se completa (queda la tarde).
mockMvc.perform(post("/api/tasks/{taskId}/toggle", taskId))
.andExpect(status().isOk())
.andExpect(jsonPath("$.done").value(true))
.andExpect(jsonPath("$.coinsEarned").value(15))
.andExpect(jsonPath("$.newBalance").value(65));
// 3) Canjear un premio de coste 5.
mockMvc.perform(post("/api/rewards/{rewardId}/redeem", premio.getId())
.param("childId", String.valueOf(nino.getId())))
.andExpect(status().isOk())
.andExpect(jsonPath("$.newBalance").value(60));
assertThat(childRepository.findById(nino.getId()).orElseThrow().getCoins()).isEqualTo(60);
}
private Child nuevoNino() {
Child c = new Child();
c.setName("Nora");
c.setMascot("🦊");
c.setAccentColor("#F2A65A");
c.setAge(7);
c.setCoins(50);
return c;
}
}

View File

@@ -0,0 +1,18 @@
# Configuración de TEST (Fase 2).
# H2 en memoria (modo PostgreSQL) para que el contexto cargue sin un Postgres real.
# Liquibase crea el esquema sobre H2. El seeder se desactiva: cada test monta sus
# propios datos deterministas. (En la Fase futura se valorará Testcontainers.)
spring:
datasource:
url: jdbc:h2:mem:recordalexia;MODE=PostgreSQL;DB_CLOSE_DELAY=-1
driver-class-name: org.h2.Driver
username: sa
jpa:
hibernate:
ddl-auto: none
liquibase:
change-log: classpath:db/changelog/db.changelog-master.yaml
recordalexia:
seed:
enabled: false

55
docker-compose.yml Normal file
View File

@@ -0,0 +1,55 @@
# Stack local de recordaLexia: PostgreSQL + backend (Spring Boot) + frontend (Nginx).
# Pensado para ejecución doméstica/homelab. Las credenciales se leen de .env
# (ver .env.example); nunca van escritas aquí en texto plano.
services:
postgres:
image: postgres:16-alpine
container_name: recordalexia-postgres
environment:
POSTGRES_DB: ${DB_NAME}
POSTGRES_USER: ${DB_USER}
POSTGRES_PASSWORD: ${DB_PASSWORD}
TZ: Europe/Madrid
volumes:
- pgdata:/var/lib/postgresql/data
healthcheck:
# El backend espera a que la BD esté realmente lista, no solo arrancada.
test: ["CMD-SHELL", "pg_isready -U ${DB_USER} -d ${DB_NAME}"]
interval: 10s
timeout: 5s
retries: 5
restart: unless-stopped
backend:
build:
context: ./backend
container_name: recordalexia-backend
depends_on:
postgres:
condition: service_healthy
environment:
# Conexión a la BD: host interno del servicio postgres del compose.
DB_HOST: postgres
DB_PORT: "5432"
DB_NAME: ${DB_NAME}
# Spring enlaza estas dos sobre spring.datasource.* por binding relajado.
SPRING_DATASOURCE_USERNAME: ${DB_USER}
SPRING_DATASOURCE_PASSWORD: ${DB_PASSWORD}
TZ: Europe/Madrid
expose:
- "8080"
restart: unless-stopped
frontend:
build:
context: ./frontend
container_name: recordalexia-frontend
depends_on:
- backend
ports:
# Acceso a la app desde la red local del homelab (tablet en modo kiosko).
- "${WEB_PORT:-8088}:80"
restart: unless-stopped
volumes:
pgdata:

View File

@@ -0,0 +1,31 @@
# ADR 0001 — Fijar Spring Boot 3.5.x (no Spring Boot 4)
- **Estado:** aceptada
- **Fecha:** 2026-06-21
- **Fase:** 1 (esqueleto)
## Contexto
Al generar el backend con Spring Initializr, la versión por defecto ofrecida es
**Spring Boot 4.1.0** (arrastra Spring Framework 7 y un layout de *starters*
modular nuevo: `spring-boot-starter-webmvc`, starters de test por módulo).
El contrato del proyecto (CLAUDE.md y prompt director) fija explícitamente
**Spring Boot 3.x / Java 21**.
## Decisión
Se fija el backend a **Spring Boot 3.5.15** (última 3.x disponible en Initializr),
con `spring-boot-starter-web`, `liquibase-core` y `spring-boot-starter-test`.
## Motivación
- Cumplir el stack contratado sin introducir un salto de versión mayor no aprobado.
- Boot 4 / Spring 7 es muy reciente; el ecosistema y los ejemplos del equipo
siguen sobre 3.x.
## Consecuencias
- Cuando se quiera adoptar Boot 4 deberá hacerse mediante un ADR propio que
contemple la migración de starters y `jakarta`.
- El wrapper de Gradle queda fijado por Initializr; no se requiere Gradle en host.

View File

@@ -0,0 +1,39 @@
# ADR 0002 — OpenDyslexic como tipografía por defecto (conmutable)
- **Estado:** aceptada
- **Fecha:** 2026-06-21
- **Fase:** 1 (esqueleto / preparación de tipografías)
## Contexto
El handoff de diseño fija como tokens tipográficos **Fredoka** (títulos/labels) y
**Nunito** (texto). Para una app dirigida a peques con TDAH (y con vocación de
lectura accesible) se solicita usar **OpenDyslexic**, fuente diseñada para mejorar
la legibilidad. Esto entra en tensión con los tokens fijos del handoff, por lo que
se decide cómo conviven.
## Decisión
1. **OpenDyslexic es la tipografía por defecto y se aplica a TODO el texto**
(títulos, etiquetas y cuerpo).
2. **Es una preferencia conmutable por niño, activada de serie.** Al desactivarla,
la UI cae a las tipografías de marca del handoff (Fredoka/Nunito).
3. **Las tres familias se empaquetan en local** vía `@fontsource` (sin CDN): la
app funciona en un kiosko sin internet garantizado.
## Implementación
- Fichero único de tokens `frontend/src/styles/_theme.scss`: define
`--font-display` y `--font-body`, que por defecto apuntan a OpenDyslexic.
- Interruptor: atributo `data-dyslexia-font` en `<html>`. Con valor `off`, los
tokens caen a las familias de marca. Se inicializa a `on` en `index.html`.
- `FontPreferenceService` aplica/persiste la preferencia (hoy en `localStorage`).
## Consecuencias
- La UI nunca referencia una familia concreta, solo `var(--font-display/-body)`:
el cambio de tipografía no toca componentes.
- **Pendiente Fase 5:** la preferencia se conectará a los ajustes por niño del
backend, sustituyendo el almacenamiento en `localStorage`.
- Se cargan solo los subsets `latin` y `latin-ext` (suficientes para ES/CA) para
no inflar el bundle.

View File

@@ -0,0 +1,51 @@
# ADR 0003 — Decisiones de dominio y seguridad (Fase 2 backend)
- **Estado:** aceptada
- **Fecha:** 2026-06-21
- **Fase:** 2 (dominio + API + seguridad)
## Contexto
El contrato de backend deja varias decisiones "a tu criterio". Se documentan aquí
las tomadas al implementar el dominio, la lógica de negocio y la seguridad.
## Decisiones
1. **i18n por columnas embebidas** `label_es` / `label_ca` (no tabla de
traducciones). Encaja con el shape de los DTOs y simplifica las consultas.
2. **Capas, sin hexagonal completa.** Entidad JPA como modelo de dominio (con
comportamiento donde aporta: monedero en `Child`, marcado en `DailyTask`), DTOs
tipo `record` en el borde. **Nunca se exponen entidades**.
3. **Mañana vs. tarde.** La mañana se modela con `Activity` (catálogo) +
`WeeklyTemplateEntry` (asignación por día). La tarde con `AfternoonRoutine`
directa por día. Se evita una FK polimórfica.
4. **Eventos = banner, no tareas.** Los `SpecialEvent` (examen/deberes) se muestran
en el banner informativo del día (`specialEvents[]`), NO se materializan como
`DailyTask` marcables (coherente con el prototipo). El enum `TaskOrigin.EVENT`
queda disponible por si se decide lo contrario más adelante.
5. **Bonos reconciliables.** El bono de bloque y el de día se modelan como
transacciones de monedas con el mismo motivo: +importe al otorgar, -importe al
revertir. "Suma neta > 0" indica bono activo. Hace el marcar/desmarcar robusto
ante cualquier secuencia.
6. **Seguridad ligera.** PIN de padres con hash BCrypt; al validarlo se abre una
sesión en memoria identificada por un valor opaco (cabecera `X-Parent-Session`).
`/api/parents/**` exige rol PARENT; el resto (kiosko del niño) es abierto. Todo
encapsulado en el paquete `security` para poder sustituirlo por Keycloak.
7. **Semilla por `DataSeeder`** (ApplicationRunner, solo si la BD está vacía), no
por Liquibase. Más mantenible y tipado. Desactivable con
`recordalexia.seed.enabled=false` (lo usan los tests).
8. **Tests sobre H2** (modo PostgreSQL) con Liquibase. Se valorará Testcontainers
en una iteración posterior para fidelidad total con PostgreSQL.
## Consecuencias
- Los textos visibles siempre viajan en ES y CA; el frontend elige idioma.
- Cambiar a Keycloak no afecta a controladores ni servicios de negocio.
- La zona horaria del negocio (Europe/Madrid) se centraliza en un `Clock` inyectable.

View File

@@ -0,0 +1,114 @@
# Backend de recordaLexia (app de rutinas para niños con TDAH) — Spring Boot 3 / Java 21
## Objetivo
Implementa el backend de una app web familiar (uso doméstico, homelab) que presenta
a niños con TDAH sus tareas del día: material para el cole por la mañana y rutinas de
la tarde, con gamificación por monedas y tienda de recompensas. Los padres configuran
horario, eventos y premios. Es MULTI-NIÑO y BILINGÜE (español / catalán).
Trabajas en LOCAL. No despliegues en nube ni asumas infraestructura corporativa.
## Stack (NADA corporativo: ni Keycloak, ni Gravitee, ni Camunda, ni K8s, ni Jenkins)
- Java 21, Spring Boot 3.x, Gradle 8.
- PostgreSQL (Liquibase para el esquema).
- Spring Web (REST), Spring Data JPA, Spring Security (auth ligera, ver abajo).
- Arquitectura limpia por capas (controller / service / repository / domain). No hace
falta hexagonal completa, pero mantén el dominio aislado de JPA donde sea razonable.
- Docker multi-stage + docker-compose (postgres + backend; deja hueco para el frontend
Angular tras Nginx).
- Comenta el código en español, sobre todo la lógica de negocio.
## i18n (requisito derivado del diseño)
La UI es ES/CA. Todos los textos visibles para el niño (materiales, actividades,
rutinas, premios, tipos de evento) deben almacenarse con sus dos variantes:
campos `labelEs` y `labelCa` (o una tabla de traducciones, a tu criterio). La API
devuelve ambas y el frontend elige según el idioma activo.
## Modelo de dominio
- Child: id, name, mascot (emoji), accentColor, age, coins (saldo),
departureTime (hora de salida de la mañana, para el temporizador),
y ajustes: viewMode (BOARD | FOCUS), soundEnabled, ttsEnabled, language (ES | CA).
- ParentUser: credenciales del panel de padres (PIN configurable, no hardcodeado).
- Activity (actividad del cole, p. ej. "Gimnasia"): id, labelEs, labelCa, icon (emoji),
color.
- MaterialItem: id, labelEs, labelCa, icon (emoji), color, categoría. Relación N:M con
Activity (Gimnasia -> equipación, zapatillas, toalla, agua).
- WeeklyTemplateEntry: plantilla recurrente. childId, dayOfWeek (LUNVIE),
slot (MORNING | AFTERNOON), referencia a Activity (mañana) o a una rutina de tarde,
order (para reordenar), coinsReward. Define "lo normal" de cada día.
- AfternoonRoutine: rutina de tarde recurrente. childId, dayOfWeek, labelEs, labelCa,
icon, color, order. (Reordenables en el panel de padres.)
- SpecialEvent: childId, date, type (EXAM | HOMEWORK), titleEs, titleCa, icon, color.
- DailyTask: INSTANCIA de un día. childId, date, slot, labelEs, labelCa, icon, color,
status (PENDING | DONE), coinsReward, completedAt, origen (plantilla o evento).
- CoinTransaction: childId, date, amount (positivo al ganar, negativo al canjear), motivo.
- Reward: premio canjeable. labelEs, labelCa, icon, color, cost (monedas), active.
- RewardRedemption: childId, rewardId, date, cost.
## Lógica de negocio clave
- Generación del día: al pedir el día de hoy (o vía scheduler matutino), si no existen
DailyTask para childId+fecha, se generan desde (a) WeeklyTemplateEntry y rutinas del
dayOfWeek y (b) SpecialEvent de esa fecha. Idempotente.
- Marcar DailyTask como DONE: cambiar estado, registrar completedAt, sumar coinsReward,
crear CoinTransaction. Si se desmarca, revertir de forma coherente.
- Bono por completar bloque (mañana/tarde) y por completar el día entero: aplica
coinsPerBlock / coinsPerDay configurables (ver gamificación).
- Tienda: al canjear, validar saldo suficiente, descontar, crear CoinTransaction
(negativa) y RewardRedemption. Si no llega, devolver error claro ("te faltan N").
- Progreso del día por slot y global, por niño.
- Zona horaria fija (Europe/Madrid) para decidir qué es "hoy".
- Conservar histórico de DailyTask y canjes (no borrar al pasar el día).
## Gamificación (configurable)
Parámetros por niño (o globales con override por niño): coinsPerTask (def. 5),
coinsPerBlock (def. 10), coinsPerDay (def. 20). Expónlos para que el panel de padres
los edite.
## API REST (orientativa, ajústala con criterio)
- GET /api/children -> perfiles (con mascot, color, edad, coins).
- GET /api/children/{id}/today -> { morning[], afternoon[], specialEvents[],
progress, wallet, timer } (timer derivado de departureTime).
- POST /api/tasks/{taskId}/toggle -> marca/desmarca y ajusta monedas.
- GET /api/children/{id}/wallet -> saldo + historial.
- GET /api/children/{id}/rewards -> tienda visible para ese niño.
- POST /api/rewards/{rewardId}/redeem -> canje (valida saldo, descuenta).
- PUT /api/children/{id}/settings -> viewMode, sound, tts, language, departureTime.
- Panel de padres (requiere auth):
- CRUD de Child.
- CRUD de Activity y MaterialItem (con icon, color, labelEs/labelCa, asociaciones).
- CRUD de WeeklyTemplateEntry (horario por niño).
- CRUD de AfternoonRoutine (con order).
- CRUD de SpecialEvent.
- CRUD de Reward.
- PUT de parámetros de gamificación.
## Seguridad (ligera, doméstica)
- Modo kiosko (niño): lectura + marcar tareas + canjear, sin login molesto
(token de dispositivo o sesión persistente del kiosko).
- Panel de padres: protegido por PIN configurable. Roles CHILD y PARENT.
- Sin Keycloak/OAuth2 en esta fase; déjalo encapsulado por si se externaliza luego.
## Datos semilla (reproducir el prototipo de Claude Design)
- Niños: Nora 🦊 (#F2A65A, 7, 42 monedas), Leo 🐢 (#5BC0BE, 9, 28),
Mía 🦉 (#A78BD0, 6, 55).
- Material de cole (ES/CA): estuche/estoig ✏️, libro de mates/llibre de mates 📘,
flauta 🎵, ropa de gimnasia/roba d'EF 👕, zapatillas/sabatilles 👟, almuerzo/esmorzar 🍎.
- Rutinas de tarde: deshacer la mochila/buidar la motxilla 🎒, merendar/berenar 🥪,
hacer los deberes/fer els deures 📝, practicar piano 🎹, recoger la mesa/parar taula 🍽️.
- Actividades: Gimnasia 🤸 (equipación, zapatillas, toalla, agua), Música 🎵
(flauta, libreta), Matemáticas 📘 (libro, regla, estuche), Lengua 📖 (lectura, cuaderno).
- Premios (ES/CA): 30 min tablet/tauleta 🎮 (20), peli en familia/pel·lícula 🍿 (50),
tarde en el parque/tarda al parc 🛝 (40), elijo la cena/trio el sopar 🍕 (30),
30 min más despierto/més despert 🌙 (60), sorpresa dino 🦖 (80).
- Una semana de plantilla de ejemplo y un par de eventos (Examen de Lengua 📋,
Ficha de mates 📎) para poder probar /today al arrancar.
- PIN de padres por defecto 1234 (configurable).
## Entregables
- Proyecto Gradle compilable, estructura por capas.
- Entidades JPA + Liquibase + datos semilla anteriores.
- Controladores REST con DTOs (no exponer entidades), con labelEs/labelCa.
- Tests unitarios de generación del día, marcado/monedas (incl. bonos de bloque/día)
y canje de premios (JUnit 5 + Mockito; integración con MockMvc en endpoints clave).
- Dockerfile multi-stage + docker-compose.yml (postgres + backend).
- README breve: arranque local y notas para el homelab.

Some files were not shown because too many files have changed in this diff Show More