App web multi-usuario para estudiar AWS con tu grupo de clase. Cuatro modos de juego, repetición espaciada Leitner, perfiles con foto, comparación de progreso, leaderboard, feed de actividad, recuperación de contraseña asistida por admin y deploy con un solo comando.
- Demo rápida
- Features
- Tech stack
- Quick start
- Producción con Docker
- Variables de entorno
- Estructura del proyecto
- API REST
- Modelo de datos
- Recuperación de contraseña
- Modos de juego — detalle
- Stats dashboard
- Personalización
- Mobile
- Catálogo curado
- Íconos oficiales de AWS
- Persistencia
- Agregar servicios o features
- Licencia y atribución
recall.tu-dominio.com/login → entrar con apodo + contraseña
/register → registrarse con invite code
/forgot → instrucciones de recuperación
/reset → setear nueva contraseña con token de admin
/ → 4 mode tiles + continue bar + dash row
/flashcards/play → Leitner 5 cajas, espacio voltea, J/K responde
/memorama/play → tableros 6/8/12/18 con cronómetro
/drilldown/play/:parent → MC con distractores hermanos
/exam/play → timer regresivo, q-dots, pass/fail
/stats → distribución Leitner + heat strip + history
/miembros → grid de clase con foto + racha + last seen
/u/:nick → perfil público con heatmap 28d
/compare/:a/:b → cara a cara con ganador por fila
/leaderboard → rankings por modo
/feed → actividad reciente de la clase
/ajustes → apariencia (6 acentos + 4 fuentes), perfil, contraseña
/admin → invite codes + miembros (reset / eliminar)
| Modo | Mecánica | Variantes |
|---|---|---|
| Flashcards | Leitner 5 cajas, muestreo ponderado, atajos Space / J / K |
acrónimo ↔ nombre, servicio → descripción, caso de uso → servicio, ícono → nombre, servicio → relacionados |
| Memorama | Tableros 6 / 8 / 12 / 18 pares, cronómetro opcional, récords | ícono ↔ nombre, acrónimo ↔ nombre completo, servicio ↔ caso de uso, servicio ↔ categoría |
| Drilldown | Discriminación de features dentro de un padre — distractores hermanos | 4 opciones A/B/C/D con feedback inmediato (matchPop/shake) |
| Examen | Simulación contra reloj, umbral 70 %, revisión con explicación | 4 variantes flashcard-MC + drilldown-MC, mix flashcards / mixed 70-30 / drilldown |
- 🔐 Registro cerrado con invite codes (admin genera, los demás se unen)
- 👤 Perfiles con nombre completo, apodo único, foto (resize automático a 256×256 webp)
- ✏️ Edición de perfil y cambio de contraseña in-app desde
/ajustes - 🆘 Recuperación de contraseña asistida por admin con tokens de un solo uso (TTL 24h) — sin necesidad de email ni SMTP
- 🗑️ Eliminar miembros con doble confirmación (typing del apodo) —
ON DELETE CASCADElimpia progresos, sesiones, records y feed - 🏆 Leaderboards por modo (cartas dominadas, % drilldown, exámenes aprobados, partidas de memorama)
- 🤝 Comparación 1-a-1 entre dos usuarios con ganador resaltado por fila
- 📅 Heatmap de actividad tipo GitHub (últimos 28 días)
- 🔥 Streak (días consecutivos) calculada server-side desde múltiples fuentes
- 📰 Feed con actividad reciente de toda la clase (cartas dominadas, exámenes, récords)
- 👁️ Last seen en lista de miembros con throttle de 60 s
- 📷 Open Graph + Twitter Card preconfiguradas — preview decente al compartir el link en WhatsApp/Slack/Discord
- 🌗 Tema light / dark (preferencia local del dispositivo)
- 🎨 6 acentos curados con contraste preservado en ambos temas
- ✍️ 4 pares tipográficos: DM Sans, Plus Jakarta, Quicksand, OpenDyslexic
- ♿ OpenDyslexic servida vía jsdelivr — opción específicamente diseñada para dislexia
- 💾 Persistencia por usuario (sincronizada entre dispositivos vía
user_configs)
- 📐 Layout responsive con breakpoints en
768px(tablet/laptop chica) y480px(móvil) - 👤 Una sola navegación — el dropdown del avatar es el único menú en desktop y mobile (sin drawer extra, sin top-nav redundante)
- 📲 Safe areas iPhone (
viewport-fit=cover) y altura real con100dvh+ fallback@supports - 🔍 Sin auto-zoom en iOS: inputs a 16 px en mobile
- 👆 Tap feedback en
@media (hover: none)— animaciones sin requerir hover - 🎯 Grids 4→2→1 columna, q-dots de examen envuelven multilínea, heatmap colapsa a 14×2,
tablas de admin con scroll horizontal, flashcard altura
clamp(320px, 60vh, 460px)
- Distribución Leitner con barras de color por caja
- Top servicios y features para repasar
- Dominio por categoría y por servicio padre
- Histórico de exámenes con pass/fail
- Reset granular por sección con confirmación
|
|
|
Necesitas Node 20+ y npm 10+.
# 1) Instala dependencias en ambos lados
npm install
( cd server && npm install )
# 2) Levanta el backend (terminal 1)
cd server
BOOTSTRAP_INVITE=FIRSTRUN COOKIE_SECRET="$(openssl rand -hex 32)" npm run dev
# → Fastify escuchando en http://localhost:8080
# 3) Levanta el frontend (terminal 2)
npm run dev
# → Vite en http://localhost:5173, proxy /api y /uploads → :8080Abre http://localhost:5173/register, registra con el invite FIRSTRUN y quedas como admin.
| Lugar | Script | Hace |
|---|---|---|
| raíz | npm run dev |
Vite dev server con HMR |
| raíz | npm run build |
tsc -b && vite build → dist/ |
| raíz | npm run typecheck |
tsc -b --noEmit |
| raíz | npm run preview |
Sirve el build de producción |
server/ |
npm run dev |
tsx watch con auto-reload |
server/ |
npm run build |
tsc → server/dist/ |
server/ |
npm start |
node dist/index.js (usa el build) |
server/ |
npm run typecheck |
tsc --noEmit |
La app se empaqueta en una imagen multi-stage (Node 20 bookworm-slim) que sirve
tanto el SPA como la API en :8080. Caddy va delante para terminar TLS contra
tu dominio.
cp .env.example .env
# edita DOMAIN, COOKIE_SECRET (openssl rand -hex 32) y BOOTSTRAP_INVITE
docker compose up -d --buildCaddy obtiene certificado Let's Encrypt automáticamente cuando el DNS apunta al
host (TCP 80 + 443 expuestos). Para pruebas locales puedes poner DOMAIN=localhost
— Caddy emite un certificado interno (acepta el warning del navegador).
flowchart LR
Browser(("🌐 navegador"))
Caddy["<b>Caddy 2</b><br/>:80 / :443<br/>Let's Encrypt TLS<br/>reverse_proxy"]
App["<b>app :8080</b> (Fastify)<br/>/api/* · /uploads/* · SPA fallback"]
DB[("vol: db_data<br/>recall.db + WAL")]
Up[("vol: uploads<br/>avatars/*.webp")]
CD[("vol: caddy_data<br/>certs + ACME")]
Browser -->|HTTPS| Caddy
Caddy --> App
Caddy --- CD
App --- DB
App --- Up
| Volumen | Contenido | Sobrevive down/up |
|---|---|---|
db_data |
recall.db + recall.db-wal + recall.db-shm |
✅ |
uploads |
avatars/<userId>.webp |
✅ |
caddy_data |
certificados + ACME state | ✅ |
caddy_config |
runtime config | ✅ |
docker run --rm \
-v aws-recall_db_data:/data -v "$PWD":/out \
alpine cp /data/recall.db /out/recall-$(date +%F).db💡 SQLite con WAL permite COPY consistente sin parar la app, siempre que copies también
recall.db-walsi vas a restaurar en caliente.
docker compose logs -f app caddy # streaming
docker compose restart app # rebuild rápido
docker compose down # detiene pero mantiene volúmenes
docker compose down -v # ⚠️ destruye datosSi ya tienes nginx / Traefik / un load balancer delante, elimina el servicio
caddy del docker-compose.yml y publica directo app:8080. Asegúrate de
mandar X-Forwarded-Proto: https y respetar la cookie Secure cuando termines
TLS arriba.
| Variable | Default | Uso |
|---|---|---|
PORT |
8080 |
Puerto HTTP del backend |
HOST |
0.0.0.0 |
Bind address |
DB_PATH |
server/data/recall.db |
Ruta del archivo SQLite |
UPLOADS_DIR |
server/data/uploads |
Carpeta para avatares |
PUBLIC_DIR |
server/public |
Estáticos del frontend (solo prod) |
COOKIE_SECRET |
dev fallback | Obligatorio en prod. openssl rand -hex 32 |
BOOTSTRAP_INVITE |
(vacío) | Si está set y no hay usuarios, se siembra como invite. El primer registro queda como admin |
NODE_ENV |
(vacío) | production activa cookies Secure, logs no-debug |
Ejemplo de .env.example:
DOMAIN=recall.example.com
COOKIE_SECRET=replace_me_with_openssl_rand_hex_32
BOOTSTRAP_INVITE=FIRSTRUN.
├── data/ ← catálogo curado de AWS (no DB)
│ ├── services.json ← 181 servicios con features para Drilldown
│ └── categories.json ← 17 categorías con color oficial AWS
│
├── src/ ← frontend (Vite + React + TS)
│ ├── App.tsx ← rutas (con ProtectedRoute)
│ ├── main.tsx ← monta AuthProvider + aplica apariencia
│ ├── styles.css ← sistema visual completo + bloque Responsive Mobile
│ │
│ ├── lib/
│ │ ├── types.ts ← Service, ServiceFeature, ExamConfig…
│ │ ├── data.ts ← getters, filterServices
│ │ ├── deck-builder.ts ← mazos de flashcards
│ │ ├── board-builder.ts ← tableros de memorama
│ │ ├── drilldown.ts ← generador MC
│ │ ├── exam.ts ← generador MC mixto
│ │ ├── spaced-rep.ts ← Leitner 5 cajas + muestreo ponderado
│ │ ├── stats.ts ← agregaciones del dashboard
│ │ ├── shuffle.ts
│ │ ├── api.ts ← fetch wrapper con credentials + 401 handling
│ │ ├── write-queue.ts ← cola con coalescing + retry + sendBeacon
│ │ ├── progress-store.ts ← cache + queue, firmas sync
│ │ ├── auth-context.tsx ← AuthProvider + useAuth()
│ │ ├── social-api.ts ← wrappers de endpoints sociales
│ │ ├── theme-presets.ts ← 6 acentos + 4 fuentes + applyAppearance()
│ │ └── format.ts ← formatRelative / formatTimeMs / formatPercent
│ │
│ ├── components/
│ │ ├── AppShell.tsx ← Topbar + dropdown del avatar (única nav, desktop y mobile)
│ │ ├── ProtectedRoute.tsx ← redirige a /login si no hay sesión
│ │ ├── PhotoUpload.tsx ← <Avatar> + uploader 256×256 webp
│ │ ├── FiltersControl.tsx ← chips de tier + categoría con contador
│ │ └── ServiceIcon.tsx ← <img> con fallback a placeholder
│ │
│ └── views/
│ ├── Home.tsx ← hero + continue-bar + mode tiles
│ ├── Login.tsx, Register.tsx
│ ├── Forgot.tsx ← /forgot — instrucciones de recuperación
│ ├── Reset.tsx ← /reset?token=… — set nueva contraseña + auto-login
│ ├── FlashcardSetup.tsx, FlashcardSession.tsx
│ ├── MemoramaSetup.tsx, MemoramaBoard.tsx
│ ├── DrilldownSetup.tsx, DrilldownSession.tsx
│ ├── ExamSetup.tsx, ExamSession.tsx
│ ├── StatsDashboard.tsx
│ ├── Members.tsx ← grid de clase con foto + racha + last seen
│ ├── PublicProfile.tsx ← /u/:nick — stats + heatmap 28d
│ ├── Compare.tsx ← /compare/:a/:b — cara a cara
│ ├── Leaderboard.tsx ← rankings por modo
│ ├── Feed.tsx ← actividad reciente de la clase
│ ├── Admin.tsx ← invites + miembros + reset/eliminar contraseña
│ └── Settings.tsx ← /ajustes — apariencia + perfil + contraseña
│
├── server/ ← backend (Fastify + better-sqlite3)
│ ├── package.json
│ ├── tsconfig.json
│ └── src/
│ ├── index.ts ← Fastify, routes, SPA fallback
│ ├── env.ts ← config por env vars
│ ├── db.ts ← better-sqlite3 + migración + bootstrap invite
│ ├── auth.ts ← bcrypt + sesiones + requireAuth/Admin
│ ├── validation.ts ← ApiError + validadores
│ ├── types.ts ← UserRow, SessionRow, InviteRow…
│ ├── schema.sql ← 10 tablas
│ └── routes/
│ ├── auth.ts ← /api/auth/* (login, register, reset, me…)
│ ├── me.ts ← /api/me/{profile,photo,password}
│ ├── admin.ts ← /api/admin/{invites,users,users/:nick/{reset-password,DELETE}}
│ ├── progress.ts ← flashcards/drilldown/memorama/exam/config + DELETEs
│ └── social.ts ← /api/users, /:nick, /compare, /leaderboard, /feed
│
├── public/
│ ├── icons/ ← 181 SVGs oficiales + icon-map.md
│ ├── og.png ← Open Graph 1200×630 (preview en chats)
│ ├── favicon.svg
│ └── Asset-Package_07312025…/ ← paquete oficial extraído
│
├── scripts/
│ ├── gen-icon-map.mjs ← regenera public/icons/icon-map.md
│ └── install-icons.mjs ← copia los SVGs desde el paquete oficial
│
├── Dockerfile ← multi-stage: frontend + backend + runtime
├── docker-compose.yml ← servicios app + caddy + volúmenes
├── Caddyfile ← reverse proxy + TLS automático
├── .env.example ← variables que debes editar antes de up
├── .dockerignore
└── README.md
Todos los endpoints excepto /api/health, /api/auth/login, /api/auth/register,
/api/auth/bootstrap-status y /api/auth/reset (GET y POST) requieren una
cookie de sesión válida.
| Método | Ruta | Body | Respuesta |
|---|---|---|---|
POST |
/api/auth/register |
{invite_code, nickname, full_name, password} |
{user} + cookie |
POST |
/api/auth/login |
{nickname, password} |
{user} + cookie |
POST |
/api/auth/logout |
— | {ok} + cookie cleared |
GET |
/api/auth/me |
— | {user} (401 si no hay sesión) |
GET |
/api/auth/bootstrap-status |
— | {hasUsers} |
GET |
/api/auth/reset?token=… |
— | {ok, nickname, fullName, expiresAt} o 400 |
POST |
/api/auth/reset |
{token, new_password} |
{user} + cookie (auto-login). Destruye sesiones viejas del user |
| Método | Ruta | Body | Respuesta |
|---|---|---|---|
PATCH |
/api/me/profile |
{full_name?} |
{user} |
POST |
/api/me/photo |
multipart file (≤5MB) |
{user} con photoUrl |
DELETE |
/api/me/photo |
— | {user} |
PUT |
/api/me/password |
{old, new} |
{ok, reauth: true} |
| Método | Ruta | Body | Respuesta |
|---|---|---|---|
GET |
/api/admin/invites |
— | {invites: [...]} |
POST |
/api/admin/invites |
{count?: 1-20, ttl_days?: 1-365} |
{codes: [...]} |
DELETE |
/api/admin/invites/:code |
— | {ok} o 400 si ya usado |
GET |
/api/admin/users |
— | {users: [...]} |
POST |
/api/admin/users/:nickname/reset-password |
— | {token, expiresAt, targetNickname, targetFullName}. Rechaza self-reset con 400 |
DELETE |
/api/admin/users/:nickname |
{confirm: nickname} |
{ok, deletedNickname}. Rechaza self-delete y confirm incorrecto con 400. ON DELETE CASCADE limpia toda la data del user |
| Método | Ruta | Cuerpo / Respuesta |
|---|---|---|
GET |
/api/progress/flashcards |
Record<cardId, CardProgress> |
PATCH |
/api/progress/flashcards |
upsert parcial; detecta tránsito a box 5 → log card_mastered |
DELETE |
/api/progress/flashcards |
resetea progreso del user |
GET |
/api/progress/drilldown |
Record<featureId, DrilldownFeatureProgress> |
POST |
/api/progress/drilldown/answer |
{featureId, correct} |
DELETE |
/api/progress/drilldown |
reset |
GET |
/api/memorama/stats |
MemoramaStats |
POST |
/api/memorama/game |
{pairs, moves, timeMs} → calcula records + log memo_record |
DELETE |
/api/memorama/stats |
reset |
GET |
/api/exam/attempts |
array (cap 50) |
POST |
/api/exam/attempts |
ExamAttempt + log exam_passed/exam_failed |
DELETE |
/api/exam/attempts |
reset |
| Método | Ruta | Body |
|---|---|---|
GET |
/api/config/:kind |
{value: T | null} |
PUT |
/api/config/:kind |
JSON arbitrario |
:kind ∈ { flashcard, memorama, exam, filters, theme, appearance }
| Método | Ruta | Respuesta |
|---|---|---|
GET |
/api/users |
{users: [...]} con streak por user |
GET |
/api/users/:nickname |
{user, stats, streak, heatmap28} |
GET |
/api/compare?a=&b= |
{a, b} lado a lado |
GET |
/api/leaderboard?mode= |
mode ∈ flashcards | memorama | drilldown | exams |
GET |
/api/feed?limit= |
{events: [...]} con user info inline |
| Método | Ruta | Respuesta |
|---|---|---|
GET |
/api/health |
{ok: true, ts} (usado por el HEALTHCHECK del container) |
SQLite con 10 tablas. PK compuestas donde aplica; WAL activo;
foreign_keys = ON. Housekeeping al bootear: expirar sesiones, recortar
activity_log a 180 días, limpiar reset tokens.
users id, full_name, nickname (UNIQUE NOCASE), password_hash,
photo_path, is_admin, created_at, last_active_at
invite_codes code (PK), created_by → users, used_by → users, used_at,
created_at, expires_at
sessions id (PK 32 hex), user_id → users, expires_at, last_active_at
password_reset_tokens token (PK 64 hex), user_id → users, created_by → users,
created_at, expires_at, used_at [INDEX user_id]
flashcard_progress PK (user_id, card_id), box, reviews, lapses, last_reviewed
drilldown_progress PK (user_id, feature_id), attempts, correct, last_attempt
memorama_stats PK (user_id, pairs), best_moves, best_time, played
exam_attempts id, user_id, timestamp, total, answered, correct,
duration_ms, passed, config_json [INDEX user_id, timestamp DESC]
user_configs PK (user_id, kind), json
kind ∈ flashcard | memorama | exam | filters | theme | appearance
activity_log id, user_id, kind, occurred_at, payload_json
kind ∈ card_mastered | exam_passed | exam_failed | memo_record
[INDEX user_id, occurred_at DESC] + [INDEX occurred_at DESC]
streakyheatmap28se calculan al vuelo desdeactivity_log∪flashcard_progress.last_reviewed∪drilldown_progress.last_attempt∪exam_attempts.timestamp. No hay tabla precomputada — el cálculo es O(eventos del user).
Recall no recolecta email al registrarse, así que el reset no se hace solo por correo. En su lugar el admin de la clase emite un token de un solo uso que se comparte fuera de banda (WhatsApp, Slack, en persona).
sequenceDiagram
autonumber
participant U as Usuario
participant A as Admin
participant S as Server
U->>A: "olvidé mi contraseña"
A->>S: POST /api/admin/users/:nick/reset-password
S-->>A: { token, expiresAt }
A-->>U: comparte link de reset (WhatsApp/Slack)
U->>S: POST /api/auth/reset { token, new_password }
Note over S: hash new pass · consume token · destroy old sessions
S-->>U: { user } + cookie (auto-login)
| Aspecto | Decisión |
|---|---|
| Entropía del token | 32 bytes hex (256 bits) |
| TTL | 24 horas desde emisión |
| Single-use | used_at se setea al consumir; el GET valida que sea NULL |
| Stacking | al emitir uno nuevo se borran los previos sin usar del mismo user |
| Sesiones viejas | se destruyen TODAS al consumir el token → cierra cuentas tomadas |
| Auto-login | el POST exitoso emite cookie nueva → user va directo a / |
| Self-reset del admin | rechazado (400) — el admin usa /ajustes con su pass actual |
| No-leak | el GET solo expone {nickname, fullName} cuando el token es válido |
| Housekeeping | tokens expirados + tokens usados con >30 días se borran al bootear |
Flujo de usuario:
- En
/login, click "¿Olvidaste tu contraseña?" → llega a/forgot. /forgotle dice cómo proceder (pídele a tu admin).- El admin va a
/admin → Miembros, busca al usuario y clickea "Resetear contraseña". - La fila se expande con la URL completa y un botón Copiar link.
- El admin la comparte por el canal de la clase.
- El usuario abre la URL →
/reset?token=…valida el token y muestra "Hola, <Nombre>" + form de nueva contraseña. - Al confirmar, el server hashea, destruye sesiones viejas, emite cookie nueva
y la
Reset.tsxllamarefreshUser()→ redirige a/.
- Filtros tier + categoría, selector de variantes (5 tipos).
- Sesiones de 20 cartas; las cajas bajas pesan más al muestrear
(
weightForBox(box) = 6 - box). - Aciertas → promueve a la siguiente caja (máx. 5). Fallas → vuelve a la caja 1.
- Atajos:
Spacevoltea,J / 1para "Repasar",K / 2para "Sé". - Persistencia: caja, reviews, lapses, lastReviewed por carta.
- Activity log: tránsito a caja 5 ⇒
card_mastered.
- Filtros + tipo de par + tamaño. El botón Iniciar se deshabilita si no hay pares suficientes para el tamaño elegido.
- Cronómetro y movimientos en HUD; récords por tamaño persistidos.
- Al terminar: lista de servicios vistos en la partida.
- Activity log: mejor tiempo o menos movimientos ⇒
memo_record.
- Setup lista padres con ≥2 features agrupados por categoría (26 padres, 105 features).
- Sesión: header del padre (ícono + descripción), prompt con la descripción de la feature en cita, 4 opciones A/B/C/D con feedback inmediato.
- Distractores: features hermanas; fallback a otros padres si <3.
- Persistencia:
attempts,correct,lastAttemptpor feature.
- 10 / 20 / 40 preguntas, 60 / 90 / 120 s c/u, mix
flashcards | mixed (70/30) | drilldown. - 4 variantes flashcard (caso de uso → servicio, servicio → descripción, acrónimo → nombre completo, ícono → servicio) + drilldown-MC.
- Distractores prefieren misma categoría que el target.
- Sesión: timer regresivo prominente (
warn<60 s,danger<15 s pulsante), q-dots navegables, terminar / entregar / auto-cierre al expirar. - Resultado pass/fail (umbral 70 %), revisión por pregunta con explicación.
- Historial persistido (cap 50 intentos por usuario).
- Activity log:
exam_passedoexam_failedcon score y duración.
/stats — hero con eyebrow + h-display. Por modo:
- Stat-cards con la principal en
is-accent - Distribución Leitner con barras de color por caja
- Top servicios / features para repasar (ranking por score combinado)
- Dominio por categoría / padre con barra de progreso
- Historial de exámenes con pass/fail y revisión por pregunta
- Reset granular por sección con
confirm()→ llama alDELETE /api/...
/ajustes — selectores visuales con preview en vivo. Persistencia en
user_configs.appearance (sincronizado entre dispositivos del mismo usuario).
| ID | Color | Nombre |
|---|---|---|
coral |
#FF8B6B |
Coral |
blue |
#6BB6FF |
Azul (default) |
mint |
#4FCF94 |
Mint |
lila |
#B58CFF |
Líla |
rosa |
#FF6B9D |
Rosa |
amber |
#F5A623 |
Ámbar |
Cada uno trae accent + accent-2 (shade) + accent-ink (contrast) curados
para preservar legibilidad en tema light y dark.
| ID | Display | Mono | Hint |
|---|---|---|---|
dm |
DM Sans | DM Mono | |
jakarta |
Plus Jakarta Sans | JetBrains Mono | default |
quicksand |
Quicksand | Space Mono | |
dyslexic |
OpenDyslexic | OpenDyslexicMono | ♿ Amigable para dislexia |
OpenDyslexic (licencia OFL) se sirve desde el CDN de jsdelivr. Para self-host completo (LAN sin internet), descarga los
.woff2desde github.com/antijingoist/open-dyslexic apublic/fonts/y ajusta las URLs ensrc/styles.css.
light / dark toggle desde la 🌙/☀️ del topbar. Persistencia local
(localStorage.aws-study-cards:v2:theme) — la preferencia de tema no se
sincroniza entre dispositivos a propósito (ambient lighting es per-device).
La app está optimizada para celulares y tablets. El sistema visual nunca
fue mobile-first, pero hay un bloque Responsive Mobile aditivo al final
de styles.css que ajusta layouts. La navegación es la misma en todos los
tamaños: click en el avatar → dropdown. No hay hamburger, no hay drawer
lateral, no hay top-nav adicional — un solo patrón.
| Ancho | Cambios |
|---|---|
> 768 px |
Layout desktop: brand a la izquierda, theme toggle + streak + avatar a la derecha, grids 4 columnas, heatmap 28×1. |
≤ 768 px |
Topbar más compacto (sin brand-tag "aws"). Grids mode-grid/stats-grid/how-grid colapsan a 2 columnas. Flashcard usa height: clamp(320px, 60vh, 460px). Memorama con tipografía adaptativa. Exam q-dots envuelven multilínea y crecen a 22 px (más tap-target). Tablas de admin con scroll horizontal. .reset-link-box se apila. .user-dropdown con width: min(260px, calc(100vw - 24px)) para no desbordar. |
≤ 480 px |
Home y stats a 1 columna. Heatmap colapsa a 14 cols × 2 filas. Topbar más compacto aún. Pill 🔥 de racha oculta para liberar espacio. |
- 📏
100dvhcon@supportsy fallback a100vh— altura real del viewport contando barras dinámicas de iOS/Android. - 📲
viewport-fit=coverenindex.html+ safe areas iPhone. - 🔍 Inputs a 16 px en mobile — evita el auto-zoom de Safari en focus.
- 👆
@media (hover: none)— el tap feedback no depende de hover, los.btn:activey.mode-tile:activequedan visibles. - 🎯 Dropdown del avatar dimensionado dinámicamente: en desktop tiene
min-width: 220pxanclado a la derecha del avatar; en mobile pasa awidth: min(260px, calc(100vw - 24px))yright: -6pxpara evitar desbordar el viewport.
- No hay drawer ni hamburger — el dropdown del avatar es suficiente para 10-15 destinos. Un drawer separado solo agregaría otro patrón a mantener y otro lugar donde los links pueden divergir.
- No hay app nativa ni PWA — es una SPA responsive servida por Fastify. Si quieres "instalarla" como app en home screen, los browsers móviles modernos lo permiten via Add to Home Screen y la experiencia es decente gracias a las safe areas.
- No hay layouts diferentes para mobile — los mismos componentes reflowan. Esto mantiene el bundle único y evita drift entre versiones.
| Servicios | 181 |
| Categorías | 17 (compute, storage, database, networking, security, management, analytics, integration, containers, devtools, migration, cost, ml, iot, media, frontend, euc) |
| Tier 1 — Core | 29 servicios |
| Tier 2 — Operación / Arquitectura | 35 servicios |
| Tier 3 — Especializados | 20 servicios |
| Tier 4 — Extendido | 97 servicios |
| Drilldown — padres con features | 26 (Bedrock, SageMaker, S3, CloudWatch, Kinesis, Step Functions, GuardDuty, IAM Identity Center, Lambda, DynamoDB, VPC, Glue, EventBridge, Amplify, CodeGuru, Q, Cognito, API Gateway, Route 53, IAM, KMS, CloudFront, Athena, ECS, Aurora, Redshift) |
| Drilldown — features totales | 105 |
| Íconos oficiales versionados | 181 (release 07/31/2025) |
Los 181 SVGs (resolución 64×64, release 07/31/2025) están versionados en
public/icons/<id>.svg. El paquete oficial completo extraído vive en
public/Asset-Package_07312025.../, que sirve de fuente al instalador.
Para regenerar todos los íconos —por ejemplo si AWS publica un release nuevo o agregas servicios al JSON— corre:
node scripts/install-icons.mjsEl script indexa el paquete, prueba varios patrones de nombre por servicio
(Arch_<Name>_64.svg, con/sin prefijo Amazon/AWS, fullName, acrónimo) y aplica
6 OVERRIDES para los nombres irregulares: Glacier, Snow Family,
WorkSpaces, AppStream, Outposts y FSx for Windows. Cobertura
actual: 181/181.
Detalles, mapeo de IDs → nombres oficiales y notas de licencia en
public/icons/README.md y
public/icons/icon-map.md. Si algún archivo
falta, ServiceIcon cae a un placeholder con acrónimo y color de categoría.
Los íconos y nombres de servicios son trademarks de Amazon.com, Inc. Su uso aquí es nominativo, conforme a las trademark guidelines de AWS. Ver
NOTICE.
| Tabla | Contenido |
|---|---|
users |
identidad + foto + last_active_at + is_admin |
invite_codes |
códigos generados, quién y cuándo los usó, expiración |
sessions |
tokens de cookie, last_active_at con throttle de 60 s |
flashcard_progress |
(user, cardId) → box / reviews / lapses / lastReviewed |
drilldown_progress |
(user, featureId) → attempts / correct / lastAttempt |
memorama_stats |
(user, pairs) → best_moves / best_time / played |
exam_attempts |
hasta 50 por user, rotación automática al insertar |
user_configs |
JSON por (user, kind) |
activity_log |
append-only de eventos (feed + heatmap rico) |
El cache en memoria se hidrata al login con bootstrapStore() (Promise.all
de 8 endpoints en paralelo). Las firmas loadX()/saveX() se mantienen sync
para no tocar las vistas existentes. Las escrituras pasan por
write-queue.ts:
- 🔁 Coalescing para PATCH/PUT (ej. flashcard map completo) — sólo la versión más reciente sale en cada flush
- 📤 Append para POST (events como drilldown answer, exam attempt)
- 🔄 Retry con backoff ante 5xx / errores de red; descarta cola ante 401/403
y dispara logout global; descarta job individual (con
console.warn) ante cualquier otro 4xx (400/404/422…) — retry no resuelve errores de cliente - 📡
sendBeaconenbeforeunloadpara POSTs append pendientes - 🟢 Re-flush al volver
online
| Key | Contenido |
|---|---|
aws-study-cards:v2:theme |
"light" o "dark" |
aws-study-cards:v2:filters |
últimos filtros tier / categoría compartidos |
Reset granular por modo desde
/stats— cada botón llama al endpointDELETE /api/...correspondiente.
Edita data/services.json. Cada servicio sigue el esquema definido en
src/lib/types.ts (Service para entradas top-level, ServiceFeature para
items en features?: []). Tiers o categorías nuevos no requieren cambios
de código — aparecen automáticamente en los selectores.
Tras editar:
node scripts/install-icons.mjs # íconos nuevos
node scripts/gen-icon-map.mjs > public/icons/icon-map.md # mapa reproducibleEl código de este proyecto se distribuye bajo licencia MIT —
puedes usarlo, modificarlo y redistribuirlo libremente conservando el aviso de
copyright. Los íconos, nombres de servicios y otros assets de terceros
mantienen sus propias licencias; ver NOTICE.
Este proyecto no está afiliado ni patrocinado por Amazon Web Services. Los nombres de servicios e íconos son trademarks de Amazon.com, Inc.
- AWS Architecture Icons — © Amazon.com, Inc. Uso nominativo bajo trademark guidelines.
- OpenDyslexic — Abelardo Gonzalez, licencia OFL.
- DM Sans / DM Mono / Plus Jakarta Sans / JetBrains Mono / Quicksand / Space Mono — todas Open Font License vía Google Fonts.
Hecho con ☕ y tsc --strict por
@dlfno para sus compañeros de clase.