01-overview.md
Visión general · Task Cloud
Task Cloud es una Progressive Web App de gestión de tareas personales con soporte offline real, instalable en la pantalla de inicio y con sincronización opcional en la nube mediante un PIN de 8 dígitos.
El repositorio es un monorepo pnpm workspaces + Turborepo:
apps/web— Angular 22 + Ionic 8 (PWA)apps/api— NestJS 11 sobre Neon Postgres
En producción se despliega como un único proyecto Vercel: el front se sirve como estáticos y Nest se envuelve en una función serverless (api/index.js) detrás de /api/* (same-origin, sin CORS).
Enlaces
- Web: https://task-manager-cristiancode.vercel.app
- GitHub: Criscode2022/task-manager-cloud
- Health:
GET /api/health→{ "ok": true, "service": "task-cloud-nest-api" }
Stack
| Capa | Tecnología |
|---|---|
| Monorepo | Turborepo + pnpm workspaces |
| Web | Angular 22 (Signal Forms, signals), Ionic 8, service worker + manifest, tokens SCSS («Ballpoint Ink») |
| API | NestJS 11, jose, bcryptjs |
| Datos | Neon Postgres (@neondatabase/serverless) |
| Deploy | Vercel (static + serverless function) |
02-product.md
Producto y features
Problema
Las apps de tareas suelen exigir registro completo, fallan sin red o acoplan el cliente a un BaaS monolítico. El objetivo era una PWA instalable, usable 100% offline, con sync opcional controlada por una API propia sobre Postgres serverless (Nest + Neon + Vercel).
Solución
Cliente offline-first con Ionic Storage. Con sesión activa, sube y baja tareas vía Nest. Auth ligera por PIN de 8 dígitos (hash + pepper) y sesiones JWT. Deploy unificado en Vercel.
Características
Captura en la cabecera
El campo de captura vive en la cabecera de tinta: se escribe el título y se pulsa Intro. Al escribir se despliega una nota opcional, la prioridad y las etiquetas; tras añadir, el campo se vacía y conserva el foco para la siguiente tarea. Escape con un borrador pide confirmación antes de descartarlo.
Prioridad y etiquetas
Campos priority (low / medium / high) y tags[] en Postgres, con índices GIN. En la UI la prioridad se escribe como palabra (Alta, Media, Baja) junto a su color, nunca solo por color. Hasta seis etiquetas por tarea, en minúsculas y sin duplicados, con sugerencias de las que ya existen.
Completar, editar y borrar
Tocar el anillo completa la tarea: un trazo de tinta tacha el título y la fila espera 650 ms antes de pasar a Hechas. Tocar el texto abre el editor en su sitio. En el móvil, deslizar a la derecha edita y a la izquierda borra; una pulsación larga copia el texto. Borrar es inmediato y ofrece Deshacer durante cinco segundos.
Filtros y búsqueda
Un control segmentado Todas / Pendientes / Hechas con contadores, chips de prioridad y de etiqueta, y una búsqueda en la cabecera sobre título, nota y etiquetas.
Offline-first
Ionic Storage mantiene la lista usable sin red; la nube no es SPOF de la UI.
Sync unitario y bulk
API REST: POST /tasks, POST /tasks/bulk, PUT, DELETE protegidos por JWT.
Auth PIN + sesión
Registro/login con PIN, JWT (sub + sid), tabla sessions y rate-limit en auth. El PIN se muestra una sola vez, en dos grupos de cuatro, con botón de copiar.
PWA instalable
Service worker Angular y manifest con iconos propios. El botón de instalar de la cabecera abre el diálogo de instalación del navegador en Chrome, Edge y Samsung Internet. En iPhone y iPad, donde una web no puede lanzar la instalación, muestra los pasos de Compartir → Añadir a pantalla de inicio. Se oculta cuando la app ya está instalada.
i18n y temas
ngx-translate con idiomas EN/ES en el cliente, y modo claro y oscuro.
Diseño «Ballpoint Ink»
Cabecera azul bolígrafo con el campo de captura, tareas en grupos blancos sobre un fondo de tinta pálida y Bricolage Grotesque para las palabras que se leen y escriben. Sistema completo en apps/web/DESIGN.md.
03-architecture.md
Arquitectura
Diagrama lógico
Browser (PWA Ionic/Angular)
│ offline: Ionic Storage
│ online + sesión: HTTP /api/*
▼
NestJS (apps/api) ──@neondatabase/serverless──► Neon Postgres
▲
api/index.js (Vercel serverless, warm cache)
mcp-server/ (opcional, Python FastMCP) ──► Neon
Capas
- Cliente — Angular 22 + Ionic 8. Estilos en SCSS con los tokens de «Ballpoint Ink» (
apps/web/src/theme/variables.scss); Angular Material solo aportaMatDialogyMatSnackBar.- Lista:
TabListPageconTaskComposerComponent(captura) yTaskEditorComponent(edición en línea), ambos con Signal Forms. - Controles compartidos en
core/components/: control segmentado, campo de etiquetas, diálogo del PIN y guía de instalación. - Servicios:
TaskService(lista y almacenamiento),TaskNeonServiceyNeonApiService(sync),UserService(PIN y sesión),InstallService(instalación),ThemeServiceyLanguageService. - Techo v22×Ionic:
11-angular-skill.md.
- Lista:
- PWA —
apps/web/public/aporta el manifest y los iconos, que el build copia a la raíz; el service worker se registra solo en producción. - API — NestJS 11: módulos
auth,users,tasks,health, rate-limit y exception filter. - Datos — Neon Postgres:
users,tasks,sessions. - Deploy — Un proyecto Vercel: turbo build web+api; output
apps/web/www; rewrites/api/*→ función. - Local — API
:3001, web:4200con proxy/api;pnpm start:full.
Principios
- Offline primero; sync opcional.
- Secretos solo en servidor (
DATABASE_URL,JWT_SECRET,PIN_PEPPER). - Same-origin en producción (
API_BASE_URL=/api).
04-monorepo.md
Layout del monorepo
| Path | Rol |
|---|---|
apps/web | PWA Ionic/Angular (output www/; public/ aporta manifest e iconos) |
apps/api | NestJS (dist/) consumido por la función Vercel |
api/index.js | Entry serverless: boot Nest + cache warm |
e2e/ + playwright.config.ts | Tests e2e con Playwright |
docs/ | Este caso de estudio |
packages/ | Reservado para shared packages |
mcp-server/ | MCP Python opcional (FastMCP + uv), fuera de los workspaces pnpm |
vercel.json | Build, functions, rewrites SPA + /api |
turbo.json | Tasks, env y remote cache |
Scripts raíz
| Script | Descripción |
|---|---|
pnpm api | Nest en watch |
pnpm start | Angular + proxy /api |
pnpm start:full / pnpm dev | API + web vía turbo |
pnpm build | Build de todos los packages |
pnpm build:web / build:api | Build selectivo |
pnpm lint / lint:ci | ESLint |
pnpm test:ci | Tests unitarios web headless (Karma) |
pnpm test:api | Tests unitarios de la API (Vitest) |
pnpm test:integration | Integración HTTP de la API (Supertest) |
pnpm test:e2e | Playwright (Chromium) |
pnpm test:scripts | Tests de los scripts de scripts/ |
El smoke contra Neon real es pnpm --filter @task-cloud/api test:api (ver integración).
Workspaces
pnpm-workspace.yaml:
packages:
- "apps/*"
- "packages/*"
Package manager: pnpm 11. Node ≥ 22.
05-api.md
Superficie API (Nest)
Prefijo global: /api. Todo lo que no es público exige Authorization: Bearer <jwt> (guard global; las rutas públicas llevan @Public()).
Rutas públicas
| Método | Path | Notas |
|---|---|---|
GET | /api, /api/health | Healthcheck del servicio Nest |
POST | /api/users | Registro con PIN → sesión JWT |
POST | /api/auth/login | Login por PIN → token + expires_at |
GET | /api/users/by-pin/:hash | Ruta antigua: responde 410 Gone y remite a POST /api/auth/login |
Auth (protegidas)
| Método | Path | Notas |
|---|---|---|
POST | /api/auth/logout | Revoca sesión |
GET | /api/auth/me | Usuario actual |
Usuarios (protegidas)
| Método | Path | Notas |
|---|---|---|
GET | /api/users/:id | Solo el propio usuario |
DELETE | /api/users/:id | Revoca sus sesiones y borra el propio usuario con sus tareas en la nube |
Tareas (protegidas)
| Método | Path | Notas |
|---|---|---|
GET | /api/tasks | Lista del usuario autenticado |
POST | /api/tasks | Crear tarea |
POST | /api/tasks/bulk | Carga masiva |
PUT | /api/tasks/:id | Actualizar (ownership) |
DELETE | /api/tasks/:id | Borrar una |
DELETE | /api/tasks | Borrar todas del usuario |
Rate limit
@nestjs/throttler en memoria, por instancia de función: 100 peticiones por minuto en general, y 10 cada 15 minutos por IP en POST /api/users y POST /api/auth/login.
Ownership
Las mutaciones validan user_id === auth.userId. No hay multi-tenant organizacional: cada PIN/usuario es un namespace de tareas.
06-auth.md
Modelo de autenticación
Flujo
- En Ajustes (Settings) de la PWA, el usuario elige «Subir tareas y obtener un PIN»: se crea la cuenta y la app muestra el PIN de 8 dígitos una sola vez.
- El servidor hashea el PIN (
bcryptjs+PIN_PEPPER) y guardapin_hash/pin_lookup. - Se crea una fila en
sessions(UUID) y se emite un JWT (jose, HS256) con claimssub(userId) ysid(sessionId). - El cliente guarda el token y lo envía en
Authorization: Bearer …. - Logout revoca la sesión; TTL configurable (
SESSION_TTL_SECONDS, default 24h).
Seguridad
- Rate-limit en registro y login: 10 intentos cada 15 minutos por IP, en memoria por instancia de función. El límite está fijado en el código:
AUTH_RATE_LIMITyAUTH_RATE_WINDOW_MSpasan la validación del entorno, pero hoy no se leen. DATABASE_URLnunca se expone al browser.- PIN no se almacena en claro.
Por qué PIN y no email/password
Onboarding en segundos para uso personal multi-dispositivo, sin buzones ni reset de contraseña, manteniendo hash + pepper + rate-limit.
07-data-model.md
Modelo de datos (Neon Postgres)
Tablas
users
idBIGSERIAL PKpin_hashTEXT NOT NULLpin_lookupTEXT (índice único parcial)created_attimestamptz
tasks
idBIGSERIAL PKuser_idBIGINT FK → users ON DELETE CASCADEtitleTEXT NOT NULLdescriptionTEXTdoneBOOLEAN DEFAULT falsepriorityTEXT DEFAULTmediumCHECK (low|medium|high)tagsTEXT[] DEFAULT{}created_at/updated_at(triggerhandle_updated_at)
sessions
idUUID PKuser_idBIGINT FKexpires_at/revoked_at/created_at
Índices
tasks(user_id),tasks(done),tasks(priority)- GIN en
tasks(tags) tasks(created_at DESC)sessions(user_id),sessions(expires_at)
Migraciones
SQL versionado en el repo: neon-migration.sql, neon-auth-migration.sql.
08-deploy-vercel.md
Deploy en Vercel (un solo proyecto)
Idea
apps/web→ estáticos en CDN (apps/web/www)apps/api→ función serverless víaapi/index.js(incluyeapps/api/dist/**)/api/*reescrito a la función; el resto a la SPA
Config (vercel.json)
buildCommand:pnpm exec turbo run build --filter=@task-cloud/web --filter=@task-cloud/apioutputDirectory:apps/web/wwwfunctions.api/index.js.maxDuration: 30- Rewrites:
/api/:path*→/api/index; resto →/index.html
Variables de entorno
| Nombre | Requerido | Notas |
|---|---|---|
DATABASE_URL | sí | Connection string Neon |
JWT_SECRET | sí | openssl rand -hex 32 |
PIN_PEPPER | sí | openssl rand -hex 32 |
SESSION_TTL_SECONDS | no | default 86400 |
API_BASE_URL | no | default /api (same origin) |
ALLOWED_ORIGINS | no | Solo si otro origen debe llamar a la API |
ALLOW_VERCEL_PREVIEWS | no | default true: acepta https://*.vercel.app |
AUTH_RATE_LIMIT y AUTH_RATE_WINDOW_MS se validan pero hoy no se leen: el límite de auth está fijado en 10 cada 15 minutos.
Smoke
GET https://task-manager-cristiancode.vercel.app/api/healthGET /manifest.webmanifestdevuelve el JSON del manifest, no elindex.html. Si devuelve HTML, el build no copióapps/web/public/y el navegador no ofrecerá instalar la app.- Abrir la web → Ajustes → «Subir tareas y obtener un PIN» → crear/sync tareas
- En Chrome, el botón de instalar de la cabecera abre el diálogo de instalación
Local
pnpm install
cp .env.example .env # DATABASE_URL, JWT_SECRET, PIN_PEPPER
pnpm api # :3001
pnpm start # :4200 con proxy /api 09-decisions.md
Decisiones
Neon + Nest en lugar de Supabase client
La nube es Postgres en Neon y una API Nest propia: SQL y esquema controlados, secretos solo en servidor, mismo patrón que el resto del portfolio. Se dejó el acoplamiento a Supabase Auth/Realtime porque el producto solo necesita PIN, sesiones y tareas.
Turborepo monorepo + un deploy
Web y API comparten lockfile, turbo cache y un solo pipeline Vercel. Evita dos proyectos desincronizados y simplifica same-origin /api.
Offline primero, sync opcional
Las features core de tareas no requieren API; la nube es continuidad multi-dispositivo, no el camino crítico de cada tap.
PIN en vez de email/password
Onboarding en segundos para uso personal, sin buzones ni flujos de reset, manteniendo hash + pepper + rate-limit.
Angular 22 con Zone (no zoneless) por Ionic
La skill Angular 22 pide zoneless, [formField] en todos los controles y View Transitions del router. Ionic 8.8 no lo permite del todo: sigue pidiendo zone.js, sus ion-* no implementan FormValueControl y la navegación va por ion-router-outlet. Se mantiene Zone y se usan Signal Forms + httpResource. Los formularios de tareas ya no dependen de controles Ionic: usan inputs nativos y controles propios que implementan FormValueControl, así que [formField] cubre todos sus campos. El techo está en 11-angular-skill.md.
Rediseño «Ballpoint Ink» (2026-10-19)
Sustituye el look de teal, piedra y Literata. La captura sube a la cabecera porque añadir una tarea es lo más frecuente; completar dibuja un trazo de tinta para que el gesto principal tenga respuesta visible. La prioridad sigue en rojo, ámbar y verde escrita como palabra, separada del azul de la marca. Sistema en apps/web/DESIGN.md.
Controles propios en vez de campos de Angular Material
El control segmentado (estado, prioridad, idioma) y el campo de etiquetas son componentes propios con FormValueControl, teclado y ARIA de radiogroup. Material queda solo para MatDialog y MatSnackBar. Así los formularios funcionan con [formField] y el aspecto no depende de los temas de Material.
Deshacer en vez de confirmar
Borrar una tarea es inmediato y ofrece Deshacer durante cinco segundos. Una confirmación en cada borrado frena el uso diario; Deshacer protege del error sin interrumpir. Los borrados masivos de Ajustes sí piden confirmación y dicen qué se borra y dónde.
Instalar con el diálogo del navegador
En Chrome, Edge y Samsung Internet la app guarda el evento beforeinstallprompt y el botón de la cabecera abre el diálogo de instalación del navegador en un toque. iOS no ofrece ninguna API de instalación, así que en iPhone y iPad el mismo botón muestra los pasos de Compartir → Añadir a pantalla de inicio. Donde no hay forma de instalar (Firefox, Safari de escritorio) o la app ya está instalada, el botón no aparece.
10-challenges-results.md
Retos y aprendizajes
Serverless Nest en Vercel
Adapter Express, cold start, maxDuration y reutilizar la app entre invocaciones warm (api/index.js).
Convergencia local ↔ nube
Mapear ids locales tras upload, bulk sync y no pisar ownership entre usuarios.
Env dual (web + api)
set-env.js genera environment.*.local.ts desde el .env raíz; secretos nunca van al bundle del browser.
Una PWA que no se podía instalar
El manifest y los iconos viven en apps/web/public/, pero el builder browser de Angular solo copia las carpetas que angular.json lista en assets, y public/ no estaba. El build salía sin manifest y Vercel respondía a /manifest.webmanifest con el index.html, así que Chrome nunca ofrecía instalar. Se arregló añadiendo public/ a los assets, y las comprobaciones tras cada deploy (VERCEL.md, 08-deploy-vercel.md) incluyen que el manifest devuelva JSON.
Instalar en cada plataforma
Solo los navegadores Chromium exponen beforeinstallprompt. El evento puede llegar antes de que cargue la página de la lista, así que InstallService se crea al arrancar con provideAppInitializer. En iOS no hay API: la app muestra los pasos, que en iOS 26 cambiaron (Compartir puede estar dentro del botón •••).
fakeAsync y la carga perezosa de Ionic
Ionic carga el código de cada componente la primera vez que aparece. Si eso ocurre dentro de un test fakeAsync, el vaciado de temporizadores al final del test dispara el timeout de carga del chunk y Karma registra ChunkLoadError, aunque el test pase. El test de la tarea tachada usa el reloj de Jasmine, que solo avanza los temporizadores que el test pide.
Resultados
- Monorepo operativo con CI en GitHub Actions: lint, unitarios web (77 tests) y API, integración HTTP de la API, build de la API y e2e con Playwright.
- PWA en producción en Vercel (estáticos + API Nest en
/api) sobre Neon. - API con health, auth PIN/JWT, CRUD/bulk de tareas y esquema Neon versionado en SQL.
- Rediseño «Ballpoint Ink» con captura en la cabecera, edición en línea, Deshacer e instalación en un toque.
- Código abierto como referencia Angular/Ionic + Nest + Neon + Turborepo + Vercel.
11-angular-skill.md
Skill Angular
La guía de estilo Angular de Task Cloud está en .claude/skills/angular/.
| Archivo | Contenido |
|---|---|
SKILL.md | Defaults Angular 22 + house style |
style-standards.md | Signals first, visibilidad, const, spread, early returns |
signals.md | computed, effect, resource/httpResource, zoneless |
signal-forms.md | Signal Forms (estable en v22) |
components.md | Standalone, @if/@for, SSR, Vitest |
animations.md | animate.enter / View Transitions |
La skill pide mirar el major del repo y no mezclar eras. Task Cloud está en Angular 22.1 + Ionic 8.8.18 + TypeScript 6.0. v22 hace zoneless el default en apps nuevas; esta app declara provideZoneChangeDetection() porque Ionic 8 sigue apoyándose en Zone.js.
Revisión 2026-08-13 (Angular 22)
Hallazgos aplicados:
- Upgrade a Angular 22.1.1 + Material/CDK 22.1 + CLI 22.1. TypeScript 6.0.3. Ionic 8.8.18 (peer
>=16). - Signal Forms en crear y editar tarea:
form()+FormField+required/maxLength+submit(). El modelo es unsignalplano (taskModel/editModel).ion-selectymat-selectescriben el modelo (no tienen CVA de Signal Forms). httpResourcepara lecturas GET:cloudTasks(GET /tasks) ymeResource(GET /auth/me), disparados porsession. Mutaciones (POST/PUT/DELETE) siguen enHttpClient; tras mutar se llamacloudTasks.reload().- HTTP Fetch,
@if, OnPush y Zone explícito se mantienen.
Revisión 2026-10-20 (rediseño «Ballpoint Ink»)
- Formularios de tareas sin controles Ionic.
TaskComposerComponentextiende la baseTaskForm;TaskEditorComponentcrea suform()sobre un borradorlinkedSignalde la tarea. Ambos validan conrequired/maxLength. Título y nota son inputs nativos; prioridad y etiquetas usanapp-segmented-controlyapp-tag-field, que implementanFormValueControl(model()para el valor). Todos los campos van con[formField]; ya no hayion-selectnimat-selectque escriban el modelo a mano. - Estado derivado con
computed. Filtros, contadores, resumen y grupos (pendientes / hechas) de la lista soncomputed. - Servicio nuevo con
@Service().InstallServiceusa el decorador de v22 y se instancia al arrancar conprovideAppInitializer, para no perder elbeforeinstallpromptdel navegador. - Material reducido a
MatDialogyMatSnackBar.
Qué Angular 22 no se puede (o no se debe) aplicar por Ionic 8
Ionic 8.8 declara zone.js como peer y sus web components (ion-select, overlays, ion-router-outlet, tabs) no implementan las APIs signal-first de v22. Esto no es deuda de la skill: es un techo del runtime Ionic. No forzar estas APIs hasta que Ionic las soporte.
| API Angular 22 | Por qué Ionic la bloquea | Qué hacemos aquí |
|---|---|---|
Zoneless (provideZonelessChangeDetection(), quitar zone.js) | Peer oficial zone.js >= 0.13. Eventos, overlays y navegación de Ionic siguen notificando la vista vía Zone. Sin Zone, taps y modales se quedan mudos. | provideZoneChangeDetection() + zone.js |
[formField] en controles Ionic | ion-select, ion-checkbox, ion-toggle, ion-input no implementan FormValueControl. Signal Forms solo enlaza nativos o controles con FormValueControl. | Formularios de tareas con inputs nativos y controles propios (app-segmented-control, app-tag-field) enlazados con [formField]; ion-toggle / ion-checkbox solo fuera de formularios (modo oscuro, confirmación del PIN) |
| View Transitions del router Angular | La navegación real pasa por ion-router-outlet + IonicRouteStrategy, no por las transiciones del Router de Angular. | Transiciones de Ionic; no withViewTransitions() |
| Angular ARIA en overlays | Alertas, popovers y action sheets son de Ionic. Sustituirlos por Angular ARIA rompe el look y el focus trap nativo. | Alertas de Ionic; diálogos del PIN y de instalación con MatDialog. Los controles nuevos no-Ionic llevan su propio ARIA (el segmentado es un radiogroup con tabindex itinerante) |
| SSR / hidratación | PWA + Capacitor + IonicStorage son cliente. No hay servidor de render para ion-*. | SPA + service worker |
No atribuir a Ionic (queda fuera por el builder/legado, no por el peer):
- Vitest — el builder webpack/Karma de esta app
- Shell
NgModule(IonicModule.forRoot(),TabsModule) — Ionic 8 sí tieneprovideIonicAngular(); no se ha migrado el andamiaje de tabs
Revisar esta tabla al subir de major de Ionic. Si el peer deja de pedir zone.js, se puede pasar a zoneless; los formularios de tareas ya no dependen de que los ion-* implementen FormValueControl.
House style (extracto)
- Estado y derivación en
signal/computed/linkedSignal/httpResource. RxJS solo para streams reales; al entrar,toSignal(). private readonlypor defecto;protectedsolo si la plantilla lo lee.constpara locales; nuncavar.- Arrays/objetos por spread; nunca
push/splicesobre estado compartido. - Guards arriba, happy path plano.
- En código nuevo: standalone,
inject(),input()/output(), Signal Forms,httpResourcepara lecturas.
Referencia en el repo de la app
Los agentes que lean .claude/skills/angular/SKILL.md aplican estas reglas al tocar apps/web.