Merge branch 'webui'

main
brobert 1 week ago
commit d070ac3ab0

@ -16,6 +16,25 @@ PORT=3007
# Entorno
NODE_ENV="production" # production | development | test
TZ="Europe/Madrid" # Zona horaria usada para "hoy/mañana" y render de fechas
WEB_BASE_URL="https://taskbot.server.brobert.net" # Host público de la web (p.ej. https://wtask.org en producción)
# DB_PATH="./data/tasks.db" # Si se define, ignora DATA_DIR y usa esta ruta exacta
ONBOARDING_FALLBACK_MIN_DIGITS=8 # A2: longitud mínima para conservar números en menciones/tokens; por defecto 8
# Onboarding A3 (prompts únicos por grupo)
# Habilita/deshabilita la publicación (por defecto true fuera de test)
# ONBOARDING_PROMPTS_ENABLED=true
# Permite publicación durante tests específicos
# ONBOARDING_ENABLE_IN_TEST=false
#
# Onboarding A4 (DM JIT y palabra clave)
# Palabra clave de alta por DM: activar
# En tests, los prompts JIT (A4) y los mensajes al grupo (A3) solo se envían si ONBOARDING_ENABLE_IN_TEST=true
# Umbral de cobertura (publica si coverage < threshold). Por defecto 1.0
# ONBOARDING_COVERAGE_THRESHOLD=1
# Periodo de gracia tras la última verificación de miembros (segundos). Por defecto 90
# ONBOARDING_GRACE_SECONDS=90
# Cooldown entre publicaciones (días). Por defecto 7
# ONBOARDING_COOLDOWN_DAYS=7
# Sincronización de grupos (opcional)
# Intervalo en milisegundos; por defecto 86400000 (24h). En desarrollo puede bajarse (mínimo recomendable 10000ms).
@ -69,6 +88,9 @@ TZ="Europe/Madrid" # Zona horaria usada para "hoy/mañana" y render de fe
# METRICS_ENABLED=true
# METRICS_FORMAT=prom # prom|json
# Migrador (opcional)
# MIGRATIONS_LOG_LEVEL="silent" # Silencia logs del migrador (en test ya se silencian automáticamente)
# Control de acceso por grupos (multicomunidad)
# Modo: off|discover|enforce (por defecto off)
# GROUP_GATING_MODE=discover

7
.gitignore vendored

@ -1,4 +1,5 @@
# dependencies (bun install)
#
node_modules
# output
@ -41,7 +42,5 @@ docs/evolution-api.envs
/data/*
!data/.gitkeep
#sveltekit
.sveltekit
apps
tmp
tmp/
apps/web/tmp/

@ -1,27 +1,47 @@
# Use standard Bun image for debugging (switch back to alpine later)
FROM oven/bun:1.1 as base
FROM oven/bun:debian as base
# Install basic debugging tools
RUN apt-get update && apt-get install -y curl netcat sqlite3
RUN apt-get update && apt-get install -y sqlite3
WORKDIR /app
# Default data dir for SQLite; can be overridden by DB_PATH at runtime (DB_PATH has priority)
ENV DATA_DIR=/app/data
# Create data directory with proper permissions
RUN mkdir -p /app/data && chown -R bun:bun /app/data
# Install dependencies first (better layer caching)
# Install bot dependencies first (better layer caching)
COPY package.json bun.lock ./
RUN bun install
# Copy only necessary files
# Prepare and install web dependencies
COPY apps/web/package.json apps/web/
WORKDIR /app/apps/web
# No instalar optionalDependencies (better-sqlite3) en build de producción
RUN bun install --no-optional
# Copy sources
WORKDIR /app
COPY src/ ./src/
COPY index.ts ./
COPY apps/web/ /app/apps/web/
COPY proxy.ts ./
# Build the web app
WORKDIR /app/apps/web
RUN bunx svelte-kit sync && bun run build
# Return to root workdir
WORKDIR /app
# More forgiving health check during debugging
# More forgiving health check during debugging (router en 3000)
HEALTHCHECK --start-period=30s --interval=30s --timeout=3s --retries=3 \
CMD curl -f http://localhost:${PORT:-3007}/health || exit 0
CMD curl -f http://localhost:3000/health || exit 0
# Server runs on port from environment variable
EXPOSE ${PORT:-3007}
# Expose router port
EXPOSE 3000
# Declare volume for persistent data by default
VOLUME ["/app/data"]
# Make script executable
COPY startup.sh ./

@ -13,10 +13,12 @@ Taskbot ayuda a coordinar grupos en WhatsApp: crea y asigna tareas, recuerda pen
## Características
- Gestión de tareas: crear, asignar, reclamar/soltar, fechas límite y código corto de referencia.
- Edición desde la web: reclamar/soltar, editar descripción y actualizar fecha de vencimiento desde /app; completar tareas y ver “Completadas (24 h)”.
- Recordatorios configurables por usuario (frecuencia y hora, respetando zona horaria).
- Control de acceso por grupos: modos off, discover y enforce; aprobación y bloqueo por admins.
- Sincronización de grupos y miembros con cachés y schedulers configurables.
- Alias de identidad con normalización de IDs.
- Acceso web por token mágico (/t web) con página intermedia anti-preview y sesión por cookie (idle 2h); tokens de 10 min de un solo uso.
- Métricas listas para Prometheus en el endpoint /metrics.
- Rate limiting por usuario para evitar abuso.
- Persistencia simple con SQLite, migraciones automáticas y PRAGMAs seguros (WAL, FK, etc.).
@ -26,7 +28,7 @@ Taskbot ayuda a coordinar grupos en WhatsApp: crea y asigna tareas, recuerda pen
- No es un framework general de bots ni un CRM.
- No conecta directamente con WhatsApp: requiere Evolution API.
- No gestiona flujos conversacionales complejos ni multimedia avanzada.
- No incluye panel web; la interacción es vía WhatsApp.
- Panel web: login operativo, lista de tareas con acciones básicas (reclamar/soltar, editar texto y fecha, completar; sección “Completadas (24 h)”), vista de grupos (contadores "abiertas" y "sin responsable" con lista sin límite y botón “Reclamar”; tarjetas ordenadas por cantidad de “sin responsable”) y página de preferencias de recordatorios; la interacción principal sigue siendo WhatsApp.
- Está optimizado para un despliegue por comunidad/instancia (no multi-tenant masivo).
## Cómo funciona (alto nivel)
@ -37,6 +39,7 @@ Taskbot ayuda a coordinar grupos en WhatsApp: crea y asigna tareas, recuerda pen
4. Las respuestas se encolan y envían a través de Evolution API.
5. Schedulers ejecutan sincronización de grupos/miembros, recordatorios y tareas de mantenimiento.
6. Las métricas se exponen en /metrics (Prometheus o JSON).
7. Un proxy interno en Bun sirve web y bot bajo el mismo dominio: /webhook y /metrics → bot; el resto → web. Actualmente, la compresión HTTP está desactivada temporalmente (sin Content-Encoding).
## Uso básico
@ -71,8 +74,11 @@ Variables clave:
- REMINDERS_GRACE_MINUTES (ventana de gracia tras la hora; por defecto 60).
- ALLOWED_GROUPS (semilla inicial), NOTIFY_ADMINS_ON_DISCOVERY.
- METRICS_ENABLED, PORT.
- WEB_BASE_URL (host público de la web para generar enlaces absolutos; usado por /t web).
- Rate limit: RATE_LIMIT_PER_MIN, RATE_LIMIT_BURST.
- Intervalos y retención: GROUP_SYNC_INTERVAL_MS, GROUP_MEMBERS_SYNC_INTERVAL_MS, GROUP_MEMBERS_INACTIVE_RETENTION_DAYS.
- DB_PATH: ruta al archivo SQLite. Tiene prioridad sobre DATA_DIR y permite aislar BD por rama/entorno. Ej.: DB_PATH='./data/tasks.db'
- DATA_DIR: directorio raíz para la base de datos SQLite compartida (por defecto ./data).
Consulta:
- docs/operations.md para operación, endpoints y variables de entorno.
@ -85,10 +91,20 @@ Consulta:
- Migraciones up-only al arranque; logging de eventos de migración.
- Copias de seguridad: respaldar el directorio data/ y planificar retención.
## Pruebas (bun:test)
- Suite web implementada con build programático: los tests construyen apps/web (adapter-node) una única vez, arrancan el servidor en un puerto efímero y hacen peticiones HTTP reales.
- Sin dependencias externas: bun:test, bun:sqlite y helpers propios.
- Cobertura actual: endpoints /api/me/tasks (gating, orden, búsqueda con ESCAPE, soonDays y paginación), /api/me/preferences (GET y POST) y página /app/preferences; además de helpers de servidor para build/arranque.
- Ejecución: bun test tests/web
## Estado y licencia
- Nombre provisional: “Taskbot”.
- Licencia por definir (software libre; se evaluará GPLv3/AGPL/MIT/Apache-2.0).
- Etapa 1 (autenticación web): completada. /login (GET intermedio + POST), sesión con idle 2h, logout y ruta /app protegida; desplegado con proxy interno en Bun.
- Etapa 2 (lectura de datos - MVP): completada. GET /api/me/tasks (orden por due_date asc con NULL al final, búsqueda con ESCAPE, filtros soonDays/dueBefore, paginación page/limit), GET /api/me/groups (contadores open/unassigned) y GET /api/groups/:id/tasks (unassignedFirst, onlyUnassigned, limit). UI: /app (Mis tareas, filtros/búsqueda/paginación) y /app/groups (bloque “sin responsable” con prefetch).
- Etapa 3 (preferencias): completada. GET/POST /api/me/preferences y página /app/preferences con cálculo de “próximo recordatorio” coherente con la TZ y semántica del bot.
- Edición de tareas en web: completada. Reclamar/soltar, editar fecha y descripción desde /app; completar tareas y mostrar “Completadas (24 h)”; reclamar desde /app/groups; lista "sin responsable" sin límite y fichas ordenadas por cantidad de "sin responsable" (con gating y validación).
- Roadmap y contribuciones: pendientes de publicación.
## Enlaces

@ -0,0 +1,23 @@
node_modules
# Output
.output
.vercel
.netlify
.wrangler
/.svelte-kit
/build
# OS
.DS_Store
Thumbs.db
# Env
.env
.env.*
!.env.example
!.env.test
# Vite
vite.config.js.timestamp-*
vite.config.ts.timestamp-*

@ -0,0 +1 @@
engine-strict=true

@ -0,0 +1,38 @@
# sv
Everything you need to build a Svelte project, powered by [`sv`](https://github.com/sveltejs/cli).
## Creating a project
If you're seeing this, you've probably already done this step. Congrats!
```sh
# create a new project in the current directory
npx sv create
# create a new project in my-app
npx sv create my-app
```
## Developing
Once you've created a project and installed dependencies with `npm install` (or `pnpm install` or `yarn`), start a development server:
```sh
npm run dev
# or start the server and open the app in a new browser tab
npm run dev -- --open
```
## Building
To create a production version of your app:
```sh
npm run build
```
You can preview the production build with `npm run preview`.
> To deploy your app, you may need to install an [adapter](https://svelte.dev/docs/kit/adapters) for your target environment.

@ -0,0 +1,345 @@
{
"lockfileVersion": 1,
"workspaces": {
"": {
"name": "web",
"dependencies": {
"better-sqlite3": "^12.4.1",
},
"devDependencies": {
"@sveltejs/adapter-auto": "^6.1.0",
"@sveltejs/adapter-node": "^5.3.3",
"@sveltejs/kit": "^2.43.2",
"@sveltejs/vite-plugin-svelte": "^6.2.0",
"@types/bun": "^1.3.0",
"svelte": "^5.39.5",
"svelte-check": "^4.3.2",
"typescript": "^5.9.2",
"vite": "^7.1.7",
},
},
},
"packages": {
"@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.25.10", "", { "os": "aix", "cpu": "ppc64" }, "sha512-0NFWnA+7l41irNuaSVlLfgNT12caWJVLzp5eAVhZ0z1qpxbockccEt3s+149rE64VUI3Ml2zt8Nv5JVc4QXTsw=="],
"@esbuild/android-arm": ["@esbuild/android-arm@0.25.10", "", { "os": "android", "cpu": "arm" }, "sha512-dQAxF1dW1C3zpeCDc5KqIYuZ1tgAdRXNoZP7vkBIRtKZPYe2xVr/d3SkirklCHudW1B45tGiUlz2pUWDfbDD4w=="],
"@esbuild/android-arm64": ["@esbuild/android-arm64@0.25.10", "", { "os": "android", "cpu": "arm64" }, "sha512-LSQa7eDahypv/VO6WKohZGPSJDq5OVOo3UoFR1E4t4Gj1W7zEQMUhI+lo81H+DtB+kP+tDgBp+M4oNCwp6kffg=="],
"@esbuild/android-x64": ["@esbuild/android-x64@0.25.10", "", { "os": "android", "cpu": "x64" }, "sha512-MiC9CWdPrfhibcXwr39p9ha1x0lZJ9KaVfvzA0Wxwz9ETX4v5CHfF09bx935nHlhi+MxhA63dKRRQLiVgSUtEg=="],
"@esbuild/darwin-arm64": ["@esbuild/darwin-arm64@0.25.10", "", { "os": "darwin", "cpu": "arm64" }, "sha512-JC74bdXcQEpW9KkV326WpZZjLguSZ3DfS8wrrvPMHgQOIEIG/sPXEN/V8IssoJhbefLRcRqw6RQH2NnpdprtMA=="],
"@esbuild/darwin-x64": ["@esbuild/darwin-x64@0.25.10", "", { "os": "darwin", "cpu": "x64" }, "sha512-tguWg1olF6DGqzws97pKZ8G2L7Ig1vjDmGTwcTuYHbuU6TTjJe5FXbgs5C1BBzHbJ2bo1m3WkQDbWO2PvamRcg=="],
"@esbuild/freebsd-arm64": ["@esbuild/freebsd-arm64@0.25.10", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-3ZioSQSg1HT2N05YxeJWYR+Libe3bREVSdWhEEgExWaDtyFbbXWb49QgPvFH8u03vUPX10JhJPcz7s9t9+boWg=="],
"@esbuild/freebsd-x64": ["@esbuild/freebsd-x64@0.25.10", "", { "os": "freebsd", "cpu": "x64" }, "sha512-LLgJfHJk014Aa4anGDbh8bmI5Lk+QidDmGzuC2D+vP7mv/GeSN+H39zOf7pN5N8p059FcOfs2bVlrRr4SK9WxA=="],
"@esbuild/linux-arm": ["@esbuild/linux-arm@0.25.10", "", { "os": "linux", "cpu": "arm" }, "sha512-oR31GtBTFYCqEBALI9r6WxoU/ZofZl962pouZRTEYECvNF/dtXKku8YXcJkhgK/beU+zedXfIzHijSRapJY3vg=="],
"@esbuild/linux-arm64": ["@esbuild/linux-arm64@0.25.10", "", { "os": "linux", "cpu": "arm64" }, "sha512-5luJWN6YKBsawd5f9i4+c+geYiVEw20FVW5x0v1kEMWNq8UctFjDiMATBxLvmmHA4bf7F6hTRaJgtghFr9iziQ=="],
"@esbuild/linux-ia32": ["@esbuild/linux-ia32@0.25.10", "", { "os": "linux", "cpu": "ia32" }, "sha512-NrSCx2Kim3EnnWgS4Txn0QGt0Xipoumb6z6sUtl5bOEZIVKhzfyp/Lyw4C1DIYvzeW/5mWYPBFJU3a/8Yr75DQ=="],
"@esbuild/linux-loong64": ["@esbuild/linux-loong64@0.25.10", "", { "os": "linux", "cpu": "none" }, "sha512-xoSphrd4AZda8+rUDDfD9J6FUMjrkTz8itpTITM4/xgerAZZcFW7Dv+sun7333IfKxGG8gAq+3NbfEMJfiY+Eg=="],
"@esbuild/linux-mips64el": ["@esbuild/linux-mips64el@0.25.10", "", { "os": "linux", "cpu": "none" }, "sha512-ab6eiuCwoMmYDyTnyptoKkVS3k8fy/1Uvq7Dj5czXI6DF2GqD2ToInBI0SHOp5/X1BdZ26RKc5+qjQNGRBelRA=="],
"@esbuild/linux-ppc64": ["@esbuild/linux-ppc64@0.25.10", "", { "os": "linux", "cpu": "ppc64" }, "sha512-NLinzzOgZQsGpsTkEbdJTCanwA5/wozN9dSgEl12haXJBzMTpssebuXR42bthOF3z7zXFWH1AmvWunUCkBE4EA=="],
"@esbuild/linux-riscv64": ["@esbuild/linux-riscv64@0.25.10", "", { "os": "linux", "cpu": "none" }, "sha512-FE557XdZDrtX8NMIeA8LBJX3dC2M8VGXwfrQWU7LB5SLOajfJIxmSdyL/gU1m64Zs9CBKvm4UAuBp5aJ8OgnrA=="],
"@esbuild/linux-s390x": ["@esbuild/linux-s390x@0.25.10", "", { "os": "linux", "cpu": "s390x" }, "sha512-3BBSbgzuB9ajLoVZk0mGu+EHlBwkusRmeNYdqmznmMc9zGASFjSsxgkNsqmXugpPk00gJ0JNKh/97nxmjctdew=="],
"@esbuild/linux-x64": ["@esbuild/linux-x64@0.25.10", "", { "os": "linux", "cpu": "x64" }, "sha512-QSX81KhFoZGwenVyPoberggdW1nrQZSvfVDAIUXr3WqLRZGZqWk/P4T8p2SP+de2Sr5HPcvjhcJzEiulKgnxtA=="],
"@esbuild/netbsd-arm64": ["@esbuild/netbsd-arm64@0.25.10", "", { "os": "none", "cpu": "arm64" }, "sha512-AKQM3gfYfSW8XRk8DdMCzaLUFB15dTrZfnX8WXQoOUpUBQ+NaAFCP1kPS/ykbbGYz7rxn0WS48/81l9hFl3u4A=="],
"@esbuild/netbsd-x64": ["@esbuild/netbsd-x64@0.25.10", "", { "os": "none", "cpu": "x64" }, "sha512-7RTytDPGU6fek/hWuN9qQpeGPBZFfB4zZgcz2VK2Z5VpdUxEI8JKYsg3JfO0n/Z1E/6l05n0unDCNc4HnhQGig=="],
"@esbuild/openbsd-arm64": ["@esbuild/openbsd-arm64@0.25.10", "", { "os": "openbsd", "cpu": "arm64" }, "sha512-5Se0VM9Wtq797YFn+dLimf2Zx6McttsH2olUBsDml+lm0GOCRVebRWUvDtkY4BWYv/3NgzS8b/UM3jQNh5hYyw=="],
"@esbuild/openbsd-x64": ["@esbuild/openbsd-x64@0.25.10", "", { "os": "openbsd", "cpu": "x64" }, "sha512-XkA4frq1TLj4bEMB+2HnI0+4RnjbuGZfet2gs/LNs5Hc7D89ZQBHQ0gL2ND6Lzu1+QVkjp3x1gIcPKzRNP8bXw=="],
"@esbuild/openharmony-arm64": ["@esbuild/openharmony-arm64@0.25.10", "", { "os": "none", "cpu": "arm64" }, "sha512-AVTSBhTX8Y/Fz6OmIVBip9tJzZEUcY8WLh7I59+upa5/GPhh2/aM6bvOMQySspnCCHvFi79kMtdJS1w0DXAeag=="],
"@esbuild/sunos-x64": ["@esbuild/sunos-x64@0.25.10", "", { "os": "sunos", "cpu": "x64" }, "sha512-fswk3XT0Uf2pGJmOpDB7yknqhVkJQkAQOcW/ccVOtfx05LkbWOaRAtn5SaqXypeKQra1QaEa841PgrSL9ubSPQ=="],
"@esbuild/win32-arm64": ["@esbuild/win32-arm64@0.25.10", "", { "os": "win32", "cpu": "arm64" }, "sha512-ah+9b59KDTSfpaCg6VdJoOQvKjI33nTaQr4UluQwW7aEwZQsbMCfTmfEO4VyewOxx4RaDT/xCy9ra2GPWmO7Kw=="],
"@esbuild/win32-ia32": ["@esbuild/win32-ia32@0.25.10", "", { "os": "win32", "cpu": "ia32" }, "sha512-QHPDbKkrGO8/cz9LKVnJU22HOi4pxZnZhhA2HYHez5Pz4JeffhDjf85E57Oyco163GnzNCVkZK0b/n4Y0UHcSw=="],
"@esbuild/win32-x64": ["@esbuild/win32-x64@0.25.10", "", { "os": "win32", "cpu": "x64" }, "sha512-9KpxSVFCu0iK1owoez6aC/s/EdUQLDN3adTxGCqxMVhrPDj6bt5dbrHDXUuq+Bs2vATFBBrQS5vdQ/Ed2P+nbw=="],
"@jridgewell/gen-mapping": ["@jridgewell/gen-mapping@0.3.13", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.0", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA=="],
"@jridgewell/remapping": ["@jridgewell/remapping@2.3.5", "", { "dependencies": { "@jridgewell/gen-mapping": "^0.3.5", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ=="],
"@jridgewell/resolve-uri": ["@jridgewell/resolve-uri@3.1.2", "", {}, "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw=="],
"@jridgewell/sourcemap-codec": ["@jridgewell/sourcemap-codec@1.5.5", "", {}, "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og=="],
"@jridgewell/trace-mapping": ["@jridgewell/trace-mapping@0.3.31", "", { "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", "@jridgewell/sourcemap-codec": "^1.4.14" } }, "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw=="],
"@polka/url": ["@polka/url@1.0.0-next.29", "", {}, "sha512-wwQAWhWSuHaag8c4q/KN/vCoeOJYshAIvMQwD4GpSb3OiZklFfvAgmj0VCBBImRpuF/aFgIRzllXlVX93Jevww=="],
"@rollup/plugin-commonjs": ["@rollup/plugin-commonjs@28.0.6", "", { "dependencies": { "@rollup/pluginutils": "^5.0.1", "commondir": "^1.0.1", "estree-walker": "^2.0.2", "fdir": "^6.2.0", "is-reference": "1.2.1", "magic-string": "^0.30.3", "picomatch": "^4.0.2" }, "peerDependencies": { "rollup": "^2.68.0||^3.0.0||^4.0.0" }, "optionalPeers": ["rollup"] }, "sha512-XSQB1K7FUU5QP+3lOQmVCE3I0FcbbNvmNT4VJSj93iUjayaARrTQeoRdiYQoftAJBLrR9t2agwAd3ekaTgHNlw=="],
"@rollup/plugin-json": ["@rollup/plugin-json@6.1.0", "", { "dependencies": { "@rollup/pluginutils": "^5.1.0" }, "peerDependencies": { "rollup": "^1.20.0||^2.0.0||^3.0.0||^4.0.0" }, "optionalPeers": ["rollup"] }, "sha512-EGI2te5ENk1coGeADSIwZ7G2Q8CJS2sF120T7jLw4xFw9n7wIOXHo+kIYRAoVpJAN+kmqZSoO3Fp4JtoNF4ReA=="],
"@rollup/plugin-node-resolve": ["@rollup/plugin-node-resolve@16.0.2", "", { "dependencies": { "@rollup/pluginutils": "^5.0.1", "@types/resolve": "1.20.2", "deepmerge": "^4.2.2", "is-module": "^1.0.0", "resolve": "^1.22.1" }, "peerDependencies": { "rollup": "^2.78.0||^3.0.0||^4.0.0" }, "optionalPeers": ["rollup"] }, "sha512-tCtHJ2BlhSoK4cCs25NMXfV7EALKr0jyasmqVCq3y9cBrKdmJhtsy1iTz36Xhk/O+pDJbzawxF4K6ZblqCnITQ=="],
"@rollup/pluginutils": ["@rollup/pluginutils@5.3.0", "", { "dependencies": { "@types/estree": "^1.0.0", "estree-walker": "^2.0.2", "picomatch": "^4.0.2" }, "peerDependencies": { "rollup": "^1.20.0||^2.0.0||^3.0.0||^4.0.0" }, "optionalPeers": ["rollup"] }, "sha512-5EdhGZtnu3V88ces7s53hhfK5KSASnJZv8Lulpc04cWO3REESroJXg73DFsOmgbU2BhwV0E20bu2IDZb3VKW4Q=="],
"@rollup/rollup-android-arm-eabi": ["@rollup/rollup-android-arm-eabi@4.52.4", "", { "os": "android", "cpu": "arm" }, "sha512-BTm2qKNnWIQ5auf4deoetINJm2JzvihvGb9R6K/ETwKLql/Bb3Eg2H1FBp1gUb4YGbydMA3jcmQTR73q7J+GAA=="],
"@rollup/rollup-android-arm64": ["@rollup/rollup-android-arm64@4.52.4", "", { "os": "android", "cpu": "arm64" }, "sha512-P9LDQiC5vpgGFgz7GSM6dKPCiqR3XYN1WwJKA4/BUVDjHpYsf3iBEmVz62uyq20NGYbiGPR5cNHI7T1HqxNs2w=="],
"@rollup/rollup-darwin-arm64": ["@rollup/rollup-darwin-arm64@4.52.4", "", { "os": "darwin", "cpu": "arm64" }, "sha512-QRWSW+bVccAvZF6cbNZBJwAehmvG9NwfWHwMy4GbWi/BQIA/laTIktebT2ipVjNncqE6GLPxOok5hsECgAxGZg=="],
"@rollup/rollup-darwin-x64": ["@rollup/rollup-darwin-x64@4.52.4", "", { "os": "darwin", "cpu": "x64" }, "sha512-hZgP05pResAkRJxL1b+7yxCnXPGsXU0fG9Yfd6dUaoGk+FhdPKCJ5L1Sumyxn8kvw8Qi5PvQ8ulenUbRjzeCTw=="],
"@rollup/rollup-freebsd-arm64": ["@rollup/rollup-freebsd-arm64@4.52.4", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-xmc30VshuBNUd58Xk4TKAEcRZHaXlV+tCxIXELiE9sQuK3kG8ZFgSPi57UBJt8/ogfhAF5Oz4ZSUBN77weM+mQ=="],
"@rollup/rollup-freebsd-x64": ["@rollup/rollup-freebsd-x64@4.52.4", "", { "os": "freebsd", "cpu": "x64" }, "sha512-WdSLpZFjOEqNZGmHflxyifolwAiZmDQzuOzIq9L27ButpCVpD7KzTRtEG1I0wMPFyiyUdOO+4t8GvrnBLQSwpw=="],
"@rollup/rollup-linux-arm-gnueabihf": ["@rollup/rollup-linux-arm-gnueabihf@4.52.4", "", { "os": "linux", "cpu": "arm" }, "sha512-xRiOu9Of1FZ4SxVbB0iEDXc4ddIcjCv2aj03dmW8UrZIW7aIQ9jVJdLBIhxBI+MaTnGAKyvMwPwQnoOEvP7FgQ=="],
"@rollup/rollup-linux-arm-musleabihf": ["@rollup/rollup-linux-arm-musleabihf@4.52.4", "", { "os": "linux", "cpu": "arm" }, "sha512-FbhM2p9TJAmEIEhIgzR4soUcsW49e9veAQCziwbR+XWB2zqJ12b4i/+hel9yLiD8pLncDH4fKIPIbt5238341Q=="],
"@rollup/rollup-linux-arm64-gnu": ["@rollup/rollup-linux-arm64-gnu@4.52.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-4n4gVwhPHR9q/g8lKCyz0yuaD0MvDf7dV4f9tHt0C73Mp8h38UCtSCSE6R9iBlTbXlmA8CjpsZoujhszefqueg=="],
"@rollup/rollup-linux-arm64-musl": ["@rollup/rollup-linux-arm64-musl@4.52.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-u0n17nGA0nvi/11gcZKsjkLj1QIpAuPFQbR48Subo7SmZJnGxDpspyw2kbpuoQnyK+9pwf3pAoEXerJs/8Mi9g=="],
"@rollup/rollup-linux-loong64-gnu": ["@rollup/rollup-linux-loong64-gnu@4.52.4", "", { "os": "linux", "cpu": "none" }, "sha512-0G2c2lpYtbTuXo8KEJkDkClE/+/2AFPdPAbmaHoE870foRFs4pBrDehilMcrSScrN/fB/1HTaWO4bqw+ewBzMQ=="],
"@rollup/rollup-linux-ppc64-gnu": ["@rollup/rollup-linux-ppc64-gnu@4.52.4", "", { "os": "linux", "cpu": "ppc64" }, "sha512-teSACug1GyZHmPDv14VNbvZFX779UqWTsd7KtTM9JIZRDI5NUwYSIS30kzI8m06gOPB//jtpqlhmraQ68b5X2g=="],
"@rollup/rollup-linux-riscv64-gnu": ["@rollup/rollup-linux-riscv64-gnu@4.52.4", "", { "os": "linux", "cpu": "none" }, "sha512-/MOEW3aHjjs1p4Pw1Xk4+3egRevx8Ji9N6HUIA1Ifh8Q+cg9dremvFCUbOX2Zebz80BwJIgCBUemjqhU5XI5Eg=="],
"@rollup/rollup-linux-riscv64-musl": ["@rollup/rollup-linux-riscv64-musl@4.52.4", "", { "os": "linux", "cpu": "none" }, "sha512-1HHmsRyh845QDpEWzOFtMCph5Ts+9+yllCrREuBR/vg2RogAQGGBRC8lDPrPOMnrdOJ+mt1WLMOC2Kao/UwcvA=="],
"@rollup/rollup-linux-s390x-gnu": ["@rollup/rollup-linux-s390x-gnu@4.52.4", "", { "os": "linux", "cpu": "s390x" }, "sha512-seoeZp4L/6D1MUyjWkOMRU6/iLmCU2EjbMTyAG4oIOs1/I82Y5lTeaxW0KBfkUdHAWN7j25bpkt0rjnOgAcQcA=="],
"@rollup/rollup-linux-x64-gnu": ["@rollup/rollup-linux-x64-gnu@4.52.4", "", { "os": "linux", "cpu": "x64" }, "sha512-Wi6AXf0k0L7E2gteNsNHUs7UMwCIhsCTs6+tqQ5GPwVRWMaflqGec4Sd8n6+FNFDw9vGcReqk2KzBDhCa1DLYg=="],
"@rollup/rollup-linux-x64-musl": ["@rollup/rollup-linux-x64-musl@4.52.4", "", { "os": "linux", "cpu": "x64" }, "sha512-dtBZYjDmCQ9hW+WgEkaffvRRCKm767wWhxsFW3Lw86VXz/uJRuD438/XvbZT//B96Vs8oTA8Q4A0AfHbrxP9zw=="],
"@rollup/rollup-openharmony-arm64": ["@rollup/rollup-openharmony-arm64@4.52.4", "", { "os": "none", "cpu": "arm64" }, "sha512-1ox+GqgRWqaB1RnyZXL8PD6E5f7YyRUJYnCqKpNzxzP0TkaUh112NDrR9Tt+C8rJ4x5G9Mk8PQR3o7Ku2RKqKA=="],
"@rollup/rollup-win32-arm64-msvc": ["@rollup/rollup-win32-arm64-msvc@4.52.4", "", { "os": "win32", "cpu": "arm64" }, "sha512-8GKr640PdFNXwzIE0IrkMWUNUomILLkfeHjXBi/nUvFlpZP+FA8BKGKpacjW6OUUHaNI6sUURxR2U2g78FOHWQ=="],
"@rollup/rollup-win32-ia32-msvc": ["@rollup/rollup-win32-ia32-msvc@4.52.4", "", { "os": "win32", "cpu": "ia32" }, "sha512-AIy/jdJ7WtJ/F6EcfOb2GjR9UweO0n43jNObQMb6oGxkYTfLcnN7vYYpG+CN3lLxrQkzWnMOoNSHTW54pgbVxw=="],
"@rollup/rollup-win32-x64-gnu": ["@rollup/rollup-win32-x64-gnu@4.52.4", "", { "os": "win32", "cpu": "x64" }, "sha512-UF9KfsH9yEam0UjTwAgdK0anlQ7c8/pWPU2yVjyWcF1I1thABt6WXE47cI71pGiZ8wGvxohBoLnxM04L/wj8mQ=="],
"@rollup/rollup-win32-x64-msvc": ["@rollup/rollup-win32-x64-msvc@4.52.4", "", { "os": "win32", "cpu": "x64" }, "sha512-bf9PtUa0u8IXDVxzRToFQKsNCRz9qLYfR/MpECxl4mRoWYjAeFjgxj1XdZr2M/GNVpT05p+LgQOHopYDlUu6/w=="],
"@standard-schema/spec": ["@standard-schema/spec@1.0.0", "", {}, "sha512-m2bOd0f2RT9k8QJx1JN85cZYyH1RqFBdlwtkSlf4tBDYLCiiZnv1fIIwacK6cqwXavOydf0NPToMQgpKq+dVlA=="],
"@sveltejs/acorn-typescript": ["@sveltejs/acorn-typescript@1.0.6", "", { "peerDependencies": { "acorn": "^8.9.0" } }, "sha512-4awhxtMh4cx9blePWl10HRHj8Iivtqj+2QdDCSMDzxG+XKa9+VCNupQuCuvzEhYPzZSrX+0gC+0lHA/0fFKKQQ=="],
"@sveltejs/adapter-auto": ["@sveltejs/adapter-auto@6.1.1", "", { "peerDependencies": { "@sveltejs/kit": "^2.0.0" } }, "sha512-cBNt4jgH4KuaNO5gRSB2CZKkGtz+OCZ8lPjRQGjhvVUD4akotnj2weUia6imLl2v07K3IgsQRyM36909miSwoQ=="],
"@sveltejs/adapter-node": ["@sveltejs/adapter-node@5.3.3", "", { "dependencies": { "@rollup/plugin-commonjs": "^28.0.1", "@rollup/plugin-json": "^6.1.0", "@rollup/plugin-node-resolve": "^16.0.0", "rollup": "^4.9.5" }, "peerDependencies": { "@sveltejs/kit": "^2.4.0" } }, "sha512-SRDVuFBkmpKGsA9b0wYaCrrSChq2Yv5Dv8g7WiZcs8E69vdQNRamN0DzQV9/rEixvuRkojATLADNeQ+6FeyVNQ=="],
"@sveltejs/kit": ["@sveltejs/kit@2.46.4", "", { "dependencies": { "@standard-schema/spec": "^1.0.0", "@sveltejs/acorn-typescript": "^1.0.5", "@types/cookie": "^0.6.0", "acorn": "^8.14.1", "cookie": "^0.6.0", "devalue": "^5.3.2", "esm-env": "^1.2.2", "kleur": "^4.1.5", "magic-string": "^0.30.5", "mrmime": "^2.0.0", "sade": "^1.8.1", "set-cookie-parser": "^2.6.0", "sirv": "^3.0.0" }, "peerDependencies": { "@opentelemetry/api": "^1.0.0", "@sveltejs/vite-plugin-svelte": "^3.0.0 || ^4.0.0-next.1 || ^5.0.0 || ^6.0.0-next.0", "svelte": "^4.0.0 || ^5.0.0-next.0", "vite": "^5.0.3 || ^6.0.0 || ^7.0.0-beta.0" }, "optionalPeers": ["@opentelemetry/api"], "bin": { "svelte-kit": "svelte-kit.js" } }, "sha512-J1fd80WokLzIm6EAV7z7C2+/C02qVAX645LZomARARTRJkbbJSY1Jln3wtBZYibUB8c9/5Z6xqLAV39VdbtWCQ=="],
"@sveltejs/vite-plugin-svelte": ["@sveltejs/vite-plugin-svelte@6.2.1", "", { "dependencies": { "@sveltejs/vite-plugin-svelte-inspector": "^5.0.0", "debug": "^4.4.1", "deepmerge": "^4.3.1", "magic-string": "^0.30.17", "vitefu": "^1.1.1" }, "peerDependencies": { "svelte": "^5.0.0", "vite": "^6.3.0 || ^7.0.0" } }, "sha512-YZs/OSKOQAQCnJvM/P+F1URotNnYNeU3P2s4oIpzm1uFaqUEqRxUB0g5ejMjEb5Gjb9/PiBI5Ktrq4rUUF8UVQ=="],
"@sveltejs/vite-plugin-svelte-inspector": ["@sveltejs/vite-plugin-svelte-inspector@5.0.1", "", { "dependencies": { "debug": "^4.4.1" }, "peerDependencies": { "@sveltejs/vite-plugin-svelte": "^6.0.0-next.0", "svelte": "^5.0.0", "vite": "^6.3.0 || ^7.0.0" } }, "sha512-ubWshlMk4bc8mkwWbg6vNvCeT7lGQojE3ijDh3QTR6Zr/R+GXxsGbyH4PExEPpiFmqPhYiVSVmHBjUcVc1JIrA=="],
"@types/bun": ["@types/bun@1.3.0", "", { "dependencies": { "bun-types": "1.3.0" } }, "sha512-+lAGCYjXjip2qY375xX/scJeVRmZ5cY0wyHYyCYxNcdEXrQ4AOe3gACgd4iQ8ksOslJtW4VNxBJ8llUwc3a6AA=="],
"@types/cookie": ["@types/cookie@0.6.0", "", {}, "sha512-4Kh9a6B2bQciAhf7FSuMRRkUWecJgJu9nPnx3yzpsfXX/c50REIqpHY4C82bXP90qrLtXtkDxTZosYO3UpOwlA=="],
"@types/estree": ["@types/estree@1.0.8", "", {}, "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w=="],
"@types/node": ["@types/node@24.7.2", "", { "dependencies": { "undici-types": "~7.14.0" } }, "sha512-/NbVmcGTP+lj5oa4yiYxxeBjRivKQ5Ns1eSZeB99ExsEQ6rX5XYU1Zy/gGxY/ilqtD4Etx9mKyrPxZRetiahhA=="],
"@types/react": ["@types/react@19.2.2", "", { "dependencies": { "csstype": "^3.0.2" } }, "sha512-6mDvHUFSjyT2B2yeNx2nUgMxh9LtOWvkhIU3uePn2I2oyNymUAX1NIsdgviM4CH+JSrp2D2hsMvJOkxY+0wNRA=="],
"@types/resolve": ["@types/resolve@1.20.2", "", {}, "sha512-60BCwRFOZCQhDncwQdxxeOEEkbc5dIMccYLwbxsS4TUNeVECQ/pBJ0j09mrHOl/JJvpRPGwO9SvE4nR2Nb/a4Q=="],
"acorn": ["acorn@8.15.0", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg=="],
"aria-query": ["aria-query@5.3.2", "", {}, "sha512-COROpnaoap1E2F000S62r6A60uHZnmlvomhfyT2DlTcrY1OrBKn2UhH7qn5wTC9zMvD0AY7csdPSNwKP+7WiQw=="],
"axobject-query": ["axobject-query@4.1.0", "", {}, "sha512-qIj0G9wZbMGNLjLmg1PT6v2mE9AH2zlnADJD/2tC6E00hgmhUOfEB6greHPAfLRSufHqROIUTkw6E+M3lH0PTQ=="],
"base64-js": ["base64-js@1.5.1", "", {}, "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA=="],
"better-sqlite3": ["better-sqlite3@12.4.1", "", { "dependencies": { "bindings": "^1.5.0", "prebuild-install": "^7.1.1" } }, "sha512-3yVdyZhklTiNrtg+4WqHpJpFDd+WHTg2oM7UcR80GqL05AOV0xEJzc6qNvFYoEtE+hRp1n9MpN6/+4yhlGkDXQ=="],
"bindings": ["bindings@1.5.0", "", { "dependencies": { "file-uri-to-path": "1.0.0" } }, "sha512-p2q/t/mhvuOj/UeLlV6566GD/guowlr0hHxClI0W9m7MWYkL1F0hLo+0Aexs9HSPCtR1SXQ0TD3MMKrXZajbiQ=="],
"bl": ["bl@4.1.0", "", { "dependencies": { "buffer": "^5.5.0", "inherits": "^2.0.4", "readable-stream": "^3.4.0" } }, "sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w=="],
"buffer": ["buffer@5.7.1", "", { "dependencies": { "base64-js": "^1.3.1", "ieee754": "^1.1.13" } }, "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ=="],
"bun-types": ["bun-types@1.3.0", "", { "dependencies": { "@types/node": "*" }, "peerDependencies": { "@types/react": "^19" } }, "sha512-u8X0thhx+yJ0KmkxuEo9HAtdfgCBaM/aI9K90VQcQioAmkVp3SG3FkwWGibUFz3WdXAdcsqOcbU40lK7tbHdkQ=="],
"chokidar": ["chokidar@4.0.3", "", { "dependencies": { "readdirp": "^4.0.1" } }, "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA=="],
"chownr": ["chownr@1.1.4", "", {}, "sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg=="],
"clsx": ["clsx@2.1.1", "", {}, "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA=="],
"commondir": ["commondir@1.0.1", "", {}, "sha512-W9pAhw0ja1Edb5GVdIF1mjZw/ASI0AlShXM83UUGe2DVr5TdAPEA1OA8m/g8zWp9x6On7gqufY+FatDbC3MDQg=="],
"cookie": ["cookie@0.6.0", "", {}, "sha512-U71cyTamuh1CRNCfpGY6to28lxvNwPG4Guz/EVjgf3Jmzv0vlDp1atT9eS5dDjMYHucpHbWns6Lwf3BKz6svdw=="],
"csstype": ["csstype@3.1.3", "", {}, "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw=="],
"debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="],
"decompress-response": ["decompress-response@6.0.0", "", { "dependencies": { "mimic-response": "^3.1.0" } }, "sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ=="],
"deep-extend": ["deep-extend@0.6.0", "", {}, "sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA=="],
"deepmerge": ["deepmerge@4.3.1", "", {}, "sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A=="],
"detect-libc": ["detect-libc@2.1.2", "", {}, "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ=="],
"devalue": ["devalue@5.3.2", "", {}, "sha512-UDsjUbpQn9kvm68slnrs+mfxwFkIflOhkanmyabZ8zOYk8SMEIbJ3TK+88g70hSIeytu4y18f0z/hYHMTrXIWw=="],
"end-of-stream": ["end-of-stream@1.4.5", "", { "dependencies": { "once": "^1.4.0" } }, "sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg=="],
"esbuild": ["esbuild@0.25.10", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.25.10", "@esbuild/android-arm": "0.25.10", "@esbuild/android-arm64": "0.25.10", "@esbuild/android-x64": "0.25.10", "@esbuild/darwin-arm64": "0.25.10", "@esbuild/darwin-x64": "0.25.10", "@esbuild/freebsd-arm64": "0.25.10", "@esbuild/freebsd-x64": "0.25.10", "@esbuild/linux-arm": "0.25.10", "@esbuild/linux-arm64": "0.25.10", "@esbuild/linux-ia32": "0.25.10", "@esbuild/linux-loong64": "0.25.10", "@esbuild/linux-mips64el": "0.25.10", "@esbuild/linux-ppc64": "0.25.10", "@esbuild/linux-riscv64": "0.25.10", "@esbuild/linux-s390x": "0.25.10", "@esbuild/linux-x64": "0.25.10", "@esbuild/netbsd-arm64": "0.25.10", "@esbuild/netbsd-x64": "0.25.10", "@esbuild/openbsd-arm64": "0.25.10", "@esbuild/openbsd-x64": "0.25.10", "@esbuild/openharmony-arm64": "0.25.10", "@esbuild/sunos-x64": "0.25.10", "@esbuild/win32-arm64": "0.25.10", "@esbuild/win32-ia32": "0.25.10", "@esbuild/win32-x64": "0.25.10" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-9RiGKvCwaqxO2owP61uQ4BgNborAQskMR6QusfWzQqv7AZOg5oGehdY2pRJMTKuwxd1IDBP4rSbI5lHzU7SMsQ=="],
"esm-env": ["esm-env@1.2.2", "", {}, "sha512-Epxrv+Nr/CaL4ZcFGPJIYLWFom+YeV1DqMLHJoEd9SYRxNbaFruBwfEX/kkHUJf55j2+TUbmDcmuilbP1TmXHA=="],
"esrap": ["esrap@2.1.0", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.4.15" } }, "sha512-yzmPNpl7TBbMRC5Lj2JlJZNPml0tzqoqP5B1JXycNUwtqma9AKCO0M2wHrdgsHcy1WRW7S9rJknAMtByg3usgA=="],
"estree-walker": ["estree-walker@2.0.2", "", {}, "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w=="],
"expand-template": ["expand-template@2.0.3", "", {}, "sha512-XYfuKMvj4O35f/pOXLObndIRvyQ+/+6AhODh+OKWj9S9498pHHn/IMszH+gt0fBCRWMNfk1ZSp5x3AifmnI2vg=="],
"fdir": ["fdir@6.5.0", "", { "peerDependencies": { "picomatch": "^3 || ^4" }, "optionalPeers": ["picomatch"] }, "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg=="],
"file-uri-to-path": ["file-uri-to-path@1.0.0", "", {}, "sha512-0Zt+s3L7Vf1biwWZ29aARiVYLx7iMGnEUl9x33fbB/j3jR81u/O2LbqK+Bm1CDSNDKVtJ/YjwY7TUd5SkeLQLw=="],
"fs-constants": ["fs-constants@1.0.0", "", {}, "sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow=="],
"fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="],
"function-bind": ["function-bind@1.1.2", "", {}, "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA=="],
"github-from-package": ["github-from-package@0.0.0", "", {}, "sha512-SyHy3T1v2NUXn29OsWdxmK6RwHD+vkj3v8en8AOBZ1wBQ/hCAQ5bAQTD02kW4W9tUp/3Qh6J8r9EvntiyCmOOw=="],
"hasown": ["hasown@2.0.2", "", { "dependencies": { "function-bind": "^1.1.2" } }, "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ=="],
"ieee754": ["ieee754@1.2.1", "", {}, "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA=="],
"inherits": ["inherits@2.0.4", "", {}, "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ=="],
"ini": ["ini@1.3.8", "", {}, "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew=="],
"is-core-module": ["is-core-module@2.16.1", "", { "dependencies": { "hasown": "^2.0.2" } }, "sha512-UfoeMA6fIJ8wTYFEUjelnaGI67v6+N7qXJEvQuIGa99l4xsCruSYOVSQ0uPANn4dAzm8lkYPaKLrrijLq7x23w=="],
"is-module": ["is-module@1.0.0", "", {}, "sha512-51ypPSPCoTEIN9dy5Oy+h4pShgJmPCygKfyRCISBI+JoWT/2oJvK8QPxmwv7b/p239jXrm9M1mlQbyKJ5A152g=="],
"is-reference": ["is-reference@3.0.3", "", { "dependencies": { "@types/estree": "^1.0.6" } }, "sha512-ixkJoqQvAP88E6wLydLGGqCJsrFUnqoH6HnaczB8XmDH1oaWU+xxdptvikTgaEhtZ53Ky6YXiBuUI2WXLMCwjw=="],
"kleur": ["kleur@4.1.5", "", {}, "sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ=="],
"locate-character": ["locate-character@3.0.0", "", {}, "sha512-SW13ws7BjaeJ6p7Q6CO2nchbYEc3X3J6WrmTTDto7yMPqVSZTUyY5Tjbid+Ab8gLnATtygYtiDIJGQRRn2ZOiA=="],
"magic-string": ["magic-string@0.30.19", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.5" } }, "sha512-2N21sPY9Ws53PZvsEpVtNuSW+ScYbQdp4b9qUaL+9QkHUrGFKo56Lg9Emg5s9V/qrtNBmiR01sYhUOwu3H+VOw=="],
"mimic-response": ["mimic-response@3.1.0", "", {}, "sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ=="],
"minimist": ["minimist@1.2.8", "", {}, "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA=="],
"mkdirp-classic": ["mkdirp-classic@0.5.3", "", {}, "sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A=="],
"mri": ["mri@1.2.0", "", {}, "sha512-tzzskb3bG8LvYGFF/mDTpq3jpI6Q9wc3LEmBaghu+DdCssd1FakN7Bc0hVNmEyGq1bq3RgfkCb3cmQLpNPOroA=="],
"mrmime": ["mrmime@2.0.1", "", {}, "sha512-Y3wQdFg2Va6etvQ5I82yUhGdsKrcYox6p7FfL1LbK2J4V01F9TGlepTIhnK24t7koZibmg82KGglhA1XK5IsLQ=="],
"ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="],
"nanoid": ["nanoid@3.3.11", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w=="],
"napi-build-utils": ["napi-build-utils@2.0.0", "", {}, "sha512-GEbrYkbfF7MoNaoh2iGG84Mnf/WZfB0GdGEsM8wz7Expx/LlWf5U8t9nvJKXSp3qr5IsEbK04cBGhol/KwOsWA=="],
"node-abi": ["node-abi@3.78.0", "", { "dependencies": { "semver": "^7.3.5" } }, "sha512-E2wEyrgX/CqvicaQYU3Ze1PFGjc4QYPGsjUrlYkqAE0WjHEZwgOsGMPMzkMse4LjJbDmaEuDX3CM036j5K2DSQ=="],
"once": ["once@1.4.0", "", { "dependencies": { "wrappy": "1" } }, "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w=="],
"path-parse": ["path-parse@1.0.7", "", {}, "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw=="],
"picocolors": ["picocolors@1.1.1", "", {}, "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="],
"picomatch": ["picomatch@4.0.3", "", {}, "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q=="],
"postcss": ["postcss@8.5.6", "", { "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg=="],
"prebuild-install": ["prebuild-install@7.1.3", "", { "dependencies": { "detect-libc": "^2.0.0", "expand-template": "^2.0.3", "github-from-package": "0.0.0", "minimist": "^1.2.3", "mkdirp-classic": "^0.5.3", "napi-build-utils": "^2.0.0", "node-abi": "^3.3.0", "pump": "^3.0.0", "rc": "^1.2.7", "simple-get": "^4.0.0", "tar-fs": "^2.0.0", "tunnel-agent": "^0.6.0" }, "bin": { "prebuild-install": "bin.js" } }, "sha512-8Mf2cbV7x1cXPUILADGI3wuhfqWvtiLA1iclTDbFRZkgRQS0NqsPZphna9V+HyTEadheuPmjaJMsbzKQFOzLug=="],
"pump": ["pump@3.0.3", "", { "dependencies": { "end-of-stream": "^1.1.0", "once": "^1.3.1" } }, "sha512-todwxLMY7/heScKmntwQG8CXVkWUOdYxIvY2s0VWAAMh/nd8SoYiRaKjlr7+iCs984f2P8zvrfWcDDYVb73NfA=="],
"rc": ["rc@1.2.8", "", { "dependencies": { "deep-extend": "^0.6.0", "ini": "~1.3.0", "minimist": "^1.2.0", "strip-json-comments": "~2.0.1" }, "bin": { "rc": "./cli.js" } }, "sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw=="],
"readable-stream": ["readable-stream@3.6.2", "", { "dependencies": { "inherits": "^2.0.3", "string_decoder": "^1.1.1", "util-deprecate": "^1.0.1" } }, "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA=="],
"readdirp": ["readdirp@4.1.2", "", {}, "sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg=="],
"resolve": ["resolve@1.22.10", "", { "dependencies": { "is-core-module": "^2.16.0", "path-parse": "^1.0.7", "supports-preserve-symlinks-flag": "^1.0.0" }, "bin": { "resolve": "bin/resolve" } }, "sha512-NPRy+/ncIMeDlTAsuqwKIiferiawhefFJtkNSW0qZJEqMEb+qBt/77B/jGeeek+F0uOeN05CDa6HXbbIgtVX4w=="],
"rollup": ["rollup@4.52.4", "", { "dependencies": { "@types/estree": "1.0.8" }, "optionalDependencies": { "@rollup/rollup-android-arm-eabi": "4.52.4", "@rollup/rollup-android-arm64": "4.52.4", "@rollup/rollup-darwin-arm64": "4.52.4", "@rollup/rollup-darwin-x64": "4.52.4", "@rollup/rollup-freebsd-arm64": "4.52.4", "@rollup/rollup-freebsd-x64": "4.52.4", "@rollup/rollup-linux-arm-gnueabihf": "4.52.4", "@rollup/rollup-linux-arm-musleabihf": "4.52.4", "@rollup/rollup-linux-arm64-gnu": "4.52.4", "@rollup/rollup-linux-arm64-musl": "4.52.4", "@rollup/rollup-linux-loong64-gnu": "4.52.4", "@rollup/rollup-linux-ppc64-gnu": "4.52.4", "@rollup/rollup-linux-riscv64-gnu": "4.52.4", "@rollup/rollup-linux-riscv64-musl": "4.52.4", "@rollup/rollup-linux-s390x-gnu": "4.52.4", "@rollup/rollup-linux-x64-gnu": "4.52.4", "@rollup/rollup-linux-x64-musl": "4.52.4", "@rollup/rollup-openharmony-arm64": "4.52.4", "@rollup/rollup-win32-arm64-msvc": "4.52.4", "@rollup/rollup-win32-ia32-msvc": "4.52.4", "@rollup/rollup-win32-x64-gnu": "4.52.4", "@rollup/rollup-win32-x64-msvc": "4.52.4", "fsevents": "~2.3.2" }, "bin": { "rollup": "dist/bin/rollup" } }, "sha512-CLEVl+MnPAiKh5pl4dEWSyMTpuflgNQiLGhMv8ezD5W/qP8AKvmYpCOKRRNOh7oRKnauBZ4SyeYkMS+1VSyKwQ=="],
"sade": ["sade@1.8.1", "", { "dependencies": { "mri": "^1.1.0" } }, "sha512-xal3CZX1Xlo/k4ApwCFrHVACi9fBqJ7V+mwhBsuf/1IOKbBy098Fex+Wa/5QMubw09pSZ/u8EY8PWgevJsXp1A=="],
"safe-buffer": ["safe-buffer@5.2.1", "", {}, "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ=="],
"semver": ["semver@7.7.3", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q=="],
"set-cookie-parser": ["set-cookie-parser@2.7.1", "", {}, "sha512-IOc8uWeOZgnb3ptbCURJWNjWUPcO3ZnTTdzsurqERrP6nPyv+paC55vJM0LpOlT2ne+Ix+9+CRG1MNLlyZ4GjQ=="],
"simple-concat": ["simple-concat@1.0.1", "", {}, "sha512-cSFtAPtRhljv69IK0hTVZQ+OfE9nePi/rtJmw5UjHeVyVroEqJXP1sFztKUy1qU+xvz3u/sfYJLa947b7nAN2Q=="],
"simple-get": ["simple-get@4.0.1", "", { "dependencies": { "decompress-response": "^6.0.0", "once": "^1.3.1", "simple-concat": "^1.0.0" } }, "sha512-brv7p5WgH0jmQJr1ZDDfKDOSeWWg+OVypG99A/5vYGPqJ6pxiaHLy8nxtFjBA7oMa01ebA9gfh1uMCFqOuXxvA=="],
"sirv": ["sirv@3.0.2", "", { "dependencies": { "@polka/url": "^1.0.0-next.24", "mrmime": "^2.0.0", "totalist": "^3.0.0" } }, "sha512-2wcC/oGxHis/BoHkkPwldgiPSYcpZK3JU28WoMVv55yHJgcZ8rlXvuG9iZggz+sU1d4bRgIGASwyWqjxu3FM0g=="],
"source-map-js": ["source-map-js@1.2.1", "", {}, "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA=="],
"string_decoder": ["string_decoder@1.3.0", "", { "dependencies": { "safe-buffer": "~5.2.0" } }, "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA=="],
"strip-json-comments": ["strip-json-comments@2.0.1", "", {}, "sha512-4gB8na07fecVVkOI6Rs4e7T6NOTki5EmL7TUduTs6bu3EdnSycntVJ4re8kgZA+wx9IueI2Y11bfbgwtzuE0KQ=="],
"supports-preserve-symlinks-flag": ["supports-preserve-symlinks-flag@1.0.0", "", {}, "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w=="],
"svelte": ["svelte@5.39.11", "", { "dependencies": { "@jridgewell/remapping": "^2.3.4", "@jridgewell/sourcemap-codec": "^1.5.0", "@sveltejs/acorn-typescript": "^1.0.5", "@types/estree": "^1.0.5", "acorn": "^8.12.1", "aria-query": "^5.3.1", "axobject-query": "^4.1.0", "clsx": "^2.1.1", "esm-env": "^1.2.1", "esrap": "^2.1.0", "is-reference": "^3.0.3", "locate-character": "^3.0.0", "magic-string": "^0.30.11", "zimmerframe": "^1.1.2" } }, "sha512-8MxWVm2+3YwrFbPaxOlT1bbMi6OTenrAgks6soZfiaS8Fptk4EVyRIFhJc3RpO264EeSNwgjWAdki0ufg4zkGw=="],
"svelte-check": ["svelte-check@4.3.3", "", { "dependencies": { "@jridgewell/trace-mapping": "^0.3.25", "chokidar": "^4.0.1", "fdir": "^6.2.0", "picocolors": "^1.0.0", "sade": "^1.7.4" }, "peerDependencies": { "svelte": "^4.0.0 || ^5.0.0-next.0", "typescript": ">=5.0.0" }, "bin": { "svelte-check": "bin/svelte-check" } }, "sha512-RYP0bEwenDXzfv0P1sKAwjZSlaRyqBn0Fz1TVni58lqyEiqgwztTpmodJrGzP6ZT2aHl4MbTvWP6gbmQ3FOnBg=="],
"tar-fs": ["tar-fs@2.1.4", "", { "dependencies": { "chownr": "^1.1.1", "mkdirp-classic": "^0.5.2", "pump": "^3.0.0", "tar-stream": "^2.1.4" } }, "sha512-mDAjwmZdh7LTT6pNleZ05Yt65HC3E+NiQzl672vQG38jIrehtJk/J3mNwIg+vShQPcLF/LV7CMnDW6vjj6sfYQ=="],
"tar-stream": ["tar-stream@2.2.0", "", { "dependencies": { "bl": "^4.0.3", "end-of-stream": "^1.4.1", "fs-constants": "^1.0.0", "inherits": "^2.0.3", "readable-stream": "^3.1.1" } }, "sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ=="],
"tinyglobby": ["tinyglobby@0.2.15", "", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.3" } }, "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ=="],
"totalist": ["totalist@3.0.1", "", {}, "sha512-sf4i37nQ2LBx4m3wB74y+ubopq6W/dIzXg0FDGjsYnZHVa1Da8FH853wlL2gtUhg+xJXjfk3kUZS3BRoQeoQBQ=="],
"tunnel-agent": ["tunnel-agent@0.6.0", "", { "dependencies": { "safe-buffer": "^5.0.1" } }, "sha512-McnNiV1l8RYeY8tBgEpuodCC1mLUdbSN+CYBL7kJsJNInOP8UjDDEwdk6Mw60vdLLrr5NHKZhMAOSrR2NZuQ+w=="],
"typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="],
"undici-types": ["undici-types@7.14.0", "", {}, "sha512-QQiYxHuyZ9gQUIrmPo3IA+hUl4KYk8uSA7cHrcKd/l3p1OTpZcM0Tbp9x7FAtXdAYhlasd60ncPpgu6ihG6TOA=="],
"util-deprecate": ["util-deprecate@1.0.2", "", {}, "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw=="],
"vite": ["vite@7.1.9", "", { "dependencies": { "esbuild": "^0.25.0", "fdir": "^6.5.0", "picomatch": "^4.0.3", "postcss": "^8.5.6", "rollup": "^4.43.0", "tinyglobby": "^0.2.15" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", "jiti": ">=1.21.0", "less": "^4.0.0", "lightningcss": "^1.21.0", "sass": "^1.70.0", "sass-embedded": "^1.70.0", "stylus": ">=0.54.8", "sugarss": "^5.0.0", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "jiti", "less", "lightningcss", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-4nVGliEpxmhCL8DslSAUdxlB6+SMrhB0a1v5ijlh1xB1nEPuy1mxaHxysVucLHuWryAxLWg6a5ei+U4TLn/rFg=="],
"vitefu": ["vitefu@1.1.1", "", { "peerDependencies": { "vite": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0-beta.0" }, "optionalPeers": ["vite"] }, "sha512-B/Fegf3i8zh0yFbpzZ21amWzHmuNlLlmJT6n7bu5e+pCHUKQIfXSYokrqOBGEMMe9UG2sostKQF9mml/vYaWJQ=="],
"wrappy": ["wrappy@1.0.2", "", {}, "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ=="],
"zimmerframe": ["zimmerframe@1.1.4", "", {}, "sha512-B58NGBEoc8Y9MWWCQGl/gq9xBCe4IiKM0a2x7GZdQKOW5Exr8S1W24J6OgM1njK8xCRGvAJIL/MxXHf6SkmQKQ=="],
"@rollup/plugin-commonjs/is-reference": ["is-reference@1.2.1", "", { "dependencies": { "@types/estree": "*" } }, "sha512-U82MsXXiFIrjCK4otLT+o2NA2Cd2g5MLoOVXUZjIOhLurrRxpEXzI8O0KZHr3IjLvlAH1kTPYSuqer5T9ZVBKQ=="],
}
}

@ -0,0 +1,29 @@
{
"name": "web",
"private": true,
"version": "0.0.1",
"type": "module",
"scripts": {
"dev": "vite dev",
"build": "vite build",
"preview": "vite preview",
"start": "bun ./build/index.js",
"prepare": "svelte-kit sync || echo ''",
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
"check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch"
},
"devDependencies": {
"@sveltejs/adapter-auto": "^6.1.0",
"@sveltejs/adapter-node": "^5.3.3",
"@sveltejs/kit": "^2.43.2",
"@sveltejs/vite-plugin-svelte": "^6.2.0",
"@types/bun": "^1.3.0",
"svelte": "^5.39.5",
"svelte-check": "^4.3.2",
"typescript": "^5.9.2",
"vite": "^7.1.7"
},
"optionalDependencies": {
"better-sqlite3": "^12.4.1"
}
}

@ -0,0 +1,14 @@
// See https://svelte.dev/docs/kit/types#app.d.ts
/* See https://svelte.dev/docs/kit/types#app.d.ts */
declare global {
namespace App {
interface Locals {
userId?: string | null;
}
// interface Error {}
// interface PageData {}
// interface PageState {}
// interface Platform {}
}
}
export {};

@ -0,0 +1,11 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
%sveltekit.head%
</head>
<body data-sveltekit-preload-data="hover">
<div style="display: contents">%sveltekit.body%</div>
</body>
</html>

@ -0,0 +1,124 @@
import type { Handle } from '@sveltejs/kit';
import { getDb } from '$lib/server/db';
import { sha256Hex } from '$lib/server/crypto';
import { isProd, sessionIdleTtlMs, isDev, DEV_BYPASS_AUTH, DEV_DEFAULT_USER } from '$lib/server/env';
function toIsoSql(d: Date): string {
return d.toISOString().replace('T', ' ').replace('Z', '');
}
export const handle: Handle = async ({ event, resolve }) => {
// Bypass de auth en desarrollo (siempre activo en dev para entorno de pruebas)
const bypass = isDev();
if (bypass) {
const qp = event.url.searchParams.get('__as')?.trim();
const current = event.cookies.get('dev_as') || '';
const user = qp && qp.length ? qp : (current || DEV_DEFAULT_USER);
if (qp && qp.length && qp !== current) {
event.cookies.set('dev_as', user, {
path: '/',
httpOnly: true,
sameSite: 'lax',
secure: isProd(),
maxAge: 60 * 60 * 24 * 30 // 30 días
});
}
event.locals.userId = user;
}
// Sesión por cookie 'sid'
const isLogout = event.url.pathname === '/api/logout' || event.url.pathname.startsWith('/api/logout/');
const sid = event.cookies.get('sid');
if (!bypass && sid) {
try {
const db = await getDb();
const hash = await sha256Hex(sid);
// Validar sesión vigente
const row = db
.prepare(
`SELECT user_id FROM web_sessions
WHERE session_hash = ?
AND expires_at > strftime('%Y-%m-%d %H:%M:%f','now')
LIMIT 1`
)
.get(hash) as { user_id: string } | undefined;
if (row?.user_id) {
event.locals.userId = row.user_id;
// Renovar expiración por inactividad y last_seen_at
const newExpIso = toIsoSql(new Date(Date.now() + sessionIdleTtlMs));
try {
db.prepare(
`UPDATE web_sessions
SET last_seen_at = strftime('%Y-%m-%d %H:%M:%f','now'),
expires_at = ?
WHERE session_hash = ?`
).run(newExpIso, hash);
} catch {
// Si no existe last_seen_at en el esquema, al menos renovar expires_at
try {
db.prepare(
`UPDATE web_sessions
SET expires_at = ?
WHERE session_hash = ?`
).run(newExpIso, hash);
} catch {}
}
// Refrescar cookie (idle) excepto durante /api/logout
if (!isLogout) {
event.cookies.set('sid', sid, {
path: '/',
httpOnly: true,
sameSite: 'lax',
secure: isProd(),
maxAge: Math.floor(sessionIdleTtlMs / 1000)
});
}
} else {
// Sesión inválida/expirada
event.cookies.delete('sid', { path: '/', httpOnly: true, sameSite: 'lax', secure: isProd() });
}
} catch {
// En caso de error de DB, no romper la request; continuar sin sesión
}
}
const response = await resolve(event);
// Cabeceras de seguridad y caché: solo para HTML
try {
const ct = response.headers.get('content-type') || '';
if (ct.includes('text/html')) {
response.headers.set('cache-control', 'no-store');
response.headers.set('X-Frame-Options', 'DENY');
response.headers.set('Referrer-Policy', 'no-referrer');
response.headers.set('X-Content-Type-Options', 'nosniff');
// Mitigar aviso de “preload no usado” en CSS:
// Filtrar del header Link los preloads con as=style (dejamos modulepreload para JS).
const link = response.headers.get('Link') || response.headers.get('link');
if (link) {
const filtered = link
.split(',')
.map((s) => s.trim())
.filter((seg) => !/;\s*as=style\b/i.test(seg));
if (filtered.length > 0) {
response.headers.set('Link', filtered.join(', '));
} else {
response.headers.delete('Link');
}
}
}
} catch {
// Ignorar si la implementación de Response no permite set()
}
// Indicador de bypass en respuestas (útil en dev)
try {
if (bypass) {
response.headers.set('X-Dev-Auth', 'bypass');
}
} catch {}
return response;
};

@ -0,0 +1 @@
<?xml version="1.0" encoding="utf-8"?><svg version="1.1" id="Layer_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px" viewBox="0 0 122.88 100.6" style="enable-background:new 0 0 122.88 100.6" xml:space="preserve"><style type="text/css">.st0{fill:#272727;} .st1{fill-rule:evenodd;clip-rule:evenodd;fill:#D8453E;}</style><g><path class="st0" d="M72.58,0c6.8,0,13.3,1.36,19.23,3.81c6.16,2.55,11.7,6.29,16.33,10.92l0,0c4.63,4.63,8.37,10.17,10.92,16.34 c2.46,5.93,3.81,12.43,3.81,19.23c0,6.8-1.36,13.3-3.81,19.23c-2.55,6.16-6.29,11.7-10.92,16.33l0,0 c-4.63,4.63-10.17,8.37-16.34,10.92c-5.93,2.46-12.43,3.81-19.23,3.81c-6.8,0-13.3-1.36-19.23-3.81 c-6.15-2.55-11.69-6.28-16.33-10.92l-0.01-0.01c-4.64-4.64-8.37-10.17-10.92-16.33c-0.79-1.91-1.47-3.87-2.02-5.89 c1.05,0.1,2.12,0.15,3.2,0.15c2.05,0,4.05-0.19,6-0.54c0.32,0.97,0.67,1.93,1.06,2.87c2.09,5.05,5.17,9.6,8.99,13.43 c3.82,3.82,8.38,6.9,13.43,8.99c4.87,2.02,10.21,3.13,15.83,3.13c5.62,0,10.96-1.11,15.83-3.13c5.05-2.09,9.6-5.17,13.43-8.99 c3.82-3.82,6.9-8.38,8.99-13.43c2.02-4.87,3.13-10.21,3.13-15.83c0-5.62-1.11-10.96-3.13-15.83c-2.09-5.05-5.17-9.6-8.99-13.43 c-3.82-3.82-8.38-6.9-13.43-8.99c-4.87-2.02-10.21-3.13-15.83-3.13c-5.62,0-10.96,1.11-15.83,3.13c-0.44,0.18-0.87,0.37-1.3,0.56 c-1.65-2.61-3.66-4.97-5.95-7.02c1.25-0.65,2.53-1.24,3.84-1.79C59.28,1.36,65.78,0,72.58,0L72.58,0z M66.8,26.39 c0-1.23,0.5-2.35,1.31-3.16c0.81-0.81,1.93-1.31,3.16-1.31c1.23,0,2.35,0.5,3.16,1.31c0.81,0.81,1.31,1.93,1.31,3.16v23.47 l17.54,10.4c1.05,0.62,1.76,1.62,2.05,2.73c0.28,1.1,0.15,2.31-0.47,3.37l0,0.01l0,0c-0.62,1.05-1.62,1.76-2.73,2.05 c-1.1,0.28-2.31,0.15-3.37-0.47l-0.01,0l0,0L69.1,56.29c-0.67-0.38-1.24-0.92-1.64-1.57c-0.42-0.68-0.66-1.48-0.66-2.32V26.39 L66.8,26.39z"/><path class="st1" d="M27.27,3.18c15.06,0,27.27,12.21,27.27,27.27c0,15.06-12.21,27.27-27.27,27.27C12.21,57.73,0,45.52,0,30.45 C0,15.39,12.21,3.18,27.27,3.18L27.27,3.18z M24.35,41.34h5.82v5.16h-5.82V41.34L24.35,41.34L24.35,41.34z M30.17,37.77h-5.82 c-0.58-7.07-1.8-11.56-1.8-18.63c0-2.61,2.12-4.72,4.72-4.72c2.61,0,4.72,2.12,4.72,4.72C32,26.2,30.76,30.7,30.17,37.77 L30.17,37.77L30.17,37.77L30.17,37.77z"/></g></svg>

After

Width:  |  Height:  |  Size: 2.1 KiB

@ -0,0 +1 @@
<?xml version="1.0" encoding="utf-8"?><svg version="1.1" id="Layer_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px" viewBox="0 0 106.86 122.88" style="enable-background:new 0 0 106.86 122.88" xml:space="preserve"><style type="text/css">.st0{fill-rule:evenodd;clip-rule:evenodd;}</style><g><path class="st0" d="M39.62,64.58c-1.46,0-2.64-1.41-2.64-3.14c0-1.74,1.18-3.14,2.64-3.14h34.89c1.46,0,2.64,1.41,2.64,3.14 c0,1.74-1.18,3.14-2.64,3.14H39.62L39.62,64.58z M46.77,116.58c1.74,0,3.15,1.41,3.15,3.15c0,1.74-1.41,3.15-3.15,3.15H7.33 c-2.02,0-3.85-0.82-5.18-2.15C0.82,119.4,0,117.57,0,115.55V7.33c0-2.02,0.82-3.85,2.15-5.18C3.48,0.82,5.31,0,7.33,0h90.02 c2.02,0,3.85,0.83,5.18,2.15c1.33,1.33,2.15,3.16,2.15,5.18v50.14c0,1.74-1.41,3.15-3.15,3.15c-1.74,0-3.15-1.41-3.15-3.15V7.33 c0-0.28-0.12-0.54-0.31-0.72c-0.19-0.19-0.44-0.31-0.72-0.31H7.33c-0.28,0-0.54,0.12-0.73,0.3C6.42,6.8,6.3,7.05,6.3,7.33v108.21 c0,0.28,0.12,0.54,0.3,0.72c0.19,0.19,0.45,0.31,0.73,0.31H46.77L46.77,116.58z M98.7,74.34c-0.51-0.49-1.1-0.72-1.78-0.71 c-0.68,0.01-1.26,0.27-1.74,0.78l-3.91,4.07l10.97,10.59l3.95-4.11c0.47-0.48,0.67-1.1,0.66-1.78c-0.01-0.67-0.25-1.28-0.73-1.74 L98.7,74.34L98.7,74.34z M78.21,114.01c-1.45,0.46-2.89,0.94-4.33,1.41c-1.45,0.48-2.89,0.97-4.33,1.45 c-3.41,1.12-5.32,1.74-5.72,1.85c-0.39,0.12-0.16-1.48,0.7-4.81l2.71-10.45l0,0l20.55-21.38l10.96,10.55L78.21,114.01L78.21,114.01 z M39.62,86.95c-1.46,0-2.65-1.43-2.65-3.19c0-1.76,1.19-3.19,2.65-3.19h17.19c1.46,0,2.65,1.43,2.65,3.19 c0,1.76-1.19,3.19-2.65,3.19H39.62L39.62,86.95z M39.62,42.26c-1.46,0-2.64-1.41-2.64-3.14c0-1.74,1.18-3.14,2.64-3.14h34.89 c1.46,0,2.64,1.41,2.64,3.14c0,1.74-1.18,3.14-2.64,3.14H39.62L39.62,42.26z M24.48,79.46c2.06,0,3.72,1.67,3.72,3.72 c0,2.06-1.67,3.72-3.72,3.72c-2.06,0-3.72-1.67-3.72-3.72C20.76,81.13,22.43,79.46,24.48,79.46L24.48,79.46z M24.48,57.44 c2.06,0,3.72,1.67,3.72,3.72c0,2.06-1.67,3.72-3.72,3.72c-2.06,0-3.72-1.67-3.72-3.72C20.76,59.11,22.43,57.44,24.48,57.44 L24.48,57.44z M24.48,35.42c2.06,0,3.72,1.67,3.72,3.72c0,2.06-1.67,3.72-3.72,3.72c-2.06,0-3.72-1.67-3.72-3.72 C20.76,37.08,22.43,35.42,24.48,35.42L24.48,35.42z"/></g></svg>

After

Width:  |  Height:  |  Size: 2.1 KiB

@ -0,0 +1 @@
<svg id="Layer_1" data-name="Layer 1" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 108.01 122.88"><defs><style>.cls-1{fill-rule:evenodd;}</style></defs><title>emergency-exit</title><path class="cls-1" d="M.5,0H15a.51.51,0,0,1,.5.5V83.38L35.16,82h.22l.24,0c2.07-.14,3.65-.26,4.73-1.23l1.86-2.17a1.12,1.12,0,0,1,1.49-.18l9.35,6.28a1.15,1.15,0,0,1,.49,1c0,.55-.19.7-.61,1.08A11.28,11.28,0,0,0,51.78,88a27.27,27.27,0,0,1-3,3.1,15.84,15.84,0,0,1-3.68,2.45c-2.8,1.36-5.45,1.54-8.59,1.76l-.24,0-.21,0L15.5,96.77v25.61a.52.52,0,0,1-.5.5H.5a.51.51,0,0,1-.5-.5V.5A.5.5,0,0,1,.5,0ZM46,59.91l9-19.12-.89-.25a12.43,12.43,0,0,0-4.77-.82c-1.9.28-3.68,1.42-5.67,2.7-.83.53-1.69,1.09-2.62,1.63-.7.33-1.51.86-2.19,1.25l-8.7,5a1.11,1.11,0,0,1-1.51-.42l-5.48-9.64a1.1,1.1,0,0,1,.42-1.51c3.43-2,7.42-4,10.75-6.14,4-2.49,7.27-4.48,11.06-5.42s8-.8,13.89,1c2.12.59,4.55,1.48,6.55,2.2,1,.35,1.8.66,2.44.87,9.86,3.29,13.19,9.66,15.78,14.6,1.12,2.13,2.09,4,3.34,5,.51.42,1.67.27,3,.09a21.62,21.62,0,0,1,2.64-.23c4.32-.41,8.66-.66,13-1a1.1,1.1,0,0,1,1.18,1L108,61.86A1.11,1.11,0,0,1,107,63L95,63.9c-5.33.38-9.19.66-15-2.47l-.12-.07a23.23,23.23,0,0,1-7.21-8.5l0,0L65.73,68.4a63.9,63.9,0,0,0,5.85,5.32c6,5,11,9.21,9.38,20.43a23.89,23.89,0,0,1-.65,2.93c-.27,1-.56,1.9-.87,2.84-2.29,6.54-4.22,13.5-6.29,20.13a1.1,1.1,0,0,1-1,.81l-11.66.78a1,1,0,0,1-.39,0,1.12,1.12,0,0,1-.75-1.38c2.45-8.12,5-16.25,7.39-24.38a29,29,0,0,0,.87-3,7,7,0,0,0,.08-2.65l0-.24a4.16,4.16,0,0,0-.73-2.22,53.23,53.23,0,0,0-8.76-5.57c-3.75-2.07-7.41-4.08-10.25-7a12.15,12.15,0,0,1-3.59-7.36A14.76,14.76,0,0,1,46,59.91ZM80.07,6.13a12.29,12.29,0,0,1,13.1,11.39v0a12.29,12.29,0,0,1-24.52,1.72v0A12.3,12.3,0,0,1,80,6.13ZM3.34,35H6.69V51.09H3.34V35Z"/></svg>

After

Width:  |  Height:  |  Size: 1.7 KiB

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="107" height="128" viewBox="0 0 107 128"><title>svelte-logo</title><path d="M94.157 22.819c-10.4-14.885-30.94-19.297-45.792-9.835L22.282 29.608A29.92 29.92 0 0 0 8.764 49.65a31.5 31.5 0 0 0 3.108 20.231 30 30 0 0 0-4.477 11.183 31.9 31.9 0 0 0 5.448 24.116c10.402 14.887 30.942 19.297 45.791 9.835l26.083-16.624A29.92 29.92 0 0 0 98.235 78.35a31.53 31.53 0 0 0-3.105-20.232 30 30 0 0 0 4.474-11.182 31.88 31.88 0 0 0-5.447-24.116" style="fill:#ff3e00"/><path d="M45.817 106.582a20.72 20.72 0 0 1-22.237-8.243 19.17 19.17 0 0 1-3.277-14.503 18 18 0 0 1 .624-2.435l.49-1.498 1.337.981a33.6 33.6 0 0 0 10.203 5.098l.97.294-.09.968a5.85 5.85 0 0 0 1.052 3.878 6.24 6.24 0 0 0 6.695 2.485 5.8 5.8 0 0 0 1.603-.704L69.27 76.28a5.43 5.43 0 0 0 2.45-3.631 5.8 5.8 0 0 0-.987-4.371 6.24 6.24 0 0 0-6.698-2.487 5.7 5.7 0 0 0-1.6.704l-9.953 6.345a19 19 0 0 1-5.296 2.326 20.72 20.72 0 0 1-22.237-8.243 19.17 19.17 0 0 1-3.277-14.502 17.99 17.99 0 0 1 8.13-12.052l26.081-16.623a19 19 0 0 1 5.3-2.329 20.72 20.72 0 0 1 22.237 8.243 19.17 19.17 0 0 1 3.277 14.503 18 18 0 0 1-.624 2.435l-.49 1.498-1.337-.98a33.6 33.6 0 0 0-10.203-5.1l-.97-.294.09-.968a5.86 5.86 0 0 0-1.052-3.878 6.24 6.24 0 0 0-6.696-2.485 5.8 5.8 0 0 0-1.602.704L37.73 51.72a5.42 5.42 0 0 0-2.449 3.63 5.79 5.79 0 0 0 .986 4.372 6.24 6.24 0 0 0 6.698 2.486 5.8 5.8 0 0 0 1.602-.704l9.952-6.342a19 19 0 0 1 5.295-2.328 20.72 20.72 0 0 1 22.237 8.242 19.17 19.17 0 0 1 3.277 14.503 18 18 0 0 1-8.13 12.053l-26.081 16.622a19 19 0 0 1-5.3 2.328" style="fill:#fff"/></svg>

After

Width:  |  Height:  |  Size: 1.5 KiB

@ -0,0 +1 @@
<?xml version="1.0" encoding="utf-8"?><svg version="1.1" id="Layer_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px" viewBox="0 0 122.88 91.99" style="enable-background:new 0 0 122.88 91.99" xml:space="preserve"><style type="text/css">.st0{fill-rule:evenodd;clip-rule:evenodd;}</style><g><path class="st0" d="M45.13,35.29h-0.04c-7.01-0.79-16.42,0.01-20.78,0C17.04,35.6,9.47,41.91,5.02,51.3 c-2.61,5.51-3.3,9.66-3.73,15.55C0.42,72.79-0.03,78.67,0,84.47c1.43,9.03,12.88,6.35,13.85,0l1.39-18.2 c0.21-2.75,0.4-4.61,1.51-7.23c0.52-1.23,1.15-2.28,1.89-3.15l0.69,33.25l-0.39,2.78h31.49l-0.42-3.1l0.61-36.67 c3.2-1.29,5.96-1.89,8.39-1.99c-0.12,0.25-0.25,0.5-0.37,0.75c-2.61,5.51-3.3,9.66-3.73,15.55c-0.86,5.93-1.32,11.81-1.29,17.61 c1.43,9.03,12.88,6.35,13.85,0l1.39-18.2c0.21-2.75,0.4-4.61,1.51-7.23c0.52-1.23,1.15-2.28,1.89-3.15l0.69,33.25l-0.46,3.24h31.62 l-0.48-3.55l0.49-28.62v0.56l0.1-4.87c0.74,0.87,1.36,1.92,1.89,3.15c1.12,2.62,1.3,4.48,1.51,7.23l1.39,18.2 c1.34,8.68,13.85,8.85,13.85,0c0.03-5.81-0.42-11.68-1.29-17.61c-0.43-5.89-1.12-10.04-3.73-15.55 c-4.57-9.65-10.48-14.76-19.45-15.81c-5.53-0.45-14.82,0.06-20.36-0.1c-1.38,0.19-2.74,0.47-4.06,0.87 c-3.45-0.48-8.01-1.07-12.56-1.09C54.76,34.77,48.15,35.91,45.13,35.29L45.13,35.29z M88.3,0c9.01,0,16.32,7.31,16.32,16.32 c0,9.01-7.31,16.32-16.32,16.32c-9.01,0-16.32-7.31-16.32-16.32C71.98,7.31,79.29,0,88.3,0L88.3,0z M34.56,0 c9.01,0,16.32,7.31,16.32,16.32c0,9.01-7.31,16.32-16.32,16.32s-16.32-7.31-16.32-16.32C18.24,7.31,25.55,0,34.56,0L34.56,0z"/></g></svg>

After

Width:  |  Height:  |  Size: 1.5 KiB

@ -0,0 +1 @@
<?xml version="1.0" encoding="utf-8"?><svg version="1.1" id="Layer_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px" width="96px" height="96px" viewBox="0 0 96 96" enable-background="new 0 0 96 96" xml:space="preserve"><g><path fill-rule="evenodd" clip-rule="evenodd" fill="#6BBE66" d="M48,0c26.51,0,48,21.49,48,48S74.51,96,48,96S0,74.51,0,48 S21.49,0,48,0L48,0z M26.764,49.277c0.644-3.734,4.906-5.813,8.269-3.79c0.305,0.182,0.596,0.398,0.867,0.646l0.026,0.025 c1.509,1.446,3.2,2.951,4.876,4.443l1.438,1.291l17.063-17.898c1.019-1.067,1.764-1.757,3.293-2.101 c5.235-1.155,8.916,5.244,5.206,9.155L46.536,63.366c-2.003,2.137-5.583,2.332-7.736,0.291c-1.234-1.146-2.576-2.312-3.933-3.489 c-2.35-2.042-4.747-4.125-6.701-6.187C26.993,52.809,26.487,50.89,26.764,49.277L26.764,49.277z"/></g></svg>

After

Width:  |  Height:  |  Size: 835 B

@ -0,0 +1 @@
<?xml version="1.0" encoding="utf-8"?><svg version="1.1" id="Layer_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px" viewBox="0 0 121.2 122.88" style="enable-background:new 0 0 121.2 122.88" xml:space="preserve"><style type="text/css">.st0{fill-rule:evenodd;clip-rule:evenodd;}</style><g><path class="st0" d="M66.17,24.52c9.78,12.13,14.55,26.46,13.93,39.58c-4.52-11.08-11.54-22.31-20.85-32.65l-3.12,3.12 c-0.35,0.35-0.93,0.35-1.28,0l-9.87-9.87c-0.35-0.35-0.35-0.93,0-1.28l3.03-3.03C37.4,11.2,25.96,4.37,14.75,0.13 c13.22-0.99,27.81,3.57,40.2,13.31l1.36-1.36c0.35-0.35,0.93-0.35,1.28,0l9.87,9.87c0.35,0.35,0.35,0.93,0,1.28L66.17,24.52 L66.17,24.52z M49.32,58.69v-4.05l19.11,2.04L49.32,58.69L49.32,58.69z M57.83,74.36l-2.87-2.87l14.96-12.07L57.83,74.36 L57.83,74.36z M111.77,35.18l2.32,5.02l-24.85,8.4L111.77,35.18L111.77,35.18z M92.26,20.63l5.19,1.91L85.82,46.05L92.26,20.63 L92.26,20.63z M102.7,57.6l18.5,11.47v53.81H25.73c19.44-19.44,46.04-25.61,61.42-52.24C92.99,60.52,91.01,49.7,102.7,57.6 L102.7,57.6z M44.6,27.81l7.99,7.99L9.64,78.76c-2.2,2.2-5.8,2.2-7.99,0l0,0c-2.2-2.2-2.2-5.8,0-7.99L44.6,27.81L44.6,27.81z"/></g></svg>

After

Width:  |  Height:  |  Size: 1.1 KiB

@ -0,0 +1 @@
<?xml version="1.0" encoding="utf-8"?><svg version="1.1" id="Layer_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px" viewBox="0 0 122.88 99.56" style="enable-background:new 0 0 122.88 99.56" xml:space="preserve"><style type="text/css">.st0{fill:#393939;} .st1{fill-rule:evenodd;clip-rule:evenodd;fill:#38AE48;}</style><g><path class="st0" d="M73.1,0c6.73,0,13.16,1.34,19.03,3.78c6.09,2.52,11.57,6.22,16.16,10.81c4.59,4.58,8.28,10.06,10.81,16.17 c2.43,5.87,3.78,12.3,3.78,19.03c0,6.73-1.34,13.16-3.78,19.03c-2.52,6.09-6.22,11.58-10.81,16.16 c-4.58,4.59-10.06,8.28-16.17,10.81c-5.87,2.43-12.3,3.78-19.03,3.78c-6.73,0-13.16-1.34-19.03-3.77 c-6.09-2.52-11.57-6.22-16.16-10.81l-0.01-0.01c-4.59-4.59-8.29-10.07-10.81-16.16c-0.78-1.89-1.45-3.83-2-5.82 c1.04,0.1,2.1,0.15,3.17,0.15c2.03,0,4.01-0.18,5.94-0.53c0.32,0.96,0.67,1.91,1.05,2.84c2.07,5,5.11,9.51,8.9,13.29 c3.78,3.78,8.29,6.82,13.29,8.9c4.81,1.99,10.11,3.1,15.66,3.1c5.56,0,10.85-1.1,15.66-3.1c5-2.07,9.51-5.11,13.29-8.9 c3.78-3.78,6.82-8.29,8.9-13.29c1.99-4.81,3.1-10.11,3.1-15.66c0-5.56-1.1-10.85-3.1-15.66c-2.07-5-5.11-9.51-8.9-13.29 c-3.78-3.78-8.29-6.82-13.29-8.9c-4.81-1.99-10.11-3.1-15.66-3.1c-5.56,0-10.85,1.1-15.66,3.1c-0.43,0.18-0.86,0.37-1.28,0.56 c-1.64-2.58-3.62-4.92-5.89-6.95c1.24-0.64,2.51-1.23,3.8-1.77C59.94,1.34,66.37,0,73.1,0L73.1,0z M67.38,26.12 c0-1.22,0.5-2.33,1.3-3.13c0.8-0.8,1.9-1.3,3.12-1.3c1.22,0,2.33,0.5,3.13,1.3c0.8,0.8,1.3,1.91,1.3,3.13v23.22l17.35,10.29 c1.04,0.62,1.74,1.6,2.03,2.7c0.28,1.09,0.15,2.29-0.47,3.34c-0.62,1.04-1.6,1.74-2.7,2.03c-1.09,0.28-2.29,0.15-3.33-0.47 L69.65,55.71c-0.67-0.37-1.22-0.91-1.62-1.55c-0.41-0.67-0.65-1.46-0.65-2.3V26.12L67.38,26.12z"/><path class="st1" d="M26.99,2.56c14.91,0,26.99,12.08,26.99,26.99c0,14.91-12.08,26.99-26.99,26.99C12.08,56.54,0,44.45,0,29.55 C0,14.64,12.08,2.56,26.99,2.56L26.99,2.56z M15.05,30.27c0.36-2.1,2.76-3.27,4.65-2.13c0.17,0.1,0.34,0.22,0.49,0.36l0.02,0.01 c0.85,0.81,1.8,1.66,2.74,2.5l0.81,0.73l9.59-10.06c0.57-0.6,0.99-0.99,1.85-1.18c2.94-0.65,5.01,2.95,2.93,5.15L26.17,38.19 c-1.13,1.2-3.14,1.31-4.35,0.16c-0.69-0.64-1.45-1.3-2.21-1.96c-1.32-1.15-2.67-2.32-3.77-3.48 C15.18,32.25,14.89,31.17,15.05,30.27L15.05,30.27z"/></g></svg>

After

Width:  |  Height:  |  Size: 2.2 KiB

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" shape-rendering="geometricPrecision" text-rendering="geometricPrecision" image-rendering="optimizeQuality" fill-rule="evenodd" clip-rule="evenodd" viewBox="0 0 512 506.47"><path fill-rule="nonzero" d="M294.23 17.11c0-9.42 9.3-17.11 20.88-17.11 11.57 0 20.86 7.64 20.86 17.11v74.84c0 9.42-9.29 17.11-20.86 17.11-11.58 0-20.88-7.64-20.88-17.11V17.11zm119.98 304.68 14.12 14.1c3.3 3.32 3.3 8.86 0 12.18l-24.21 24.21 24.21 24.21c3.32 3.32 3.32 8.86 0 12.18l-14.11 14.1c-3.31 3.32-8.82 3.35-12.17 0l-24.21-24.21-24.24 24.23c-3.3 3.3-8.82 3.34-12.16 0l-14.11-14.11c-3.35-3.35-3.35-8.83 0-12.17l24.23-24.23-24.21-24.23c-3.35-3.34-3.35-8.81 0-12.16l14.1-14.1c3.35-3.35 8.86-3.32 12.16 0L377.84 346l24.21-24.21c3.35-3.35 8.89-3.29 12.16 0zm-36.4-83.69c37.02 0 70.6 15.04 94.88 39.32C496.96 301.69 512 335.24 512 372.3c0 36.97-15.04 70.56-39.34 94.83-24.25 24.3-57.8 39.34-94.85 39.34-37.03 0-70.56-15.04-94.84-39.3-24.32-24.27-39.34-57.85-39.34-94.87 0-37.06 15.04-70.61 39.31-94.88l.69-.64c24.24-23.9 57.53-38.68 94.18-38.68zm78.75 55.44c-20.15-20.14-48-32.61-78.75-32.61-30.5 0-58.14 12.25-78.19 32.02l-.55.59c-20.14 20.15-32.62 48.01-32.62 78.76 0 30.74 12.46 58.6 32.61 78.75 20.1 20.13 47.98 32.6 78.75 32.6 30.75 0 58.6-12.47 78.75-32.62 20.14-20.08 32.61-47.95 32.61-78.73 0-30.75-12.47-58.61-32.61-78.76zM56.81 242.28c-1.18 0-2.24-5.2-2.24-11.57 0-6.39.93-11.54 2.24-11.54h56.94c1.18 0 2.24 5.2 2.24 11.54 0 6.38-.93 11.57-2.24 11.57H56.81zm90.77 0c-1.18 0-2.24-5.2-2.24-11.57 0-6.39.93-11.54 2.24-11.54h56.94c1.18 0 2.24 5.2 2.24 11.54 0 6.38-.93 11.57-2.24 11.57h-56.94zm90.77 0c-1.18 0-2.24-5.2-2.24-11.57 0-6.39.93-11.54 2.24-11.54h56.94c1.18 0 2.24 5.15 2.24 11.49-5.7 3.55-11.19 7.44-16.42 11.62h-42.76zM56.94 308.51c-1.19 0-2.23-5.2-2.23-11.57 0-6.38.92-11.58 2.23-11.58h56.93c1.19 0 2.25 5.2 2.25 11.58 0 6.37-.94 11.57-2.25 11.57H56.94zm90.77 0c-1.19 0-2.23-5.2-2.23-11.57 0-6.38.92-11.58 2.23-11.58h56.93c1.19 0 2.25 5.2 2.25 11.58 0 6.37-.94 11.57-2.25 11.57h-56.93zm-90.63 66.28c-1.19 0-2.25-5.2-2.25-11.59 0-6.36.92-11.57 2.25-11.57h56.93c1.17 0 2.23 5.21 2.23 11.57 0 6.39-.92 11.59-2.23 11.59H57.08zm90.77 0c-1.19 0-2.25-5.2-2.25-11.59 0-6.36.92-11.57 2.25-11.57h56.93c1.17 0 2.23 5.21 2.23 11.57 0 6.39-.92 11.59-2.23 11.59h-56.93zM106.82 17.11c0-9.42 9.3-17.11 20.86-17.11 11.59 0 20.88 7.64 20.88 17.11v74.84c0 9.42-9.34 17.11-20.88 17.11-11.56 0-20.86-7.64-20.86-17.11V17.11zM22.98 163.63h397.38V77.46c0-2.94-1.19-5.53-3.09-7.43-1.89-1.9-4.6-3.08-7.42-3.08h-38.1c-6.38 0-11.58-5.2-11.58-11.57 0-6.38 5.2-11.58 11.58-11.58h38.1c9.32 0 17.68 3.76 23.82 9.88 6.12 6.14 9.87 14.5 9.87 23.82v136.81c-7.6-2.61-15.41-4.73-23.43-6.29v-21.37h.25H22.98V409.8c0 2.95 1.18 5.53 3.08 7.43 1.9 1.9 4.61 3.08 7.45 3.08h188.84c2.14 8.02 4.85 15.83 8.11 23.36H33.71c-9.3 0-17.69-3.76-23.82-9.89C3.77 427.71 0 419.35 0 410V77.55c0-9.29 3.77-17.7 9.89-23.82 6.13-6.13 14.49-9.89 23.82-9.89h40.68c6.37 0 11.57 5.2 11.57 11.57 0 6.39-5.2 11.59-11.57 11.59H33.71c-2.96 0-5.53 1.18-7.43 3.08-1.9 1.9-3.08 4.59-3.08 7.42v86.17h-.22v-.04zm158.95-96.68c-6.39 0-11.57-5.2-11.57-11.57 0-6.38 5.18-11.58 11.57-11.58h77.55c6.37 0 11.57 5.2 11.57 11.58 0 6.37-5.2 11.57-11.57 11.57h-77.55z"/></svg>

After

Width:  |  Height:  |  Size: 3.1 KiB

@ -0,0 +1,3 @@
# allow crawling everything by default
User-agent: *
Disallow:

@ -0,0 +1 @@
<?xml version="1.0" encoding="utf-8"?><svg version="1.1" id="Layer_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px" width="105.765px" height="122.88px" viewBox="0 0 105.765 122.88" enable-background="new 0 0 105.765 122.88" xml:space="preserve"><g><path d="M82.872,90.81c-2.983-8.16-7.707-14.175-13.283-18.06c-3.772-2.629-7.914-4.284-12.133-4.97 c-4.236-0.686-8.583-0.408-12.747,0.828C35.573,71.323,27.33,78.716,22.903,90.81H82.872L82.872,90.81z M20.618,27.21h64.535 c0.346-2.922,1.154-13.713,1.119-16.995H19.497C19.462,13.498,20.27,24.288,20.618,27.21L20.618,27.21L20.618,27.21z M0.91,112.665 h9.567C10.222,85.12,22.648,68.03,38.027,61.466C22.637,54.9,10.205,37.79,10.478,10.214l-9.567,0c-0.501,0-0.909-0.46-0.909-1.025 L0,1.024C0,0.46,0.409,0,0.91,0h103.944c0.5,0,0.91,0.46,0.91,1.024v8.164c0,0.563-0.41,1.024-0.91,1.024h-9.57 c0.225,23.214-8.581,39.038-20.546,47.376c-2.188,1.522-4.543,2.832-6.994,3.873c2.446,1.049,4.81,2.354,6.992,3.88 c11.955,8.332,20.756,24.139,20.546,47.321l9.572,0.001c0.5,0,0.91,0.463,0.91,1.026v8.162c0,0.564-0.41,1.027-0.91,1.027H0.91 c-0.501,0-0.909-0.463-0.909-1.026v-8.162C0.001,113.128,0.41,112.665,0.91,112.665L0.91,112.665L0.91,112.665z"/></g></svg>

After

Width:  |  Height:  |  Size: 1.2 KiB

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" shape-rendering="geometricPrecision" text-rendering="geometricPrecision" image-rendering="optimizeQuality" fill-rule="evenodd" clip-rule="evenodd" viewBox="0 0 512 406.6"><path d="M334.1 1.64a202.504 202.504 0 0 1 135.16 77.02c68.84 88.6 52.82 216.19-35.78 285.03-.08.05-.14.11-.22.18-88.57 68.82-216.15 52.81-284.97-35.76-.04-.06-.09-.12-.14-.17A204.822 204.822 0 0 1 125.31 291a168.69 168.69 0 0 0 37.79-5.42 172.61 172.61 0 0 0 13.55 20.29c56.7 72.81 161.67 85.86 234.46 29.15 72.8-56.69 85.84-161.66 29.15-234.46-40.28-51.71-107.08-75.09-170.82-59.79a171.08 171.08 0 0 0-21.88-31.29c2.46-.8 4.95-1.51 7.46-2.21 25.77-7.13 52.69-9.03 79.19-5.63h-.11zM0 129.16v-15.4C3.97 50.8 56.26.95 120.21.92h.05c66.58-.01 120.55 53.93 120.59 120.51.03 66.58-53.93 120.56-120.51 120.59C56.33 242.04 3.97 192.17 0 129.16zm106.56 31.56h27.62v24.45h-27.62v-24.45zm27.6-14.21h-27.6c-2.75-33.56-8.53-32.84-8.53-66.35 0-12.37 10.03-22.39 22.39-22.39 12.36 0 22.4 10.02 22.4 22.39 0 33.49-5.85 32.83-8.66 66.35zm163.46-42c1.24-9.88 10.24-16.88 20.09-15.64h.04c9.82 1.32 16.73 10.32 15.46 20.13l-11.7 94.09 65.06 50.55c7.85 6.1 9.3 17.4 3.2 25.28a18.011 18.011 0 0 1-11.95 6.82c-4.73.62-9.51-.68-13.26-3.62l-72.82-56.61a17.818 17.818 0 0 1-5.79-7.08 18.336 18.336 0 0 1-1.46-9.67l13.13-104.2v-.05z"/></svg>

After

Width:  |  Height:  |  Size: 1.3 KiB

@ -0,0 +1 @@
<svg id="Layer_1" data-name="Layer 1" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 122.88 112.9"><title>time-period</title><path d="M35.69,101.21a24.08,24.08,0,0,0-4.23-11.35c-2.27-3.17-5.22-5.33-8.32-5.33s-6.06,2.16-8.33,5.33a24.08,24.08,0,0,0-4.23,11.35Zm78.39-73.63a4.17,4.17,0,0,0-7.37,3.81,4.68,4.68,0,0,0,.37.7,44,44,0,0,1,3.6,6.74,4.17,4.17,0,0,0,7.94-2.29,4.32,4.32,0,0,0-.3-1,52.05,52.05,0,0,0-4.24-7.93ZM107.14,16.5a4.63,4.63,0,0,1-3.23,5.18L91.54,25.46a4.63,4.63,0,1,1-2.69-8.86L90,16.24A47,47,0,0,0,22.46,44.49H13.84A55.33,55.33,0,0,1,94.7,9.33l-1.16-3A4.64,4.64,0,1,1,102.22,3l4.62,12.09a4.81,4.81,0,0,1,.3,1.42ZM67.6,104.55a53.52,53.52,0,0,0,9.43-.87,4.17,4.17,0,0,1,1,8.25,61.44,61.44,0,0,1-7.38.94c-1.31.06-3,0-4.34,0a55.19,55.19,0,0,1-10.91-1.33V103a46.85,46.85,0,0,0,12.15,1.59Zm23.25-6a4.17,4.17,0,1,0,4.09,7.26,55.27,55.27,0,0,0,7.46-5.06,4.17,4.17,0,0,0-3.89-7.21,4.07,4.07,0,0,0-1.34.73,47.39,47.39,0,0,1-6.32,4.28Zm16.42-15.64a4.16,4.16,0,1,0,7.06,4.41,55.51,55.51,0,0,0,4.15-8,4.17,4.17,0,0,0-7.15-4.14,4.11,4.11,0,0,0-.54.93,46,46,0,0,1-3.52,6.79Zm7.13-21.62a4.17,4.17,0,0,0,8.16,1.46,3.91,3.91,0,0,0,.15-.83,56.09,56.09,0,0,0,0-9,4.16,4.16,0,1,0-8.3.69,47.78,47.78,0,0,1,0,7.66ZM59.12,35a4.29,4.29,0,0,1,8.57,0V61.09l17.84,7.85a4.28,4.28,0,1,1-3.44,7.83L61.91,67.9a4.29,4.29,0,0,1-2.79-4V35ZM12.59,70.51h21.1a20.92,20.92,0,0,0,2-7H10.56a20.7,20.7,0,0,0,2,7ZM2.47,105.83a2.09,2.09,0,1,1,0-4.1H5.55a28.67,28.67,0,0,1,5.13-14.44,19.38,19.38,0,0,1,6.1-5.67,18.41,18.41,0,0,1-6.17-5.21,24.83,24.83,0,0,1-5.07-14H2.61a2.09,2.09,0,1,1,0-4.1H43.93a2.09,2.09,0,1,1,0,4.1h-3.2a24.83,24.83,0,0,1-5.07,14,18.41,18.41,0,0,1-6.17,5.21,19.38,19.38,0,0,1,6.1,5.67,28.67,28.67,0,0,1,5.13,14.44H43.8a2.09,2.09,0,1,1,0,4.1H2.47Z"/></svg>

After

Width:  |  Height:  |  Size: 1.7 KiB

@ -0,0 +1 @@
// place files you want to import through the `$lib` alias in this folder.

@ -0,0 +1,111 @@
import { getDb } from './db';
import { randomTokenBase64Url, sha256Hex } from './crypto';
import { WEB_BASE_URL } from './env';
export type CalendarTokenType = 'personal' | 'group' | 'aggregate';
function toIsoSql(d: Date = new Date()): string {
return d.toISOString().replace('T', ' ').replace('Z', '');
}
function requireBaseUrl(): string {
const base = (WEB_BASE_URL || '').trim();
if (!base) {
throw new Error('[calendar-tokens] WEB_BASE_URL no está configurado');
}
return base.replace(/\/+$/, '');
}
export function buildCalendarIcsUrl(type: CalendarTokenType, token: string): string {
const base = requireBaseUrl();
const segment = type === 'personal' ? 'personal' : type === 'group' ? 'group' : 'aggregate';
return `${base}/ics/${segment}/${token}.ics`;
}
export async function findActiveToken(
type: CalendarTokenType,
userId: string,
groupId?: string | null
): Promise<{
id: number;
type: CalendarTokenType;
user_id: string;
group_id: string | null;
token_hash: string;
token_plain: string | null;
created_at: string;
revoked_at: string | null;
last_used_at: string | null;
} | null> {
const db = await getDb();
const sql = groupId
? `
SELECT id, type, user_id, group_id, token_hash, token_plain, created_at, revoked_at, last_used_at
FROM calendar_tokens
WHERE type = ? AND user_id = ? AND group_id = ? AND revoked_at IS NULL
ORDER BY id DESC
LIMIT 1
`
: `
SELECT id, type, user_id, group_id, token_hash, token_plain, created_at, revoked_at, last_used_at
FROM calendar_tokens
WHERE type = ? AND user_id = ? AND group_id IS NULL AND revoked_at IS NULL
ORDER BY id DESC
LIMIT 1
`;
const stmt = db.prepare(sql);
const row = groupId
? stmt.get(type, userId, groupId)
: stmt.get(type, userId);
return (row as any) || null;
}
/**
* Crea un nuevo token ICS y devuelve la URL completa (no se guarda el token en claro).
* Lanza si existe una entrada activa y se viola la unicidad; usar findActiveToken antes si quieres evitar error.
*/
export async function createCalendarTokenUrl(
type: CalendarTokenType,
userId: string,
groupId?: string | null
): Promise<{ url: string; token: string; id: number }> {
const db = await getDb();
const token = randomTokenBase64Url(32);
const tokenHash = await sha256Hex(token);
const createdAt = toIsoSql(new Date());
const insert = db.prepare(`
INSERT INTO calendar_tokens (type, user_id, group_id, token_hash, token_plain, created_at)
VALUES (?, ?, ?, ?, ?, ?)
`);
const res = insert.run(type, userId, groupId ?? null, tokenHash, token, createdAt);
const id = Number(res.lastInsertRowid || 0);
return { url: buildCalendarIcsUrl(type, token), token, id };
}
/**
* Revoca el token activo (si existe) y crea uno nuevo. Devuelve la nueva URL completa.
*/
export async function rotateCalendarTokenUrl(
type: CalendarTokenType,
userId: string,
groupId?: string | null
): Promise<{ url: string; token: string; id: number; revoked: number | null }> {
const db = await getDb();
const now = toIsoSql(new Date());
const existing = await findActiveToken(type, userId, groupId ?? null);
let revoked: number | null = null;
if (existing) {
db.prepare(`UPDATE calendar_tokens SET revoked_at = ? WHERE id = ? AND revoked_at IS NULL`).run(
now,
existing.id
);
revoked = existing.id;
}
const created = await createCalendarTokenUrl(type, userId, groupId ?? null);
return { ...created, revoked };
}

@ -0,0 +1 @@
export { randomTokenBase64Url, sha256Hex } from '../../../../../src/utils/crypto';

@ -0,0 +1,165 @@
import { mkdirSync, existsSync } from 'fs';
import { dirname } from 'path';
import { resolveDbAbsolutePath, isDev, DEV_AUTOSEED_DB, DEV_DEFAULT_USER } from './env';
function applyDefaultPragmas(instance: any): void {
try {
instance.exec(`PRAGMA busy_timeout = 5000;`);
// Intentar activar WAL (si no es soportado, SQLite devolverá 'memory' u otro modo)
try {
if (typeof instance.query === 'function') {
instance.query(`PRAGMA journal_mode = WAL`)?.get?.();
} else {
instance.prepare?.(`PRAGMA journal_mode = WAL`)?.get?.();
}
} catch {}
instance.exec(`PRAGMA synchronous = NORMAL;`);
instance.exec(`PRAGMA wal_autocheckpoint = 1000;`);
// Asegurar claves foráneas siempre activas
instance.exec(`PRAGMA foreign_keys = ON;`);
} catch (e) {
console.warn('[web/db] No se pudieron aplicar PRAGMAs (WAL, busy_timeout...):', e);
}
}
/**
* Intenta cargar un constructor de Database compatible:
* - En Bun (SSR nativo): bun:sqlite
* - En Node (Vite dev SSR): better-sqlite3
*/
async function importSqliteDatabase(): Promise<any> {
// En desarrollo (Vite SSR), cargar better-sqlite3 vía require de Node para mantener el contexto CJS
if (import.meta.env.DEV) {
const modModule: any = await import('node:module');
const require = modModule.createRequire(import.meta.url);
const mod = require('better-sqlite3');
return (mod as any).default || (mod as any).Database || mod;
}
// En producción (Bun en runtime), usar bun:sqlite nativo
const mod: any = await import('bun:sqlite');
return (mod as any).Database || (mod as any).default || mod;
}
/**
* Abre la BD compartida. En desarrollo, si el archivo no existe y DEV_AUTOSEED_DB=true,
* inicializa el esquema (migraciones) y siembra datos de demo.
* Nota: usa bun:sqlite si está disponible; en SSR Node usa better-sqlite3.
*/
async function openDb(filename: string = 'tasks.db'): Promise<any> {
const absolutePath = resolveDbAbsolutePath(filename);
const firstCreate = !existsSync(absolutePath);
// Crear directorio padre si no existe
try {
mkdirSync(dirname(absolutePath), { recursive: true });
} catch (err: any) {
if (err?.code !== 'EEXIST') throw err;
}
const DatabaseCtor = await importSqliteDatabase();
const instance = new DatabaseCtor(absolutePath);
applyDefaultPragmas(instance);
// Auto-inicialización de esquema en desarrollo si falta y seed opcional
if (isDev()) {
// ¿Existe la tabla principal?
let hasTasksTable = false;
try {
instance.prepare(`SELECT 1 FROM tasks LIMIT 1`).get();
hasTasksTable = true;
} catch {}
// Si no existe el esquema, aplicar inicialización/migraciones
if (!hasTasksTable) {
const isBun = typeof (globalThis as any).Bun !== 'undefined';
if (isBun) {
// En Bun podemos reutilizar initializeDatabase del repo principal
try {
const dbModule = await import('../../../../../src/db');
if (typeof (dbModule as any).initializeDatabase === 'function') {
(dbModule as any).initializeDatabase(instance);
hasTasksTable = true;
console.info('[web/db] DEV: esquema inicializado (Bun initializeDatabase).');
}
} catch (e) {
console.warn('[web/db] No se pudo ejecutar initializeDatabase en dev (Bun):', e);
}
} else {
// En SSR Node: aplicar migraciones directamente con compat para .query
try {
const mod = await import('../../../../../src/db/migrations/index.ts');
const list = (mod as any).migrations as any[];
const compat: any = instance;
if (typeof compat.query !== 'function') {
compat.query = (sql: string) => ({
all: () => compat.prepare(sql).all(),
get: () => compat.prepare(sql).get()
});
}
try { compat.exec?.(`PRAGMA foreign_keys = ON;`); } catch {}
for (const m of list) {
try {
await (m.up as any)(compat);
} catch (e) {
console.warn('[web/db] Error aplicando migración en dev (Node):', (m as any)?.name ?? '(sin nombre)', e);
}
}
// Verificar de nuevo
try {
compat.prepare(`SELECT 1 FROM tasks LIMIT 1`).get();
hasTasksTable = true;
console.info('[web/db] DEV: esquema inicializado (migraciones aplicadas en Node).');
} catch {}
} catch (e) {
console.warn('[web/db] No se pudieron aplicar migraciones en dev (Node):', e);
}
}
}
// Seed de datos de demo si la tabla está vacía (por defecto habilitado en dev)
try {
let count = 0;
try {
const row = instance.prepare(`SELECT COUNT(1) AS c FROM tasks`).get() as any;
count = Number(row?.c ?? 0);
} catch {
// Si aún no existe la tabla, no seedear
count = 0;
}
const shouldSeed = (typeof DEV_AUTOSEED_DB === 'boolean' ? DEV_AUTOSEED_DB : true);
if (count === 0 && shouldSeed) {
console.info('[web/db] DEV: tabla tasks vacía; iniciando seed de demo...');
try {
const seed = await import('./dev-seed');
if (typeof (seed as any).seedDev === 'function') {
await (seed as any).seedDev(instance, DEV_DEFAULT_USER);
console.info('[web/db] DEV: seed de demo completado.');
} else {
console.warn('[web/db] DEV: módulo dev-seed sin función seedDev; omitiendo seed.');
}
} catch (e) {
console.warn('[web/db] DEV: no se pudo cargar/ejecutar dev-seed; omitiendo seed. Error:', e);
}
} else {
console.info(`[web/db] DEV: seed no aplicado (count=${count}, DEV_AUTOSEED_DB=${shouldSeed}).`);
}
} catch (e) {
console.warn('[web/db] DEV: error al evaluar seed de demo:', e);
}
}
return instance;
}
let _db: any | null = null;
/**
* Devuelve una única instancia compartida (lazy) de la BD.
*/
export async function getDb(filename: string = 'tasks.db'): Promise<any> {
if (_db) return _db;
_db = await openDb(filename);
return _db;
}

@ -0,0 +1,201 @@
/**
* Semilla enriquecida de datos de demo para desarrollo.
* Inserta usuarios, grupos (allowed/pending), membresías, preferencias y un set amplio de tareas.
* Idempotente: solo ejecuta si la tabla tasks está vacía.
*/
function toIsoYmd(d: Date): string {
const yyyy = d.getUTCFullYear();
const mm = String(d.getUTCMonth() + 1).padStart(2, '0');
const dd = String(d.getUTCDate()).padStart(2, '0');
return `${yyyy}-${mm}-${dd}`;
}
function addDays(base: Date, days: number): Date {
return new Date(base.getTime() + days * 24 * 3600 * 1000);
}
function isoSql(dt: Date): string {
return dt.toISOString().replace('T', ' ').replace('Z', '');
}
export async function seedDev(db: any, defaultUser: string): Promise<void> {
try { db.exec(`PRAGMA foreign_keys = ON;`); } catch {}
// Si ya hay tareas, asumimos BD poblada
try {
const row = db.prepare(`SELECT COUNT(*) AS c FROM tasks`).get() as any;
if (row && Number(row.c || 0) > 0) return;
} catch {}
const now = new Date();
const today = toIsoYmd(now);
const tomorrow = toIsoYmd(addDays(now, 1));
const nextWeek = toIsoYmd(addDays(now, 7));
const overdue = toIsoYmd(addDays(now, -1));
const inTwoDays = toIsoYmd(addDays(now, 2));
const noDate = null;
// Usuarios (incluye el de desarrollo aunque no sea numérico)
const DEV = (defaultUser || '').trim();
const U1 = DEV; // usuario de desarrollo (puede no ser numérico; lo insertamos igualmente)
const U2 = '34600123456';
const U3 = '5550001111';
const U4 = '600123456';
const U5 = '34987654321';
const users = [U1, U2, U3, U4, U5].filter(Boolean);
const insertUser = db.prepare(`
INSERT OR IGNORE INTO users (id, first_seen, last_seen)
VALUES (?, strftime('%Y-%m-%d %H:%M:%f','now'), strftime('%Y-%m-%d %H:%M:%f','now'))
`);
for (const u of users) insertUser.run(u);
// Grupos (IDs tipo JID) y estados
const G_FAM = 'g-familia@g.us';
const G_TRA = 'g-trabajo@g.us';
const G_VOL = 'g-voluntariado@g.us';
const G_COM = 'g-compras@g.us';
const G_VAR = 'g-varios@g.us';
const groups: Array<{ id: string; name: string; allowed: 'allowed' | 'pending' | 'blocked' }> = [
{ id: G_FAM, name: 'Familia', allowed: 'allowed' },
{ id: G_TRA, name: 'Trabajo', allowed: 'allowed' },
{ id: G_VOL, name: 'Voluntariado', allowed: 'allowed' },
{ id: G_COM, name: 'Compras', allowed: 'allowed' }, // allowed pero sin membresía del usuario DEV
{ id: G_VAR, name: 'Varios', allowed: 'pending' } // pendiente/bloqueado para validar gating
];
const insertGroup = db.prepare(`
INSERT OR IGNORE INTO groups (id, community_id, name, active, last_verified)
VALUES (?, 'comm-dev', ?, 1, strftime('%Y-%m-%d %H:%M:%f','now'))
`);
const insertAllowed = db.prepare(`
INSERT OR IGNORE INTO allowed_groups (group_id, label, status, discovered_at, updated_at, discovered_by)
VALUES (?, ?, ?, strftime('%Y-%m-%d %H:%M:%f','now'), strftime('%Y-%m-%d %H:%M:%f','now'), NULL)
`);
for (const g of groups) {
insertGroup.run(g.id, g.name);
try { insertAllowed.run(g.id, g.name, g.allowed); } catch {}
}
// Membresías activas: el usuario DEV en Familia, Trabajo, Voluntariado; otros usuarios repartidos
const insertMember = db.prepare(`
INSERT OR REPLACE INTO group_members (group_id, user_id, is_admin, is_active, first_seen_at, last_seen_at)
VALUES (?, ?, 0, 1, strftime('%Y-%m-%d %H:%M:%f','now'), strftime('%Y-%m-%d %H:%M:%f','now'))
`);
for (const gid of [G_FAM, G_TRA, G_VOL]) insertMember.run(gid, U1);
// Otros usuarios en distintos grupos para facilitar múltiples responsables
insertMember.run(G_FAM, U2);
insertMember.run(G_FAM, U3);
insertMember.run(G_TRA, U3);
insertMember.run(G_TRA, U4);
insertMember.run(G_VOL, U5);
// Compras: allowed pero sin membresía del usuario DEV (solo U2), para validar gating
insertMember.run(G_COM, U2);
// Preferencias del usuario DEV
try {
db.prepare(`
INSERT OR REPLACE INTO user_preferences (user_id, reminder_freq, reminder_time, last_reminded_on, updated_at)
VALUES (?, 'daily', '08:30', NULL, strftime('%Y-%m-%d %H:%M:%f','now'))
`).run(U1);
} catch {}
// Insertadores
const insertTask = db.prepare(`
INSERT INTO tasks (description, due_date, group_id, created_by, completed, completed_at, completed_by)
VALUES (?, ?, ?, ?, COALESCE(?, 0), ?, ?)
`);
const assignStmt = db.prepare(`
INSERT OR IGNORE INTO task_assignments (task_id, user_id, assigned_by)
VALUES (?, ?, ?)
`);
// Helpers para completadas
const completedRecent = isoSql(addDays(now, 0)); // ahora
const completed2hAgo = isoSql(new Date(Date.now() - 2 * 3600 * 1000));
const completed12hAgo = isoSql(new Date(Date.now() - 12 * 3600 * 1000));
const completed48hAgo = isoSql(new Date(Date.now() - 48 * 3600 * 1000));
const completed72hAgo = isoSql(new Date(Date.now() - 72 * 3600 * 1000));
type Spec = {
desc: string;
due: string | null;
group: string | null;
createdBy: string;
completed?: 0 | 1;
completedAt?: string | null;
completedBy?: string | null;
assignees?: string[]; // responsables
};
const specs: Spec[] = [
// Familia (mezcla: sin responsables, 1 responsable, múltiples, completadas reciente/antigua)
{ desc: 'Compra semanal para la casa', due: today, group: G_FAM, createdBy: U1, assignees: [] },
{ desc: 'Llevar coche al taller', due: tomorrow, group: G_FAM, createdBy: U1, assignees: [U1] },
{ desc: 'Organizar cumpleaños (lista de invitados y tarta)', due: nextWeek, group: G_FAM, createdBy: U2, assignees: [U1, U2] },
{ desc: 'Revisar facturas de luz y gas', due: overdue, group: G_FAM, createdBy: U1, assignees: [], completed: 0 },
{ desc: 'Pedir cita pediatra', due: inTwoDays, group: G_FAM, createdBy: U1, assignees: [U3] },
{ desc: 'Sacar basura orgánica', due: noDate, group: G_FAM, createdBy: U3, assignees: [], completed: 1, completedAt: completed2hAgo, completedBy: U1 },
{ desc: 'Regar plantas del balcón (abundante agua)', due: today, group: G_FAM, createdBy: U1, assignees: [U1, U3] },
// Trabajo
{ desc: 'Preparar informe trimestral de ventas', due: nextWeek, group: G_TRA, createdBy: U3, assignees: [U1] },
{ desc: 'Reunión con proveedor clave', due: tomorrow, group: G_TRA, createdBy: U4, assignees: [], completed: 1, completedAt: completed12hAgo, completedBy: U1 },
{ desc: 'Actualizar tablero de tareas del sprint', due: today, group: G_TRA, createdBy: U1, assignees: [U1, U4] },
{ desc: 'Revisión de PRs acumuladas', due: overdue, group: G_TRA, createdBy: U1, assignees: [U3] },
{ desc: 'Definir OKRs del próximo trimestre', due: noDate, group: G_TRA, createdBy: U1, assignees: [] },
{ desc: 'Publicar release menor (v1.0.1)', due: inTwoDays, group: G_TRA, createdBy: U2, assignees: [U1], completed: 1, completedAt: completed48hAgo, completedBy: U2 },
// Voluntariado
{ desc: 'Clasificar donaciones de ropa', due: today, group: G_VOL, createdBy: U5, assignees: [] },
{ desc: 'Coordinar recogida de alimentos', due: tomorrow, group: G_VOL, createdBy: U1, assignees: [U1, U5] },
{ desc: 'Llamar a nuevos voluntarios (lista AM)', due: nextWeek, group: G_VOL, createdBy: U1, assignees: [U1] },
{ desc: 'Actualizar listado de familias beneficiarias', due: overdue, group: G_VOL, createdBy: U5, assignees: [], completed: 1, completedAt: completed72hAgo, completedBy: U5 },
{ desc: 'Solicitar permiso para evento solidario', due: noDate, group: G_VOL, createdBy: U1, assignees: [] },
// Compras (allowed pero sin membresía del usuario DEV)
{ desc: 'Comprar detergente y suavizante', due: today, group: G_COM, createdBy: U2, assignees: [U2] },
{ desc: 'Reponer comida para mascotas', due: tomorrow, group: G_COM, createdBy: U2, assignees: [] },
{ desc: 'Comparar precios de frutas y verduras', due: nextWeek, group: G_COM, createdBy: U2, assignees: [U2], completed: 1, completedAt: completed2hAgo, completedBy: U2 },
{ desc: 'Planificar compra mensual a granel', due: noDate, group: G_COM, createdBy: U2, assignees: [] },
// Personales (group_id NULL)
{ desc: 'Pagar recibo del móvil', due: overdue, group: null, createdBy: U1, assignees: [U1] },
{ desc: 'Hacer copia de seguridad del portátil', due: today, group: null, createdBy: U1, assignees: [], completed: 1, completedAt: completed2hAgo, completedBy: U1 },
{ desc: 'Llamar al banco para aclarar comisión', due: tomorrow, group: null, createdBy: U1, assignees: [U1] },
{ desc: 'Leer artículo técnico pendiente', due: noDate, group: null, createdBy: U1, assignees: [] },
{ desc: 'Renovar DNI (pedir cita)', due: inTwoDays, group: null, createdBy: U1, assignees: [U1] },
{ desc: 'Terminar curso online de accesibilidad', due: nextWeek, group: null, createdBy: U1, assignees: [U1, U3] },
{ desc: 'Ordenar fotos antiguas en la nube (muchas carpetas)', due: noDate, group: null, createdBy: U1, assignees: [], completed: 1, completedAt: completed48hAgo, completedBy: U1 },
// Más casos para densidad y mezcla (≈ 3035 total)
{ desc: 'Preparar lista de la compra grande', due: tomorrow, group: G_FAM, createdBy: U1, assignees: [U1, U2] },
{ desc: 'Pintar habitación pequeña', due: nextWeek, group: G_FAM, createdBy: U2, assignees: [] },
{ desc: 'Plan de pruebas regresivas', due: today, group: G_TRA, createdBy: U1, assignees: [U4] },
{ desc: 'Reunión semanal con equipo', due: today, group: G_TRA, createdBy: U3, assignees: [U1, U3] },
{ desc: 'Retirar material donado del almacén', due: tomorrow, group: G_VOL, createdBy: U5, assignees: [] },
{ desc: 'Preparar carteles del evento solidario (texto largo y revisión)', due: noDate, group: G_VOL, createdBy: U1, assignees: [U1] }
];
// Transacción para insertar todo
db.transaction(() => {
for (const t of specs) {
const res = insertTask.run(
t.desc,
t.due ?? null,
t.group ?? null,
t.createdBy,
t.completed ?? 0,
t.completed ? (t.completedAt ?? completedRecent) : null,
t.completed ? (t.completedBy ?? t.createdBy) : null
);
const id = Number((res as any)?.lastInsertRowid ?? 0);
if (id > 0 && Array.isArray(t.assignees)) {
const seen = new Set<string>();
for (const uid of t.assignees) {
if (uid && !seen.has(uid)) {
seen.add(uid);
assignStmt.run(id, uid, t.createdBy);
}
}
}
}
})();
}

@ -0,0 +1,46 @@
import { join, resolve } from 'path';
import { env } from '$env/dynamic/private';
/**
* Resuelve la ruta absoluta al archivo de la base de datos SQLite compartida.
* Prioridad:
* 1) DB_PATH (ruta completa al archivo)
* 2) DATA_DIR + filename (en prod por defecto /app/data; en dev por defecto ./tmp)
*/
export function resolveDbAbsolutePath(filename: string = 'tasks.db'): string {
const dbPathEnv = (env.DB_PATH || '').trim();
if (dbPathEnv) {
return resolve(dbPathEnv);
}
const isProdEnv = String(env.NODE_ENV || 'development').trim().toLowerCase() === 'production';
const dataDir = env.DATA_DIR ? String(env.DATA_DIR) : (isProdEnv ? '/app/data' : 'tmp');
return resolve(join(dataDir, filename));
}
export const WEB_BASE_URL = (env.WEB_BASE_URL || '').trim();
export const COOKIE_SECRET = (env.COOKIE_SECRET || '').trim();
const SESSION_IDLE_TTL_MIN = Number(env.SESSION_IDLE_TTL_MIN || 120);
export const sessionIdleTtlMs = Math.max(1, Math.floor(SESSION_IDLE_TTL_MIN)) * 60 * 1000;
export const NODE_ENV = (env.NODE_ENV || 'development').trim().toLowerCase();
export const isProd = () => NODE_ENV === 'production';
export const isDev = () => NODE_ENV === 'development';
// Flags de desarrollo (solo en entornos no productivos)
const toBool = (v: string) => ['1', 'true', 'yes', 'on'].includes(String(v || '').trim().toLowerCase());
export const DEV_BYPASS_AUTH = toBool(env.DEV_BYPASS_AUTH || '');
export const DEV_DEFAULT_USER = (env.DEV_DEFAULT_USER || 'demo').trim();
export const DEV_AUTOSEED_DB = toBool(env.DEV_AUTOSEED_DB || '');
// ICS: horizonte en meses y rate limit (por minuto, 0 = desactivado)
const ICS_HORIZON_MONTHS = Number(env.ICS_HORIZON_MONTHS || 12);
export const icsHorizonMonths = Math.max(1, Math.floor(ICS_HORIZON_MONTHS));
const ICS_RATE_LIMIT_PER_MIN = Number(env.ICS_RATE_LIMIT_PER_MIN || 0);
export const icsRateLimitPerMin = Math.max(0, Math.floor(ICS_RATE_LIMIT_PER_MIN));
// Uncomplete window (minutos; por defecto 1440 = 24h)
const UNCOMPLETE_WINDOW_MIN_RAW = Number(env.UNCOMPLETE_WINDOW_MIN || 1440);
export const UNCOMPLETE_WINDOW_MIN = Math.max(1, Math.floor(UNCOMPLETE_WINDOW_MIN_RAW));
export const uncompleteWindowMs = UNCOMPLETE_WINDOW_MIN * 60 * 1000;

@ -0,0 +1,91 @@
import { sha256Hex } from './crypto';
function escapeIcsText(s: string): string {
return String(s)
.replace(/\\/g, '\\\\')
.replace(/\n/g, '\\n')
.replace(/\r/g, '')
.replace(/,/g, '\\,')
.replace(/;/g, '\\;');
}
function foldIcsLine(line: string): string {
// 75 octetos; para simplicidad contamos caracteres (UTF-8 simple en nuestro caso)
const max = 75;
if (line.length <= max) return line;
const parts: string[] = [];
let i = 0;
while (i < line.length) {
const chunk = line.slice(i, i + max);
parts.push(i === 0 ? chunk : ' ' + chunk);
i += max;
}
return parts.join('\r\n');
}
function padTaskId(id: number, width: number = 4): string {
const s = String(Math.max(0, Math.floor(id)));
if (s.length >= width) return s;
return '0'.repeat(width - s.length) + s;
}
function ymdToBasic(ymd: string): string {
// Espera YYYY-MM-DD
const m = /^(\d{4})-(\d{2})-(\d{2})$/.exec(ymd);
if (!m) return '';
return `${m[1]}${m[2]}${m[3]}`;
}
function addDays(ymd: string, days: number): string {
const m = /^(\d{4})-(\d{2})-(\d{2})$/.exec(ymd);
if (!m) return ymd;
const d = new Date(Date.UTC(Number(m[1]), Number(m[2]) - 1, Number(m[3])));
d.setUTCDate(d.getUTCDate() + days);
const yyyy = String(d.getUTCFullYear()).padStart(4, '0');
const mm = String(d.getUTCMonth() + 1).padStart(2, '0');
const dd = String(d.getUTCDate()).padStart(2, '0');
return `${yyyy}-${mm}-${dd}`;
}
export type IcsEvent = {
id: number;
description: string;
due_date: string; // YYYY-MM-DD
group_name?: string | null;
prefix?: string; // ej: "T" para [T0123]
};
export async function buildIcsCalendar(title: string, events: IcsEvent[]): Promise<{ body: string; etag: string }> {
const lines: string[] = [];
lines.push('BEGIN:VCALENDAR');
lines.push('VERSION:2.0');
lines.push('PRODID:-//TaskWhatsApp//Calendar//ES');
lines.push('CALSCALE:GREGORIAN');
lines.push('METHOD:PUBLISH');
lines.push(`X-WR-CALNAME:${escapeIcsText(title)}`);
lines.push('X-WR-TIMEZONE:UTC');
for (const ev of events) {
const idPad = padTaskId(ev.id);
const summary = `[${ev.prefix || 'T'}${idPad}] ${ev.description}`;
const dtStart = ymdToBasic(ev.due_date);
const dtEnd = ymdToBasic(addDays(ev.due_date, 1));
const uid = `task-${ev.id}@tw`;
lines.push('BEGIN:VEVENT');
lines.push(foldIcsLine(`UID:${uid}`));
lines.push(foldIcsLine(`SUMMARY:${escapeIcsText(summary)}`));
lines.push(`DTSTART;VALUE=DATE:${dtStart}`);
lines.push(`DTEND;VALUE=DATE:${dtEnd}`);
if (ev.group_name) {
lines.push(foldIcsLine(`CATEGORIES:${escapeIcsText(ev.group_name || '')}`));
}
lines.push('END:VEVENT');
}
lines.push('END:VCALENDAR');
const body = lines.join('\r\n') + '\r\n';
const etag = await sha256Hex(body);
return { body, etag: `W/"${etag}"` };
}

@ -0,0 +1,41 @@
import { writable } from 'svelte/store';
export type ToastType = 'info' | 'success' | 'error';
export type ToastItem = {
id: string;
type: ToastType;
message: string;
timeout?: number;
};
export const toasts = writable<ToastItem[]>([]);
function uid(): string {
return Math.random().toString(36).slice(2) + Date.now().toString(36);
}
export function show(message: string, type: ToastType = 'info', timeout = 2500): string {
const id = uid();
toasts.update((list) => [...list, { id, type, message, timeout }]);
if (timeout > 0) {
setTimeout(() => dismiss(id), timeout);
}
return id;
}
export function success(message: string, timeout = 2500): string {
return show(message, 'success', timeout);
}
export function error(message: string, timeout = 3500): string {
return show(message, 'error', timeout);
}
export function info(message: string, timeout = 2500): string {
return show(message, 'info', timeout);
}
export function dismiss(id: string): void {
toasts.update((list) => list.filter((t) => t.id !== id));
}

@ -0,0 +1,104 @@
/* Reset/normalización ligera */
*,
*::before,
*::after {
box-sizing: border-box;
}
html,
body {
height: 100%;
}
body {
margin: 0;
font-family: ui-sans-serif, system-ui, -apple-system, Segoe UI, Roboto, Noto Sans, Ubuntu, Cantarell, Helvetica Neue, Arial, "Apple Color Emoji", "Segoe UI Emoji";
font-size: 16px;
line-height: 1.5;
background: var(--color-bg);
color: var(--color-text);
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
img,
svg,
video {
display: block;
max-width: 100%;
height: 0.8rem;
}
a {
color: inherit;
text-decoration: none;
}
button {
font: inherit;
}
/* Accesibilidad: foco visible */
:focus-visible {
outline: 2px solid var(--color-primary);
outline-offset: 2px;
}
/* Utilidades */
.container {
max-width: 960px;
margin: 0 auto;
padding: 0 var(--space-4);
}
.sr-only {
position: absolute !important;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
white-space: nowrap;
border: 0;
}
/* Controles base */
button,
input[type="submit"] {
display: inline-flex;
align-items: center;
justify-content: center;
min-height: 36px;
padding: 0 12px;
border: 1px solid var(--color-border);
border-radius: var(--radius-sm);
background: var(--color-surface);
color: var(--color-text);
cursor: pointer;
box-shadow: var(--shadow-sm);
transition: box-shadow 0.15s ease, transform 0.05s ease;
}
button:hover,
button:focus-visible {
box-shadow: var(--shadow-md);
}
button:active {
transform: translateY(0.5px) scale(0.99);
box-shadow: var(--shadow-sm);
}
button.primary {
background: var(--color-primary);
border-color: var(--color-primary);
color: white;
}
button:disabled,
button:disabled:hover,
button:disabled:focus-visible {
opacity: 0.6;
cursor: not-allowed;
box-shadow: none;
transform: none;
}

@ -0,0 +1,41 @@
:root {
--color-bg: #ffffff;
--color-surface: #f7f7f8;
--color-text: #111111;
--color-text-muted: #555555;
--color-border: #e5e7eb;
--color-primary: #2563eb; /* azul */
--color-danger: #dc2626; /* rojo */
--color-warning: #d97706; /* ámbar */
--color-success: #16a34a; /* verde */
--color-primary-muted: #60a5fa55;
--radius-sm: 6px;
--radius-md: 8px;
--shadow-sm: 0 1px 2px rgba(0,0,0,0.06);
--shadow-md: 0 4px 12px rgba(0,0,0,0.08);
--space-1: 4px;
--space-2: 8px;
--space-3: 12px;
--space-4: 16px;
--space-5: 24px;
}
@media (prefers-color-scheme: dark) {
:root {
--color-bg: #0b0c0f;
--color-surface: #14161a;
--color-text: #e6e7eb;
--color-text-muted: #a1a1aa;
--color-border: #26272b;
--color-primary: #60a5fa;
--color-danger: #f87171;
--color-warning: #fbbf24;
--color-success: #34d399;
--color-primary-muted: #60a5fa55;
}
}

@ -0,0 +1,35 @@
<script lang="ts">
export let tone: 'default' | 'warning' | 'danger' | 'success' = 'default';
</script>
<span class={`badge ${tone}`}><slot /></span>
<style>
.badge {
display: inline-flex;
align-items: center;
line-height: 1;
font-size: 12px;
padding: 4px 6px;
border-radius: 999px;
border: 1px solid var(--color-border);
background: var(--color-surface);
color: var(--color-text);
gap: 6px;
}
.badge.warning {
background: rgba(217, 119, 6, 0.12);
border-color: rgba(217, 119, 6, 0.35);
color: var(--color-warning);
}
.badge.danger {
background: rgba(220, 38, 38, 0.12);
border-color: rgba(220, 38, 38, 0.35);
color: var(--color-danger);
}
.badge.success {
background: rgba(22, 163, 74, 0.12);
border-color: rgba(22, 163, 74, 0.35);
color: var(--color-success);
}
</style>

@ -0,0 +1,53 @@
<script lang="ts">
export let variant: 'primary' | 'secondary' | 'ghost' | 'danger' = 'secondary';
export let size: 'sm' | 'md' = 'md';
export let type: 'button' | 'submit' | 'reset' = 'button';
export let disabled: boolean = false;
</script>
<button class={`btn ${variant} ${size}`} {type} {disabled} on:click on:keydown on:focus on:blur {...$$restProps}>
<slot />
</button>
<style>
.btn {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 8px;
padding: 0 12px;
border-radius: var(--radius-sm);
border: 1px solid var(--color-border);
background: var(--color-surface);
color: var(--color-text);
min-height: 36px;
cursor: pointer;
transition: background 120ms ease, box-shadow 120ms ease, transform 80ms ease;
text-decoration: none;
}
.btn.sm { min-height: 30px; padding: 0 10px; font-size: 0.95rem; }
.btn.md { min-height: 36px; }
.btn:hover { box-shadow: var(--shadow-sm); }
.btn:active { transform: translateY(0.5px); }
.btn:disabled { opacity: 0.6; cursor: not-allowed; }
.btn.primary {
background: var(--color-primary);
border-color: var(--color-primary);
color: white;
}
.btn.primary:hover { filter: brightness(0.98); }
.btn.danger {
background: var(--color-danger);
border-color: var(--color-danger);
color: white;
}
.btn.ghost {
background: transparent;
border-color: transparent;
}
.btn.secondary {
background: var(--color-surface);
}
</style>

@ -0,0 +1,24 @@
<script lang="ts">
export let width: string = '100%';
export let height: string = '12px';
export let radius: string = '6px';
</script>
<div class="skeleton" style={`width:${width};height:${height};border-radius:${radius};`} />
<style>
.skeleton {
background: linear-gradient(90deg, rgba(0,0,0,0.06), rgba(0,0,0,0.12), rgba(0,0,0,0.06));
background-size: 200% 100%;
animation: shimmer 1.2s infinite;
}
@keyframes shimmer {
0% { background-position: 200% 0; }
100% { background-position: -200% 0; }
}
@media (prefers-color-scheme: dark) {
.skeleton {
background: linear-gradient(90deg, rgba(255,255,255,0.08), rgba(255,255,255,0.16), rgba(255,255,255,0.08));
}
}
</style>

@ -0,0 +1,11 @@
<span class="sr-only"><slot /></span>
<style>
.sr-only {
position: absolute !important;
width: 1px; height: 1px;
padding: 0; margin: -1px;
overflow: hidden; clip: rect(0,0,0,0);
white-space: nowrap; border: 0;
}
</style>

@ -0,0 +1,82 @@
<script lang="ts">
import Card from '$lib/ui/layout/Card.svelte';
import Button from '$lib/ui/atoms/Button.svelte';
import { copyToClipboard } from '$lib/utils/copy';
import { success as toastSuccess, error as toastError } from '$lib/stores/toasts';
import { createEventDispatcher } from 'svelte';
export let title: string;
export let description: string = '';
export let url: string | null = null;
export let rotating: boolean = false;
const dispatch = createEventDispatcher<{ rotate: void }>();
async function handleCopy() {
if (!url) return;
const ok = await copyToClipboard(url);
if (ok) toastSuccess('Enlace de calendario copiado');
else toastError('No se pudo copiar el enlace');
}
function handleRotate() {
dispatch('rotate');
}
</script>
<Card>
<div class="head">
<div class="titles">
<div class="title">{title}</div>
{#if description}<div class="desc">{description}</div>{/if}
{#if url}
<div class="url">{url}</div>
{:else}
<div class="hint">Por seguridad, la URL no se muestra. Pulsa “Rotar” para generar una nueva.</div>
{/if}
</div>
<div class="actions">
<Button variant="secondary" size="sm" on:click={handleCopy} disabled={!url} aria-disabled={!url} title={!url ? 'URL no disponible' : undefined}>Copiar</Button>
<Button variant="danger" size="sm" on:click={handleRotate} disabled={rotating} aria-disabled={rotating} aria-busy={rotating} title={rotating ? 'Actualizando…' : undefined}>
{rotating ? 'Rotando…' : 'Rotar'}
</Button>
</div>
</div>
</Card>
<style>
.head {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--space-3);
}
.titles {
min-width: 0;
}
.title {
font-weight: 600;
}
.desc {
color: var(--color-text-muted);
margin-top: 2px;
font-size: 0.95rem;
}
.url {
margin-top: 6px;
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;
font-size: 12px;
color: var(--color-text-muted);
word-break: break-all;
}
.actions {
display: inline-flex;
gap: 8px;
flex-shrink: 0;
}
.hint {
margin-top: 6px;
color: var(--color-text-muted);
font-size: 12px;
}
</style>

@ -0,0 +1,108 @@
<script lang="ts">
import Card from '$lib/ui/layout/Card.svelte';
import Badge from '$lib/ui/atoms/Badge.svelte';
import { success, error as toastError } from '$lib/stores/toasts';
export type Counts = { open: number; unassigned: number };
export type TaskPreview = { id: number; description: string; due_date: string | null; display_code: number | null };
export let id: string;
export let name: string | null = null;
export let counts: Counts = { open: 0, unassigned: 0 };
export let previews: TaskPreview[] = [];
let busyTaskId: number | null = null;
async function claim(taskId: number) {
if (busyTaskId) return;
busyTaskId = taskId;
try {
const res = await fetch(`/api/tasks/${taskId}/claim`, { method: 'POST' });
if (res.ok) {
success('Tarea reclamada');
// Actualizar estado local sin recargar
previews = previews.filter((t) => t.id !== taskId);
counts = { ...counts, unassigned: Math.max(0, (counts?.unassigned ?? 0) - 1) };
} else {
const txt = await res.text();
toastError(txt || 'No se pudo reclamar');
}
} catch {
toastError('Error de red');
} finally {
busyTaskId = null;
}
}
</script>
<Card>
<div class="header">
<strong class="name">{name ?? id}</strong>
<div class="badges">
<Badge>abiertas: {counts.open}</Badge>
<Badge tone="warning">sin responsable: {counts.unassigned}</Badge>
</div>
</div>
{#if previews?.length}
<div class="previews">
<em class="title">Sin responsable:</em>
<ul class="list">
{#each previews as t}
<li class="row">
<div class="info">
<span>#{t.display_code ?? t.id}{t.description}</span>
{#if t.due_date}<small class="muted"> (vence: {t.due_date})</small>{/if}
</div>
<div class="actions">
<button class="btn" on:click|preventDefault={() => claim(t.id)} disabled={busyTaskId === t.id}>Reclamar</button>
</div>
</li>
{/each}
</ul>
</div>
{/if}
</Card>
<style>
.header {
display: flex;
align-items: center;
gap: var(--space-2);
justify-content: space-between;
}
.name { font-size: 1rem; }
.badges { display: inline-flex; gap: var(--space-2); flex-wrap: wrap; }
.previews { margin-top: var(--space-3); }
.title { color: var(--color-text); }
.list { margin: 6px 0 0 18px; padding: 0; }
.list li { margin: 4px 0; }
.muted { color: var(--color-text-muted); }
.row {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--space-2);
}
.info {
display: inline-flex;
align-items: baseline;
gap: 6px;
flex-wrap: wrap;
}
.actions {
display: inline-flex;
gap: 6px;
}
.btn {
padding: 3px 8px;
border: 1px solid var(--color-border);
background: var(--color-surface);
color: var(--color-text);
border-radius: 6px;
font-size: 13px;
cursor: pointer;
}
.btn[disabled] { opacity: .6; cursor: not-allowed; }
</style>

@ -0,0 +1,911 @@
<script lang="ts">
import {
compareYmd,
todayYmdUTC,
ymdToDmy,
isToday,
isTomorrow,
} from "$lib/utils/date";
import { success, error as toastError } from "$lib/stores/toasts";
import { tick, onDestroy, createEventDispatcher } from "svelte";
import { fade } from "svelte/transition";
import Popover from "$lib/ui/feedback/Popover.svelte";
import { normalizeDigits, buildWaMeUrl } from "$lib/utils/phone";
import { colorForGroup } from "$lib/utils/groupColor";
import Hourglass from "$lib/ui/icons/Hourglass.svelte";
import duedateicon from "$lib/assets/on-time-icon.svg";
import releaseicon from "$lib/assets/emergency-exit-icon.svg";
import overdueicon from "$lib/assets/time-period-icon.svg";
import asigneesicon from "$lib/assets/friends-icon.svg";
import claimicon from "$lib/assets/mining-icon.svg";
import changedateicon from "$lib/assets/remove-date-calendar-icon.svg";
export let id: number;
export let description: string;
export let due_date: string | null = null;
export let display_code: number | null = null;
export let assignees: string[] = [];
export let currentUserId: string | null | undefined = null;
export let completed: boolean = false;
export let completed_at: string | null = null;
export let groupName: string | null = null;
export let groupId: string | null = null;
const dispatch = createEventDispatcher<{ changed: { id: number; action: string; patch: any } }>();
const code = display_code ?? id;
const codeStr = String(code).padStart(4, "0");
$: isAssigned =
!!currentUserId &&
assignees.some(
(a) => normalizeDigits(a) === normalizeDigits(currentUserId),
);
$: today = todayYmdUTC();
$: overdue = !!due_date && compareYmd(due_date, today) < 0;
$: imminent = !!due_date && (isToday(due_date) || isTomorrow(due_date));
$: dateDmy = due_date ? ymdToDmy(due_date) : "";
$: groupLabel = groupName != null ? groupName : "Personal";
$: gc = groupId ? colorForGroup(groupId) : null;
let editing = false;
let dateValue: string = due_date ?? "";
let busy = false;
// Popover de responsables
let showAssignees = false;
let assigneesButtonEl: HTMLButtonElement | null = null;
$: assigneesCount = Array.isArray(assignees) ? assignees.length : 0;
$: canUnassign = !(groupId == null && assigneesCount === 1 && isAssigned);
$: assigneesAria =
assigneesCount === 0
? "Sin responsables"
: `${assigneesCount} responsable${assigneesCount === 1 ? "" : "s"}${isAssigned ? "; tú incluido" : ""}`;
onDestroy(() => {
// Cerrar popover si se desmonta el item (por navegación o filtrado)
showAssignees = false;
});
// Edición de texto (inline)
let editingText = false;
let descEl: HTMLElement | null = null;
async function doClaim() {
if (busy) return;
busy = true;
try {
const res = await fetch(`/api/tasks/${id}/claim`, { method: "POST" });
if (res.ok) {
// Actualizar estado local (añadirte si no estabas)
if (currentUserId) {
const set = new Set<string>(assignees || []);
set.add(String(currentUserId));
assignees = Array.from(set);
}
success("Tarea reclamada");
dispatch("changed", { id, action: "claim", patch: { assignees } });
} else {
const txt = await res.text();
toastError(txt || "No se pudo reclamar");
}
} catch (e) {
toastError("Error de red");
} finally {
busy = false;
}
}
async function doComplete() {
if (busy || completed) return;
busy = true;
try {
const hadNoAssignees = assigneesCount === 0;
const res = await fetch(`/api/tasks/${id}/complete`, { method: "POST" });
if (res.ok) {
const data = await res.json().catch(() => null);
const newCompletedAt: string | null = data?.task?.completed_at ? String(data.task.completed_at) : new Date().toISOString().replace('T', ' ').replace('Z', '');
// Si no tenía responsables, el backend te auto-asigna: reflejarlo localmente
if (hadNoAssignees && currentUserId) {
const set = new Set<string>(assignees || []);
set.add(String(currentUserId));
assignees = Array.from(set);
}
completed = true;
completed_at = newCompletedAt;
success(hadNoAssignees ? "Te has asignado y completado la tarea" : "Tarea completada");
dispatch("changed", { id, action: "complete", patch: { completed: true, completed_at, assignees } });
} else {
const txt = await res.text();
toastError(txt || "No se pudo completar la tarea");
}
} catch {
toastError("Error de red");
} finally {
busy = false;
}
}
async function doUncomplete() {
if (busy || !completed) return;
busy = true;
try {
const res = await fetch(`/api/tasks/${id}/uncomplete`, { method: "POST" });
if (res.ok) {
await res.json().catch(() => null);
completed = false;
completed_at = null;
success("Tarea reabierta");
dispatch("changed", { id, action: "uncomplete", patch: { completed: false, completed_at: null } });
} else {
const txt = await res.text();
toastError(txt || "No se pudo deshacer completar");
}
} catch {
toastError("Error de red");
} finally {
busy = false;
}
}
async function doUnassign() {
if (busy) return;
busy = true;
try {
const res = await fetch(`/api/tasks/${id}/unassign`, { method: "POST" });
if (res.ok) {
if (currentUserId) {
const after = (assignees || []).filter((a) => normalizeDigits(a) !== normalizeDigits(String(currentUserId)));
assignees = after;
}
success("Asignación eliminada");
dispatch("changed", { id, action: "unassign", patch: { assignees } });
} else {
const txt = await res.text();
toastError(txt || "No se pudo soltar");
}
} catch {
toastError("Error de red");
} finally {
busy = false;
}
}
async function saveDate() {
if (busy) return;
busy = true;
try {
const body = {
due_date: dateValue && dateValue.trim() !== "" ? dateValue.trim() : null,
};
const res = await fetch(`/api/tasks/${id}`, {
method: "PATCH",
headers: { "content-type": "application/json" },
body: JSON.stringify(body),
});
if (res.ok) {
due_date = body.due_date;
success("Fecha actualizada");
dispatch("changed", { id, action: "update_due", patch: { due_date } });
editing = false;
} else {
const txt = await res.text();
toastError(txt || "No se pudo actualizar la fecha");
}
} catch {
toastError("Error de red");
} finally {
busy = false;
}
}
function toggleEdit() {
editing = !editing;
if (editing) editingText = false;
dateValue = due_date ?? "";
}
function clearDate() {
if (busy) return;
if (!confirm("¿Quitar la fecha de vencimiento?")) return;
dateValue = "";
saveDate();
}
function toggleEditText() {
editingText = !editingText;
if (editingText) {
editing = false;
// Asegurar que el elemento refleja el texto actual y enfocarlo
if (descEl) {
descEl.textContent = description;
}
tick().then(() => {
if (descEl) {
descEl.focus();
placeCaretAtEnd(descEl);
}
});
}
}
function placeCaretAtEnd(el: HTMLElement) {
const range = document.createRange();
range.selectNodeContents(el);
range.collapse(false);
const sel = window.getSelection();
sel?.removeAllRanges();
sel?.addRange(range);
}
async function saveText() {
if (busy) return;
const raw = (descEl?.textContent || "").replace(/\s+/g, " ").trim();
if (raw.length < 1 || raw.length > 1000) {
toastError("La descripción debe tener entre 1 y 1000 caracteres.");
return;
}
if (raw === description) {
editingText = false;
return;
}
busy = true;
try {
const res = await fetch(`/api/tasks/${id}`, {
method: "PATCH",
headers: { "content-type": "application/json" },
body: JSON.stringify({ description: raw }),
});
if (res.ok) {
description = raw;
success("Descripción actualizada");
dispatch("changed", { id, action: "update_desc", patch: { description } });
editingText = false;
} else {
const txt = await res.text();
toastError(txt || "No se pudo actualizar la descripción");
}
} catch {
toastError("Error de red");
} finally {
busy = false;
}
}
function cancelText() {
if (descEl) {
descEl.textContent = description;
}
editingText = false;
}
</script>
<li class="task" class:completed in:fade={{ duration: 180 }} out:fade={{ duration: 180 }}>
<div class="code">{codeStr}</div>
<div
tabindex="0"
class="desc"
class:editing={editingText}
class:completed
contenteditable={editingText && !completed}
role="textbox"
aria-label="Descripción de la tarea"
spellcheck="true"
bind:this={descEl}
on:keydown={(e) => {
if (e.key === "Escape") {
e.preventDefault();
cancelText();
} else if ((e.ctrlKey || e.metaKey) && e.key === "Enter") {
e.preventDefault();
saveText();
} else if (e.key === "Enter") {
e.preventDefault();
}
}}
>
{description}
</div>
<div class="meta">
<span
class="group-badge"
title="Grupo"
style={gc
? `--gc-border: ${gc.border}; --gc-bg: ${gc.bg}; --gc-text: ${gc.text};`
: undefined}>{groupLabel}</span
>
{#if due_date}
<span
class="date-badge"
class:overdue
class:soon={imminent}
title={overdue ? "Vencida" : imminent ? "Próxima" : "Fecha"}
>
{#if !overdue && !imminent}
<svg
version="1.1"
id="Layer_5"
xmlns="http://www.w3.org/2000/svg"
xmlns:xlink="http://www.w3.org/1999/xlink"
x="0px"
y="0px"
viewBox="0 0 122.88 99.56"
style="enable-background:new 0 0 122.88 99.56"
xml:space="preserve"
><style type="text/css">
.st0 {
fill: var(--color-text);
}
.st1 {
fill-rule: evenodd;
clip-rule: evenodd;
fill: #38ae48;
}
</style><g
><path
class="st0"
d="M73.1,0c6.73,0,13.16,1.34,19.03,3.78c6.09,2.52,11.57,6.22,16.16,10.81c4.59,4.58,8.28,10.06,10.81,16.17 c2.43,5.87,3.78,12.3,3.78,19.03c0,6.73-1.34,13.16-3.78,19.03c-2.52,6.09-6.22,11.58-10.81,16.16 c-4.58,4.59-10.06,8.28-16.17,10.81c-5.87,2.43-12.3,3.78-19.03,3.78c-6.73,0-13.16-1.34-19.03-3.77 c-6.09-2.52-11.57-6.22-16.16-10.81l-0.01-0.01c-4.59-4.59-8.29-10.07-10.81-16.16c-0.78-1.89-1.45-3.83-2-5.82 c1.04,0.1,2.1,0.15,3.17,0.15c2.03,0,4.01-0.18,5.94-0.53c0.32,0.96,0.67,1.91,1.05,2.84c2.07,5,5.11,9.51,8.9,13.29 c3.78,3.78,8.29,6.82,13.29,8.9c4.81,1.99,10.11,3.1,15.66,3.1c5.56,0,10.85-1.1,15.66-3.1c5-2.07,9.51-5.11,13.29-8.9 c3.78-3.78,6.82-8.29,8.9-13.29c1.99-4.81,3.1-10.11,3.1-15.66c0-5.56-1.1-10.85-3.1-15.66c-2.07-5-5.11-9.51-8.9-13.29 c-3.78-3.78-8.29-6.82-13.29-8.9c-4.81-1.99-10.11-3.1-15.66-3.1c-5.56,0-10.85,1.1-15.66,3.1c-0.43,0.18-0.86,0.37-1.28,0.56 c-1.64-2.58-3.62-4.92-5.89-6.95c1.24-0.64,2.51-1.23,3.8-1.77C59.94,1.34,66.37,0,73.1,0L73.1,0z M67.38,26.12 c0-1.22,0.5-2.33,1.3-3.13c0.8-0.8,1.9-1.3,3.12-1.3c1.22,0,2.33,0.5,3.13,1.3c0.8,0.8,1.3,1.91,1.3,3.13v23.22l17.35,10.29 c1.04,0.62,1.74,1.6,2.03,2.7c0.28,1.09,0.15,2.29-0.47,3.34c-0.62,1.04-1.6,1.74-2.7,2.03c-1.09,0.28-2.29,0.15-3.33-0.47 L69.65,55.71c-0.67-0.37-1.22-0.91-1.62-1.55c-0.41-0.67-0.65-1.46-0.65-2.3V26.12L67.38,26.12z"
/><path
class="st1"
d="M26.99,2.56c14.91,0,26.99,12.08,26.99,26.99c0,14.91-12.08,26.99-26.99,26.99C12.08,56.54,0,44.45,0,29.55 C0,14.64,12.08,2.56,26.99,2.56L26.99,2.56z M15.05,30.27c0.36-2.1,2.76-3.27,4.65-2.13c0.17,0.1,0.34,0.22,0.49,0.36l0.02,0.01 c0.85,0.81,1.8,1.66,2.74,2.5l0.81,0.73l9.59-10.06c0.57-0.6,0.99-0.99,1.85-1.18c2.94-0.65,5.01,2.95,2.93,5.15L26.17,38.19 c-1.13,1.2-3.14,1.31-4.35,0.16c-0.69-0.64-1.45-1.3-2.21-1.96c-1.32-1.15-2.67-2.32-3.77-3.48 C15.18,32.25,14.89,31.17,15.05,30.27L15.05,30.27z"
/></g
></svg
>
{:else if imminent}
<svg
version="1.1"
id="Layer_5"
xmlns="http://www.w3.org/2000/svg"
xmlns:xlink="http://www.w3.org/1999/xlink"
x="0px"
y="0px"
viewBox="0 0 122.88 99.56"
style="enable-background:new 0 0 122.88 99.56"
xml:space="preserve"
><style type="text/css">
.st0 {
fill: var(--color-text);
}
.st3 {
fill-rule: evenodd;
clip-rule: evenodd;
fill: var(--color-warning);
}
</style><g
><path
class="st0"
d="M73.1,0c6.73,0,13.16,1.34,19.03,3.78c6.09,2.52,11.57,6.22,16.16,10.81c4.59,4.58,8.28,10.06,10.81,16.17 c2.43,5.87,3.78,12.3,3.78,19.03c0,6.73-1.34,13.16-3.78,19.03c-2.52,6.09-6.22,11.58-10.81,16.16 c-4.58,4.59-10.06,8.28-16.17,10.81c-5.87,2.43-12.3,3.78-19.03,3.78c-6.73,0-13.16-1.34-19.03-3.77 c-6.09-2.52-11.57-6.22-16.16-10.81l-0.01-0.01c-4.59-4.59-8.29-10.07-10.81-16.16c-0.78-1.89-1.45-3.83-2-5.82 c1.04,0.1,2.1,0.15,3.17,0.15c2.03,0,4.01-0.18,5.94-0.53c0.32,0.96,0.67,1.91,1.05,2.84c2.07,5,5.11,9.51,8.9,13.29 c3.78,3.78,8.29,6.82,13.29,8.9c4.81,1.99,10.11,3.1,15.66,3.1c5.56,0,10.85-1.1,15.66-3.1c5-2.07,9.51-5.11,13.29-8.9 c3.78-3.78,6.82-8.29,8.9-13.29c1.99-4.81,3.1-10.11,3.1-15.66c0-5.56-1.1-10.85-3.1-15.66c-2.07-5-5.11-9.51-8.9-13.29 c-3.78-3.78-8.29-6.82-13.29-8.9c-4.81-1.99-10.11-3.1-15.66-3.1c-5.56,0-10.85,1.1-15.66,3.1c-0.43,0.18-0.86,0.37-1.28,0.56 c-1.64-2.58-3.62-4.92-5.89-6.95c1.24-0.64,2.51-1.23,3.8-1.77C59.94,1.34,66.37,0,73.1,0L73.1,0z M67.38,26.12 c0-1.22,0.5-2.33,1.3-3.13c0.8-0.8,1.9-1.3,3.12-1.3c1.22,0,2.33,0.5,3.13,1.3c0.8,0.8,1.3,1.91,1.3,3.13v23.22l17.35,10.29 c1.04,0.62,1.74,1.6,2.03,2.7c0.28,1.09,0.15,2.29-0.47,3.34c-0.62,1.04-1.6,1.74-2.7,2.03c-1.09,0.28-2.29,0.15-3.33-0.47 L69.65,55.71c-0.67-0.37-1.22-0.91-1.62-1.55c-0.41-0.67-0.65-1.46-0.65-2.3V26.12L67.38,26.12z"
/><path
class="st3"
d="M26.99,2.56c14.91,0,26.99,12.08,26.99,26.99c0,14.91-12.08,26.99-26.99,26.99C12.08,56.54,0,44.45,0,29.55 C0,14.64,12.08,2.56,26.99,2.56L26.99,2.56z M15.05,30.27c0.36-2.1,2.76-3.27,4.65-2.13c0.17,0.1,0.34,0.22,0.49,0.36l0.02,0.01 c0.85,0.81,1.8,1.66,2.74,2.5l0.81,0.73l9.59-10.06c0.57-0.6,0.99-0.99,1.85-1.18c2.94-0.65,5.01,2.95,2.93,5.15L26.17,38.19 c-1.13,1.2-3.14,1.31-4.35,0.16c-0.69-0.64-1.45-1.3-2.21-1.96c-1.32-1.15-2.67-2.32-3.77-3.48 C15.18,32.25,14.89,31.17,15.05,30.27L15.05,30.27z"
/></g
></svg
>
{:else}
<svg
version="1.1"
id="Layer_2"
x="0px"
y="0px"
viewBox="0 0 122.88 100.6"
style="enable-background:new 0 0 122.88 100.6"
xml:space="preserve"
><style type="text/css">
.st0 {
fill: var(--color-text);
}
.st2 {
fill-rule: evenodd;
clip-rule: evenodd;
fill: #d8453e;
}
</style><g
><path
class="st0"
d="M72.58,0c6.8,0,13.3,1.36,19.23,3.81c6.16,2.55,11.7,6.29,16.33,10.92l0,0c4.63,4.63,8.37,10.17,10.92,16.34 c2.46,5.93,3.81,12.43,3.81,19.23c0,6.8-1.36,13.3-3.81,19.23c-2.55,6.16-6.29,11.7-10.92,16.33l0,0 c-4.63,4.63-10.17,8.37-16.34,10.92c-5.93,2.46-12.43,3.81-19.23,3.81c-6.8,0-13.3-1.36-19.23-3.81 c-6.15-2.55-11.69-6.28-16.33-10.92l-0.01-0.01c-4.64-4.64-8.37-10.17-10.92-16.33c-0.79-1.91-1.47-3.87-2.02-5.89 c1.05,0.1,2.12,0.15,3.2,0.15c2.05,0,4.05-0.19,6-0.54c0.32,0.97,0.67,1.93,1.06,2.87c2.09,5.05,5.17,9.6,8.99,13.43 c3.82,3.82,8.38,6.9,13.43,8.99c4.87,2.02,10.21,3.13,15.83,3.13c5.62,0,10.96-1.11,15.83-3.13c5.05-2.09,9.6-5.17,13.43-8.99 c3.82-3.82,6.9-8.38,8.99-13.43c2.02-4.87,3.13-10.21,3.13-15.83c0-5.62-1.11-10.96-3.13-15.83c-2.09-5.05-5.17-9.6-8.99-13.43 c-3.82-3.82-8.38-6.9-13.43-8.99c-4.87-2.02-10.21-3.13-15.83-3.13c-5.62,0-10.96,1.11-15.83,3.13c-0.44,0.18-0.87,0.37-1.3,0.56 c-1.65-2.61-3.66-4.97-5.95-7.02c1.25-0.65,2.53-1.24,3.84-1.79C59.28,1.36,65.78,0,72.58,0L72.58,0z M66.8,26.39 c0-1.23,0.5-2.35,1.31-3.16c0.81-0.81,1.93-1.31,3.16-1.31c1.23,0,2.35,0.5,3.16,1.31c0.81,0.81,1.31,1.93,1.31,3.16v23.47 l17.54,10.4c1.05,0.62,1.76,1.62,2.05,2.73c0.28,1.1,0.15,2.31-0.47,3.37l0,0.01l0,0c-0.62,1.05-1.62,1.76-2.73,2.05 c-1.1,0.28-2.31,0.15-3.37-0.47l-0.01,0l0,0L69.1,56.29c-0.67-0.38-1.24-0.92-1.64-1.57c-0.42-0.68-0.66-1.48-0.66-2.32V26.39 L66.8,26.39z"
/><path
class="st2"
d="M27.27,3.18c15.06,0,27.27,12.21,27.27,27.27c0,15.06-12.21,27.27-27.27,27.27C12.21,57.73,0,45.52,0,30.45 C0,15.39,12.21,3.18,27.27,3.18L27.27,3.18z M24.35,41.34h5.82v5.16h-5.82V41.34L24.35,41.34L24.35,41.34z M30.17,37.77h-5.82 c-0.58-7.07-1.8-11.56-1.8-18.63c0-2.61,2.12-4.72,4.72-4.72c2.61,0,4.72,2.12,4.72,4.72C32,26.2,30.76,30.7,30.17,37.77 L30.17,37.77L30.17,37.77L30.17,37.77z"
/></g
></svg
>
{/if}
{dateDmy}
</span>
{/if}
</div>
<div class="complete">
{#if completed}
<button
class="btn primary primary-action"
aria-label="Deshacer completar"
title="Deshacer completar"
on:click|preventDefault={doUncomplete}
disabled={busy}
>
↩️ Deshacer
</button>
{:else}
<button
class="btn primary primary-action"
aria-label="Completar"
title="Completar"
on:click|preventDefault={doComplete}
disabled={busy}
><svg viewBox="0 0 96 96" xml:space="preserve"
><g
><path
fill-rule="evenodd"
clip-rule="evenodd"
fill="#6BBE66"
class=""
d="M48,0c26.51,0,48,21.49,48,48S74.51,96,48,96S0,74.51,0,48 S21.49,0,48,0L48,0z M26.764,49.277c0.644-3.734,4.906-5.813,8.269-3.79c0.305,0.182,0.596,0.398,0.867,0.646l0.026,0.025 c1.509,1.446,3.2,2.951,4.876,4.443l1.438,1.291l17.063-17.898c1.019-1.067,1.764-1.757,3.293-2.101 c5.235-1.155,8.916,5.244,5.206,9.155L46.536,63.366c-2.003,2.137-5.583,2.332-7.736,0.291c-1.234-1.146-2.576-2.312-3.933-3.489 c-2.35-2.042-4.747-4.125-6.701-6.187C26.993,52.809,26.487,50.89,26.764,49.277L26.764,49.277z"
/></g
></svg
>
Completar
</button>
{/if}
</div>
<div class="assignees-container">
{#if assigneesCount === 0}
<button
class="assignees-badge empty"
type="button"
aria-haspopup="dialog"
aria-expanded="false"
aria-controls={"assignees-popover-" + id}
title="Sin responsables"
disabled
bind:this={assigneesButtonEl}
>
🙅
</button>
{:else}
<button
class="assignees-badge"
class:mine={isAssigned}
type="button"
aria-haspopup="dialog"
aria-expanded={showAssignees}
aria-controls={"assignees-popover-" + id}
title={assigneesAria}
aria-label={assigneesAria}
on:click|preventDefault={() => (showAssignees = true)}
bind:this={assigneesButtonEl}
>
<span class="icon" aria-hidden="true">
<svg viewBox="0 0 122.88 91.99" xml:space="preserve"
><g
><path
class="icon-btn-svg"
d="M45.13,35.29h-0.04c-7.01-0.79-16.42,0.01-20.78,0C17.04,35.6,9.47,41.91,5.02,51.3 c-2.61,5.51-3.3,9.66-3.73,15.55C0.42,72.79-0.03,78.67,0,84.47c1.43,9.03,12.88,6.35,13.85,0l1.39-18.2 c0.21-2.75,0.4-4.61,1.51-7.23c0.52-1.23,1.15-2.28,1.89-3.15l0.69,33.25l-0.39,2.78h31.49l-0.42-3.1l0.61-36.67 c3.2-1.29,5.96-1.89,8.39-1.99c-0.12,0.25-0.25,0.5-0.37,0.75c-2.61,5.51-3.3,9.66-3.73,15.55c-0.86,5.93-1.32,11.81-1.29,17.61 c1.43,9.03,12.88,6.35,13.85,0l1.39-18.2c0.21-2.75,0.4-4.61,1.51-7.23c0.52-1.23,1.15-2.28,1.89-3.15l0.69,33.25l-0.46,3.24h31.62 l-0.48-3.55l0.49-28.62v0.56l0.1-4.87c0.74,0.87,1.36,1.92,1.89,3.15c1.12,2.62,1.3,4.48,1.51,7.23l1.39,18.2 c1.34,8.68,13.85,8.85,13.85,0c0.03-5.81-0.42-11.68-1.29-17.61c-0.43-5.89-1.12-10.04-3.73-15.55 c-4.57-9.65-10.48-14.76-19.45-15.81c-5.53-0.45-14.82,0.06-20.36-0.1c-1.38,0.19-2.74,0.47-4.06,0.87 c-3.45-0.48-8.01-1.07-12.56-1.09C54.76,34.77,48.15,35.91,45.13,35.29L45.13,35.29z M88.3,0c9.01,0,16.32,7.31,16.32,16.32 c0,9.01-7.31,16.32-16.32,16.32c-9.01,0-16.32-7.31-16.32-16.32C71.98,7.31,79.29,0,88.3,0L88.3,0z M34.56,0 c9.01,0,16.32,7.31,16.32,16.32c0,9.01-7.31,16.32-16.32,16.32s-16.32-7.31-16.32-16.32C18.24,7.31,25.55,0,34.56,0L34.56,0z"
/></g
></svg
></span
>
<span class="count">{assigneesCount}</span>
</button>
{/if}
</div>
<div class="actions">
{#if !completed}
{#if !isAssigned}
<button
class="icon-btn secondary-action"
aria-label="Reclamar"
on:click|preventDefault={doClaim}
disabled={busy}
><svg viewBox="0 0 121.2 122.88"
><g
><path
class="icon-btn-svg"
d="M66.17,24.52c9.78,12.13,14.55,26.46,13.93,39.58c-4.52-11.08-11.54-22.31-20.85-32.65l-3.12,3.12 c-0.35,0.35-0.93,0.35-1.28,0l-9.87-9.87c-0.35-0.35-0.35-0.93,0-1.28l3.03-3.03C37.4,11.2,25.96,4.37,14.75,0.13 c13.22-0.99,27.81,3.57,40.2,13.31l1.36-1.36c0.35-0.35,0.93-0.35,1.28,0l9.87,9.87c0.35,0.35,0.35,0.93,0,1.28L66.17,24.52 L66.17,24.52z M49.32,58.69v-4.05l19.11,2.04L49.32,58.69L49.32,58.69z M57.83,74.36l-2.87-2.87l14.96-12.07L57.83,74.36 L57.83,74.36z M111.77,35.18l2.32,5.02l-24.85,8.4L111.77,35.18L111.77,35.18z M92.26,20.63l5.19,1.91L85.82,46.05L92.26,20.63 L92.26,20.63z M102.7,57.6l18.5,11.47v53.81H25.73c19.44-19.44,46.04-25.61,61.42-52.24C92.99,60.52,91.01,49.7,102.7,57.6 L102.7,57.6z M44.6,27.81l7.99,7.99L9.64,78.76c-2.2,2.2-5.8,2.2-7.99,0l0,0c-2.2-2.2-2.2-5.8,0-7.99L44.6,27.81L44.6,27.81z"
/></g
></svg
>
Reclamar</button
>
{:else}
<button
class="icon-btn secondary-action"
aria-label="Soltar"
title={canUnassign ? "Soltar" : "No puedes soltar una tarea personal. Márcala como completada para eliminarla"}
on:click|preventDefault={doUnassign}
disabled={busy || !canUnassign}
><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 108.01 122.88">
<path
class="icon-btn-svg"
d="M.5,0H15a.51.51,0,0,1,.5.5V83.38L35.16,82h.22l.24,0c2.07-.14,3.65-.26,4.73-1.23l1.86-2.17a1.12,1.12,0,0,1,1.49-.18l9.35,6.28a1.15,1.15,0,0,1,.49,1c0,.55-.19.7-.61,1.08A11.28,11.28,0,0,0,51.78,88a27.27,27.27,0,0,1-3,3.1,15.84,15.84,0,0,1-3.68,2.45c-2.8,1.36-5.45,1.54-8.59,1.76l-.24,0-.21,0L15.5,96.77v25.61a.52.52,0,0,1-.5.5H.5a.51.51,0,0,1-.5-.5V.5A.5.5,0,0,1,.5,0ZM46,59.91l9-19.12-.89-.25a12.43,12.43,0,0,0-4.77-.82c-1.9.28-3.68,1.42-5.67,2.7-.83.53-1.69,1.09-2.62,1.63-.7.33-1.51.86-2.19,1.25l-8.7,5a1.11,1.11,0,0,1-1.51-.42l-5.48-9.64a1.1,1.1,0,0,1,.42-1.51c3.43-2,7.42-4,10.75-6.14,4-2.49,7.27-4.48,11.06-5.42s8-.8,13.89,1c2.12.59,4.55,1.48,6.55,2.2,1,.35,1.8.66,2.44.87,9.86,3.29,13.19,9.66,15.78,14.6,1.12,2.13,2.09,4,3.34,5,.51.42,1.67.27,3,.09a21.62,21.62,0,0,1,2.64-.23c4.32-.41,8.66-.66,13-1a1.1,1.1,0,0,1,1.18,1L108,61.86A1.11,1.11,0,0,1,107,63L95,63.9c-5.33.38-9.19.66-15-2.47l-.12-.07a23.23,23.23,0,0,1-7.21-8.5l0,0L65.73,68.4a63.9,63.9,0,0,0,5.85,5.32c6,5,11,9.21,9.38,20.43a23.89,23.89,0,0,1-.65,2.93c-.27,1-.56,1.9-.87,2.84-2.29,6.54-4.22,13.5-6.29,20.13a1.1,1.1,0,0,1-1,.81l-11.66.78a1,1,0,0,1-.39,0,1.12,1.12,0,0,1-.75-1.38c2.45-8.12,5-16.25,7.39-24.38a29,29,0,0,0,.87-3,7,7,0,0,0,.08-2.65l0-.24a4.16,4.16,0,0,0-.73-2.22,53.23,53.23,0,0,0-8.76-5.57c-3.75-2.07-7.41-4.08-10.25-7a12.15,12.15,0,0,1-3.59-7.36A14.76,14.76,0,0,1,46,59.91ZM80.07,6.13a12.29,12.29,0,0,1,13.1,11.39v0a12.29,12.29,0,0,1-24.52,1.72v0A12.3,12.3,0,0,1,80,6.13ZM3.34,35H6.69V51.09H3.34V35Z"
/></svg
>
Soltar</button
>
{/if}
{#if !editingText}
<button
class="icon-btn secondary-action"
aria-label="Editar texto"
title="Editar texto"
on:click|preventDefault={toggleEditText}
disabled={busy}
><svg viewBox="0 0 121.48 122.88"
><g
><path
class="icon-btn-svg"
d="M96.84,2.22l22.42,22.42c2.96,2.96,2.96,7.8,0,10.76l-12.4,12.4L73.68,14.62l12.4-12.4 C89.04-0.74,93.88-0.74,96.84,2.22L96.84,2.22z M70.18,52.19L70.18,52.19l0,0.01c0.92,0.92,1.38,2.14,1.38,3.34 c0,1.2-0.46,2.41-1.38,3.34v0.01l-0.01,0.01L40.09,88.99l0,0h-0.01c-0.26,0.26-0.55,0.48-0.84,0.67h-0.01 c-0.3,0.19-0.61,0.34-0.93,0.45c-1.66,0.58-3.59,0.2-4.91-1.12h-0.01l0,0v-0.01c-0.26-0.26-0.48-0.55-0.67-0.84v-0.01 c-0.19-0.3-0.34-0.61-0.45-0.93c-0.58-1.66-0.2-3.59,1.11-4.91v-0.01l30.09-30.09l0,0h0.01c0.92-0.92,2.14-1.38,3.34-1.38 c1.2,0,2.41,0.46,3.34,1.38L70.18,52.19L70.18,52.19L70.18,52.19z M45.48,109.11c-8.98,2.78-17.95,5.55-26.93,8.33 C-2.55,123.97-2.46,128.32,3.3,108l9.07-32v0l-0.03-0.03L67.4,20.9l33.18,33.18l-55.07,55.07L45.48,109.11L45.48,109.11z M18.03,81.66l21.79,21.79c-5.9,1.82-11.8,3.64-17.69,5.45c-13.86,4.27-13.8,7.13-10.03-6.22L18.03,81.66L18.03,81.66z"
/></g
></svg
>
Editar</button
>
{:else}
<button
class="btn primary secondary-action"
on:click|preventDefault={saveText}
disabled={busy}>Guardar</button
>
<button
class="btn ghost secondary-action"
on:click|preventDefault={cancelText}
disabled={busy}>Cancelar</button
>
{/if}
{#if !editing}
<button
class="icon-btn secondary-action"
aria-label="Editar fecha"
title="Editar fecha"
on:click|preventDefault={toggleEdit}
disabled={busy}
><svg viewBox="0 0 110.01 122.88" xml:space="preserve"
><g
><path
class="icon-btn-svg"
d="M1.87,14.69h22.66L24.5,14.3V4.13C24.5,1.86,26.86,0,29.76,0c2.89,0,5.26,1.87,5.26,4.13V14.3l-0.03,0.39 h38.59l-0.03-0.39V4.13C73.55,1.86,75.91,0,78.8,0c2.89,0,5.26,1.87,5.26,4.13V14.3l-0.03,0.39h24.11c1.03,0,1.87,0.84,1.87,1.87 v19.46c0,1.03-0.84,1.87-1.87,1.87H1.87C0.84,37.88,0,37.04,0,36.01V16.55C0,15.52,0.84,14.69,1.87,14.69L1.87,14.69z M71.6,74.59 c2.68-0.02,4.85,2.14,4.85,4.82c-0.01,2.68-2.19,4.87-4.87,4.89l-11.76,0.08l-0.08,11.77c-0.02,2.66-2.21,4.81-4.89,4.81 c-2.68-0.01-4.84-2.17-4.81-4.83l0.08-11.69L38.4,84.54c-2.68,0.02-4.85-2.14-4.85-4.82c0.01-2.68,2.19-4.88,4.87-4.9l11.76-0.08 l0.08-11.77c0.02-2.66,2.21-4.82,4.89-4.81c2.68,0,4.83,2.16,4.81,4.82l-0.08,11.69L71.6,74.59L71.6,74.59L71.6,74.59z M0.47,42.19 h109.08c0.26,0,0.46,0.21,0.46,0.46l0,0v79.76c0,0.25-0.21,0.46-0.46,0.46l-109.08,0c-0.25,0-0.46-0.21-0.46-0.46V42.66 C0,42.4,0.21,42.19,0.47,42.19L0.47,42.19L0.47,42.19z M8.84,50.58h93.84c0.52,0,0.94,0.45,0.94,0.94v62.85 c0,0.49-0.45,0.94-0.94,0.94H8.39c-0.49,0-0.94-0.42-0.94-0.94v-62.4c0-1.03,0.84-1.86,1.86-1.86L8.84,50.58L8.84,50.58z M78.34,29.87c2.89,0,5.26-1.87,5.26-4.13V15.11l-0.03-0.41H73.11l-0.03,0.41v10.16c0,2.27,2.36,4.13,5.25,4.13L78.34,29.87 L78.34,29.87z M29.29,29.87c2.89,0,5.26-1.87,5.26-4.13V15.11l-0.03-0.41H24.06l-0.03,0.41v10.16c0,2.27,2.36,4.13,5.25,4.13V29.87 L29.29,29.87z"
/></g
></svg
>
Fecha</button
>
{:else}
<input class="date" type="date" bind:value={dateValue} />
<button
class="btn primary secondary-action"
on:click|preventDefault={saveDate}
disabled={busy}>Guardar</button
>
<button
class="btn danger secondary-action"
on:click|preventDefault={clearDate}
disabled={busy}>Quitar</button
>
<button
class="btn ghost secondary-action"
on:click|preventDefault={toggleEdit}
disabled={busy}>Cancelar</button
>
{/if}
{/if}
</div>
<Popover
bind:open={showAssignees}
ariaLabel="Responsables"
id={"assignees-popover-" + id}
>
<h3 class="popover-title">Responsables</h3>
{#if assigneesCount === 0}
<p class="muted">No hay responsables asignados.</p>
{:else}
<ul class="assignees-list">
{#each assignees as a}
<li>
<a
href={buildWaMeUrl(normalizeDigits(a))}
target="_blank"
rel="noopener noreferrer nofollow"
>
{normalizeDigits(a)}
</a>
{#if currentUserId && normalizeDigits(a) === normalizeDigits(currentUserId)}
<span class="you-pill"></span>
{/if}
</li>
{/each}
</ul>
{/if}
<div class="popover-actions">
<button class="btn ghost" on:click={() => (showAssignees = false)}
>Cerrar</button
>
</div>
</Popover>
</li>
<style>
.task {
display: grid;
grid-template-columns: 1fr max-content;
grid-template-rows: min-content max-content max-content max-content;
grid-gap: 2px;
padding: 4px 0 8px 0;
border-bottom: 2px dashed var(--color-border);
position: relative;
margin: 0 0 4px 0;
}
.task:last-child {
border-bottom: 0;
}
.code {
font-weight: 300;
font-size: 0.8rem;
color: var(--color-text-muted);
font-family: monospace;
letter-spacing: 0.5px;
grid-row: 1/2;
grid-column: 1/2;
align-self: center;
}
.completed {
opacity: 0.5;
}
.desc {
padding: 8px 4px;
grid-column: 1/3;
grid-row: 2/3;
}
.desc.editing {
outline: 2px solid var(--color-primary);
outline-offset: 2px;
background: var(--color-surface);
padding: 2px 4px;
border-radius: 4px;
white-space: normal;
text-overflow: clip;
grid-column: 1/3;
grid-row: 2/3;
margin: 16px 0;
}
.desc.completed {
text-decoration: line-through;
}
.meta {
justify-self: end;
align-items: start;
grid-row: 1/2;
grid-column: 2/3;
display: inline-flex;
gap: 6px;
align-items: center;
}
.muted {
color: var(--color-text-muted);
}
.group-badge {
padding: 2px 6px;
border-radius: 6px;
border: 1px solid var(--gc-border, var(--color-border));
background: var(--gc-bg, transparent);
color: var(--gc-text, inherit);
font-size: 12px;
}
.date-badge {
display: inline-flex;
align-items: center;
gap: 4px;
padding: 4px 6px;
border-radius: 6px;
border: 1px solid transparent;
font-size: 12px;
}
.date-badge img {
max-height: 1rem;
min-width: 1.2rem;
}
.date-badge.overdue {
border-color: var(--color-danger);
}
.date-badge.soon {
border-color: var(--color-warning);
}
.assignees-container {
grid-row: 4/5;
grid-column: 1/2;
}
.task.completed {
opacity: 0.7;
}
.complete {
grid-row: 3/4;
grid-column: 2/3;
justify-self: end;
}
.actions {
justify-self: stretch;
grid-column: 2/3;
grid-row: 4/5;
margin: 2px 0 4px 0;
display: flex;
flex-wrap: nowrap;
gap: 6px;
align-items: center;
justify-content: flex-end;
}
.btn {
padding: 4px 8px;
border: 1px solid var(--color-border);
background: var(--color-surface);
color: var(--color-text);
border-radius: 6px;
font-size: 13px;
cursor: pointer;
font-family: monospace;
box-shadow: 0 0 8px 4px var(--color-border);
margin-bottom: 4px;
}
.btn[disabled] {
opacity: 0.6;
cursor: not-allowed;
}
.btn.primary {
border-color: var(--color-primary);
background: var(--color-primary-muted);
color: var(--color-text);
}
.btn.primary svg {
margin-right: 8px;
}
.btn.ghost {
background: transparent;
}
.btn.danger {
background: var(--color-danger);
color: #fff;
border-color: transparent;
}
.icon-btn {
border: 1px solid var(--color-surface);
border-radius: 6px;
background: var(--color-surface);
font-size: 12px;
line-height: 1;
font-family: monospace;
box-shadow: 0 0 8px 4px var(--color-border);
}
.icon-btn svg {
margin-right: 8px;
}
.date {
padding: 4px 6px;
font-size: 14px;
}
@media (max-width: 768px) {
.actions {
justify-self: stretch;
}
.actions .secondary-action {
flex: 0 0 auto;
}
/* Botón de completar a ancho completo en mobile */
.complete {
grid-column: 1/3;
justify-self: stretch;
}
.complete .btn {
width: 100%;
}
}
@media (max-width: 480px) {
.task {
grid-template-columns: 1fr;
}
.meta {
justify-self: end;
}
/* En 1 columna, colocamos acciones en una fila propia bajo el badge */
.actions {
grid-row: 4/5;
grid-column: 1/3;
justify-self: flex-end;
}
.icon-btn {
padding: 2px 8px;
}
}
/* Badge de responsables */
.assignees-badge {
display: inline-flex;
align-items: center;
justify-self: start;
gap: 8px;
padding: 2px 8px;
border-radius: 6px;
border: 1px solid var(--color-border);
background: var(--color-surface);
font-size: 12px;
cursor: pointer;
box-shadow: 0 0 4px 4px var(--color-border);
}
.assignees-badge .icon {
font-size: 16px;
line-height: 1;
}
.assignees-badge .count {
font-size: 12px;
line-height: 1;
}
.assignees-badge.mine {
border-color: var(--color-surface);
}
.assignees-badge.mine .icon {
position: relative;
}
.assignees-badge.mine .icon::after {
content: "";
position: absolute;
right: -6px;
top: -6px;
width: 8px;
height: 8px;
background: var(--color-primary);
border: 1px solid var(--color-surface);
border-radius: 50%;
}
.assignees-badge[aria-expanded="true"] {
border-color: var(--color-primary);
}
.assignees-badge:disabled {
cursor: not-allowed;
opacity: 0.6;
}
.assignees-badge.empty {
padding: 2px 6px;
gap: 0;
}
.assignees-list {
list-style: none;
margin: 8px 0;
padding: 0;
}
.assignees-list li {
display: flex;
align-items: center;
gap: 8px;
padding: 4px 0;
}
.assignees-list a {
color: var(--color-primary);
text-decoration: none;
}
.assignees-list a:hover,
.assignees-list a:focus-visible {
text-decoration: underline;
}
.popover-actions {
display: flex;
justify-content: flex-end;
margin-top: 8px;
}
.popover-title {
margin: 0 0 4px 0;
font-size: 0.95rem;
}
.you-pill {
margin-left: 6px;
padding: 1px 6px;
border-radius: 999px;
background: rgba(37, 99, 235, 0.12);
color: var(--color-primary);
font-size: 11px;
}
.icon-btn-svg {
fill-rule: evenodd;
clip-rule: evenodd;
fill: var(--color-text);
}
</style>

@ -0,0 +1,13 @@
<div class="empty">
<slot />
</div>
<style>
.empty {
padding: var(--space-4);
border: 1px dashed var(--color-border);
border-radius: var(--radius-md);
color: var(--color-text-muted);
text-align: center;
}
</style>

@ -0,0 +1,16 @@
<div class="error-banner" role="alert">
<slot />
</div>
<style>
.error-banner {
padding: 10px 12px;
border: 1px solid var(--color-danger);
background: rgba(220,38,38,0.08);
color: var(--color-text);
border-radius: var(--radius-md);
}
@media (prefers-color-scheme: dark) {
.error-banner { background: rgba(248,113,113,0.12); }
}
</style>

@ -0,0 +1,125 @@
<script lang="ts">
import { tick, onDestroy } from 'svelte';
import { createEventDispatcher } from 'svelte';
import { browser } from '$app/environment';
export let open: boolean = false;
export let ariaLabel: string = 'Diálogo';
export let id: string | undefined;
const dispatch = createEventDispatcher();
let panelEl: HTMLElement | null = null;
let lastActive: Element | null = null;
function close() {
open = false;
dispatch('closed');
}
function handleKeydown(e: KeyboardEvent) {
if (e.key === 'Escape') {
e.preventDefault();
close();
} else if (e.key === 'Tab' && panelEl) {
const focusables = Array.from(
panelEl.querySelectorAll<HTMLElement>(
'a[href], button:not([disabled]), textarea, input, select, [tabindex]:not([tabindex="-1"])'
)
).filter((el) => el.offsetParent !== null);
if (focusables.length === 0) {
e.preventDefault();
return;
}
const first = focusables[0];
const last = focusables[focusables.length - 1];
const active = document.activeElement as HTMLElement | null;
if (e.shiftKey) {
if (active === first || !panelEl.contains(active)) {
e.preventDefault();
last.focus();
}
} else {
if (active === last) {
e.preventDefault();
first.focus();
}
}
}
}
$: if (open) {
if (browser) {
lastActive = document.activeElement;
tick().then(() => {
panelEl?.focus();
document.body.style.overflow = 'hidden';
});
}
} else {
if (browser) {
document.body.style.overflow = '';
if (lastActive instanceof HTMLElement) {
tick().then(() => lastActive?.focus());
}
}
}
onDestroy(() => {
if (browser) {
document.body.style.overflow = '';
}
});
</script>
{#if open}
<div
class="popover-overlay"
role="button"
tabindex="0"
aria-label="Cerrar"
on:click={close}
on:keydown={(e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); close(); } }}
></div>
<div
class="popover-panel"
role="dialog"
aria-modal="true"
{id}
aria-label={ariaLabel}
tabindex="-1"
bind:this={panelEl}
on:keydown={handleKeydown}
>
<slot />
</div>
{/if}
<style>
.popover-overlay {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.35);
z-index: 1000;
}
.popover-panel {
position: fixed;
z-index: 1001;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
max-width: min(420px, 92vw);
width: 92vw;
background: var(--color-surface);
color: var(--color-text);
border: 1px solid var(--color-border);
border-radius: 10px;
box-shadow: var(--shadow-md);
padding: 12px;
outline: none;
}
@media (prefers-color-scheme: dark) {
.popover-overlay {
background: rgba(0, 0, 0, 0.5);
}
}
</style>

@ -0,0 +1,65 @@
<script lang="ts">
import { toasts, dismiss } from '$lib/stores/toasts';
</script>
<div class="toast-region" role="region" aria-live="polite" aria-atomic="true">
{#each $toasts as t (t.id)}
<div class="toast {t.type}">
<div class="msg">{t.message}</div>
<button class="close" aria-label="Cerrar" on:click={() => dismiss(t.id)}>×</button>
</div>
{/each}
</div>
<style>
.toast-region {
position: fixed;
bottom: 16px;
left: 16px;
display: flex;
flex-direction: column;
gap: 8px;
z-index: 50;
pointer-events: none;
}
.toast {
pointer-events: auto;
display: flex;
align-items: center;
gap: 10px;
max-width: 360px;
background: var(--color-surface);
color: var(--color-text);
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
box-shadow: var(--shadow-md);
padding: 10px 12px;
}
.toast.success { border-color: rgba(22, 163, 74, 0.4); }
.toast.error { border-color: rgba(220, 38, 38, 0.4); }
.msg { flex: 1; }
.close {
background: transparent;
border: none;
color: inherit;
font-size: 18px;
line-height: 1;
padding: 2px 6px;
border-radius: var(--radius-sm);
cursor: pointer;
}
.close:hover,
.close:focus-visible {
background: rgba(0,0,0,0.06);
}
/* Evitar solape con tabbar móvil */
@media (max-width: 768px) {
.toast-region {
bottom: calc(16px + 48px + env(safe-area-inset-bottom));
}
}
@media (prefers-color-scheme: dark) {
.close:hover,
.close:focus-visible { background: rgba(255,255,255,0.08); }
}
</style>

@ -0,0 +1,27 @@
<script lang="ts">
export let size: number = 16;
export let className: string = '';
export let ariaLabel: string | undefined;
export let title: string | undefined;
</script>
<svg
width={size}
height={size}
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
class={className}
role={ariaLabel ? 'img' : undefined}
aria-label={ariaLabel}
aria-hidden={ariaLabel ? undefined : 'true'}
>
{#if title}<title>{title}</title>{/if}
<path d="M6 2h12" />
<path d="M6 22h12" />
<path d="M8 4l8 8" />
<path d="M8 20l8-8" />
</svg>

@ -0,0 +1,45 @@
<script lang="ts">
export type Option = { label: string; value: string };
export let name: string;
export let options: Option[] = [];
export let value: string;
</script>
<div class="segmented" role="radiogroup" aria-label={name}>
{#each options as opt}
<label class={`item ${value === opt.value ? 'active' : ''}`}>
<input type="radio" {name} value={opt.value} bind:group={value} />
<span>{opt.label}</span>
</label>
{/each}
</div>
<style>
.segmented {
display: inline-flex;
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
overflow: hidden;
background: var(--color-surface);
}
.item {
position: relative;
display: inline-flex;
align-items: center;
gap: 8px;
padding: 6px 10px;
border-right: 1px solid var(--color-border);
cursor: pointer;
user-select: none;
}
.item:last-child { border-right: 0; }
.item input { position: absolute; opacity: 0; pointer-events: none; }
.item.active {
background: rgba(37,99,235,0.12);
color: var(--color-primary);
font-weight: 600;
}
@media (prefers-color-scheme: dark) {
.item.active { background: rgba(96,165,250,0.14); }
}
</style>

@ -0,0 +1,32 @@
<script lang="ts">
export let type: string = 'text';
export let name: string | undefined;
export let value: string | number | undefined = undefined;
export let placeholder: string = '';
export let disabled: boolean = false;
</script>
<input
class="textfield"
{type}
{name}
bind:value
{placeholder}
{disabled}
/>
<style>
.textfield {
width: 100%;
min-height: 36px;
padding: 8px 10px;
border: 1px solid var(--color-border);
border-radius: var(--radius-sm);
background: var(--color-bg);
color: var(--color-text);
}
.textfield:focus-visible {
outline: 2px solid var(--color-primary);
outline-offset: 2px;
}
</style>

@ -0,0 +1,326 @@
<script lang="ts">
import { page } from "$app/stores";
import Toast from "$lib/ui/feedback/Toast.svelte";
$: pathname = $page.url.pathname;
$: currentTitle =
pathname === "/app"
? "Tareas"
: pathname.startsWith("/app/groups")
? "Grupos"
: pathname.startsWith("/app/preferences")
? "Recordatorios"
: pathname.startsWith("/app/integrations")
? "Calendarios"
: "Tareas";
</script>
<header class="app-header">
<div class="container row">
<a class="brand" href="/app" aria-label="Inicio">Tareas</a>
<nav class="nav">
<a href="/app" class:active={$page.url.pathname === "/app"}>Tareas</a>
<a
href="/app/groups"
class:active={$page.url.pathname.startsWith("/app/groups")}>Grupos</a
>
<a
href="/app/preferences"
class:active={$page.url.pathname.startsWith("/app/preferences")}
>Recordatorios</a
>
<a
href="/app/integrations"
class="calendar"
class:active={$page.url.pathname.startsWith("/app/integrations")}
>Calendarios</a
>
</nav>
<form method="POST" action="/api/logout">
<button type="submit" class="logout">Cerrar sesión</button>
</form>
</div>
</header>
<!-- Barra superior móvil (solo título) -->
<div class="mobile-topbar" aria-hidden="true">
<div class="container topbar-inner">{currentTitle}</div>
</div>
<svelte:head>
<meta name="viewport" content="width=device-width, initial-scale=1" />
</svelte:head>
<main class="container main">
<slot />
</main>
<nav class="tabbar" aria-label="Navegación inferior">
<a
href="/app"
class:active={$page.url.pathname === "/app"}
aria-label="Tareas"
>
<span class="icon"
><svg viewBox="0 0 117.45 122.88">
<path
class="tabbar-icon-svg"
d="M53.4,91.75c-1.96,0-3.54-1.59-3.54-3.54s1.59-3.54,3.54-3.54h19.85c1.96,0,3.54,1.59,3.54,3.54s-1.59,3.54-3.54,3.54H53.4 L53.4,91.75z M23.23,88.24c-0.8-1.2-0.48-2.82,0.72-3.63c1.2-0.8,2.82-0.48,3.63,0.72L29,87.45l5.65-6.88 c0.92-1.11,2.56-1.27,3.68-0.36c1.11,0.92,1.27,2.56,0.36,3.68l-7.82,9.51c-0.17,0.22-0.38,0.42-0.62,0.58 c-1.2,0.8-2.82,0.48-3.63-0.72L23.23,88.24L23.23,88.24z M23.23,63.34c-0.8-1.2-0.48-2.82,0.72-3.63c1.2-0.8,2.82-0.48,3.63,0.72 L29,62.55l5.65-6.88c0.92-1.11,2.56-1.27,3.68-0.36c1.11,0.92,1.27,2.56,0.36,3.68l-7.82,9.51c-0.17,0.22-0.38,0.42-0.62,0.58 c-1.2,0.8-2.82,0.48-3.63-0.72L23.23,63.34L23.23,63.34z M23.23,38.43c-0.8-1.2-0.48-2.82,0.72-3.63c1.2-0.8,2.82-0.48,3.63,0.72 L29,37.64l5.65-6.88c0.92-1.11,2.56-1.27,3.68-0.36c1.11,0.92,1.27,2.56,0.36,3.68l-7.82,9.51c-0.17,0.22-0.38,0.42-0.62,0.58 c-1.2,0.8-2.82,0.48-3.63-0.72L23.23,38.43L23.23,38.43z M53.4,39.03c-1.96,0-3.54-1.59-3.54-3.54s1.59-3.54,3.54-3.54h36.29 c1.96,0,3.54,1.59,3.54,3.54s-1.59,3.54-3.54,3.54H53.4L53.4,39.03z M8.22,0h101.02c2.27,0,4.33,0.92,5.81,2.4 c1.48,1.48,2.4,3.54,2.4,5.81v106.44c0,2.27-0.92,4.33-2.4,5.81c-1.48,1.48-3.54,2.4-5.81,2.4H8.22c-2.27,0-4.33-0.92-5.81-2.4 C0.92,119,0,116.93,0,114.66V8.22C0,5.95,0.92,3.88,2.4,2.4C3.88,0.92,5.95,0,8.22,0L8.22,0z M109.24,7.08H8.22 c-0.32,0-0.61,0.13-0.82,0.34c-0.21,0.21-0.34,0.5-0.34,0.82v106.44c0,0.32,0.13,0.61,0.34,0.82c0.21,0.21,0.5,0.34,0.82,0.34 h101.02c0.32,0,0.61-0.13,0.82-0.34c0.21-0.21,0.34-0.5,0.34-0.82V8.24c0-0.32-0.13-0.61-0.34-0.82 C109.84,7.21,109.55,7.08,109.24,7.08L109.24,7.08z M53.4,65.39c-1.96,0-3.54-1.59-3.54-3.54s1.59-3.54,3.54-3.54h36.29 c1.96,0,3.54,1.59,3.54,3.54s-1.59,3.54-3.54,3.54H53.4L53.4,65.39z"
/>
</svg></span
>
<span class="label">Tareas</span>
</a>
<a
href="/app/groups"
class:active={$page.url.pathname.startsWith("/app/groups")}
aria-label="Grupos"
>
<span class="icon"
><svg viewBox="0 0 122.88 91.99"
><g
><path
class="tabbar-icon-svg"
d="M45.13,35.29h-0.04c-7.01-0.79-16.42,0.01-20.78,0C17.04,35.6,9.47,41.91,5.02,51.3 c-2.61,5.51-3.3,9.66-3.73,15.55C0.42,72.79-0.03,78.67,0,84.47c1.43,9.03,12.88,6.35,13.85,0l1.39-18.2 c0.21-2.75,0.4-4.61,1.51-7.23c0.52-1.23,1.15-2.28,1.89-3.15l0.69,33.25l-0.39,2.78h31.49l-0.42-3.1l0.61-36.67 c3.2-1.29,5.96-1.89,8.39-1.99c-0.12,0.25-0.25,0.5-0.37,0.75c-2.61,5.51-3.3,9.66-3.73,15.55c-0.86,5.93-1.32,11.81-1.29,17.61 c1.43,9.03,12.88,6.35,13.85,0l1.39-18.2c0.21-2.75,0.4-4.61,1.51-7.23c0.52-1.23,1.15-2.28,1.89-3.15l0.69,33.25l-0.46,3.24h31.62 l-0.48-3.55l0.49-28.62v0.56l0.1-4.87c0.74,0.87,1.36,1.92,1.89,3.15c1.12,2.62,1.3,4.48,1.51,7.23l1.39,18.2 c1.34,8.68,13.85,8.85,13.85,0c0.03-5.81-0.42-11.68-1.29-17.61c-0.43-5.89-1.12-10.04-3.73-15.55 c-4.57-9.65-10.48-14.76-19.45-15.81c-5.53-0.45-14.82,0.06-20.36-0.1c-1.38,0.19-2.74,0.47-4.06,0.87 c-3.45-0.48-8.01-1.07-12.56-1.09C54.76,34.77,48.15,35.91,45.13,35.29L45.13,35.29z M88.3,0c9.01,0,16.32,7.31,16.32,16.32 c0,9.01-7.31,16.32-16.32,16.32c-9.01,0-16.32-7.31-16.32-16.32C71.98,7.31,79.29,0,88.3,0L88.3,0z M34.56,0 c9.01,0,16.32,7.31,16.32,16.32c0,9.01-7.31,16.32-16.32,16.32s-16.32-7.31-16.32-16.32C18.24,7.31,25.55,0,34.56,0L34.56,0z"
/></g
></svg
></span
>
<span class="label">Grupos</span>
</a>
<a
href="/app/preferences"
class:active={$page.url.pathname.startsWith("/app/preferences")}
aria-label="Recordatorios"
>
<span class="icon"
><svg fill-rule="evenodd" clip-rule="evenodd" viewBox="0 0 493 511.92"
><path
class="tabbar-icon-svg"
fill-rule="nonzero"
d="M277.16 41.75c49.87 6.77 94.55 29.88 128.47 63.79 40.67 40.67 65.83 96.87 65.83 158.93 0 62.08-25.15 118.28-65.83 158.96a227.22 227.22 0 0 1-25.34 21.83l27.24 38.33c5.68 8.18 3.65 19.42-4.54 25.11-8.19 5.68-19.44 3.65-25.12-4.54l-28.28-39.78c-30.84 15.91-65.83 24.89-102.92 24.89-37.7 0-73.23-9.28-104.43-25.69l-26.59 39.71c-5.54 8.28-16.76 10.5-25.04 4.95-8.29-5.54-10.5-16.75-4.95-25.03l26.07-38.95a225.636 225.636 0 0 1-24-20.83c-40.68-40.68-65.84-96.89-65.84-158.96 0-62.07 25.16-118.26 65.84-158.94 36.44-36.43 85.34-60.39 139.74-65.03 16.45-1.4 33.38-.96 49.69 1.25zm204.53 102.98c17.3-41.28 15.24-84.52-9.51-113.49-29.7-34.77-83.39-38.75-133.26-14.3 53.01 36.36 101.12 78.78 142.77 127.79zm-470.15 1.35C-6.1 104.02-4.01 59.97 21.21 30.45 51.47-4.97 106.18-9.03 156.99 15.88c-54 37.06-103.03 80.26-145.45 130.2zm269.3 101.47 67.65-1.18c9.97-.17 18.19 7.76 18.36 17.73.18 9.97-7.76 18.19-17.73 18.37l-69.51 1.21c-6.61 11.32-18.89 18.93-32.94 18.93-21.05 0-38.12-17.08-38.12-38.13 0-14.52 8.13-27.15 20.08-33.58v-87.35c0-9.97 8.07-18.05 18.04-18.05 9.97 0 18.06 8.08 18.06 18.05v87.35a38.324 38.324 0 0 1 16.11 16.65zm99.27-116.5c-34.14-34.14-81.32-55.26-133.43-55.26-52.1 0-99.28 21.12-133.42 55.26-34.15 34.14-55.27 81.32-55.27 133.43 0 52.11 21.12 99.28 55.27 133.43 34.14 34.14 81.31 55.26 133.41 55.26 52.12 0 99.29-21.12 133.43-55.26 34.14-34.15 55.28-81.32 55.28-133.44 0-52.1-21.13-99.27-55.27-133.42z"
/></svg
></span
>
<span class="label">Alertas</span>
</a>
<a
href="/app/integrations"
class="calendar"
class:active={$page.url.pathname.startsWith("/app/integrations")}
aria-label="Calendarios"
>
<span class="icon"
><svg viewBox="0 0 110.01 122.88"
><g
><path
class="tabbar-icon-svg"
d="M1.87,14.69h22.66L24.5,14.3V4.13C24.5,1.86,26.86,0,29.76,0c2.89,0,5.26,1.87,5.26,4.13V14.3l-0.03,0.39 h38.59l-0.03-0.39V4.13C73.55,1.86,75.91,0,78.8,0c2.89,0,5.26,1.87,5.26,4.13V14.3l-0.03,0.39h24.11c1.03,0,1.87,0.84,1.87,1.87 v19.46c0,1.03-0.84,1.87-1.87,1.87H1.87C0.84,37.88,0,37.04,0,36.01V16.55C0,15.52,0.84,14.69,1.87,14.69L1.87,14.69z M0.47,42.19 h109.08c0.26,0,0.46,0.21,0.46,0.46l0,0v79.76c0,0.25-0.21,0.46-0.46,0.46l-109.08,0c-0.25,0-0.47-0.21-0.47-0.46V42.66 C0,42.4,0.21,42.19,0.47,42.19L0.47,42.19L0.47,42.19z M97.27,52.76H83.57c-0.83,0-1.5,0.63-1.5,1.4V66.9c0,0.77,0.67,1.4,1.5,1.4 h13.71c0.83,0,1.51-0.63,1.51-1.4V54.16C98.78,53.39,98.1,52.76,97.27,52.76L97.27,52.76z M12.24,74.93h13.7 c0.83,0,1.51,0.63,1.51,1.4v12.74c0,0.77-0.68,1.4-1.51,1.4H12.71c-0.83,0-1.5-0.63-1.5-1.4V75.87c0-0.77,0.68-1.4,1.5-1.4 L12.24,74.93L12.24,74.93z M12.24,97.11h13.7c0.83,0,1.51,0.63,1.51,1.4v12.74c0,0.77-0.68,1.4-1.51,1.4l-13.24,0 c-0.83,0-1.5-0.63-1.5-1.4V98.51c0-0.77,0.68-1.4,1.5-1.4L12.24,97.11L12.24,97.11z M12.24,52.76h13.7c0.83,0,1.51,0.63,1.51,1.4 V66.9c0,0.77-0.68,1.4-1.51,1.4l-13.24,0c-0.83,0-1.5-0.63-1.5-1.4V54.16c0-0.77,0.68-1.4,1.5-1.4L12.24,52.76L12.24,52.76z M36.02,52.76h13.71c0.83,0,1.5,0.63,1.5,1.4V66.9c0,0.77-0.68,1.4-1.5,1.4l-13.71,0c-0.83,0-1.51-0.63-1.51-1.4V54.16 C34.51,53.39,35.19,52.76,36.02,52.76L36.02,52.76L36.02,52.76z M36.02,74.93h13.71c0.83,0,1.5,0.63,1.5,1.4v12.74 c0,0.77-0.68,1.4-1.5,1.4H36.02c-0.83,0-1.51-0.63-1.51-1.4V75.87c0-0.77,0.68-1.4,1.51-1.4V74.93L36.02,74.93z M36.02,97.11h13.71 c0.83,0,1.5,0.63,1.5,1.4v12.74c0,0.77-0.68,1.4-1.5,1.4l-13.71,0c-0.83,0-1.51-0.63-1.51-1.4V98.51 C34.51,97.74,35.19,97.11,36.02,97.11L36.02,97.11L36.02,97.11z M59.79,52.76H73.5c0.83,0,1.51,0.63,1.51,1.4V66.9 c0,0.77-0.68,1.4-1.51,1.4l-13.71,0c-0.83,0-1.51-0.63-1.51-1.4V54.16C58.29,53.39,58.96,52.76,59.79,52.76L59.79,52.76 L59.79,52.76z M59.79,74.93H73.5c0.83,0,1.51,0.63,1.51,1.4v12.74c0,0.77-0.68,1.4-1.51,1.4H59.79c-0.83,0-1.51-0.63-1.51-1.4 V75.87c0-0.77,0.68-1.4,1.51-1.4V74.93L59.79,74.93z M97.27,74.93H83.57c-0.83,0-1.5,0.63-1.5,1.4v12.74c0,0.77,0.67,1.4,1.5,1.4 h13.71c0.83,0,1.51-0.63,1.51-1.4l0-13.21c0-0.77-0.68-1.4-1.51-1.4L97.27,74.93L97.27,74.93z M97.27,97.11H83.57 c-0.83,0-1.5,0.63-1.5,1.4v12.74c0,0.77,0.67,1.4,1.5,1.4h13.71c0.83,0,1.51-0.63,1.51-1.4l0-13.21c0-0.77-0.68-1.4-1.51-1.4 L97.27,97.11L97.27,97.11z M59.79,97.11H73.5c0.83,0,1.51,0.63,1.51,1.4v12.74c0,0.77-0.68,1.4-1.51,1.4l-13.71,0 c-0.83,0-1.51-0.63-1.51-1.4V98.51C58.29,97.74,58.96,97.11,59.79,97.11L59.79,97.11L59.79,97.11z M7.01,47.71h96.92 c0.52,0,0.94,0.44,0.94,0.94v67.77c0,0.5-0.44,0.94-0.94,0.94H6.08c-0.5,0-0.94-0.42-0.94-0.94V49.58 C5.14,48.55,5.98,47.71,7.01,47.71L7.01,47.71L7.01,47.71z M78.8,29.4c2.89,0,5.26-1.87,5.26-4.13V15.11l-0.03-0.41H73.58 l-0.03,0.41v10.16C73.55,27.54,75.91,29.4,78.8,29.4L78.8,29.4L78.8,29.4z M29.76,29.4c2.89,0,5.26-1.87,5.26-4.13V15.11 l-0.03-0.41H24.53l-0.03,0.41v10.16C24.5,27.54,26.86,29.4,29.76,29.4L29.76,29.4z"
/></g
></svg
></span
>
<span class="label">Calendarios</span>
</a>
<form
method="POST"
action="/api/logout"
class="logout-tab"
aria-label="Salir"
>
<button type="submit">
<span class="icon"
><svg viewBox="0 0 113.525 122.879"
><g
><path
class="tabbar-icon-svg"
d="M78.098,13.509l0.033,0.013h0.008c2.908,1.182,5.699,2.603,8.34,4.226c2.621,1.612,5.121,3.455,7.467,5.491 c11.992,10.408,19.58,25.764,19.58,42.879v0.016h-0.006c-0.006,15.668-6.361,29.861-16.633,40.127 c-10.256,10.256-24.434,16.605-40.09,16.613v0.006h-0.033h-0.015v-0.006c-15.666-0.004-29.855-6.357-40.123-16.627l-0.005,0.004 C6.365,95.994,0.015,81.814,0.006,66.15H0v-0.033v-0.039h0.006c0.004-6.898,1.239-13.511,3.492-19.615 c0.916-2.486,2.009-4.897,3.255-7.21C13.144,27.38,23.649,18.04,36.356,13.142l2.634-1.017v2.817v18.875v1.089l-0.947,0.569 l-0.007,0.004l-0.008,0.005l-0.007,0.004c-1.438,0.881-2.809,1.865-4.101,2.925l0.004,0.004c-1.304,1.079-2.532,2.242-3.659,3.477 h-0.007c-5.831,6.375-9.393,14.881-9.393,24.22v0.016h-0.007c0.002,9.9,4.028,18.877,10.527,25.375l-0.004,0.004 c6.492,6.488,15.457,10.506,25.349,10.512v-0.006h0.033h0.015v0.006c9.907-0.002,18.883-4.025,25.374-10.518 S92.66,76.045,92.668,66.148H92.66v-0.033V66.09h0.008c-0.002-6.295-1.633-12.221-4.484-17.362 c-0.451-0.811-0.953-1.634-1.496-2.453c-2.719-4.085-6.252-7.591-10.359-10.266l-0.885-0.577v-1.042V15.303v-2.857L78.098,13.509 L78.098,13.509z M47.509,0h18.507h1.938v1.937v49.969v1.937h-1.938H47.509h-1.937v-1.937V1.937V0H47.509L47.509,0z"
/></g
></svg
></span
>
<span class="label">Salir</span>
</button>
</form>
</nav>
<Toast />
<style>
.app-header {
position: sticky;
top: 0;
z-index: 10;
background: var(--color-surface);
border-bottom: 1px solid var(--color-border);
backdrop-filter: saturate(180%) blur(8px);
-webkit-backdrop-filter: saturate(180%) blur(8px);
}
.row {
display: grid;
grid-template-columns: auto 1fr auto;
align-items: center;
gap: var(--space-3);
min-height: 58px;
}
.brand {
font-weight: 700;
color: var(--color-primary);
text-decoration: none;
letter-spacing: 0.2px;
font-size: 1.05rem;
}
.nav {
display: flex;
gap: var(--space-2);
margin-left: auto;
}
.nav a {
position: relative;
padding: 8px 12px;
border-radius: var(--radius-sm);
text-decoration: none;
color: inherit;
}
.nav a:hover,
.nav a:focus-visible {
background: rgba(0, 0, 0, 0.04);
}
.nav a.active {
background: rgba(37, 99, 235, 0.12);
color: var(--color-primary);
font-weight: 600;
}
.nav a.active::after {
content: "";
position: absolute;
left: 8px;
right: 8px;
bottom: 3px;
height: 2px;
background: var(--color-primary);
border-radius: 1px;
opacity: 0.9;
}
@media (prefers-color-scheme: dark) {
.nav a:hover,
.nav a:focus-visible {
background: rgba(255, 255, 255, 0.06);
}
.nav a.active {
background: rgba(96, 165, 250, 0.14);
}
}
.logout {
margin-left: var(--space-2);
min-height: 36px;
padding: 0 10px;
}
.main {
padding-top: var(--space-4);
padding-bottom: var(--space-4);
}
/* Barra superior móvil oculta por defecto */
.mobile-topbar {
display: none;
}
/* Barra de pestañas inferior (solo móvil) */
.tabbar {
position: fixed;
bottom: 0;
left: 0;
right: 0;
background: var(--color-surface);
border-top: 1px solid var(--color-border);
display: none;
z-index: 20;
min-height: 48px;
padding-bottom: env(safe-area-inset-bottom);
}
@media (max-width: 768px) {
.tabbar {
display: grid;
grid-template-columns: repeat(5, 1fr);
align-items: center;
}
.tabbar a,
.tabbar button {
display: grid;
align-items: center;
justify-content: center;
gap: 6px;
padding: 6px 8px;
color: inherit;
text-decoration: none;
background: transparent;
border: 1px solid var(--color-surface);
box-shadow: 0 0 8px 4px var(--color-border);
}
.tabbar form.logout-tab {
display: flex;
align-items: center;
justify-content: center;
margin: 0;
padding: 0;
}
.tabbar a.active {
color: var(--color-primary);
font-weight: 600;
}
/* Atenuar la pestaña de Calendarios cuando está inactiva */
.tabbar a.calendar {
opacity: 0.8;
}
.tabbar a.calendar.active {
opacity: 1;
}
.tabbar .icon {
font-size: 16px;
line-height: 1;
justify-self: center;
}
.tabbar .label {
font-size: 12px;
line-height: 1;
font-family: monospace;
font-size: 0.6rem;
}
.tabbar-icon-svg {
fill: var(--color-text);
}
/* Reservar espacio en el main para no tapar contenido y la barra superior */
.main {
padding-top: calc(var(--space-4) + 24px + env(safe-area-inset-top));
padding-bottom: calc(var(--space-4) + 48px + env(safe-area-inset-bottom));
}
}
@media (max-width: 480px) {
.tabbar .label {
display: auto;
}
}
/* Ocultar header y mostrar topbar en móvil */
@media (max-width: 768px) {
.app-header {
display: none;
}
.mobile-topbar {
display: block;
position: sticky;
top: 0;
z-index: 12;
background: var(--color-surface);
border-bottom: 1px solid var(--color-border);
min-height: 24px;
padding-top: env(safe-area-inset-top);
}
.mobile-topbar .topbar-inner {
display: flex;
align-items: center;
min-height: 24px;
font-weight: 600;
}
}
</style>

@ -0,0 +1,14 @@
<div class="card">
<slot />
</div>
<style>
.card {
background: var(--color-surface);
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
/* sin sombra: contenedor no interactivo */
padding: var(--space-1);
position: relative;
}
</style>

@ -0,0 +1,32 @@
<script lang="ts">
export let prevHref: string | null = null;
export let nextHref: string | null = null;
</script>
<nav class="pagination" aria-label="Paginación">
{#if prevHref}
<a class="link" rel="prev" href={prevHref}>Anterior</a>
{/if}
{#if nextHref}
<a class="link" rel="next" href={nextHref}>Siguiente</a>
{/if}
</nav>
<style>
.pagination {
display: flex;
gap: var(--space-2);
margin-top: var(--space-3);
}
.link {
padding: 6px 10px;
border-radius: var(--radius-sm);
border: 1px solid var(--color-border);
background: var(--color-surface);
text-decoration: none;
}
.link:hover,
.link:focus-visible {
box-shadow: var(--shadow-sm);
}
</style>

@ -0,0 +1,22 @@
export async function copyToClipboard(text: string): Promise<boolean> {
try {
if (navigator?.clipboard?.writeText) {
await navigator.clipboard.writeText(text);
return true;
}
} catch {}
try {
const ta = document.createElement('textarea');
ta.value = text;
ta.setAttribute('readonly', '');
ta.style.position = 'absolute';
ta.style.left = '-9999px';
document.body.appendChild(ta);
ta.select();
const ok = document.execCommand('copy');
document.body.removeChild(ta);
return ok;
} catch {
return false;
}
}

@ -0,0 +1,45 @@
export function todayYmdUTC(): string {
const d = new Date();
const y = d.getUTCFullYear();
const m = String(d.getUTCMonth() + 1).padStart(2, '0');
const day = String(d.getUTCDate()).padStart(2, '0');
return `${y}-${m}-${day}`;
}
export function compareYmd(a: string, b: string): number {
// returns -1 if a<b, 0 if equal, 1 if a>b
if (a === b) return 0;
return a < b ? -1 : 1;
}
export function addDaysYmd(ymd: string, days: number): string {
const d = new Date(`${ymd}T00:00:00Z`);
d.setUTCDate(d.getUTCDate() + days);
const y = d.getUTCFullYear();
const m = String(d.getUTCMonth() + 1).padStart(2, '0');
const day = String(d.getUTCDate()).padStart(2, '0');
return `${y}-${m}-${day}`;
}
export function dueStatus(ymd: string | null, soonDays: number = 3): 'none' | 'overdue' | 'soon' {
if (!ymd) return 'none';
const today = todayYmdUTC();
if (compareYmd(ymd, today) < 0) return 'overdue';
const soonCut = addDaysYmd(today, soonDays);
if (compareYmd(ymd, soonCut) <= 0) return 'soon';
return 'none';
}
export function ymdToDmy(ymd: string): string {
const m = /^\s*(\d{4})-(\d{2})-(\d{2})\s*$/.exec(ymd || '');
if (!m) return ymd;
return `${m[3]}/${m[2]}/${m[1]}`;
}
export function isToday(ymd: string): boolean {
return ymd === todayYmdUTC();
}
export function isTomorrow(ymd: string): boolean {
return ymd === addDaysYmd(todayYmdUTC(), 1);
}

@ -0,0 +1,53 @@
export type GroupColor = {
border: string;
bg: string;
text: string;
};
const PALETTE: GroupColor[] = [
// 1) Blue
{ border: '#2563EB', bg: '#DBEAFE', text: '#1E3A8A' },
// 2) Indigo
{ border: '#4F46E5', bg: '#E0E7FF', text: '#312E81' },
// 3) Violet
{ border: '#7C3AED', bg: '#EDE9FE', text: '#4C1D95' },
// 4) Purple
{ border: '#9333EA', bg: '#F3E8FF', text: '#581C87' },
// 5) Fuchsia
{ border: '#C026D3', bg: '#FAE8FF', text: '#701A75' },
// 6) Pink
{ border: '#DB2777', bg: '#FCE7F3', text: '#831843' },
// 7) Rose
{ border: '#E11D48', bg: '#FFE4E6', text: '#881337' },
// 8) Red
{ border: '#DC2626', bg: '#FEE2E2', text: '#7F1D1D' },
// 9) Orange
{ border: '#EA580C', bg: '#FFE7D1', text: '#7C2D12' },
// 10) Amber
{ border: '#D97706', bg: '#FEF3C7', text: '#78350F' },
// 11) Green
{ border: '#16A34A', bg: '#DCFCE7', text: '#14532D' },
// 12) Teal
{ border: '#0D9488', bg: '#CCFBF1', text: '#134E4A' }
];
function hashString(input: string): number {
// Hash sencillo y rápido (similar a multiplicador 31)
let h = 0;
for (let i = 0; i < input.length; i++) {
h = (h * 31 + input.charCodeAt(i)) | 0;
}
// Convertir a entero positivo de 32 bits
return h >>> 0;
}
/**
* Devuelve un esquema de color determinista para un groupId dado.
* - Si groupId es falsy o vacío, devuelve null (usar estilos neutros por defecto).
*/
export function colorForGroup(groupId: string | null | undefined): GroupColor | null {
const s = String(groupId || '').trim();
if (!s) return null;
const idx = hashString(s) % PALETTE.length;
return PALETTE[idx];
}

@ -0,0 +1,8 @@
export function normalizeDigits(input: string | null | undefined): string {
return String(input ?? '').replace(/\D+/g, '');
}
export function buildWaMeUrl(input: string): string {
const digits = normalizeDigits(input);
return `https://wa.me/${digits}`;
}

@ -0,0 +1,16 @@
<script lang="ts">
import '$lib/styles/tokens.css';
import '$lib/styles/base.css';
import favicon from '$lib/assets/favicon.svg';
import Toast from '$lib/ui/feedback/Toast.svelte';
</script>
<svelte:head>
<link rel="icon" href={favicon} />
<meta name="robots" content="noindex,nofollow" />
</svelte:head>
<slot />
<Toast />

@ -0,0 +1,4 @@
<h1>Welcome to SvelteKit</h1>
<p>Visit <a href="https://svelte.dev/docs/kit">svelte.dev/docs/kit</a> to read the documentation</p>
<p><a href="/app">Ir al panel</a></p>

@ -0,0 +1,114 @@
import type { RequestHandler } from './$types';
import { getDb } from '$lib/server/db';
export const GET: RequestHandler = async (event) => {
// Requiere sesión
const userId = event.locals.userId ?? null;
if (!userId) {
return new Response('Unauthorized', { status: 401 });
}
const groupId = event.params.id;
if (!groupId) {
return new Response('Bad Request', { status: 400 });
}
const url = new URL(event.request.url);
const unassignedFirst =
(url.searchParams.get('unassignedFirst') || '').trim().toLowerCase() === 'true';
const onlyUnassigned =
(url.searchParams.get('onlyUnassigned') || '').trim().toLowerCase() === 'true';
let limit = parseInt(url.searchParams.get('limit') || '', 10);
if (!Number.isFinite(limit) || limit <= 0) limit = 0;
if (limit > 100) limit = 100;
const db = await getDb();
// Gating: grupo permitido + usuario es miembro activo
const allowed = db
.prepare(`SELECT 1 FROM allowed_groups WHERE group_id = ? AND status = 'allowed' LIMIT 1`)
.get(groupId);
const active = db
.prepare(
`SELECT 1 FROM group_members WHERE group_id = ? AND user_id = ? AND is_active = 1 LIMIT 1`
)
.get(groupId, userId);
if (!allowed || !active) {
return new Response('Forbidden', { status: 403 });
}
const orderParts: string[] = [];
if (unassignedFirst) {
orderParts.push(
`CASE WHEN EXISTS (SELECT 1 FROM task_assignments a WHERE a.task_id = t.id) THEN 1 ELSE 0 END ASC`
);
}
orderParts.push(
`CASE WHEN t.due_date IS NULL THEN 1 ELSE 0 END`,
`t.due_date ASC`,
`t.id ASC`
);
const whereParts = [
`t.group_id = ?`,
`COALESCE(t.completed, 0) = 0`,
`t.completed_at IS NULL`
];
if (onlyUnassigned) {
whereParts.push(
`NOT EXISTS (SELECT 1 FROM task_assignments a WHERE a.task_id = t.id)`
);
}
const params: any[] = [groupId];
const sql = `
SELECT t.id, t.description, t.due_date, t.group_id, t.display_code
FROM tasks t
WHERE ${whereParts.join(' AND ')}
ORDER BY ${orderParts.join(', ')}${limit > 0 ? ' LIMIT ?' : ''}`;
if (limit > 0) params.push(limit);
const rows = db.prepare(sql).all(...params) as any[];
let items = rows.map((r) => ({
id: Number(r.id),
description: String(r.description || ''),
due_date: r.due_date ? String(r.due_date) : null,
group_id: r.group_id ? String(r.group_id) : null,
display_code: r.display_code != null ? Number(r.display_code) : null,
assignees: [] as string[]
}));
// Cargar asignados
if (items.length > 0 && !onlyUnassigned) {
const ids = items.map((it) => it.id);
const placeholders = ids.map(() => '?').join(',');
const assignRows = db
.prepare(
`SELECT task_id, user_id
FROM task_assignments
WHERE task_id IN (${placeholders})
ORDER BY assigned_at ASC`
)
.all(...ids) as any[];
const map = new Map<number, string[]>();
for (const row of assignRows) {
const tid = Number(row.task_id);
const uid = String(row.user_id);
if (!map.has(tid)) map.set(tid, []);
map.get(tid)!.push(uid);
}
for (const it of items) {
it.assignees = map.get(it.id) || [];
}
}
return new Response(JSON.stringify({ items }), {
status: 200,
headers: { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-store' }
});
};

@ -0,0 +1,87 @@
import type { RequestHandler } from './$types';
import { getDb } from '$lib/server/db';
import { findActiveToken, createCalendarTokenUrl, buildCalendarIcsUrl, rotateCalendarTokenUrl } from '$lib/server/calendar-tokens';
export const GET: RequestHandler = async (event) => {
// Requiere sesión
const userId = event.locals.userId ?? null;
if (!userId) {
return new Response('Unauthorized', { status: 401 });
}
const db = await getDb();
// Listar solo grupos permitidos donde el usuario está activo (mismo gating que /api/me/groups)
const groups = db
.prepare(
`SELECT g.id, g.name
FROM groups g
INNER JOIN group_members gm
ON gm.group_id = g.id AND gm.user_id = ? AND gm.is_active = 1
INNER JOIN allowed_groups ag
ON ag.group_id = g.id AND ag.status = 'allowed'
WHERE COALESCE(g.active, 1) = 1 AND COALESCE(g.is_community, 0) = 0 AND COALESCE(g.archived, 0) = 0
ORDER BY (g.name IS NULL) ASC, g.name ASC, g.id ASC`
)
.all(userId) as Array<{ id: string; name: string | null }>;
// Personal
const personalExisting = await findActiveToken('personal', userId, null);
const personal = await (async () => {
if (personalExisting) {
if (personalExisting.token_plain) {
return { url: buildCalendarIcsUrl('personal', personalExisting.token_plain) };
}
const rotated = await rotateCalendarTokenUrl('personal', userId, null);
return { url: rotated.url };
} else {
const created = await createCalendarTokenUrl('personal', userId, null);
return { url: created.url };
}
})();
// Aggregate (multigrupo)
const aggregateExisting = await findActiveToken('aggregate', userId, null);
const aggregate = await (async () => {
if (aggregateExisting) {
if (aggregateExisting.token_plain) {
return { url: buildCalendarIcsUrl('aggregate', aggregateExisting.token_plain) };
}
const rotated = await rotateCalendarTokenUrl('aggregate', userId, null);
return { url: rotated.url };
} else {
const created = await createCalendarTokenUrl('aggregate', userId, null);
return { url: created.url };
}
})();
// Por grupo (B): autogenerar si falta
const groupFeeds: Array<{ groupId: string; groupName: string | null; url: string | null }> = [];
for (const g of groups) {
const ex = await findActiveToken('group', userId, g.id);
if (ex) {
let url: string | null;
if (ex.token_plain) {
url = buildCalendarIcsUrl('group', ex.token_plain);
} else {
const rotated = await rotateCalendarTokenUrl('group', userId, g.id);
url = rotated.url;
}
groupFeeds.push({ groupId: g.id, groupName: g.name ?? null, url });
} else {
const created = await createCalendarTokenUrl('group', userId, g.id);
groupFeeds.push({ groupId: g.id, groupName: g.name ?? null, url: created.url });
}
}
const body = {
personal,
groups: groupFeeds,
aggregate
};
return new Response(JSON.stringify(body), {
status: 200,
headers: { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-store' }
});
};

@ -0,0 +1,63 @@
import type { RequestHandler } from './$types';
import { getDb } from '$lib/server/db';
import { rotateCalendarTokenUrl } from '$lib/server/calendar-tokens';
export const POST: RequestHandler = async (event) => {
// Requiere sesión
const userId = event.locals.userId ?? null;
if (!userId) {
return new Response('Unauthorized', { status: 401 });
}
let payload: any = null;
try {
payload = await event.request.json();
} catch {
return new Response('Bad Request', { status: 400 });
}
const type = String(payload?.type || '').trim().toLowerCase();
const groupId = payload?.groupId ? String(payload.groupId).trim() : null;
if (!['personal', 'group', 'aggregate'].includes(type)) {
return new Response(JSON.stringify({ error: 'type inválido' }), {
status: 400,
headers: { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-store' }
});
}
// Validación de gating/membresía si es group
if (type === 'group') {
if (!groupId) {
return new Response(JSON.stringify({ error: 'groupId requerido para type=group' }), {
status: 400,
headers: { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-store' }
});
}
const db = await getDb();
const row = db
.prepare(
`SELECT 1
FROM groups g
INNER JOIN group_members gm
ON gm.group_id = g.id AND gm.user_id = ? AND gm.is_active = 1
INNER JOIN allowed_groups ag
ON ag.group_id = g.id AND ag.status = 'allowed'
WHERE g.id = ? AND COALESCE(g.active, 1) = 1 AND COALESCE(g.is_community, 0) = 0 AND COALESCE(g.archived, 0) = 0
LIMIT 1`
)
.get(userId, groupId) as any;
if (!row) {
return new Response(JSON.stringify({ error: 'forbidden' }), {
status: 403,
headers: { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-store' }
});
}
}
const rotated = await rotateCalendarTokenUrl(type as any, userId, groupId);
return new Response(JSON.stringify({ url: rotated.url }), {
status: 200,
headers: { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-store' }
});
};

@ -0,0 +1,32 @@
import type { RequestHandler } from './$types';
import { redirect } from '@sveltejs/kit';
import { getDb } from '$lib/server/db';
import { sha256Hex } from '$lib/server/crypto';
import { isProd } from '$lib/server/env';
export const POST: RequestHandler = async (event) => {
const sid = event.cookies.get('sid');
if (sid) {
try {
const db = await getDb();
const hash = await sha256Hex(sid);
// Intentar borrar; si falla, expirar
try {
db.prepare(`DELETE FROM web_sessions WHERE session_hash = ?`).run(hash);
} catch {
db.prepare(
`UPDATE web_sessions
SET expires_at = strftime('%Y-%m-%d %H:%M:%f','now')
WHERE session_hash = ?`
).run(hash);
}
} catch {
// Ignorar errores de DB en logout
}
}
// Limpiar cookie (asegurar mismos atributos que al crearla)
event.cookies.delete('sid', { path: '/', httpOnly: true, sameSite: 'lax', secure: isProd() });
// Redirigir a home para que el navegador navegue sin depender de JS
throw redirect(303, '/');
};

@ -0,0 +1,61 @@
import type { RequestHandler } from './$types';
import { getDb } from '$lib/server/db';
export const GET: RequestHandler = async (event) => {
// Requiere sesión
const userId = event.locals.userId ?? null;
if (!userId) {
return new Response('Unauthorized', { status: 401 });
}
const db = await getDb();
// Listar solo grupos permitidos donde el usuario está activo
const groups = db
.prepare(
`SELECT g.id, g.name
FROM groups g
INNER JOIN group_members gm
ON gm.group_id = g.id AND gm.user_id = ? AND gm.is_active = 1
INNER JOIN allowed_groups ag
ON ag.group_id = g.id AND ag.status = 'allowed'
WHERE COALESCE(g.active, 1) = 1
ORDER BY (g.name IS NULL) ASC, g.name ASC, g.id ASC`
)
.all(userId) as any[];
// Preparar statements para contadores
const countOpenStmt = db.prepare(
`SELECT COUNT(*) AS cnt
FROM tasks t
WHERE t.group_id = ?
AND COALESCE(t.completed, 0) = 0
AND t.completed_at IS NULL`
);
const countUnassignedStmt = db.prepare(
`SELECT COUNT(*) AS cnt
FROM tasks t
WHERE t.group_id = ?
AND COALESCE(t.completed, 0) = 0
AND t.completed_at IS NULL
AND NOT EXISTS (SELECT 1 FROM task_assignments a WHERE a.task_id = t.id)`
);
const items = groups.map((g) => {
const open = countOpenStmt.get(g.id) as any;
const unassigned = countUnassignedStmt.get(g.id) as any;
return {
id: String(g.id),
name: g.name != null ? String(g.name) : null,
counts: {
open: Number(open?.cnt || 0),
unassigned: Number(unassigned?.cnt || 0)
}
};
});
return new Response(JSON.stringify({ items }), {
status: 200,
headers: { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-store' }
});
};

@ -0,0 +1,127 @@
import type { RequestHandler } from './$types';
import { getDb } from '$lib/server/db';
export const GET: RequestHandler = async (event) => {
// Requiere sesión
const userId = event.locals.userId ?? null;
if (!userId) {
return new Response('Unauthorized', { status: 401 });
}
const db = await getDb();
const row = db
.prepare(
`SELECT reminder_freq AS freq, reminder_time AS time
FROM user_preferences
WHERE user_id = ?
LIMIT 1`
)
.get(userId) as any;
const body =
row && row.freq
? { freq: String(row.freq), time: row.time ? String(row.time) : null }
: { freq: 'off', time: '08:30' };
return new Response(JSON.stringify(body), {
status: 200,
headers: { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-store' }
});
};
export const POST: RequestHandler = async (event) => {
// Requiere sesión
const userId = event.locals.userId ?? null;
if (!userId) {
return new Response('Unauthorized', { status: 401 });
}
let payload: any = null;
try {
payload = await event.request.json();
} catch {
return new Response('Bad Request', { status: 400 });
}
const freqRaw = String(payload?.freq || '').trim().toLowerCase();
const timeRaw = payload?.time == null ? null : String(payload.time).trim();
const allowed = new Set(['off', 'daily', 'weekly', 'weekdays']);
if (!allowed.has(freqRaw)) {
return new Response(JSON.stringify({ error: 'freq inválida' }), {
status: 400,
headers: { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-store' }
});
}
function normalizeTime(input: string): string | null {
const m = /^\s*(\d{1,2}):(\d{1,2})\s*$/.exec(input || '');
if (!m) return null;
const h = Number(m[1]);
const min = Number(m[2]);
if (!Number.isFinite(h) || !Number.isFinite(min)) return null;
if (h < 0 || h > 23 || min < 0 || min > 59) return null;
const hh = String(h).padStart(2, '0');
const mm = String(min).padStart(2, '0');
return `${hh}:${mm}`;
}
const db = await getDb();
let timeToSave: string | null = null;
if (freqRaw === 'off') {
// Hora opcional: si viene, validar/normalizar; si no, conservar la actual o usar '08:30'
if (timeRaw && timeRaw.length > 0) {
const norm = normalizeTime(timeRaw);
if (!norm) {
return new Response(JSON.stringify({ error: 'hora inválida' }), {
status: 400,
headers: { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-store' }
});
}
timeToSave = norm;
} else {
const row = db
.prepare(
`SELECT reminder_time AS time
FROM user_preferences
WHERE user_id = ?
LIMIT 1`
)
.get(userId) as any;
timeToSave = row?.time ? String(row.time) : '08:30';
}
} else {
// daily/weekly/weekdays: si no se especifica hora, usar '08:30'
if (!timeRaw || timeRaw.length === 0) {
timeToSave = '08:30';
} else {
const norm = normalizeTime(timeRaw);
if (!norm) {
return new Response(JSON.stringify({ error: 'hora inválida' }), {
status: 400,
headers: { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-store' }
});
}
timeToSave = norm;
}
}
// Upsert preferencia (mantener last_reminded_on intacto)
db.prepare(
`INSERT INTO user_preferences (user_id, reminder_freq, reminder_time, last_reminded_on, updated_at)
VALUES (?, ?, ?, (SELECT last_reminded_on FROM user_preferences WHERE user_id = ?), strftime('%Y-%m-%d %H:%M:%f','now'))
ON CONFLICT(user_id) DO UPDATE SET
reminder_freq = excluded.reminder_freq,
reminder_time = excluded.reminder_time,
updated_at = excluded.updated_at`
).run(userId, freqRaw, timeToSave, userId);
const responseBody = { freq: freqRaw, time: timeToSave };
return new Response(JSON.stringify(responseBody), {
status: 200,
headers: { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-store' }
});
};

@ -0,0 +1,235 @@
import type { RequestHandler } from './$types';
import { getDb } from '$lib/server/db';
function clamp(n: number, min: number, max: number) {
return Math.max(min, Math.min(max, n));
}
export const GET: RequestHandler = async (event) => {
// Requiere sesión
const userId = event.locals.userId ?? null;
if (!userId) {
return new Response('Unauthorized', { status: 401 });
}
const url = new URL(event.request.url);
const search = (url.searchParams.get('search') || '').trim();
const status = (url.searchParams.get('status') || 'open').trim().toLowerCase();
const page = clamp(parseInt(url.searchParams.get('page') || '1', 10) || 1, 1, 100000);
const limit = clamp(parseInt(url.searchParams.get('limit') || '20', 10) || 20, 1, 100);
const orderParam = (url.searchParams.get('order') || 'due').trim().toLowerCase();
const order = orderParam === 'group_then_due' ? 'group_then_due' : 'due';
const dueBeforeParam = (url.searchParams.get('dueBefore') || '').trim();
const soonDaysParam = parseInt(url.searchParams.get('soonDays') || '', 10);
const soonDays = Number.isFinite(soonDaysParam) && soonDaysParam >= 0 ? Math.min(soonDaysParam, 365) : null;
let dueCutoff: string | null = dueBeforeParam || null;
if (!dueCutoff && soonDays != null) {
const d = new Date();
d.setUTCDate(d.getUTCDate() + soonDays);
dueCutoff = d.toISOString().slice(0, 10);
}
// Acepta "open" (por defecto) o "recent" (completadas <24h)
if (status !== 'open' && status !== 'recent') {
return new Response('Bad Request', { status: 400 });
}
const offset = (page - 1) * limit;
const db = await getDb();
if (status === 'recent') {
// Construir filtros para tareas completadas en <24h asignadas al usuario.
const whereParts = [
`a.user_id = ?`,
`(COALESCE(t.completed, 0) = 1 OR t.completed_at IS NOT NULL)`,
`t.completed_at IS NOT NULL AND t.completed_at >= strftime('%Y-%m-%d %H:%M:%f','now','-24 hours')`,
`(t.group_id IS NULL OR (EXISTS (SELECT 1 FROM allowed_groups ag WHERE ag.group_id = t.group_id AND ag.status = 'allowed') AND EXISTS (SELECT 1 FROM group_members gm WHERE gm.group_id = t.group_id AND gm.user_id = ? AND gm.is_active = 1) AND EXISTS (SELECT 1 FROM groups g WHERE g.id = t.group_id AND COALESCE(g.active,1)=1 AND COALESCE(g.archived,0)=0)))`
];
const params: any[] = [userId, userId];
if (search) {
whereParts.push(`t.description LIKE ? ESCAPE '\\'`);
params.push(`%${search.replace(/%/g, '\\%').replace(/_/g, '\\_')}%`);
}
// Total
const totalRow = db
.prepare(
`SELECT COUNT(*) AS cnt
FROM tasks t
INNER JOIN task_assignments a ON a.task_id = t.id
WHERE ${whereParts.join(' AND ')}`
)
.get(...params) as any;
const total = Number(totalRow?.cnt || 0);
// Items (order by completed_at DESC)
const itemsRows = db
.prepare(
`SELECT t.id, t.description, t.due_date, t.group_id, t.display_code, COALESCE(t.completed,0) as completed, t.completed_at
FROM tasks t
INNER JOIN task_assignments a ON a.task_id = t.id
WHERE ${whereParts.join(' AND ')}
ORDER BY t.completed_at DESC, t.id DESC
LIMIT ? OFFSET ?`
)
.all(...params, limit, offset) as any[];
const items = itemsRows.map((r) => ({
id: Number(r.id),
description: String(r.description || ''),
due_date: r.due_date ? String(r.due_date) : null,
group_id: r.group_id ? String(r.group_id) : null,
display_code: r.display_code != null ? Number(r.display_code) : null,
completed: Number(r.completed || 0) === 1,
completed_at: r.completed_at ? String(r.completed_at) : null,
assignees: [] as string[]
}));
// Cargar asignados
if (items.length > 0) {
const ids = items.map((it) => it.id);
const placeholders = ids.map(() => '?').join(',');
const assignRows = db
.prepare(
`SELECT task_id, user_id
FROM task_assignments
WHERE task_id IN (${placeholders})
ORDER BY assigned_at ASC`
)
.all(...ids) as any[];
const map = new Map<number, string[]>();
for (const row of assignRows) {
const tid = Number(row.task_id);
const uid = String(row.user_id);
if (!map.has(tid)) map.set(tid, []);
map.get(tid)!.push(uid);
}
for (const it of items) {
it.assignees = map.get(it.id) || [];
const personal = it.group_id == null;
const cnt = Array.isArray(it.assignees) ? it.assignees.length : 0;
const mine = (it.assignees || []).some((uid) => uid === userId);
(it as any).can_unassign = !(personal && cnt === 1 && mine);
}
}
const body = {
items,
page,
limit,
total,
hasMore: offset + items.length < total
};
return new Response(JSON.stringify(body), {
status: 200,
headers: { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-store' }
});
}
// OPEN (comportamiento existente)
// Construir filtros dinámicos (con gating por grupo permitido y membresía activa)
const whereParts = [
`a.user_id = ?`,
`COALESCE(t.completed, 0) = 0`,
`t.completed_at IS NULL`,
`(t.group_id IS NULL OR (EXISTS (SELECT 1 FROM allowed_groups ag WHERE ag.group_id = t.group_id AND ag.status = 'allowed') AND EXISTS (SELECT 1 FROM group_members gm WHERE gm.group_id = t.group_id AND gm.user_id = ? AND gm.is_active = 1) AND EXISTS (SELECT 1 FROM groups g WHERE g.id = t.group_id AND COALESCE(g.active,1)=1 AND COALESCE(g.archived,0)=0)))`
];
const params: any[] = [userId];
// Añadir userId para el chequeo de membresía en el filtro de gating
params.push(userId);
if (search) {
whereParts.push(`t.description LIKE ? ESCAPE '\\'`);
params.push(`%${search.replace(/%/g, '\\%').replace(/_/g, '\\_')}%`);
}
if (dueCutoff) {
whereParts.push(`t.due_date IS NOT NULL AND t.due_date <= ?`);
params.push(dueCutoff);
}
// Total
const totalRow = db
.prepare(
`SELECT COUNT(*) AS cnt
FROM tasks t
INNER JOIN task_assignments a ON a.task_id = t.id
WHERE ${whereParts.join(' AND ')}`
)
.get(...params) as any;
const total = Number(totalRow?.cnt || 0);
// Items
const orderBy =
order === 'group_then_due'
? `(t.group_id IS NULL) ASC, g.name COLLATE NOCASE ASC, CASE WHEN t.due_date IS NULL THEN 1 ELSE 0 END, t.due_date ASC, t.id ASC`
: `CASE WHEN t.due_date IS NULL THEN 1 ELSE 0 END, t.due_date ASC, t.id ASC`;
const itemsRows = db
.prepare(
`SELECT t.id, t.description, t.due_date, t.group_id, t.display_code
FROM tasks t
LEFT JOIN groups g ON g.id = t.group_id
INNER JOIN task_assignments a ON a.task_id = t.id
WHERE ${whereParts.join(' AND ')}
ORDER BY ${orderBy}
LIMIT ? OFFSET ?`
)
.all(...params, limit, offset) as any[];
const items = itemsRows.map((r) => ({
id: Number(r.id),
description: String(r.description || ''),
due_date: r.due_date ? String(r.due_date) : null,
group_id: r.group_id ? String(r.group_id) : null,
display_code: r.display_code != null ? Number(r.display_code) : null,
assignees: [] as string[]
}));
// Cargar asignados de todas las tareas recuperadas (si hay)
if (items.length > 0) {
const ids = items.map((it) => it.id);
const placeholders = ids.map(() => '?').join(',');
const assignRows = db
.prepare(
`SELECT task_id, user_id
FROM task_assignments
WHERE task_id IN (${placeholders})
ORDER BY assigned_at ASC`
)
.all(...ids) as any[];
const map = new Map<number, string[]>();
for (const row of assignRows) {
const tid = Number(row.task_id);
const uid = String(row.user_id);
if (!map.has(tid)) map.set(tid, []);
map.get(tid)!.push(uid);
}
for (const it of items) {
it.assignees = map.get(it.id) || [];
const personal = it.group_id == null;
const cnt = Array.isArray(it.assignees) ? it.assignees.length : 0;
const mine = (it.assignees || []).some((uid) => uid === userId);
(it as any).can_unassign = !(personal && cnt === 1 && mine);
}
}
const body = {
items,
page,
limit,
total,
hasMore: offset + items.length < total
};
return new Response(JSON.stringify(body), {
status: 200,
headers: { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-store' }
});
};

@ -0,0 +1,113 @@
import type { RequestHandler } from './$types';
import { getDb } from '$lib/server/db';
export const GET: RequestHandler = async (event) => {
// Requiere sesión
const userId = event.locals.userId ?? null;
if (!userId) {
return new Response('Unauthorized', { status: 401 });
}
const url = new URL(event.request.url);
const orderParam = (url.searchParams.get('order') || 'due').trim().toLowerCase();
const order = orderParam === 'group_then_due' ? 'group_then_due' : 'due';
const db = await getDb();
// Orden para "assigned"
const assignedOrder =
order === 'group_then_due'
? `(t.group_id IS NULL) ASC, g.name COLLATE NOCASE ASC, CASE WHEN t.due_date IS NULL THEN 1 ELSE 0 END, t.due_date ASC, t.id ASC`
: `CASE WHEN t.due_date IS NULL THEN 1 ELSE 0 END, t.due_date ASC, t.id ASC`;
// Tareas asignadas al usuario (abiertas)
const assignedRows = db
.prepare(
`SELECT t.id, t.description, t.due_date, t.group_id, t.display_code, g.name AS group_name
FROM tasks t
LEFT JOIN groups g ON g.id = t.group_id
INNER JOIN task_assignments a ON a.task_id = t.id
WHERE a.user_id = ?
AND COALESCE(t.completed, 0) = 0
AND t.completed_at IS NULL
AND (
t.group_id IS NULL OR (
EXISTS (SELECT 1 FROM allowed_groups ag WHERE ag.group_id = t.group_id AND ag.status = 'allowed')
AND EXISTS (SELECT 1 FROM group_members gm WHERE gm.group_id = t.group_id AND gm.user_id = ? AND gm.is_active = 1)
)
)
ORDER BY ${assignedOrder}`
)
.all(userId, userId) as any[];
const assigned = assignedRows.map((r) => ({
id: Number(r.id),
description: String(r.description || ''),
due_date: r.due_date ? String(r.due_date) : null,
group_id: r.group_id ? String(r.group_id) : null,
group_name: r.group_name != null ? String(r.group_name) : null, // personales => null
display_code: r.display_code != null ? Number(r.display_code) : null,
assignees: [] as string[]
}));
// Cargar asignados completos para "assigned"
if (assigned.length > 0) {
const ids = assigned.map((it) => it.id);
const placeholders = ids.map(() => '?').join(',');
const assignRows = db
.prepare(
`SELECT task_id, user_id
FROM task_assignments
WHERE task_id IN (${placeholders})
ORDER BY assigned_at ASC`
)
.all(...ids) as any[];
const map = new Map<number, string[]>();
for (const row of assignRows) {
const tid = Number(row.task_id);
const uid = String(row.user_id);
if (!map.has(tid)) map.set(tid, []);
map.get(tid)!.push(uid);
}
for (const it of assigned) {
it.assignees = map.get(it.id) || [];
}
}
// Orden para "unassigned"
const unassignedOrder =
order === 'group_then_due'
? `(t.group_id IS NULL) ASC, g.name COLLATE NOCASE ASC, CASE WHEN t.due_date IS NULL THEN 1 ELSE 0 END, t.due_date ASC, t.id ASC`
: `CASE WHEN t.due_date IS NULL THEN 1 ELSE 0 END, t.due_date ASC, t.id ASC`;
// Tareas sin responsable (solo de grupos permitidos donde soy miembro activo)
const unassignedRows = db
.prepare(
`SELECT t.id, t.description, t.due_date, t.group_id, t.display_code, g.name AS group_name
FROM tasks t
LEFT JOIN groups g ON g.id = t.group_id
WHERE t.group_id IS NOT NULL
AND COALESCE(t.completed, 0) = 0
AND t.completed_at IS NULL
AND NOT EXISTS (SELECT 1 FROM task_assignments a WHERE a.task_id = t.id)
AND EXISTS (SELECT 1 FROM allowed_groups ag WHERE ag.group_id = t.group_id AND ag.status = 'allowed')
AND EXISTS (SELECT 1 FROM group_members gm WHERE gm.group_id = t.group_id AND gm.user_id = ? AND gm.is_active = 1)
ORDER BY ${unassignedOrder}`
)
.all(userId) as any[];
const unassigned = unassignedRows.map((r) => ({
id: Number(r.id),
description: String(r.description || ''),
due_date: r.due_date ? String(r.due_date) : null,
group_id: r.group_id ? String(r.group_id) : null,
group_name: r.group_name != null ? String(r.group_name) : null,
display_code: r.display_code != null ? Number(r.display_code) : null,
assignees: [] as string[] // por definición, vacío
}));
return new Response(JSON.stringify({ assigned, unassigned }), {
status: 200,
headers: { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-store' }
});
};

@ -0,0 +1,165 @@
import type { RequestHandler } from './$types';
import { getDb } from '$lib/server/db';
function isValidYmd(input: string): boolean {
const m = /^\s*(\d{4})-(\d{2})-(\d{2})\s*$/.exec(input || '');
if (!m) return false;
const y = Number(m[1]), mo = Number(m[2]), d = Number(m[3]);
if (!Number.isFinite(y) || !Number.isFinite(mo) || !Number.isFinite(d)) return false;
if (mo < 1 || mo > 12 || d < 1 || d > 31) return false;
const dt = new Date(`${String(y).padStart(4, '0')}-${String(mo).padStart(2, '0')}-${String(d).padStart(2, '0')}T00:00:00Z`);
// Comprobar que el Date resultante coincide (evita 2025-02-31)
return dt.getUTCFullYear() === y && (dt.getUTCMonth() + 1) === mo && dt.getUTCDate() === d;
}
export const PATCH: RequestHandler = async (event) => {
const userId = event.locals.userId ?? null;
if (!userId) {
return new Response('Unauthorized', { status: 401 });
}
const idStr = event.params.id || '';
const taskId = parseInt(idStr, 10);
if (!Number.isFinite(taskId) || taskId <= 0) {
return new Response('Bad Request', { status: 400 });
}
let payload: any = null;
try {
payload = await event.request.json();
} catch {
return new Response('Bad Request', { status: 400 });
}
// Validar que al menos se envíe algún campo editable
const hasDueField = Object.prototype.hasOwnProperty.call(payload ?? {}, 'due_date');
const hasDescField = Object.prototype.hasOwnProperty.call(payload ?? {}, 'description');
if (!hasDueField && !hasDescField) {
return new Response('Bad Request', { status: 400 });
}
// due_date (opcional)
const due_date_raw = payload?.due_date;
if (hasDueField && due_date_raw !== null && due_date_raw !== undefined && typeof due_date_raw !== 'string') {
return new Response('Bad Request', { status: 400 });
}
const due_date =
!hasDueField || due_date_raw == null || String(due_date_raw).trim() === ''
? null
: String(due_date_raw).trim();
if (hasDueField && due_date !== null && !isValidYmd(due_date)) {
return new Response(JSON.stringify({ error: 'invalid_due_date' }), {
status: 400,
headers: { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-store' }
});
}
// description (opcional)
let description: string | undefined = undefined;
if (hasDescField) {
const descRaw = payload?.description;
if (descRaw !== null && descRaw !== undefined && typeof descRaw !== 'string') {
return new Response('Bad Request', { status: 400 });
}
if (descRaw == null) {
// No permitimos null en description (columna NOT NULL)
return new Response(JSON.stringify({ error: 'invalid_description' }), {
status: 400,
headers: { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-store' }
});
}
const normalized = String(descRaw).replace(/\s+/g, ' ').trim();
if (normalized.length < 1 || normalized.length > 1000) {
return new Response(JSON.stringify({ error: 'invalid_description' }), {
status: 400,
headers: { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-store' }
});
}
description = normalized;
}
const db = await getDb();
// Cargar tarea y validar abierta
const task = db
.prepare(
`SELECT id, description, due_date, group_id, created_by, COALESCE(completed, 0) AS completed, completed_at, display_code
FROM tasks
WHERE id = ?`
)
.get(taskId) as any;
if (!task) {
return new Response(JSON.stringify({ status: 'not_found' }), {
status: 404,
headers: { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-store' }
});
}
if (Number(task.completed) !== 0 || task.completed_at) {
return new Response(JSON.stringify({ status: 'completed' }), {
status: 400,
headers: { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-store' }
});
}
// Gating: grupo permitido + usuario miembro activo (si tiene group_id)
const groupId: string | null = task.group_id ? String(task.group_id) : null;
if (groupId) {
const allowed = db
.prepare(`SELECT 1 FROM allowed_groups WHERE group_id = ? AND status = 'allowed' LIMIT 1`)
.get(groupId);
const active = db
.prepare(
`SELECT 1 FROM group_members WHERE group_id = ? AND user_id = ? AND is_active = 1 LIMIT 1`
)
.get(groupId, userId);
const gstatus = db
.prepare(
`SELECT 1 FROM groups WHERE id = ? AND COALESCE(active,1)=1 AND COALESCE(archived,0)=0 LIMIT 1`
)
.get(groupId);
if (!allowed || !active || !gstatus) {
return new Response('Forbidden', { status: 403 });
}
} else {
// Tarea sin grupo: permitir si el usuario está asignado o es el creador
const isAssigned = db
.prepare(`SELECT 1 FROM task_assignments WHERE task_id = ? AND user_id = ? LIMIT 1`)
.get(taskId, userId);
const isCreator = String(task.created_by || '') === String(userId);
if (!isAssigned && !isCreator) {
return new Response('Forbidden', { status: 403 });
}
}
// Aplicar actualización
if (hasDescField && hasDueField) {
db.prepare(`UPDATE tasks SET description = ?, due_date = ? WHERE id = ?`).run(description!, due_date, taskId);
} else if (hasDescField) {
db.prepare(`UPDATE tasks SET description = ? WHERE id = ?`).run(description!, taskId);
} else if (hasDueField) {
db.prepare(`UPDATE tasks SET due_date = ? WHERE id = ?`).run(due_date, taskId);
}
const updated = db
.prepare(`SELECT id, description, due_date, display_code FROM tasks WHERE id = ?`)
.get(taskId) as any;
const body = {
status: 'updated',
task: {
id: Number(updated.id),
description: String(updated.description || ''),
due_date: updated.due_date ? String(updated.due_date) : null,
display_code: updated.display_code != null ? Number(updated.display_code) : null
}
};
return new Response(JSON.stringify(body), {
status: 200,
headers: { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-store' }
});
};

@ -0,0 +1,99 @@
import type { RequestHandler } from './$types';
import { getDb } from '$lib/server/db';
function toIsoSql(d: Date): string {
return d.toISOString().replace('T', ' ').replace('Z', '');
}
export const POST: RequestHandler = async (event) => {
const userId = event.locals.userId ?? null;
if (!userId) {
return new Response('Unauthorized', { status: 401 });
}
const idStr = event.params.id || '';
const taskId = parseInt(idStr, 10);
if (!Number.isFinite(taskId) || taskId <= 0) {
return new Response('Bad Request', { status: 400 });
}
const db = await getDb();
// Cargar tarea y validar abierta
const task = db
.prepare(
`SELECT id, description, due_date, group_id, COALESCE(completed, 0) AS completed, completed_at, display_code
FROM tasks
WHERE id = ?`
)
.get(taskId) as any;
if (!task) {
return new Response(JSON.stringify({ status: 'not_found' }), {
status: 404,
headers: { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-store' }
});
}
if (Number(task.completed) !== 0 || task.completed_at) {
return new Response(JSON.stringify({ status: 'completed' }), {
status: 400,
headers: { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-store' }
});
}
// Gating: grupo permitido + usuario miembro activo (si tiene group_id)
const groupId: string | null = task.group_id ? String(task.group_id) : null;
if (groupId) {
const allowed = db
.prepare(`SELECT 1 FROM allowed_groups WHERE group_id = ? AND status = 'allowed' LIMIT 1`)
.get(groupId);
const active = db
.prepare(
`SELECT 1 FROM group_members WHERE group_id = ? AND user_id = ? AND is_active = 1 LIMIT 1`
)
.get(groupId, userId);
if (!allowed || !active) {
return new Response('Forbidden', { status: 403 });
}
}
// Asegurar existencia del usuario (best-effort)
try {
db.transaction(() => {
db.prepare(
`INSERT INTO users (id, first_seen, last_seen)
VALUES (?, strftime('%Y-%m-%d %H:%M:%f','now'), strftime('%Y-%m-%d %H:%M:%f','now'))
ON CONFLICT(id) DO NOTHING`
).run(userId);
db.prepare(
`UPDATE users SET last_seen = strftime('%Y-%m-%d %H:%M:%f','now') WHERE id = ?`
).run(userId);
})();
} catch {}
// Reclamar (idempotente)
const res = db
.prepare(
`INSERT OR IGNORE INTO task_assignments (task_id, user_id, assigned_by)
VALUES (?, ?, ?)`
)
.run(taskId, userId, userId) as any;
const status = Number(res?.changes || 0) > 0 ? 'claimed' : 'already';
const body = {
status,
task: {
id: Number(task.id),
description: String(task.description || ''),
due_date: task.due_date ? String(task.due_date) : null,
display_code: task.display_code != null ? Number(task.display_code) : null
}
};
return new Response(JSON.stringify(body), {
status: 200,
headers: { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-store' }
});
};

@ -0,0 +1,120 @@
import type { RequestHandler } from './$types';
import { getDb } from '$lib/server/db';
export const POST: RequestHandler = async (event) => {
const userId = event.locals.userId ?? null;
if (!userId) {
return new Response('Unauthorized', { status: 401 });
}
const idStr = event.params.id || '';
const taskId = parseInt(idStr, 10);
if (!Number.isFinite(taskId) || taskId <= 0) {
return new Response('Bad Request', { status: 400 });
}
const db = await getDb();
const task = db.prepare(`
SELECT id, description, due_date, group_id, COALESCE(completed, 0) AS completed, completed_at, display_code
FROM tasks
WHERE id = ?
`).get(taskId) as any;
if (!task) {
return new Response(JSON.stringify({ status: 'not_found' }), {
status: 404,
headers: { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-store' }
});
}
// Gating:
// - Si tiene group_id: grupo allowed y miembro activo
// - Si NO tiene group_id: debe estar asignada al usuario
const groupId: string | null = task.group_id ? String(task.group_id) : null;
if (groupId) {
const allowed = db
.prepare(`SELECT 1 FROM allowed_groups WHERE group_id = ? AND status = 'allowed' LIMIT 1`)
.get(groupId);
const active = db
.prepare(`SELECT 1 FROM group_members WHERE group_id = ? AND user_id = ? AND is_active = 1 LIMIT 1`)
.get(groupId, userId);
if (!allowed || !active) {
return new Response('Forbidden', { status: 403 });
}
} else {
const isAssigned = db
.prepare(`SELECT 1 FROM task_assignments WHERE task_id = ? AND user_id = ? LIMIT 1`)
.get(taskId, userId);
if (!isAssigned) {
return new Response('Forbidden', { status: 403 });
}
}
if (Number(task.completed) !== 0 || task.completed_at) {
const body = {
status: 'already',
task: {
id: Number(task.id),
description: String(task.description || ''),
due_date: task.due_date ? String(task.due_date) : null,
display_code: task.display_code != null ? Number(task.display_code) : null,
completed: 1,
completed_at: task.completed_at ? String(task.completed_at) : null
}
};
return new Response(JSON.stringify(body), {
status: 200,
headers: { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-store' }
});
}
// Transacción: auto-asignar si no hay responsables y completar
const tx = db.transaction(() => {
const cntRow = db
.prepare(`SELECT COUNT(*) AS cnt FROM task_assignments WHERE task_id = ?`)
.get(taskId) as any;
const cnt = Number(cntRow?.cnt || 0);
if (cnt === 0) {
db.prepare(`
INSERT OR IGNORE INTO task_assignments (task_id, user_id, assigned_by)
VALUES (?, ?, ?)
`).run(taskId, userId, userId);
}
db.prepare(`
UPDATE tasks
SET completed = 1,
completed_at = strftime('%Y-%m-%d %H:%M:%f', 'now'),
completed_by = ?
WHERE id = ?
AND COALESCE(completed, 0) = 0
AND completed_at IS NULL
`).run(userId, taskId);
});
tx();
const updated = db.prepare(`
SELECT id, description, due_date, display_code, COALESCE(completed, 0) AS completed, completed_at
FROM tasks
WHERE id = ?
`).get(taskId) as any;
const statusStr = Number(updated.completed || 0) === 1 ? 'updated' : 'already';
const body = {
status: statusStr,
task: {
id: Number(updated.id),
description: String(updated.description || ''),
due_date: updated.due_date ? String(updated.due_date) : null,
display_code: updated.display_code != null ? Number(updated.display_code) : null,
completed: Number(updated.completed || 0),
completed_at: updated.completed_at ? String(updated.completed_at) : null
}
};
return new Response(JSON.stringify(body), {
status: 200,
headers: { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-store' }
});
};

@ -0,0 +1,103 @@
import type { RequestHandler } from './$types';
import { getDb } from '$lib/server/db';
export const POST: RequestHandler = async (event) => {
const userId = event.locals.userId ?? null;
if (!userId) {
return new Response('Unauthorized', { status: 401 });
}
const idStr = event.params.id || '';
const taskId = parseInt(idStr, 10);
if (!Number.isFinite(taskId) || taskId <= 0) {
return new Response('Bad Request', { status: 400 });
}
const db = await getDb();
// Cargar tarea y validar abierta
const task = db
.prepare(
`SELECT id, description, due_date, group_id, COALESCE(completed, 0) AS completed, completed_at, display_code
FROM tasks
WHERE id = ?`
)
.get(taskId) as any;
if (!task) {
return new Response(JSON.stringify({ status: 'not_found' }), {
status: 404,
headers: { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-store' }
});
}
if (Number(task.completed) !== 0 || task.completed_at) {
return new Response(JSON.stringify({ status: 'completed' }), {
status: 400,
headers: { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-store' }
});
}
// Gating: grupo permitido + usuario miembro activo (si tiene group_id)
const groupId: string | null = task.group_id ? String(task.group_id) : null;
if (groupId) {
const allowed = db
.prepare(`SELECT 1 FROM allowed_groups WHERE group_id = ? AND status = 'allowed' LIMIT 1`)
.get(groupId);
const active = db
.prepare(
`SELECT 1 FROM group_members WHERE group_id = ? AND user_id = ? AND is_active = 1 LIMIT 1`
)
.get(groupId, userId);
if (!allowed || !active) {
return new Response('Forbidden', { status: 403 });
}
}
// Bloqueo de tareas personales: si es la última asignación del propio usuario, denegar
const stats = db
.prepare(`
SELECT COUNT(*) AS cnt,
SUM(CASE WHEN user_id = ? THEN 1 ELSE 0 END) AS mine
FROM task_assignments
WHERE task_id = ?
`)
.get(userId, taskId) as any;
const cnt = Number(stats?.cnt || 0);
const mine = Number(stats?.mine || 0) > 0;
if (!groupId && cnt === 1 && mine) {
return new Response('No puedes soltar una tarea personal. Márcala como completada para eliminarla', {
status: 409,
headers: { 'content-type': 'text/plain; charset=utf-8', 'cache-control': 'no-store' }
});
}
// Eliminar asignación (idempotente)
const delRes = db
.prepare(`DELETE FROM task_assignments WHERE task_id = ? AND user_id = ?`)
.run(taskId, userId) as any;
const cntRow = db
.prepare(`SELECT COUNT(*) AS cnt FROM task_assignments WHERE task_id = ?`)
.get(taskId) as any;
const remaining = Number(cntRow?.cnt || 0);
const status = Number(delRes?.changes || 0) > 0 ? 'unassigned' : 'not_assigned';
const body = {
status,
now_unassigned: remaining === 0,
task: {
id: Number(task.id),
description: String(task.description || ''),
due_date: task.due_date ? String(task.due_date) : null,
display_code: task.display_code != null ? Number(task.display_code) : null
}
};
return new Response(JSON.stringify(body), {
status: 200,
headers: { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-store' }
});
};

@ -0,0 +1,117 @@
import type { RequestHandler } from './$types';
import { getDb } from '$lib/server/db';
import { UNCOMPLETE_WINDOW_MIN } from '$lib/server/env';
function toIsoSql(d: Date): string {
return d.toISOString().replace('T', ' ').replace('Z', '');
}
export const POST: RequestHandler = async (event) => {
const userId = event.locals.userId ?? null;
if (!userId) {
return new Response('Unauthorized', { status: 401 });
}
const idStr = event.params.id || '';
const taskId = parseInt(idStr, 10);
if (!Number.isFinite(taskId) || taskId <= 0) {
return new Response('Bad Request', { status: 400 });
}
const db = await getDb();
const task = db.prepare(`
SELECT id, description, due_date, group_id, COALESCE(completed, 0) AS completed, completed_at, display_code
FROM tasks
WHERE id = ?
`).get(taskId) as any;
if (!task) {
return new Response(JSON.stringify({ status: 'not_found' }), {
status: 404,
headers: { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-store' }
});
}
// Si ya está sin completar, es idempotente
if (Number(task.completed) === 0) {
const body = {
status: 'already',
task: {
id: Number(task.id),
description: String(task.description || ''),
due_date: task.due_date ? String(task.due_date) : null,
display_code: task.display_code != null ? Number(task.display_code) : null,
completed: 0,
completed_at: null
}
};
return new Response(JSON.stringify(body), {
status: 200,
headers: { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-store' }
});
}
// Gating:
// - Si tiene group_id: grupo allowed y miembro activo
// - Si NO tiene group_id: debe estar asignada al usuario
const groupId: string | null = task.group_id ? String(task.group_id) : null;
if (groupId) {
const allowed = db
.prepare(`SELECT 1 FROM allowed_groups WHERE group_id = ? AND status = 'allowed' LIMIT 1`)
.get(groupId);
const active = db
.prepare(`SELECT 1 FROM group_members WHERE group_id = ? AND user_id = ? AND is_active = 1 LIMIT 1`)
.get(groupId, userId);
if (!allowed || !active) {
return new Response('Forbidden', { status: 403 });
}
} else {
const isAssigned = db
.prepare(`SELECT 1 FROM task_assignments WHERE task_id = ? AND user_id = ? LIMIT 1`)
.get(taskId, userId);
if (!isAssigned) {
return new Response('Forbidden', { status: 403 });
}
}
// Validar ventana: completed_at dentro de UNCOMPLETE_WINDOW_MIN
if (!task.completed_at) {
return new Response('Forbidden', { status: 403 });
}
const cutoff = toIsoSql(new Date(Date.now() - UNCOMPLETE_WINDOW_MIN * 60 * 1000));
if (String(task.completed_at) < String(cutoff)) {
return new Response('Forbidden', { status: 403 });
}
// Deshacer completar (no tocamos completed_by)
db.prepare(`
UPDATE tasks
SET completed = 0,
completed_at = NULL
WHERE id = ?
`).run(taskId);
const updated = db.prepare(`
SELECT id, description, due_date, display_code, COALESCE(completed, 0) AS completed, completed_at
FROM tasks
WHERE id = ?
`).get(taskId) as any;
const body = {
status: 'updated',
task: {
id: Number(updated.id),
description: String(updated.description || ''),
due_date: updated.due_date ? String(updated.due_date) : null,
display_code: updated.display_code != null ? Number(updated.display_code) : null,
completed: Number(updated.completed || 0),
completed_at: updated.completed_at ? String(updated.completed_at) : null
}
};
return new Response(JSON.stringify(body), {
status: 200,
headers: { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-store' }
});
};

@ -0,0 +1,10 @@
import type { LayoutServerLoad } from './$types';
import { redirect } from '@sveltejs/kit';
export const load: LayoutServerLoad = async (event) => {
const userId = event.locals.userId ?? null;
if (!userId) {
throw redirect(303, '/login');
}
return { userId };
};

@ -0,0 +1,7 @@
<script lang="ts">
import AppShell from '$lib/ui/layout/AppShell.svelte';
</script>
<AppShell>
<slot />
</AppShell>

@ -0,0 +1,118 @@
import type { PageServerLoad } from './$types';
import { redirect } from '@sveltejs/kit';
export const load: PageServerLoad = async (event) => {
const userId = event.locals.userId ?? null;
if (!userId) {
// No hay sesión: redirigir a la home
throw redirect(303, '/');
}
// Parámetros de orden y paginación
const orderParam = (event.url.searchParams.get('order') || 'due').trim().toLowerCase();
const order: 'due' | 'group' = orderParam === 'group' ? 'group' : 'due';
const pageStr = (event.url.searchParams.get('page') || '1').trim();
const page = Math.max(1, parseInt(pageStr, 10) || 1);
// Cargar "mis tareas" desde la API interna
let openTasks: Array<{
id: number;
description: string;
due_date: string | null;
group_id: string | null;
display_code: number | null;
assignees: string[];
}> = [];
let recentTasks: Array<{
id: number;
description: string;
due_date: string | null;
group_id: string | null;
display_code: number | null;
assignees: string[];
completed?: boolean;
completed_at?: string | null;
}> = [];
let hasMore: boolean = false;
// Agregado: "Sin responsable de mis grupos"
let unassignedOpen: Array<{
id: number;
description: string;
due_date: string | null;
group_id: string | null;
display_code: number | null;
assignees: string[];
}> = [];
const groupNames: Record<string, string> = {};
try {
// Mis tareas abiertas (paginadas, orden controlado por el servidor)
const orderParamApi = order === 'group' ? 'group_then_due' : 'due';
let fetchUrl = `/api/me/tasks?limit=20&order=${encodeURIComponent(orderParamApi)}`;
fetchUrl += `&page=${encodeURIComponent(String(page))}`;
const res = await event.fetch(fetchUrl, { headers: { 'cache-control': 'no-store' } });
if (res.ok) {
const json = await res.json();
openTasks = Array.isArray(json?.items) ? json.items : [];
hasMore = Boolean(json?.hasMore);
}
// Completadas en las últimas 24h (sin paginar por ahora)
const resRecent = await event.fetch('/api/me/tasks?limit=20&status=recent', {
headers: { 'cache-control': 'no-store' }
});
if (resRecent.ok) {
const jsonRecent = await resRecent.json();
recentTasks = Array.isArray(jsonRecent?.items) ? jsonRecent.items : [];
}
// Overview: obtener "sin responsable" de todos los grupos en una sola llamada
const overviewOrder = order === 'group' ? 'group_then_due' : 'due';
const resOverview = await event.fetch(
`/api/me/tasks/overview?order=${encodeURIComponent(overviewOrder)}`,
{ headers: { 'cache-control': 'no-store' } }
);
if (resOverview.ok) {
const jsonOv = await resOverview.json();
const items: any[] = Array.isArray(jsonOv?.unassigned) ? jsonOv.unassigned : [];
unassignedOpen = items.map((it) => ({
id: Number(it.id),
description: String(it.description || ''),
due_date: it.due_date ? String(it.due_date) : null,
group_id: it.group_id ? String(it.group_id) : null,
display_code: it.display_code != null ? Number(it.display_code) : null,
assignees: Array.isArray(it.assignees) ? it.assignees.map(String) : []
}));
}
// Mis grupos (para nombres y para recolectar "sin responsable")
const resGroups = await event.fetch('/api/me/groups', {
headers: { 'cache-control': 'no-store' }
});
if (resGroups.ok) {
const jsonGroups = await resGroups.json();
const groups = Array.isArray(jsonGroups?.items) ? jsonGroups.items : [];
for (const g of groups) {
const gid = String(g.id);
const gname = g.name != null ? String(g.name) : null;
if (gname) groupNames[gid] = gname;
}
}
} catch {
// Ignorar errores y dejar listas vacías
}
return {
userId,
openTasks,
recentTasks,
unassignedOpen,
groupNames,
order,
page,
hasMore
};
};

@ -0,0 +1,335 @@
<script lang="ts">
import Card from "$lib/ui/layout/Card.svelte";
import TaskItem from "$lib/ui/data/TaskItem.svelte";
import Pagination from "$lib/ui/layout/Pagination.svelte";
type Task = {
id: number;
description: string;
due_date: string | null;
group_id: string | null;
display_code: number | null;
assignees: string[];
};
export let data: {
userId: string;
openTasks: Task[];
recentTasks: (Task & {
completed?: boolean;
completed_at?: string | null;
})[];
unassignedOpen: Task[];
groupNames: Record<string, string>;
order: "due" | "group";
page?: number | null;
hasMore?: boolean | null;
};
// Estado local para permitir actualización sin recargar ni perder scroll
let openTasks: Task[] = [...data.openTasks];
let unassignedOpen: Task[] = [...data.unassignedOpen];
let recentTasks: (Task & {
completed?: boolean;
completed_at?: string | null;
})[] = [...data.recentTasks];
function buildQuery(params: { order?: "due" | "group"; page?: number }) {
const sp = new URLSearchParams();
if (params.order) sp.set("order", params.order);
if (params.page && params.page > 1) sp.set("page", String(params.page));
return sp.toString();
}
function sortByDue(items: Task[]): Task[] {
return [...items].sort((a, b) => {
const ad = a.due_date,
bd = b.due_date;
if (ad == null && bd == null) return a.id - b.id;
if (ad == null) return 1;
if (bd == null) return -1;
if (ad < bd) return -1;
if (ad > bd) return 1;
return a.id - b.id;
});
}
function groupByGroup(
items: Task[],
): { id: string; name: string; tasks: Task[] }[] {
const map = new Map<string, Task[]>();
for (const it of items) {
const gid = it.group_id ? String(it.group_id) : "";
if (!map.has(gid)) map.set(gid, []);
map.get(gid)!.push(it);
}
const groups = Array.from(map.entries()).map(([gid, tasks]) => ({
id: gid,
name: gid ? data.groupNames[gid] || gid : "Personal",
tasks,
}));
// Mantener el orden provisto por el servidor (ya ordenado alfabéticamente con "Personal" al final)
return groups;
}
function maintainScrollWhile(mutate: () => void) {
const y = window.scrollY;
mutate();
queueMicrotask(() => window.scrollTo({ top: y }));
}
function updateTaskInLists(detail: {
id: number;
action: string;
patch: Partial<
Task & { completed?: boolean; completed_at?: string | null }
>;
}) {
const { id, action, patch } = detail;
const patchIn = (arr: Task[]) => {
const idx = arr.findIndex((t) => t.id === id);
if (idx >= 0) {
arr[idx] = { ...arr[idx], ...patch };
return true;
}
return false;
};
if (action === "complete") {
maintainScrollWhile(() => {
let moved = false;
let idx = unassignedOpen.findIndex((t) => t.id === id);
if (idx >= 0) {
const [it] = unassignedOpen.splice(idx, 1);
const completedItem: any = { ...it, ...patch, completed: true };
recentTasks = [completedItem, ...recentTasks];
moved = true;
}
idx = openTasks.findIndex((t) => t.id === id);
if (idx >= 0) {
const [it] = openTasks.splice(idx, 1);
const completedItem: any = { ...it, ...patch, completed: true };
recentTasks = [completedItem, ...recentTasks];
moved = true;
}
if (!moved) {
patchIn(recentTasks as any);
}
// Forzar reactividad en listas mutadas
openTasks = [...openTasks];
unassignedOpen = [...unassignedOpen];
recentTasks = [...recentTasks];
});
} else if (action === "uncomplete") {
maintainScrollWhile(() => {
const idx = recentTasks.findIndex((t) => t.id === id);
if (idx >= 0) {
const [it] = recentTasks.splice(idx, 1);
const reopened: any = { ...it, ...patch, completed: false };
openTasks = [reopened, ...openTasks];
} else {
patchIn(openTasks);
}
openTasks = [...openTasks];
recentTasks = [...recentTasks];
});
} else if (action === "claim") {
maintainScrollWhile(() => {
const idx = unassignedOpen.findIndex((t) => t.id === id);
if (idx >= 0) {
const [it] = unassignedOpen.splice(idx, 1);
const claimed = { ...it, ...patch };
if (!openTasks.some((x) => x.id === id)) {
openTasks = [claimed, ...openTasks];
} else {
patchIn(openTasks);
}
} else {
patchIn(openTasks);
}
openTasks = [...openTasks];
unassignedOpen = [...unassignedOpen];
});
} else if (action === "unassign") {
maintainScrollWhile(() => {
if (!patchIn(openTasks)) patchIn(unassignedOpen);
// Si quedó sin responsables, mover a "sin responsable"
const idx = openTasks.findIndex((t) => t.id === id);
if (idx >= 0 && (openTasks[idx].assignees || []).length === 0) {
const [it] = openTasks.splice(idx, 1);
unassignedOpen = [it, ...unassignedOpen];
}
openTasks = [...openTasks];
unassignedOpen = [...unassignedOpen];
});
} else {
// update_due, update_desc u otros parches ligeros
if (!patchIn(openTasks)) {
if (!patchIn(unassignedOpen)) {
patchIn(recentTasks as any);
}
}
openTasks = [...openTasks];
unassignedOpen = [...unassignedOpen];
recentTasks = [...recentTasks];
}
}
</script>
<svelte:head>
<title>Tareas</title>
<meta name="robots" content="noindex,nofollow" />
</svelte:head>
<p class="subtle">Sesión: <strong>{data.userId}</strong></p>
<div class="order-toggle">
<span>Orden:</span>
<a
class:active={data.order === "due"}
href={`/app?${buildQuery({ order: "due", page: data.page ?? 1 })}`}>Fecha</a
>
<a
class:active={data.order === "group"}
href={`/app?${buildQuery({ order: "group", page: data.page ?? 1 })}`}
>Grupo</a
>
</div>
<h2 class="section-title">Mis tareas</h2>
{#if openTasks.length === 0}
<p>No tienes tareas asignadas. Crea o reclama una para empezar.</p>
{:else}
<Card>
<ul class="list">
{#each openTasks as t (t.id)}
<TaskItem
{...t}
currentUserId={data.userId}
groupName={t.group_id
? data.groupNames[t.group_id] || t.group_id
: "Personal"}
groupId={t.group_id}
on:changed={(e) => updateTaskInLists(e.detail)}
/>
{/each}
</ul>
</Card>
{/if}
{#if (data.page ?? 1) > 1 || data.hasMore}
<Pagination
prevHref={(data.page ?? 1) > 1
? `/app?${buildQuery({ order: data.order, page: (data.page ?? 1) - 1 })}`
: null}
nextHref={data.hasMore
? `/app?${buildQuery({ order: data.order, page: (data.page ?? 1) + 1 })}`
: null}
/>
{/if}
<h2 class="section-title">Sin responsable de mis grupos</h2>
{#if unassignedOpen.length === 0}
<p>
No hay tareas sin responsable en tus grupos. Crea una nueva o invita a
alguien.
</p>
{:else if data.order === "group"}
{#each groupByGroup(unassignedOpen) as g (g.id)}
<h3 class="group-subtitle">{g.name}</h3>
<Card>
<ul class="list">
{#each g.tasks as t (t.id)}
<TaskItem
{...t}
currentUserId={data.userId}
groupName={g.name}
groupId={t.group_id}
on:changed={(e) => updateTaskInLists(e.detail)}
/>
{/each}
</ul>
</Card>
{/each}
{:else}
<Card>
<ul class="list">
{#each unassignedOpen as t (t.id)}
<TaskItem
{...t}
currentUserId={data.userId}
groupName={t.group_id
? data.groupNames[t.group_id] || t.group_id
: "Personal"}
groupId={t.group_id}
on:changed={(e) => updateTaskInLists(e.detail)}
/>
{/each}
</ul>
</Card>
{/if}
<h2 class="section-title">Completadas (últimas 24 h)</h2>
{#if recentTasks.length === 0}
<p>No hay tareas completadas recientemente.</p>
{:else}
<Card>
<ul class="list">
{#each recentTasks as t (t.id)}
<TaskItem
{...t}
currentUserId={data.userId}
groupId={t.group_id}
completed={true}
completed_at={t.completed_at ?? null}
groupName={t.group_id
? data.groupNames[t.group_id] || t.group_id
: "Personal"}
on:changed={(e) => updateTaskInLists(e.detail)}
/>
{/each}
</ul>
</Card>
{/if}
<style>
.subtle {
color: var(--color-text-muted);
margin: 0 0 1rem 0;
}
.order-toggle {
display: inline-flex;
gap: 8px;
align-items: center;
margin-bottom: 0.5rem;
}
.order-toggle a {
padding: 2px 8px;
border: 1px solid var(--color-border);
border-radius: 999px;
text-decoration: none;
color: var(--color-text);
}
.order-toggle a.active {
background: var(--color-primary);
border-color: var(--color-primary);
color: #fff;
}
.section-title {
margin: 0.5rem 0;
}
.group-subtitle {
margin: 0.5rem 0 0.25rem 0;
font-size: 0.95rem;
}
.list {
margin: 0;
padding: 0;
list-style: none;
}
.footnote {
margin-top: 0.75rem;
color: var(--color-text-muted);
}
</style>

@ -0,0 +1,35 @@
import type { PageServerLoad } from './$types';
export const load: PageServerLoad = async (event) => {
const userId = event.locals.userId ?? null;
const res = await event.fetch('/api/me/groups', { headers: { 'cache-control': 'no-store' } });
if (!res.ok) {
// El gate del layout debería impedir llegar aquí sin sesión; devolvemos vacío como salvaguarda.
return { groups: [], itemsByGroup: {}, unassignedFirst: false };
}
const data = await res.json();
const groups = Array.isArray(data?.items) ? data.items : [];
// Leer preferencia de orden para el listado del grupo
const unassignedFirst =
(event.url.searchParams.get('unassignedFirst') || '').trim().toLowerCase() === 'true';
// Recolectar TODAS las tareas abiertas por grupo (sin límite)
const itemsByGroup: Record<string, any[]> = {};
for (const g of groups) {
try {
const url = `/api/groups/${encodeURIComponent(g.id)}/tasks?limit=0${
unassignedFirst ? '&unassignedFirst=true' : ''
}`;
const r = await event.fetch(url, { headers: { 'cache-control': 'no-store' } });
if (r.ok) {
const j = await r.json();
itemsByGroup[String(g.id)] = Array.isArray(j?.items) ? j.items : [];
}
} catch {
// ignorar errores de un grupo y continuar
}
}
return { groups, itemsByGroup, unassignedFirst, userId };
};

@ -0,0 +1,240 @@
<script lang="ts">
import TaskItem from "$lib/ui/data/TaskItem.svelte";
import Card from "$lib/ui/layout/Card.svelte";
import { onMount } from "svelte";
import { slide } from "svelte/transition";
type GroupItem = {
id: string;
name: string | null;
counts: { open: number; unassigned: number };
};
type Task = {
id: number;
description: string;
due_date: string | null;
display_code: number | null;
group_id?: string | null;
assignees?: string[];
};
export let data: {
userId: string | null;
groups: GroupItem[];
itemsByGroup: Record<string, Task[]>;
unassignedFirst?: boolean;
};
const groups = data.groups || [];
let itemsByGroup: Record<string, Task[]> = {};
for (const [gid, arr] of Object.entries(data.itemsByGroup || {})) {
itemsByGroup[gid] = Array.isArray(arr as any) ? ([...(arr as any)]) : [];
}
function buildQuery(params: { unassignedFirst?: boolean }) {
const sp = new URLSearchParams();
if (params.unassignedFirst) sp.set("unassignedFirst", "true");
return sp.toString();
}
const storageKey = `groupsCollapsed:v1:${data.userId ?? "anon"}`;
let collapsed: Record<string, boolean> = {};
function hasTasks(groupId: string): boolean {
const arr = itemsByGroup[groupId] || [];
return Array.isArray(arr) && arr.length > 0;
}
function defaultCollapsedFor(groupId: string): boolean {
// Por defecto, colapsado si no tiene tareas abiertas
return !hasTasks(groupId);
}
function isOpen(groupId: string): boolean {
const v = collapsed[groupId];
if (typeof v === "boolean") return !v;
return !defaultCollapsedFor(groupId);
}
function saveCollapsed() {
try {
const currentIds = new Set(groups.map((g) => g.id));
const pruned: Record<string, boolean> = {};
for (const id of Object.keys(collapsed)) {
if (currentIds.has(id)) pruned[id] = !!collapsed[id];
}
localStorage.setItem(storageKey, JSON.stringify(pruned));
} catch {}
}
function handleToggle(groupId: string, e: Event) {
const open = (e.currentTarget as HTMLDetailsElement).open;
collapsed = { ...collapsed, [groupId]: !open };
saveCollapsed();
}
onMount(() => {
try {
const raw = localStorage.getItem(storageKey);
const saved = raw ? JSON.parse(raw) : {};
const map: Record<string, boolean> = {};
const currentIds = new Set(groups.map((g) => g.id));
for (const g of groups) {
map[g.id] =
typeof saved?.[g.id] === "boolean"
? !!saved[g.id]
: defaultCollapsedFor(g.id);
}
// Limpieza de claves obsoletas en storage
const cleaned: Record<string, boolean> = {};
for (const k of Object.keys(saved || {})) {
if (currentIds.has(k)) cleaned[k] = !!saved[k];
}
collapsed = map;
localStorage.setItem(storageKey, JSON.stringify(cleaned || map));
} catch {
// si falla, dejamos los defaults (basados en tareas)
collapsed = {};
}
});
function maintainScrollWhile(mutate: () => void) {
const y = window.scrollY;
mutate();
queueMicrotask(() => window.scrollTo({ top: y }));
}
function updateGroupTask(groupId: string, detail: { id: number; action: string; patch: Partial<Task & { completed?: boolean; completed_at?: string | null }> }) {
const { id, action, patch } = detail;
const arr = itemsByGroup[groupId] || [];
const idx = arr.findIndex((t) => t.id === id);
if (action === 'complete') {
if (idx >= 0) {
maintainScrollWhile(() => {
arr.splice(idx, 1);
itemsByGroup = { ...itemsByGroup, [groupId]: [...arr] };
});
}
return;
}
if (idx >= 0) {
arr[idx] = { ...arr[idx], ...patch };
itemsByGroup = { ...itemsByGroup, [groupId]: [...arr] };
}
}
</script>
<svelte:head>
<title>Grupos</title>
<meta name="robots" content="noindex,nofollow" />
</svelte:head>
{#if groups.length === 0}
<p>No perteneces a ningún grupo permitido.</p>
{:else}
<h1 class="title">Grupos</h1>
<div class="toolbar">
<label class="toggle">
<input
type="checkbox"
checked={!!data.unassignedFirst}
on:change={(e) => {
const checked = (e.currentTarget as HTMLInputElement).checked;
const q = buildQuery({ unassignedFirst: checked });
location.href = q ? `/app/groups?${q}` : `/app/groups`;
}}
/>
Sin responsable primero
</label>
</div>
{#each groups as g (g.id)}
<details
class="group"
open={isOpen(g.id)}
on:toggle={(e) => handleToggle(g.id, e)}
>
<summary class="group-header">
<span class="name">{g.name ?? g.id}</span>
<span class="counts">
<span class="badge">tareas: {g.counts.open}</span>
<span class="badge warn">🙅‍♂️: {g.counts.unassigned}</span>
</span>
</summary>
{#if isOpen(g.id)}
<div in:slide={{ duration: 180 }} out:slide={{ duration: 180 }}>
<Card>
<ul class="list">
{#each itemsByGroup[g.id] || [] as t (t.id)}
<TaskItem
id={t.id}
description={t.description}
due_date={t.due_date}
display_code={t.display_code}
assignees={t.assignees || []}
currentUserId={data.userId}
groupName={g.name ?? g.id}
groupId={t.group_id ?? g.id}
on:changed={(e) => updateGroupTask(g.id, e.detail)}
/>
{/each}
</ul>
</Card>
</div>
{/if}
</details>
{/each}
{/if}
<style>
.title {
margin-bottom: 0.75rem;
}
.toolbar {
display: flex;
justify-content: flex-end;
margin-bottom: 0.5rem;
}
.toggle {
display: inline-flex;
gap: 6px;
align-items: center;
}
.group {
margin: 0.5rem 0;
border: 1px solid var(--color-border);
border-radius: 8px;
background: var(--color-surface);
}
.group-header {
display: flex;
justify-content: space-between;
padding: 0.5rem 0.75rem;
cursor: pointer;
list-style: none;
user-select: none;
}
.group-header .name {
font-weight: 600;
}
.counts {
display: inline-flex;
gap: 0.5rem;
}
.badge {
padding: 2px 8px;
border: 1px solid var(--color-border);
border-radius: 999px;
font-size: 12px;
}
.badge.warn {
border-color: var(--color-warning);
}
.list {
margin: 0;
padding: 0.25rem 0.5rem 0.5rem 0.5rem;
list-style: none;
}
</style>

@ -0,0 +1,14 @@
import type { PageServerLoad } from './$types';
export const load: PageServerLoad = async ({ fetch }) => {
const res = await fetch('/api/integrations/feeds', { method: 'GET', headers: { 'cache-control': 'no-store' } });
if (!res.ok) {
return {
personal: { url: null },
aggregate: { url: null },
groups: []
};
}
const data = await res.json();
return data;
};

@ -0,0 +1,101 @@
<script lang="ts">
import FeedCard from '$lib/ui/data/FeedCard.svelte';
import EmptyState from '$lib/ui/feedback/EmptyState.svelte';
import { success, error } from '$lib/stores/toasts';
import { onMount } from 'svelte';
export let data: {
personal: { url: string | null };
aggregate: { url: string | null };
groups: Array<{ groupId: string; groupName: string | null; url: string | null }>;
};
let personalUrl: string | null = data.personal?.url ?? null;
let aggregateUrl: string | null = data.aggregate?.url ?? null;
let groups = data.groups?.map(g => ({ ...g })) || [];
let rotating: Record<string, boolean> = {};
async function rotate(type: 'personal' | 'aggregate' | 'group', groupId?: string) {
try {
if (type === 'group' && groupId) rotating[groupId] = true;
if (type === 'personal') rotating['personal'] = true;
if (type === 'aggregate') rotating['aggregate'] = true;
rotating = { ...rotating };
const res = await fetch('/api/integrations/feeds/rotate', {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ type, groupId: groupId ?? null })
});
if (!res.ok) {
error('No se puedo actualizar');
return;
}
const body = await res.json();
if (type === 'personal') {
personalUrl = body.url || null;
} else if (type === 'aggregate') {
aggregateUrl = body.url || null;
} else if (type === 'group' && groupId) {
const idx = groups.findIndex(g => g.groupId === groupId);
if (idx >= 0) {
groups[idx] = { ...groups[idx], url: body.url || null };
groups = [...groups];
}
}
success('Feed de calendario actualizado');
} catch (e) {
error('No se puedo actualizar');
} finally {
if (type === 'group' && groupId) rotating[groupId] = false;
if (type === 'personal') rotating['personal'] = false;
if (type === 'aggregate') rotating['aggregate'] = false;
rotating = { ...rotating };
}
}
</script>
<svelte:head>
<title>Integraciones</title>
<meta name="robots" content="noindex,nofollow" />
</svelte:head>
<section style="max-width: 920px; margin: 1.5rem auto; padding: 0 1rem;">
<h1 style="font-size: 1.4rem; font-weight: 600; margin-bottom: .75rem;">Integraciones</h1>
<h2 style="font-size: 1.1rem; font-weight: 600; margin: .5rem 0;">Feed personal</h2>
<FeedCard
title="Mis tareas (con fecha)"
description="Suscríbete a este feed en tu calendario para ver tus tareas con fecha de vencimiento."
url={personalUrl}
rotating={!!rotating['personal']}
on:rotate={() => rotate('personal')}
/>
<h2 style="font-size: 1.1rem; font-weight: 600; margin: 1rem 0 .5rem;">Feed multigrupo</h2>
<FeedCard
title="Mis grupos (sin responsable)"
description="Tareas sin responsable agregadas de tus grupos permitidos."
url={aggregateUrl}
rotating={!!rotating['aggregate']}
on:rotate={() => rotate('aggregate')}
/>
<h2 style="font-size: 1.1rem; font-weight: 600; margin: 1rem 0 .5rem;">Feeds por grupo (sin responsable)</h2>
{#if groups.length === 0}
<EmptyState>No hay grupos disponibles todavía. Cuando los haya, verás aquí sus feeds ICS.</EmptyState>
{:else}
<div style="display: grid; grid-template-columns: repeat(auto-fill, minmax(320px, 1fr)); gap: var(--space-3);">
{#each groups as g (g.groupId)}
<FeedCard
title={g.groupName || g.groupId}
description="Tareas sin responsable"
url={g.url}
rotating={!!rotating[g.groupId]}
on:rotate={() => rotate('group', g.groupId)}
/>
{/each}
</div>
{/if}
</section>

@ -0,0 +1,178 @@
import type { PageServerLoad, Actions } from './$types';
import { getDb } from '$lib/server/db';
import { redirect, fail } from '@sveltejs/kit';
function ymdInTZ(d: Date, tz: string): string {
const parts = new Intl.DateTimeFormat('en-GB', {
timeZone: tz,
year: 'numeric',
month: '2-digit',
day: '2-digit'
}).formatToParts(d);
const get = (t: string) => parts.find((p) => p.type === t)?.value || '';
return `${get('year')}-${get('month')}-${get('day')}`;
}
function hmInTZ(d: Date, tz: string): string {
const parts = new Intl.DateTimeFormat('en-GB', {
timeZone: tz,
hour: '2-digit',
minute: '2-digit',
hour12: false
}).formatToParts(d);
const get = (t: string) => parts.find((p) => p.type === t)?.value || '';
return `${get('hour')}:${get('minute')}`;
}
function weekdayShortInTZ(d: Date, tz: string): string {
return new Intl.DateTimeFormat('en-GB', { timeZone: tz, weekday: 'short' }).format(d);
}
function normalizeTime(input: string): string | null {
const m = /^\s*(\d{1,2}):(\d{1,2})\s*$/.exec(input || '');
if (!m) return null;
const h = Number(m[1]);
const min = Number(m[2]);
if (!Number.isFinite(h) || !Number.isFinite(min)) return null;
if (h < 0 || h > 23 || min < 0 || min > 59) return null;
const hh = String(h).padStart(2, '0');
const mm = String(min).padStart(2, '0');
return `${hh}:${mm}`;
}
function computeNextReminder(
freq: 'off' | 'daily' | 'weekly' | 'weekdays',
time: string | null,
now: Date,
tz: string
): string | null {
if (freq === 'off' || !time) return null;
const nowHM = hmInTZ(now, tz);
const [nowH, nowM] = String(nowHM).split(':');
const [cfgH, cfgM] = String(time).split(':');
const nowMin = (parseInt(nowH || '0', 10) || 0) * 60 + (parseInt(nowM || '0', 10) || 0);
const cfgMin = (parseInt(cfgH || '0', 10) || 0) * 60 + (parseInt(cfgM || '0', 10) || 0);
const allowDay = (w: string) => {
if (freq === 'daily') return true;
if (freq === 'weekly') return w === 'Mon';
// weekdays
return w !== 'Sat' && w !== 'Sun';
};
for (let offset = 0; offset < 14; offset++) {
const cand = new Date(now.getTime() + offset * 24 * 60 * 60 * 1000);
const wd = weekdayShortInTZ(cand, tz);
if (!allowDay(wd)) continue;
if (offset === 0 && nowMin >= cfgMin) {
// hoy ya pasó la hora → buscar siguiente día permitido
continue;
}
const ymd = ymdInTZ(cand, tz);
return `${ymd} ${normalizeTime(time)}`;
}
return null;
}
export const load: PageServerLoad = async ({ locals }) => {
const userId = locals.userId ?? null;
if (!userId) {
throw redirect(302, '/login');
}
const db = await getDb();
const row = db
.prepare(
`SELECT reminder_freq AS freq, reminder_time AS time
FROM user_preferences
WHERE user_id = ?
LIMIT 1`
)
.get(userId) as any;
const pref =
row && row.freq
? { freq: String(row.freq) as 'off' | 'daily' | 'weekly' | 'weekdays', time: row.time ? String(row.time) : null }
: { freq: 'off' as const, time: '08:30' as string };
const tz = (process.env.TZ && process.env.TZ.trim()) || 'Europe/Madrid';
const next = computeNextReminder(pref.freq, pref.time, new Date(), tz);
return {
pref,
tz,
next
};
};
export const actions: Actions = {
default: async ({ locals, request }) => {
const userId = locals.userId ?? null;
if (!userId) {
throw redirect(302, '/login');
}
const form = await request.formData();
const freqRaw = String(form.get('freq') || '').trim().toLowerCase();
const timeRaw = form.has('time') ? String(form.get('time') || '').trim() : null;
const allowed = new Set(['off', 'daily', 'weekly', 'weekdays']);
if (!allowed.has(freqRaw)) {
return fail(400, { error: 'freq inválida' });
}
function normalizeTime(input: string): string | null {
const m = /^\s*(\d{1,2}):(\d{1,2})\s*$/.exec(input || '');
if (!m) return null;
const h = Number(m[1]);
const min = Number(m[2]);
if (!Number.isFinite(h) || !Number.isFinite(min)) return null;
if (h < 0 || h > 23 || min < 0 || min > 59) return null;
const hh = String(h).padStart(2, '0');
const mm = String(min).padStart(2, '0');
return `${hh}:${mm}`;
}
const db = await getDb();
let timeToSave: string | null = null;
if (freqRaw === 'off') {
if (timeRaw && timeRaw.length > 0) {
const norm = normalizeTime(timeRaw);
if (!norm) return fail(400, { error: 'hora inválida' });
timeToSave = norm;
} else {
const row = db
.prepare(
`SELECT reminder_time AS time
FROM user_preferences
WHERE user_id = ?
LIMIT 1`
)
.get(userId) as any;
timeToSave = row?.time ? String(row.time) : '08:30';
}
} else {
if (!timeRaw || timeRaw.length === 0) {
timeToSave = '08:30';
} else {
const norm = normalizeTime(timeRaw);
if (!norm) return fail(400, { error: 'hora inválida' });
timeToSave = norm;
}
}
db.prepare(
`INSERT INTO user_preferences (user_id, reminder_freq, reminder_time, last_reminded_on, updated_at)
VALUES (?, ?, ?, (SELECT last_reminded_on FROM user_preferences WHERE user_id = ?), strftime('%Y-%m-%d %H:%M:%f','now'))
ON CONFLICT(user_id) DO UPDATE SET
reminder_freq = excluded.reminder_freq,
reminder_time = excluded.reminder_time,
updated_at = excluded.updated_at`
).run(userId, freqRaw, timeToSave, userId);
return { success: true, pref: { freq: freqRaw, time: timeToSave } };
}
};

@ -0,0 +1,119 @@
<script lang="ts">
import SegmentedControl from "$lib/ui/inputs/SegmentedControl.svelte";
import Card from "$lib/ui/layout/Card.svelte";
import Button from "$lib/ui/atoms/Button.svelte";
import { success as toastSuccess } from "$lib/stores/toasts";
export let data: {
pref: {
freq: "off" | "daily" | "weekly" | "weekdays";
time: string | null;
};
tz: string;
next: string | null;
};
export let form: any;
let freq: "off" | "daily" | "weekly" | "weekdays" = data.pref.freq;
let time: string = data.pref.time ?? "08:30";
$: if (form?.success) {
try {
toastSuccess("Preferencias guardadas.");
} catch {}
}
const options = [
{ label: "Apagado", value: "off" },
{ label: "Diario", value: "daily" },
{ label: "LV", value: "weekdays" },
{ label: "Semanal", value: "weekly" },
];
</script>
<svelte:head>
<title>Preferencias de recordatorios</title>
<meta name="robots" content="noindex,nofollow" />
</svelte:head>
<section class="page">
<h1 class="title">Preferencias de recordatorios</h1>
<Card>
<form method="POST" class="form">
<div>
<label for="freq">Frecuencia</label>
<SegmentedControl name="freq" {options} bind:value={freq} />
<ul class="help">
<li>Diario: cada día a la hora indicada</li>
<li>Laborables: solo de lunes a viernes</li>
<li>Semanal: los lunes</li>
</ul>
</div>
<div>
<label for="time">Hora (HH:MM)</label>
<input
id="time"
name="time"
type="time"
step="60"
bind:value={time}
disabled={freq === "off"}
/>
<p class="help">Zona horaria: {data.tz}</p>
</div>
{#if form?.error}
<div class="error">{form.error}</div>
{/if}
<div>
<Button type="submit" variant="primary">Guardar</Button>
</div>
</form>
</Card>
<div class="section">
<h2 class="subtitle">Próximo recordatorio</h2>
<ul>
<li>Servidor: {data.next ?? "—"}</li>
</ul>
</div>
</section>
<style>
.page {
max-width: 720px;
margin: 1.5rem auto;
padding: 0 1rem;
}
.title {
font-size: 1.4rem;
font-weight: 600;
margin-bottom: 0.75rem;
}
.form {
display: grid;
gap: 0.75rem;
}
label {
display: block;
font-weight: 600;
margin-bottom: 0.25rem;
}
.help {
font-size: 0.9rem;
color: var(--color-text-muted);
margin-top: 0.25rem;
}
.error {
color: var(--color-danger);
}
.section {
margin-top: 1rem;
}
.subtitle {
font-size: 1.1rem;
font-weight: 600;
}
</style>

@ -0,0 +1,90 @@
import type { RequestHandler } from './$types';
import { getDb } from '$lib/server/db';
import { sha256Hex } from '$lib/server/crypto';
import { icsHorizonMonths } from '$lib/server/env';
import { buildIcsCalendar } from '$lib/server/ics';
function toIsoSql(d = new Date()): string {
return d.toISOString().replace('T', ' ').replace('Z', '');
}
function ymdUTC(date: Date): string {
const yyyy = String(date.getUTCFullYear()).padStart(4, '0');
const mm = String(date.getUTCMonth() + 1).padStart(2, '0');
const dd = String(date.getUTCDate()).padStart(2, '0');
return `${yyyy}-${mm}-${dd}`;
}
function addMonthsUTC(date: Date, months: number): Date {
const d = new Date(Date.UTC(date.getUTCFullYear(), date.getUTCMonth(), date.getUTCDate()));
d.setUTCMonth(d.getUTCMonth() + months);
return d;
}
export const GET: RequestHandler = async ({ params, request }) => {
const db = await getDb();
const token = params.token || '';
if (!token) return new Response('Not Found', { status: 404 });
const tokenHash = await sha256Hex(token);
const row = db
.prepare(
`SELECT id, type, user_id, group_id, revoked_at
FROM calendar_tokens
WHERE token_hash = ?
LIMIT 1`
)
.get(tokenHash) as any;
if (!row) return new Response('Not Found', { status: 404 });
if (row.revoked_at) return new Response('Gone', { status: 410 });
if (String(row.type) !== 'aggregate') return new Response('Not Found', { status: 404 });
const today = new Date();
const startYmd = ymdUTC(today);
const endYmd = ymdUTC(addMonthsUTC(today, icsHorizonMonths));
// Sin responsable en todos los grupos allowed donde el usuario esté activo
const tasks = db
.prepare(
`SELECT t.id, t.description, t.due_date, g.name AS group_name
FROM tasks t
INNER JOIN groups g ON g.id = t.group_id AND COALESCE(g.active,1)=1 AND COALESCE(g.archived,0)=0 AND COALESCE(g.is_community,0)=0
INNER JOIN group_members gm ON gm.group_id = t.group_id AND gm.user_id = ? AND gm.is_active = 1
INNER JOIN allowed_groups ag ON ag.group_id = t.group_id AND ag.status = 'allowed'
WHERE COALESCE(t.completed, 0) = 0
AND t.due_date IS NOT NULL
AND t.due_date >= ? AND t.due_date <= ?
AND NOT EXISTS (SELECT 1 FROM task_assignments a WHERE a.task_id = t.id)
ORDER BY t.due_date ASC, t.id ASC`
)
.all(row.user_id, startYmd, endYmd) as Array<{ id: number; description: string; due_date: string; group_name: string | null }>;
const events = tasks.map((t) => ({
id: t.id,
description: t.description,
due_date: t.due_date,
group_name: t.group_name || null,
prefix: 'T'
}));
const { body, etag } = await buildIcsCalendar('Tareas sin responsable (mis grupos)', events);
// 304 si ETag coincide
const inm = request.headers.get('if-none-match');
if (inm && inm === etag) {
db.prepare(`UPDATE calendar_tokens SET last_used_at = ? WHERE id = ?`).run(toIsoSql(), row.id);
return new Response(null, { status: 304, headers: { ETag: etag, 'Cache-Control': 'public, max-age=300' } });
}
db.prepare(`UPDATE calendar_tokens SET last_used_at = ? WHERE id = ?`).run(toIsoSql(), row.id);
return new Response(body, {
status: 200,
headers: {
'content-type': 'text/calendar; charset=utf-8',
'cache-control': 'public, max-age=300',
ETag: etag
}
});
};

@ -0,0 +1,98 @@
import type { RequestHandler } from './$types';
import { getDb } from '$lib/server/db';
import { sha256Hex } from '$lib/server/crypto';
import { icsHorizonMonths } from '$lib/server/env';
import { buildIcsCalendar } from '$lib/server/ics';
function toIsoSql(d = new Date()): string {
return d.toISOString().replace('T', ' ').replace('Z', '');
}
function ymdUTC(date: Date): string {
const yyyy = String(date.getUTCFullYear()).padStart(4, '0');
const mm = String(date.getUTCMonth() + 1).padStart(2, '0');
const dd = String(date.getUTCDate()).padStart(2, '0');
return `${yyyy}-${mm}-${dd}`;
}
function addMonthsUTC(date: Date, months: number): Date {
const d = new Date(Date.UTC(date.getUTCFullYear(), date.getUTCMonth(), date.getUTCDate()));
d.setUTCMonth(d.getUTCMonth() + months);
return d;
}
export const GET: RequestHandler = async ({ params, request }) => {
const db = await getDb();
const token = params.token || '';
if (!token) return new Response('Not Found', { status: 404 });
const tokenHash = await sha256Hex(token);
const row = db
.prepare(
`SELECT id, type, user_id, group_id, revoked_at
FROM calendar_tokens
WHERE token_hash = ?
LIMIT 1`
)
.get(tokenHash) as any;
if (!row) return new Response('Not Found', { status: 404 });
if (row.revoked_at) return new Response('Gone', { status: 410 });
if (String(row.type) !== 'group' || !row.group_id) return new Response('Not Found', { status: 404 });
// Validar estado del grupo (activo y no archivado); en caso contrario, tratar como feed caducado
const gRow = db
.prepare(`SELECT COALESCE(active,1) as active, COALESCE(archived,0) as archived, COALESCE(is_community,0) as is_community FROM groups WHERE id = ?`)
.get(row.group_id) as any;
if (!gRow || Number(gRow.active || 0) !== 1 || Number(gRow.archived || 0) === 1 || Number(gRow.is_community || 0) === 1) {
db.prepare(`UPDATE calendar_tokens SET last_used_at = ? WHERE id = ?`).run(toIsoSql(), row.id);
return new Response('Gone', { status: 410 });
}
const today = new Date();
const startYmd = ymdUTC(today);
const endYmd = ymdUTC(addMonthsUTC(today, icsHorizonMonths));
const tasks = db
.prepare(
`SELECT t.id, t.description, t.due_date, g.name AS group_name
FROM tasks t
LEFT JOIN groups g ON g.id = t.group_id
WHERE t.group_id = ?
AND COALESCE(t.completed, 0) = 0
AND t.due_date IS NOT NULL
AND t.due_date >= ? AND t.due_date <= ?
AND NOT EXISTS (SELECT 1 FROM task_assignments a WHERE a.task_id = t.id)
ORDER BY t.due_date ASC, t.id ASC`
)
.all(row.group_id, startYmd, endYmd) as Array<{ id: number; description: string; due_date: string; group_name: string | null }>;
const events = tasks.map((t) => ({
id: t.id,
description: t.description,
due_date: t.due_date,
group_name: t.group_name || null,
prefix: 'T'
}));
const { body, etag } = await buildIcsCalendar('Tareas sin responsable (grupo)', events);
// 304 si ETag coincide
const inm = request.headers.get('if-none-match');
if (inm && inm === etag) {
// Actualizar last_used_at aunque sea 304
db.prepare(`UPDATE calendar_tokens SET last_used_at = ? WHERE id = ?`).run(toIsoSql(), row.id);
return new Response(null, { status: 304, headers: { ETag: etag, 'Cache-Control': 'public, max-age=300' } });
}
db.prepare(`UPDATE calendar_tokens SET last_used_at = ? WHERE id = ?`).run(toIsoSql(), row.id);
return new Response(body, {
status: 200,
headers: {
'content-type': 'text/calendar; charset=utf-8',
'cache-control': 'public, max-age=300',
ETag: etag
}
});
};

@ -0,0 +1,91 @@
import type { RequestHandler } from './$types';
import { getDb } from '$lib/server/db';
import { sha256Hex } from '$lib/server/crypto';
import { icsHorizonMonths } from '$lib/server/env';
import { buildIcsCalendar } from '$lib/server/ics';
function toIsoSql(d = new Date()): string {
return d.toISOString().replace('T', ' ').replace('Z', '');
}
function ymdUTC(date: Date): string {
const yyyy = String(date.getUTCFullYear()).padStart(4, '0');
const mm = String(date.getUTCMonth() + 1).padStart(2, '0');
const dd = String(date.getUTCDate()).padStart(2, '0');
return `${yyyy}-${mm}-${dd}`;
}
function addMonthsUTC(date: Date, months: number): Date {
const d = new Date(Date.UTC(date.getUTCFullYear(), date.getUTCMonth(), date.getUTCDate()));
d.setUTCMonth(d.getUTCMonth() + months);
return d;
}
export const GET: RequestHandler = async ({ params, request }) => {
const db = await getDb();
const token = params.token || '';
if (!token) return new Response('Not Found', { status: 404 });
const tokenHash = await sha256Hex(token);
const row = db
.prepare(
`SELECT id, type, user_id, group_id, revoked_at
FROM calendar_tokens
WHERE token_hash = ?
LIMIT 1`
)
.get(tokenHash) as any;
if (!row) return new Response('Not Found', { status: 404 });
if (row.revoked_at) return new Response('Gone', { status: 410 });
if (String(row.type) !== 'personal') return new Response('Not Found', { status: 404 });
const today = new Date();
const startYmd = ymdUTC(today);
const endYmd = ymdUTC(addMonthsUTC(today, icsHorizonMonths));
// "Mis tareas": asignadas al usuario; incluir privadas (group_id IS NULL) y de grupos donde esté activo y allowed.
const tasks = db
.prepare(
`SELECT t.id, t.description, t.due_date, g.name AS group_name
FROM tasks t
LEFT JOIN groups g ON g.id = t.group_id
LEFT JOIN group_members gm ON gm.group_id = t.group_id AND gm.user_id = ? AND gm.is_active = 1
LEFT JOIN allowed_groups ag ON ag.group_id = t.group_id AND ag.status = 'allowed'
WHERE COALESCE(t.completed, 0) = 0
AND t.due_date IS NOT NULL
AND t.due_date >= ? AND t.due_date <= ?
AND EXISTS (SELECT 1 FROM task_assignments a WHERE a.task_id = t.id AND a.user_id = ?)
AND (t.group_id IS NULL OR (gm.user_id IS NOT NULL AND ag.group_id IS NOT NULL AND COALESCE(g.active,1)=1 AND COALESCE(g.is_community,0)=0 AND COALESCE(g.archived,0)=0))
ORDER BY t.due_date ASC, t.id ASC`
)
.all(row.user_id, startYmd, endYmd, row.user_id) as Array<{ id: number; description: string; due_date: string; group_name: string | null }>;
const events = tasks.map((t) => ({
id: t.id,
description: t.description,
due_date: t.due_date,
group_name: t.group_name || null,
prefix: 'T'
}));
const { body, etag } = await buildIcsCalendar('Mis tareas', events);
// 304 si ETag coincide
const inm = request.headers.get('if-none-match');
if (inm && inm === etag) {
db.prepare(`UPDATE calendar_tokens SET last_used_at = ? WHERE id = ?`).run(toIsoSql(), row.id);
return new Response(null, { status: 304, headers: { ETag: etag, 'Cache-Control': 'public, max-age=300' } });
}
db.prepare(`UPDATE calendar_tokens SET last_used_at = ? WHERE id = ?`).run(toIsoSql(), row.id);
return new Response(body, {
status: 200,
headers: {
'content-type': 'text/calendar; charset=utf-8',
'cache-control': 'public, max-age=300',
ETag: etag
}
});
};

@ -0,0 +1,176 @@
import type { RequestHandler } from './$types';
import { redirect } from '@sveltejs/kit';
import { getDb } from '$lib/server/db';
import { sha256Hex, randomTokenBase64Url } from '$lib/server/crypto';
import { sessionIdleTtlMs, isProd, isDev, DEV_BYPASS_AUTH } from '$lib/server/env';
function toIsoSql(d: Date): string {
return d.toISOString().replace('T', ' ').replace('Z', '');
}
function escapeHtml(s: string): string {
return s
.replaceAll('&', '&amp;')
.replaceAll('<', '&lt;')
.replaceAll('>', '&gt;')
.replaceAll('"', '&quot;')
.replaceAll("'", '&#39;');
}
// GET: página intermedia que requiere una interacción mínima y establece una cookie "login_intent" vía JS.
// Evita que bots de previsualización canjeen el token antes de que el usuario haga clic.
export const GET: RequestHandler = async (event) => {
if (isDev() && DEV_BYPASS_AUTH) {
throw redirect(303, '/app');
}
const token = event.url.searchParams.get('token')?.trim();
if (!token) {
console.warn('[web/login] Solicitud sin token');
return new Response('Falta el token', { status: 400 });
}
// Nonce para "gate de JS"
const nonce = randomTokenBase64Url(18);
const html = `<!doctype html>
<html lang="es">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width,initial-scale=1" />
<title>Acceder</title>
<meta name="robots" content="noindex,nofollow" />
<meta name="referrer" content="no-referrer" />
<style>
body { font-family: system-ui, -apple-system, Segoe UI, Roboto, Ubuntu, Cantarell, Noto Sans, Arial, 'Apple Color Emoji', 'Segoe UI Emoji'; padding: 2rem; }
.card { max-width: 480px; margin: 0 auto; border: 1px solid #ddd; border-radius: 8px; padding: 1.5rem; }
button[disabled] { opacity: .6; cursor: not-allowed; }
</style>
</head>
<body>
<div class="card">
<h1>Acceso seguro</h1>
<p>Para continuar, pulsa Continuar. Si no funciona, asegúrate de abrir este enlace en tu navegador.</p>
<form method="POST" action="/login">
<input type="hidden" name="token" value="${escapeHtml(token)}" />
<input type="hidden" id="nonceInput" name="nonce" value="${escapeHtml(nonce)}" />
<button id="continueBtn" type="submit" disabled>Continuar</button>
</form>
<noscript>
<p><strong>JavaScript está deshabilitado.</strong> Actívalo para continuar.</p>
</noscript>
</div>
<script>
// Establecer cookie de intención con el nonce y habilitar el botón.
try {
var nonce = ${JSON.stringify(nonce)};
var cookie = 'login_intent=' + encodeURIComponent(nonce) + '; Path=/; Max-Age=600; SameSite=Lax';
if (location.protocol === 'https:') cookie += '; Secure';
document.cookie = cookie;
var btn = document.getElementById('continueBtn');
if (btn) btn.removeAttribute('disabled');
} catch {}
</script>
</body>
</html>`;
return new Response(html, {
status: 200,
headers: {
'content-type': 'text/html; charset=utf-8',
'cache-control': 'no-store, max-age=0'
}
});
};
// POST: canje real del token (uso único). Crea sesión y redirige a /app.
export const POST: RequestHandler = async (event) => {
if (isDev() && DEV_BYPASS_AUTH) {
throw redirect(303, '/app');
}
const form = await event.request.formData();
const token = String(form.get('token') || '').trim();
if (!token) {
console.warn('[web/login] POST sin token');
return new Response('Falta el token', { status: 400 });
}
// Validación del "gate de JS": cookie + nonce deben coincidir
const nonce = String(form.get('nonce') || '').trim();
const loginIntent = event.cookies.get('login_intent') || '';
if (!nonce || !loginIntent || nonce !== loginIntent) {
console.warn('[web/login] Falta o no cuadra login_intent; posible previsualizador sin JS.');
return new Response('Solicitud inválida', { status: 400 });
}
const tokenHash = await sha256Hex(token);
const db = await getDb();
// Intentar canjear el token (un solo uso, no caducado)
const res = db
.prepare(
`UPDATE web_tokens
SET used_at = strftime('%Y-%m-%d %H:%M:%f','now')
WHERE token_hash = ?
AND used_at IS NULL
AND expires_at > strftime('%Y-%m-%d %H:%M:%f','now')`
)
.run(tokenHash);
const changes = Number(res?.changes || 0);
if (changes < 1) {
console.warn('[web/login] Token no canjeado (0 cambios). Posible caducado/ya usado/no existe.');
return new Response('Enlace inválido o caducado', { status: 400 });
}
// Recuperar el user_id asociado
const row = db
.prepare(`SELECT user_id FROM web_tokens WHERE token_hash = ?`)
.get(tokenHash) as { user_id: string } | null;
const userId = row?.user_id?.trim();
if (!userId) {
return new Response('Token canjeado pero usuario no encontrado', { status: 500 });
}
// Crear sesión
const sessionToken = randomTokenBase64Url(32);
const sessionHash = await sha256Hex(sessionToken);
const sessionId = randomTokenBase64Url(16);
const expiresAtIso = toIsoSql(new Date(Date.now() + sessionIdleTtlMs));
// Datos de agente e IP (best-effort)
const userAgent = event.request.headers.get('user-agent') || null;
let ip: string | null = null;
try {
// SvelteKit 2: getClientAddress en adapters compatibles
// @ts-ignore
if (typeof event.getClientAddress === 'function') {
// @ts-ignore
ip = event.getClientAddress() || null;
}
} catch { }
if (!ip) {
const fwd = event.request.headers.get('x-forwarded-for');
ip = fwd ? fwd.split(',')[0].trim() : null;
}
db.prepare(
`INSERT INTO web_sessions (id, user_id, session_hash, expires_at, user_agent, ip)
VALUES (?, ?, ?, ?, ?, ?)`
).run(sessionId, userId, sessionHash, expiresAtIso, userAgent, ip);
// Cookie de sesión
event.cookies.set('sid', sessionToken, {
path: '/',
httpOnly: true,
sameSite: 'lax',
secure: isProd(),
maxAge: Math.floor(sessionIdleTtlMs / 1000)
});
// Eliminar cookie de intento (ya no es necesaria)
event.cookies.delete('login_intent', { path: '/' });
// Redirigir a /app
throw redirect(303, '/app');
};

@ -0,0 +1 @@
<?xml version="1.0" encoding="utf-8"?><svg version="1.1" id="Layer_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px" viewBox="0 0 122.88 100.6" style="enable-background:new 0 0 122.88 100.6" xml:space="preserve"><style type="text/css">.st0{fill:#272727;} .st1{fill-rule:evenodd;clip-rule:evenodd;fill:#D8453E;}</style><g><path class="st0" d="M72.58,0c6.8,0,13.3,1.36,19.23,3.81c6.16,2.55,11.7,6.29,16.33,10.92l0,0c4.63,4.63,8.37,10.17,10.92,16.34 c2.46,5.93,3.81,12.43,3.81,19.23c0,6.8-1.36,13.3-3.81,19.23c-2.55,6.16-6.29,11.7-10.92,16.33l0,0 c-4.63,4.63-10.17,8.37-16.34,10.92c-5.93,2.46-12.43,3.81-19.23,3.81c-6.8,0-13.3-1.36-19.23-3.81 c-6.15-2.55-11.69-6.28-16.33-10.92l-0.01-0.01c-4.64-4.64-8.37-10.17-10.92-16.33c-0.79-1.91-1.47-3.87-2.02-5.89 c1.05,0.1,2.12,0.15,3.2,0.15c2.05,0,4.05-0.19,6-0.54c0.32,0.97,0.67,1.93,1.06,2.87c2.09,5.05,5.17,9.6,8.99,13.43 c3.82,3.82,8.38,6.9,13.43,8.99c4.87,2.02,10.21,3.13,15.83,3.13c5.62,0,10.96-1.11,15.83-3.13c5.05-2.09,9.6-5.17,13.43-8.99 c3.82-3.82,6.9-8.38,8.99-13.43c2.02-4.87,3.13-10.21,3.13-15.83c0-5.62-1.11-10.96-3.13-15.83c-2.09-5.05-5.17-9.6-8.99-13.43 c-3.82-3.82-8.38-6.9-13.43-8.99c-4.87-2.02-10.21-3.13-15.83-3.13c-5.62,0-10.96,1.11-15.83,3.13c-0.44,0.18-0.87,0.37-1.3,0.56 c-1.65-2.61-3.66-4.97-5.95-7.02c1.25-0.65,2.53-1.24,3.84-1.79C59.28,1.36,65.78,0,72.58,0L72.58,0z M66.8,26.39 c0-1.23,0.5-2.35,1.31-3.16c0.81-0.81,1.93-1.31,3.16-1.31c1.23,0,2.35,0.5,3.16,1.31c0.81,0.81,1.31,1.93,1.31,3.16v23.47 l17.54,10.4c1.05,0.62,1.76,1.62,2.05,2.73c0.28,1.1,0.15,2.31-0.47,3.37l0,0.01l0,0c-0.62,1.05-1.62,1.76-2.73,2.05 c-1.1,0.28-2.31,0.15-3.37-0.47l-0.01,0l0,0L69.1,56.29c-0.67-0.38-1.24-0.92-1.64-1.57c-0.42-0.68-0.66-1.48-0.66-2.32V26.39 L66.8,26.39z"/><path class="st1" d="M27.27,3.18c15.06,0,27.27,12.21,27.27,27.27c0,15.06-12.21,27.27-27.27,27.27C12.21,57.73,0,45.52,0,30.45 C0,15.39,12.21,3.18,27.27,3.18L27.27,3.18z M24.35,41.34h5.82v5.16h-5.82V41.34L24.35,41.34L24.35,41.34z M30.17,37.77h-5.82 c-0.58-7.07-1.8-11.56-1.8-18.63c0-2.61,2.12-4.72,4.72-4.72c2.61,0,4.72,2.12,4.72,4.72C32,26.2,30.76,30.7,30.17,37.77 L30.17,37.77L30.17,37.77L30.17,37.77z"/></g></svg>

After

Width:  |  Height:  |  Size: 2.1 KiB

@ -0,0 +1 @@
<?xml version="1.0" encoding="utf-8"?><svg version="1.1" id="Layer_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px" viewBox="0 0 106.86 122.88" style="enable-background:new 0 0 106.86 122.88" xml:space="preserve"><style type="text/css">.st0{fill-rule:evenodd;clip-rule:evenodd;}</style><g><path class="st0" d="M39.62,64.58c-1.46,0-2.64-1.41-2.64-3.14c0-1.74,1.18-3.14,2.64-3.14h34.89c1.46,0,2.64,1.41,2.64,3.14 c0,1.74-1.18,3.14-2.64,3.14H39.62L39.62,64.58z M46.77,116.58c1.74,0,3.15,1.41,3.15,3.15c0,1.74-1.41,3.15-3.15,3.15H7.33 c-2.02,0-3.85-0.82-5.18-2.15C0.82,119.4,0,117.57,0,115.55V7.33c0-2.02,0.82-3.85,2.15-5.18C3.48,0.82,5.31,0,7.33,0h90.02 c2.02,0,3.85,0.83,5.18,2.15c1.33,1.33,2.15,3.16,2.15,5.18v50.14c0,1.74-1.41,3.15-3.15,3.15c-1.74,0-3.15-1.41-3.15-3.15V7.33 c0-0.28-0.12-0.54-0.31-0.72c-0.19-0.19-0.44-0.31-0.72-0.31H7.33c-0.28,0-0.54,0.12-0.73,0.3C6.42,6.8,6.3,7.05,6.3,7.33v108.21 c0,0.28,0.12,0.54,0.3,0.72c0.19,0.19,0.45,0.31,0.73,0.31H46.77L46.77,116.58z M98.7,74.34c-0.51-0.49-1.1-0.72-1.78-0.71 c-0.68,0.01-1.26,0.27-1.74,0.78l-3.91,4.07l10.97,10.59l3.95-4.11c0.47-0.48,0.67-1.1,0.66-1.78c-0.01-0.67-0.25-1.28-0.73-1.74 L98.7,74.34L98.7,74.34z M78.21,114.01c-1.45,0.46-2.89,0.94-4.33,1.41c-1.45,0.48-2.89,0.97-4.33,1.45 c-3.41,1.12-5.32,1.74-5.72,1.85c-0.39,0.12-0.16-1.48,0.7-4.81l2.71-10.45l0,0l20.55-21.38l10.96,10.55L78.21,114.01L78.21,114.01 z M39.62,86.95c-1.46,0-2.65-1.43-2.65-3.19c0-1.76,1.19-3.19,2.65-3.19h17.19c1.46,0,2.65,1.43,2.65,3.19 c0,1.76-1.19,3.19-2.65,3.19H39.62L39.62,86.95z M39.62,42.26c-1.46,0-2.64-1.41-2.64-3.14c0-1.74,1.18-3.14,2.64-3.14h34.89 c1.46,0,2.64,1.41,2.64,3.14c0,1.74-1.18,3.14-2.64,3.14H39.62L39.62,42.26z M24.48,79.46c2.06,0,3.72,1.67,3.72,3.72 c0,2.06-1.67,3.72-3.72,3.72c-2.06,0-3.72-1.67-3.72-3.72C20.76,81.13,22.43,79.46,24.48,79.46L24.48,79.46z M24.48,57.44 c2.06,0,3.72,1.67,3.72,3.72c0,2.06-1.67,3.72-3.72,3.72c-2.06,0-3.72-1.67-3.72-3.72C20.76,59.11,22.43,57.44,24.48,57.44 L24.48,57.44z M24.48,35.42c2.06,0,3.72,1.67,3.72,3.72c0,2.06-1.67,3.72-3.72,3.72c-2.06,0-3.72-1.67-3.72-3.72 C20.76,37.08,22.43,35.42,24.48,35.42L24.48,35.42z"/></g></svg>

After

Width:  |  Height:  |  Size: 2.1 KiB

@ -0,0 +1 @@
<svg id="Layer_1" data-name="Layer 1" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 108.01 122.88"><defs><style>.cls-1{fill-rule:evenodd;}</style></defs><title>emergency-exit</title><path class="cls-1" d="M.5,0H15a.51.51,0,0,1,.5.5V83.38L35.16,82h.22l.24,0c2.07-.14,3.65-.26,4.73-1.23l1.86-2.17a1.12,1.12,0,0,1,1.49-.18l9.35,6.28a1.15,1.15,0,0,1,.49,1c0,.55-.19.7-.61,1.08A11.28,11.28,0,0,0,51.78,88a27.27,27.27,0,0,1-3,3.1,15.84,15.84,0,0,1-3.68,2.45c-2.8,1.36-5.45,1.54-8.59,1.76l-.24,0-.21,0L15.5,96.77v25.61a.52.52,0,0,1-.5.5H.5a.51.51,0,0,1-.5-.5V.5A.5.5,0,0,1,.5,0ZM46,59.91l9-19.12-.89-.25a12.43,12.43,0,0,0-4.77-.82c-1.9.28-3.68,1.42-5.67,2.7-.83.53-1.69,1.09-2.62,1.63-.7.33-1.51.86-2.19,1.25l-8.7,5a1.11,1.11,0,0,1-1.51-.42l-5.48-9.64a1.1,1.1,0,0,1,.42-1.51c3.43-2,7.42-4,10.75-6.14,4-2.49,7.27-4.48,11.06-5.42s8-.8,13.89,1c2.12.59,4.55,1.48,6.55,2.2,1,.35,1.8.66,2.44.87,9.86,3.29,13.19,9.66,15.78,14.6,1.12,2.13,2.09,4,3.34,5,.51.42,1.67.27,3,.09a21.62,21.62,0,0,1,2.64-.23c4.32-.41,8.66-.66,13-1a1.1,1.1,0,0,1,1.18,1L108,61.86A1.11,1.11,0,0,1,107,63L95,63.9c-5.33.38-9.19.66-15-2.47l-.12-.07a23.23,23.23,0,0,1-7.21-8.5l0,0L65.73,68.4a63.9,63.9,0,0,0,5.85,5.32c6,5,11,9.21,9.38,20.43a23.89,23.89,0,0,1-.65,2.93c-.27,1-.56,1.9-.87,2.84-2.29,6.54-4.22,13.5-6.29,20.13a1.1,1.1,0,0,1-1,.81l-11.66.78a1,1,0,0,1-.39,0,1.12,1.12,0,0,1-.75-1.38c2.45-8.12,5-16.25,7.39-24.38a29,29,0,0,0,.87-3,7,7,0,0,0,.08-2.65l0-.24a4.16,4.16,0,0,0-.73-2.22,53.23,53.23,0,0,0-8.76-5.57c-3.75-2.07-7.41-4.08-10.25-7a12.15,12.15,0,0,1-3.59-7.36A14.76,14.76,0,0,1,46,59.91ZM80.07,6.13a12.29,12.29,0,0,1,13.1,11.39v0a12.29,12.29,0,0,1-24.52,1.72v0A12.3,12.3,0,0,1,80,6.13ZM3.34,35H6.69V51.09H3.34V35Z"/></svg>

After

Width:  |  Height:  |  Size: 1.7 KiB

@ -0,0 +1 @@
<?xml version="1.0" encoding="utf-8"?><svg version="1.1" id="Layer_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px" viewBox="0 0 122.88 91.99" style="enable-background:new 0 0 122.88 91.99" xml:space="preserve"><style type="text/css">.st0{fill-rule:evenodd;clip-rule:evenodd;}</style><g><path class="st0" d="M45.13,35.29h-0.04c-7.01-0.79-16.42,0.01-20.78,0C17.04,35.6,9.47,41.91,5.02,51.3 c-2.61,5.51-3.3,9.66-3.73,15.55C0.42,72.79-0.03,78.67,0,84.47c1.43,9.03,12.88,6.35,13.85,0l1.39-18.2 c0.21-2.75,0.4-4.61,1.51-7.23c0.52-1.23,1.15-2.28,1.89-3.15l0.69,33.25l-0.39,2.78h31.49l-0.42-3.1l0.61-36.67 c3.2-1.29,5.96-1.89,8.39-1.99c-0.12,0.25-0.25,0.5-0.37,0.75c-2.61,5.51-3.3,9.66-3.73,15.55c-0.86,5.93-1.32,11.81-1.29,17.61 c1.43,9.03,12.88,6.35,13.85,0l1.39-18.2c0.21-2.75,0.4-4.61,1.51-7.23c0.52-1.23,1.15-2.28,1.89-3.15l0.69,33.25l-0.46,3.24h31.62 l-0.48-3.55l0.49-28.62v0.56l0.1-4.87c0.74,0.87,1.36,1.92,1.89,3.15c1.12,2.62,1.3,4.48,1.51,7.23l1.39,18.2 c1.34,8.68,13.85,8.85,13.85,0c0.03-5.81-0.42-11.68-1.29-17.61c-0.43-5.89-1.12-10.04-3.73-15.55 c-4.57-9.65-10.48-14.76-19.45-15.81c-5.53-0.45-14.82,0.06-20.36-0.1c-1.38,0.19-2.74,0.47-4.06,0.87 c-3.45-0.48-8.01-1.07-12.56-1.09C54.76,34.77,48.15,35.91,45.13,35.29L45.13,35.29z M88.3,0c9.01,0,16.32,7.31,16.32,16.32 c0,9.01-7.31,16.32-16.32,16.32c-9.01,0-16.32-7.31-16.32-16.32C71.98,7.31,79.29,0,88.3,0L88.3,0z M34.56,0 c9.01,0,16.32,7.31,16.32,16.32c0,9.01-7.31,16.32-16.32,16.32s-16.32-7.31-16.32-16.32C18.24,7.31,25.55,0,34.56,0L34.56,0z"/></g></svg>

After

Width:  |  Height:  |  Size: 1.5 KiB

@ -0,0 +1 @@
<?xml version="1.0" encoding="utf-8"?><svg version="1.1" id="Layer_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px" viewBox="0 0 121.2 122.88" style="enable-background:new 0 0 121.2 122.88" xml:space="preserve"><style type="text/css">.st0{fill-rule:evenodd;clip-rule:evenodd;}</style><g><path class="st0" d="M66.17,24.52c9.78,12.13,14.55,26.46,13.93,39.58c-4.52-11.08-11.54-22.31-20.85-32.65l-3.12,3.12 c-0.35,0.35-0.93,0.35-1.28,0l-9.87-9.87c-0.35-0.35-0.35-0.93,0-1.28l3.03-3.03C37.4,11.2,25.96,4.37,14.75,0.13 c13.22-0.99,27.81,3.57,40.2,13.31l1.36-1.36c0.35-0.35,0.93-0.35,1.28,0l9.87,9.87c0.35,0.35,0.35,0.93,0,1.28L66.17,24.52 L66.17,24.52z M49.32,58.69v-4.05l19.11,2.04L49.32,58.69L49.32,58.69z M57.83,74.36l-2.87-2.87l14.96-12.07L57.83,74.36 L57.83,74.36z M111.77,35.18l2.32,5.02l-24.85,8.4L111.77,35.18L111.77,35.18z M92.26,20.63l5.19,1.91L85.82,46.05L92.26,20.63 L92.26,20.63z M102.7,57.6l18.5,11.47v53.81H25.73c19.44-19.44,46.04-25.61,61.42-52.24C92.99,60.52,91.01,49.7,102.7,57.6 L102.7,57.6z M44.6,27.81l7.99,7.99L9.64,78.76c-2.2,2.2-5.8,2.2-7.99,0l0,0c-2.2-2.2-2.2-5.8,0-7.99L44.6,27.81L44.6,27.81z"/></g></svg>

After

Width:  |  Height:  |  Size: 1.1 KiB

@ -0,0 +1 @@
<?xml version="1.0" encoding="utf-8"?><svg version="1.1" id="Layer_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px" viewBox="0 0 122.88 99.56" style="enable-background:new 0 0 122.88 99.56" xml:space="preserve"><style type="text/css">.st0{fill:#393939;} .st1{fill-rule:evenodd;clip-rule:evenodd;fill:#38AE48;}</style><g><path class="st0" d="M73.1,0c6.73,0,13.16,1.34,19.03,3.78c6.09,2.52,11.57,6.22,16.16,10.81c4.59,4.58,8.28,10.06,10.81,16.17 c2.43,5.87,3.78,12.3,3.78,19.03c0,6.73-1.34,13.16-3.78,19.03c-2.52,6.09-6.22,11.58-10.81,16.16 c-4.58,4.59-10.06,8.28-16.17,10.81c-5.87,2.43-12.3,3.78-19.03,3.78c-6.73,0-13.16-1.34-19.03-3.77 c-6.09-2.52-11.57-6.22-16.16-10.81l-0.01-0.01c-4.59-4.59-8.29-10.07-10.81-16.16c-0.78-1.89-1.45-3.83-2-5.82 c1.04,0.1,2.1,0.15,3.17,0.15c2.03,0,4.01-0.18,5.94-0.53c0.32,0.96,0.67,1.91,1.05,2.84c2.07,5,5.11,9.51,8.9,13.29 c3.78,3.78,8.29,6.82,13.29,8.9c4.81,1.99,10.11,3.1,15.66,3.1c5.56,0,10.85-1.1,15.66-3.1c5-2.07,9.51-5.11,13.29-8.9 c3.78-3.78,6.82-8.29,8.9-13.29c1.99-4.81,3.1-10.11,3.1-15.66c0-5.56-1.1-10.85-3.1-15.66c-2.07-5-5.11-9.51-8.9-13.29 c-3.78-3.78-8.29-6.82-13.29-8.9c-4.81-1.99-10.11-3.1-15.66-3.1c-5.56,0-10.85,1.1-15.66,3.1c-0.43,0.18-0.86,0.37-1.28,0.56 c-1.64-2.58-3.62-4.92-5.89-6.95c1.24-0.64,2.51-1.23,3.8-1.77C59.94,1.34,66.37,0,73.1,0L73.1,0z M67.38,26.12 c0-1.22,0.5-2.33,1.3-3.13c0.8-0.8,1.9-1.3,3.12-1.3c1.22,0,2.33,0.5,3.13,1.3c0.8,0.8,1.3,1.91,1.3,3.13v23.22l17.35,10.29 c1.04,0.62,1.74,1.6,2.03,2.7c0.28,1.09,0.15,2.29-0.47,3.34c-0.62,1.04-1.6,1.74-2.7,2.03c-1.09,0.28-2.29,0.15-3.33-0.47 L69.65,55.71c-0.67-0.37-1.22-0.91-1.62-1.55c-0.41-0.67-0.65-1.46-0.65-2.3V26.12L67.38,26.12z"/><path class="st1" d="M26.99,2.56c14.91,0,26.99,12.08,26.99,26.99c0,14.91-12.08,26.99-26.99,26.99C12.08,56.54,0,44.45,0,29.55 C0,14.64,12.08,2.56,26.99,2.56L26.99,2.56z M15.05,30.27c0.36-2.1,2.76-3.27,4.65-2.13c0.17,0.1,0.34,0.22,0.49,0.36l0.02,0.01 c0.85,0.81,1.8,1.66,2.74,2.5l0.81,0.73l9.59-10.06c0.57-0.6,0.99-0.99,1.85-1.18c2.94-0.65,5.01,2.95,2.93,5.15L26.17,38.19 c-1.13,1.2-3.14,1.31-4.35,0.16c-0.69-0.64-1.45-1.3-2.21-1.96c-1.32-1.15-2.67-2.32-3.77-3.48 C15.18,32.25,14.89,31.17,15.05,30.27L15.05,30.27z"/></g></svg>

After

Width:  |  Height:  |  Size: 2.2 KiB

@ -0,0 +1,3 @@
# allow crawling everything by default
User-agent: *
Disallow:

@ -0,0 +1 @@
<?xml version="1.0" encoding="utf-8"?><svg version="1.1" id="Layer_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px" width="105.765px" height="122.88px" viewBox="0 0 105.765 122.88" enable-background="new 0 0 105.765 122.88" xml:space="preserve"><g><path d="M82.872,90.81c-2.983-8.16-7.707-14.175-13.283-18.06c-3.772-2.629-7.914-4.284-12.133-4.97 c-4.236-0.686-8.583-0.408-12.747,0.828C35.573,71.323,27.33,78.716,22.903,90.81H82.872L82.872,90.81z M20.618,27.21h64.535 c0.346-2.922,1.154-13.713,1.119-16.995H19.497C19.462,13.498,20.27,24.288,20.618,27.21L20.618,27.21L20.618,27.21z M0.91,112.665 h9.567C10.222,85.12,22.648,68.03,38.027,61.466C22.637,54.9,10.205,37.79,10.478,10.214l-9.567,0c-0.501,0-0.909-0.46-0.909-1.025 L0,1.024C0,0.46,0.409,0,0.91,0h103.944c0.5,0,0.91,0.46,0.91,1.024v8.164c0,0.563-0.41,1.024-0.91,1.024h-9.57 c0.225,23.214-8.581,39.038-20.546,47.376c-2.188,1.522-4.543,2.832-6.994,3.873c2.446,1.049,4.81,2.354,6.992,3.88 c11.955,8.332,20.756,24.139,20.546,47.321l9.572,0.001c0.5,0,0.91,0.463,0.91,1.026v8.162c0,0.564-0.41,1.027-0.91,1.027H0.91 c-0.501,0-0.909-0.463-0.909-1.026v-8.162C0.001,113.128,0.41,112.665,0.91,112.665L0.91,112.665L0.91,112.665z"/></g></svg>

After

Width:  |  Height:  |  Size: 1.2 KiB

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" shape-rendering="geometricPrecision" text-rendering="geometricPrecision" image-rendering="optimizeQuality" fill-rule="evenodd" clip-rule="evenodd" viewBox="0 0 512 406.6"><path d="M334.1 1.64a202.504 202.504 0 0 1 135.16 77.02c68.84 88.6 52.82 216.19-35.78 285.03-.08.05-.14.11-.22.18-88.57 68.82-216.15 52.81-284.97-35.76-.04-.06-.09-.12-.14-.17A204.822 204.822 0 0 1 125.31 291a168.69 168.69 0 0 0 37.79-5.42 172.61 172.61 0 0 0 13.55 20.29c56.7 72.81 161.67 85.86 234.46 29.15 72.8-56.69 85.84-161.66 29.15-234.46-40.28-51.71-107.08-75.09-170.82-59.79a171.08 171.08 0 0 0-21.88-31.29c2.46-.8 4.95-1.51 7.46-2.21 25.77-7.13 52.69-9.03 79.19-5.63h-.11zM0 129.16v-15.4C3.97 50.8 56.26.95 120.21.92h.05c66.58-.01 120.55 53.93 120.59 120.51.03 66.58-53.93 120.56-120.51 120.59C56.33 242.04 3.97 192.17 0 129.16zm106.56 31.56h27.62v24.45h-27.62v-24.45zm27.6-14.21h-27.6c-2.75-33.56-8.53-32.84-8.53-66.35 0-12.37 10.03-22.39 22.39-22.39 12.36 0 22.4 10.02 22.4 22.39 0 33.49-5.85 32.83-8.66 66.35zm163.46-42c1.24-9.88 10.24-16.88 20.09-15.64h.04c9.82 1.32 16.73 10.32 15.46 20.13l-11.7 94.09 65.06 50.55c7.85 6.1 9.3 17.4 3.2 25.28a18.011 18.011 0 0 1-11.95 6.82c-4.73.62-9.51-.68-13.26-3.62l-72.82-56.61a17.818 17.818 0 0 1-5.79-7.08 18.336 18.336 0 0 1-1.46-9.67l13.13-104.2v-.05z"/></svg>

After

Width:  |  Height:  |  Size: 1.3 KiB

@ -0,0 +1 @@
<svg id="Layer_1" data-name="Layer 1" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 122.88 112.9"><title>time-period</title><path d="M35.69,101.21a24.08,24.08,0,0,0-4.23-11.35c-2.27-3.17-5.22-5.33-8.32-5.33s-6.06,2.16-8.33,5.33a24.08,24.08,0,0,0-4.23,11.35Zm78.39-73.63a4.17,4.17,0,0,0-7.37,3.81,4.68,4.68,0,0,0,.37.7,44,44,0,0,1,3.6,6.74,4.17,4.17,0,0,0,7.94-2.29,4.32,4.32,0,0,0-.3-1,52.05,52.05,0,0,0-4.24-7.93ZM107.14,16.5a4.63,4.63,0,0,1-3.23,5.18L91.54,25.46a4.63,4.63,0,1,1-2.69-8.86L90,16.24A47,47,0,0,0,22.46,44.49H13.84A55.33,55.33,0,0,1,94.7,9.33l-1.16-3A4.64,4.64,0,1,1,102.22,3l4.62,12.09a4.81,4.81,0,0,1,.3,1.42ZM67.6,104.55a53.52,53.52,0,0,0,9.43-.87,4.17,4.17,0,0,1,1,8.25,61.44,61.44,0,0,1-7.38.94c-1.31.06-3,0-4.34,0a55.19,55.19,0,0,1-10.91-1.33V103a46.85,46.85,0,0,0,12.15,1.59Zm23.25-6a4.17,4.17,0,1,0,4.09,7.26,55.27,55.27,0,0,0,7.46-5.06,4.17,4.17,0,0,0-3.89-7.21,4.07,4.07,0,0,0-1.34.73,47.39,47.39,0,0,1-6.32,4.28Zm16.42-15.64a4.16,4.16,0,1,0,7.06,4.41,55.51,55.51,0,0,0,4.15-8,4.17,4.17,0,0,0-7.15-4.14,4.11,4.11,0,0,0-.54.93,46,46,0,0,1-3.52,6.79Zm7.13-21.62a4.17,4.17,0,0,0,8.16,1.46,3.91,3.91,0,0,0,.15-.83,56.09,56.09,0,0,0,0-9,4.16,4.16,0,1,0-8.3.69,47.78,47.78,0,0,1,0,7.66ZM59.12,35a4.29,4.29,0,0,1,8.57,0V61.09l17.84,7.85a4.28,4.28,0,1,1-3.44,7.83L61.91,67.9a4.29,4.29,0,0,1-2.79-4V35ZM12.59,70.51h21.1a20.92,20.92,0,0,0,2-7H10.56a20.7,20.7,0,0,0,2,7ZM2.47,105.83a2.09,2.09,0,1,1,0-4.1H5.55a28.67,28.67,0,0,1,5.13-14.44,19.38,19.38,0,0,1,6.1-5.67,18.41,18.41,0,0,1-6.17-5.21,24.83,24.83,0,0,1-5.07-14H2.61a2.09,2.09,0,1,1,0-4.1H43.93a2.09,2.09,0,1,1,0,4.1h-3.2a24.83,24.83,0,0,1-5.07,14,18.41,18.41,0,0,1-6.17,5.21,19.38,19.38,0,0,1,6.1,5.67,28.67,28.67,0,0,1,5.13,14.44H43.8a2.09,2.09,0,1,1,0,4.1H2.47Z"/></svg>

After

Width:  |  Height:  |  Size: 1.7 KiB

@ -0,0 +1,21 @@
import adapter from '@sveltejs/adapter-node';
import { vitePreprocess } from '@sveltejs/vite-plugin-svelte';
/** @type {import('@sveltejs/kit').Config} */
const config = {
// Consult https://svelte.dev/docs/kit/integrations
// for more information about preprocessors
preprocess: vitePreprocess(),
kit: {
// adapter-auto only supports some environments, see https://svelte.dev/docs/kit/adapter-auto for a list.
// If your environment is not supported, or you settled on a specific environment, switch out the adapter.
// See https://svelte.dev/docs/kit/adapters for more information about adapters.
adapter: adapter({ precompress: false }),
csrf: {
trustedOrigins: ['*']
}
}
};
export default config;

@ -0,0 +1,19 @@
{
"extends": "./.svelte-kit/tsconfig.json",
"compilerOptions": {
"allowJs": true,
"checkJs": true,
"esModuleInterop": true,
"forceConsistentCasingInFileNames": true,
"resolveJsonModule": true,
"skipLibCheck": true,
"sourceMap": true,
"strict": true,
"moduleResolution": "bundler"
}
// Path aliases are handled by https://svelte.dev/docs/kit/configuration#alias
// except $lib which is handled by https://svelte.dev/docs/kit/configuration#files
//
// To make changes to top-level options such as include and exclude, we recommend extending
// the generated config; see https://svelte.dev/docs/kit/configuration#typescript
}

@ -0,0 +1,25 @@
import { sveltekit } from '@sveltejs/kit/vite';
import { defineConfig } from 'vite';
export default defineConfig(({ mode }) => {
const isDev = mode === 'development';
return {
plugins: [sveltekit()],
resolve: {
// En desarrollo, alias para usar better-sqlite3 (Vite/HMR no entiende 'bun:sqlite')
alias: isDev ? { 'bun:sqlite': 'better-sqlite3' } : {}
},
ssr: {
// En dev, externalizar better-sqlite3 (CJS nativo) para que se cargue vía require;
// en producción, externalizar 'bun:sqlite' y que lo resuelva Bun en runtime.
external: isDev ? ['better-sqlite3'] : ['bun:sqlite']
},
optimizeDeps: {
// Evitar prebundling de drivers nativos
exclude: ['bun:sqlite', 'better-sqlite3']
},
// Permitir host remoto en desarrollo
server: isDev ? { allowedHosts: ['server.brobert.net'] } : undefined
};
});

@ -0,0 +1 @@
workspaces = ["apps/*"]

@ -0,0 +1,185 @@
# Inventario de Comandos del Bot de Tareas
Ámbito: WhatsApp (DM y grupos)
Objetivo: fuente única y estable de copy y comportamiento actual.
Notas generales
- El bot responde por DM, incluso cuando escribes en un grupo, salvo mensajes muy concretos de estado. En modo de “gating” estricto de grupos (GROUP_GATING_MODE='enforce'), si el grupo no está permitido, el bot puede no responder en absoluto.
- Zona horaria: se usa TZ (por defecto Europe/Madrid) para calcular “hoy”, “mañana” y vencimientos.
- IDs: se muestran con 4 dígitos (ej.: `0026`), pero puedes escribirlos sin ceros (ej.: 26).
- Límite de listados: 10 elementos por sección; si hay más, se muestra “... y N más”.
- Fechas: formato visual DD/MM; indicador de vencida con ⚠️.
---
## /t nueva (crear)
Alias: `n`, `nueva`, `crear`, `+`
Sintaxis: `/t n <descripción> [fecha] [@menciones...]`
Parámetros
- descripción: texto libre.
- fecha (opcional): formatos aceptados:
- `YYYY-MM-DD`
- `YY-MM-DD` (se expande a `20YY-MM-DD`)
- Tokens naturales: `hoy`, `mañana` (con o sin acento)
- Se ignora puntuación adyacente simple; se usa la última fecha válida encontrada; no se aceptan fechas pasadas.
- @menciones (opcional): puedes mencionar JIDs crudos o tokens `@...`. Se filtran no plausibles y se intenta resolver alias. Si no se puede, se envía un DM al creador con instrucciones de onboarding (activar).
Asignación por contexto
- En grupos: si no hay menciones, la tarea queda “sin responsable”.
- En DM: si no hay menciones, se asigna al creador.
Grupo asociado
- Solo se asigna `group_id` si el grupo está activo. Si GROUP_GATING_MODE='enforce' y el grupo no está permitido, se crea “sin grupo”.
Ejemplos
- `/t n Preparar informe 2025-11-05 @600123456`
- `/t + Comprar pan mañana`
- `/t crear Llamar a proveedores @ana @juan`
- `/t n Presentación 25-02-02` (→ 2025-02-02)
---
## /t ver (listar)
Alias: `ver`, `mostrar`, `listar`, `ls`
Sintaxis: `/t ver [grupo|mis|todos|sin]` (el alcance es opcional)
Alcances
- `grupo`: lista pendientes del grupo actual (solo desde grupo activo).
- `mis`: tus tareas pendientes (por DM).
- `todos`: “Tus tareas” + “sin responsable”.
- En grupo: incluye “sin responsable” solo del grupo actual (compatibilidad).
- En DM: incluye “sin responsable” de todos los grupos donde eres miembro activo (si el snapshot de membresía es fresco).
- `sin`: solo tareas sin responsable del grupo actual (desde grupo).
Indicadores
- Fechas en `DD/MM`.
- ⚠️ delante de la fecha si está vencida (según TZ configurada).
Límites
- Máx. 10 elementos por sección; si hay más, se añade “... y N más”.
Ejemplos
- En grupo: `/t ver` (equivale a `grupo`), `/t ver sin`
- Por DM: `/t ver mis`, `/t ver todos`
---
## /t x (completar)
Alias: `x`, `hecho`, `completar`, `done`
Sintaxis: `/t x <id|id,id,...|id id ...>`
Soporta múltiples IDs separados por espacios y/o comas. Máx. 10 IDs.
Resolución de ID
- Primero intenta `display_code` (código corto de 4 dígitos) en tareas activas; si no, usa el ID real.
Gating de membresía (opcional)
- Si `GROUP_MEMBERS_ENFORCE=true` y el snapshot del grupo es fresco, debes ser miembro activo para completar.
Ejemplos
- `/t x 26`
- `/t x 14 19 24`
- `/t x 14,19,24`
---
## /t tomar (asumir)
Alias: `tomar`, `claim`, `asumir`, `asumo`
Sintaxis: `/t tomar <id|id,id,...|id id ...>`
Múltiples IDs; máx. 10.
Gating de membresía (opcional)
- Si `GROUP_MEMBERS_ENFORCE=true` y snapshot fresco, debes ser miembro activo para tomar tareas del grupo.
Ejemplos
- `/t tomar 12`
- `/t tomar 12 19 50`
- `/t tomar 12,19,50`
---
## /t soltar (unassign)
Alias: `soltar`, `unassign`, `dejar`, `liberar`, `renunciar`
Sintaxis: `/t soltar <id>`
Un solo ID.
Gating de membresía (opcional)
- Si `GROUP_MEMBERS_ENFORCE=true` y snapshot fresco, debes ser miembro activo para soltar tareas del grupo.
Ejemplos
- `/t soltar 26`
---
## /t configurar (recordatorios)
Alias: `config`, `configurar`
Sintaxis: `/t configurar diario|l-v|semanal|off [HH:MM]`
Valores admitidos y alias
- `diario`/`diaria` → recordatorio diario (se guarda como `daily`).
- `laborables` (`l-v`, `lv`) → lunes a viernes (se guarda como `weekdays`).
- `semanal` → semanal (asume lunes; se guarda como `weekly`).
- `off`/`apagar`/`ninguno` → sin recordatorios (se guarda como `off`).
Hora
- Formato `HH:MM` (minutos 0059; hora se normaliza a 023).
- Si omites la hora, se conserva la anterior o se usa `08:30` por defecto (y `lunes` para semanal).
Nota de localización
- Internamente se almacenan claves en inglés (`daily`, `weekdays`, `weekly`, `off`), pero el copy al usuario es en español. Pendiente de revisión futura para evitar fugas como “weekly” en mensajes.
Ejemplos
- `/t configurar diaria 09:00`
- `/t configurar l-v 08:30`
- `/t configurar semanal` (→ lunes 08:30)
- `/t configurar off`
---
## /t ayuda
Alias: `ayuda`, `help`, `?`
Sintaxis: `/t ayuda` | `/t ayuda avanzada`
Comportamiento actual
- Ayuda rápida con comandos básicos, límites y ejemplos cortos.
- “Ayuda avanzada” lista alias y detalla opciones y límites.
Nota
- El contenido de ayuda está centralizado y consistente.
---
## /t web
Sintaxis: `/t web` (solo por DM)
Descripción
- Genera un token de acceso oneshot válido 10 minutos, invalida tokens previos y devuelve una URL de login basada en `WEB_BASE_URL`.
Ejemplo
- `Acceso web: https://…/login?token=...`
“Válido durante 10 minutos. Si caduca, vuelve a enviar `/t web`.”
---
## Comandos desconocidos
Ante comandos no reconocidos, el bot responde por DM con un mensaje que incluye el encabezado “❓ Comando no reconocido”, la sugerencia “Prueba `/t ayuda`” y la ayuda rápida inline.
## Notas adicionales
- Estilo y formato:
- IDs: `codeId()` → 4 dígitos entre backticks.
- Fechas: `formatDDMM()``DD/MM`.
- Estilos disponibles: negrita `*...*`, cursiva `_<...>_`. Próximamente: `code()`, `section()`, `bullets()`.
- Gating de grupos:
- Si `GROUP_GATING_MODE='enforce'` y el grupo no está permitido, los comandos en ese grupo pueden quedar bloqueados (sin respuesta).
- Membresía de grupo:
- Si `GROUP_MEMBERS_ENFORCE=true` y el snapshot es fresco, algunas acciones requieren ser miembro activo (ver grupo, completar, tomar, soltar).

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

Loading…
Cancel
Save