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:
13
.env.example
Normal file
13
.env.example
Normal 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
25
.gitignore
vendored
Normal 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
66
CLAUDE.md
Normal 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
140
README.md
Normal 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.
|
||||
22
app-de-rutinas-visuales-para-tdah/README.md
Normal file
22
app-de-rutinas-visuales-para-tdah/README.md
Normal 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)
|
||||
BIN
app-de-rutinas-visuales-para-tdah/project/.thumbnail
Normal file
BIN
app-de-rutinas-visuales-para-tdah/project/.thumbnail
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 2.3 KiB |
630
app-de-rutinas-visuales-para-tdah/project/Rutinas TDAH.dc.html
Normal file
630
app-de-rutinas-visuales-para-tdah/project/Rutinas TDAH.dc.html
Normal 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="{"$preview":{"width":1280,"height":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>
|
||||
1513
app-de-rutinas-visuales-para-tdah/project/support.js
Normal file
1513
app-de-rutinas-visuales-para-tdah/project/support.js
Normal file
File diff suppressed because it is too large
Load Diff
BIN
artifacts/App de rutinas visuales para TDAH/.thumbnail
Normal file
BIN
artifacts/App de rutinas visuales para TDAH/.thumbnail
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 2.3 KiB |
630
artifacts/App de rutinas visuales para TDAH/Rutinas TDAH.dc.html
Normal file
630
artifacts/App de rutinas visuales para TDAH/Rutinas TDAH.dc.html
Normal 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="{"$preview":{"width":1280,"height":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>
|
||||
1513
artifacts/App de rutinas visuales para TDAH/support.js
Normal file
1513
artifacts/App de rutinas visuales para TDAH/support.js
Normal file
File diff suppressed because it is too large
Load Diff
7
backend/.dockerignore
Normal file
7
backend/.dockerignore
Normal 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
3
backend/.gitattributes
vendored
Normal file
@@ -0,0 +1,3 @@
|
||||
/gradlew text eol=lf
|
||||
*.bat text eol=crlf
|
||||
*.jar binary
|
||||
37
backend/.gitignore
vendored
Normal file
37
backend/.gitignore
vendored
Normal 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
33
backend/Dockerfile
Normal 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
38
backend/build.gradle
Normal 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()
|
||||
}
|
||||
BIN
backend/gradle/wrapper/gradle-wrapper.jar
vendored
Normal file
BIN
backend/gradle/wrapper/gradle-wrapper.jar
vendored
Normal file
Binary file not shown.
7
backend/gradle/wrapper/gradle-wrapper.properties
vendored
Normal file
7
backend/gradle/wrapper/gradle-wrapper.properties
vendored
Normal 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
251
backend/gradlew
vendored
Executable 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
94
backend/gradlew.bat
vendored
Normal 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
1
backend/settings.gradle
Normal file
@@ -0,0 +1 @@
|
||||
rootProject.name = 'recordalexia'
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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) {
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
210
backend/src/main/java/es/asepeyo/recordalexia/domain/Child.java
Normal file
210
backend/src/main/java/es/asepeyo/recordalexia/domain/Child.java
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
package es.asepeyo.recordalexia.domain;
|
||||
|
||||
/** Idioma activo de la UI para el niño. */
|
||||
public enum Language {
|
||||
ES,
|
||||
CA
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
package es.asepeyo.recordalexia.domain;
|
||||
|
||||
/** Bloque del día: mañana (cole) o tarde (rutinas). */
|
||||
public enum Slot {
|
||||
MORNING,
|
||||
AFTERNOON
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
package es.asepeyo.recordalexia.domain;
|
||||
|
||||
/** Estado de una tarea del día. */
|
||||
public enum TaskStatus {
|
||||
PENDING,
|
||||
DONE
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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()));
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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> {
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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> {
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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> {
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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) {
|
||||
}
|
||||
}
|
||||
@@ -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) {
|
||||
}
|
||||
}
|
||||
@@ -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) {
|
||||
}
|
||||
}
|
||||
@@ -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) {
|
||||
}
|
||||
}
|
||||
@@ -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) {
|
||||
}
|
||||
}
|
||||
@@ -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) {
|
||||
}
|
||||
@@ -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) {
|
||||
}
|
||||
}
|
||||
54
backend/src/main/resources/application.yml
Normal file
54
backend/src/main/resources/application.yml
Normal 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
|
||||
1
backend/src/main/resources/db/changelog/changes/.gitkeep
Normal file
1
backend/src/main/resources/db/changelog/changes/.gitkeep
Normal file
@@ -0,0 +1 @@
|
||||
# Los changesets de Liquibase de la Fase 2 (dominio) vivirán aquí.
|
||||
261
backend/src/main/resources/db/changelog/changes/001-schema.yaml
Normal file
261
backend/src/main/resources/db/changelog/changes/001-schema.yaml
Normal 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
|
||||
@@ -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
|
||||
@@ -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() {
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
18
backend/src/test/resources/application.yml
Normal file
18
backend/src/test/resources/application.yml
Normal 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
55
docker-compose.yml
Normal 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:
|
||||
31
docs/adr/0001-spring-boot-3x.md
Normal file
31
docs/adr/0001-spring-boot-3x.md
Normal 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.
|
||||
39
docs/adr/0002-tipografia-opendyslexic.md
Normal file
39
docs/adr/0002-tipografia-opendyslexic.md
Normal 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.
|
||||
51
docs/adr/0003-dominio-y-seguridad-fase2.md
Normal file
51
docs/adr/0003-dominio-y-seguridad-fase2.md
Normal 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.
|
||||
114
docs/prompt-claude-code-backend-rutinas-tdah.md
Normal file
114
docs/prompt-claude-code-backend-rutinas-tdah.md
Normal 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 (LUN–VIE),
|
||||
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
Reference in New Issue
Block a user