Files
labre-web/AGENTS.md
T

282 lines
86 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
## Development
When starting the dev server, use background mode:
```
astro dev --background
```
Manage the background server with `astro dev stop`, `astro dev status`, and `astro dev logs`.
## Testing
Todo testing manual en el navegador (probar un flujo, verificar un fix visual, QA de una feature) se hace con la skill **agent-browser**, no con curl ni asunciones. Usar el bypass temporal `?preview=alumno|admin` del middleware (solo activo en `DEV`) para simular sesión sin hacer login real, y revertirlo antes de terminar.
## Documentation
Full documentation: https://docs.astro.build
Consult these guides before working on related tasks:
- [Adding pages, dynamic routes, or middleware](https://docs.astro.build/en/guides/routing/)
- [Working with Astro components](https://docs.astro.build/en/basics/astro-components/)
- [Using React, Vue, Svelte, or other framework components](https://docs.astro.build/en/guides/framework-components/)
- [Adding or managing content](https://docs.astro.build/en/guides/content-collections/)
- [Adding styles or using Tailwind](https://docs.astro.build/en/guides/styling/)
- [Supporting multiple languages](https://docs.astro.build/en/guides/internationalization/)
## Proceso de trabajo de este proyecto
Al final de cada prompt del usuario, actualizar la sección **Bitácora** de este archivo con lo que se hizo, decisiones tomadas y qué sigue. No crear archivos de plan aparte: este CLAUDE.md es la única fuente de verdad del progreso.
Antes de ejecutar cambios de infraestructura (SSH a buglabs, docker, Cloudflare tunnel, DB) pedir confirmación al usuario si el cambio es destructivo o afecta a otros proyectos que viven en el mismo servidor (genqbar, gitea, etc).
Cuando el trabajo se pueda dividir en partes que no toquen los mismos archivos, lanzar agentes en paralelo (Agent tool) en vez de hacerlo secuencial.
---
# Proyecto: Sistema de Préstamos — Laboratorio de Sistemas Computacionales UABC
Sistema web para gestionar préstamos de material del laboratorio. Dos roles: **alumno** (solicita material) y **admin** (gestiona solicitudes, inventario y reportes). Login exclusivo con Google restringido a correo institucional `@uabc.edu.mx`.
## Decisiones de arquitectura
| Decisión | Elegido | Motivo |
|---|---|---|
| Backend | Supabase **self-hosted existente** en buglabs (`supabase.buglabs.dev`) | Ya está corriendo (Postgres 15, Auth, Kong, Studio) — no se despliega uno nuevo. Compartido con otro proyecto (genqbar). |
| Aislamiento de datos | Schema Postgres propio: **`prestamos`** | `public` ya lo usa genqbar (tablas `profiles`, `edificios`, `eventos`, etc). Evita colisión de nombres. |
| Auth | Supabase Auth + proveedor Google OAuth (a configurar, hoy no está activo en la instancia) | Auth es compartido entre apps del mismo Supabase; el proveedor Google se habilita una vez a nivel instancia. |
| Restricción de dominio `@uabc.edu.mx` | **A nivel aplicación** (middleware de Astro tras login), no a nivel GoTrue | GoTrue es compartido con otras apps del servidor; no se puede bloquear el signup global sin afectar a genqbar. |
| Alta de admin inicial | Manual vía Supabase Studio, después del primer login | Decisión del usuario — no se hardcodea ningún correo admin. |
| Frontend interactivo | Astro + **React** (islands) | Ya es un proyecto Astro; React da el ecosistema más grande para tablas/formularios/export. |
| Hosting de la app | Servidor propio (buglabs), adapter **`@astrojs/node`** | Mismo homelab que Supabase; se agrega contenedor + ruta en el túnel de Cloudflare existente. |
| Subdominio | **prestamos.buglabs.dev** | Se añade al mismo túnel Cloudflare (`3de17b3c-...`) junto a supabase/git/status/genqbar. |
| Exportar reportes | CSV nativo (sin librería) para v1 | Cero dependencias nuevas; se sube a PDF/Excel solo si se pide explícitamente. |
## Modelo de datos (schema `prestamos`)
Rediseñado 2026-08-17 para reflejar el vale de préstamo físico del LSC (un trámite agrupa varios materiales, no uno solo — ver Bitácora del 2026-08-17 para el detalle completo).
- `profiles` — id (=auth.users.id), email, nombre, matricula, semestre (nullable, sin UI aún — ver backlog), rol (`alumno`|`admin`), created_at
- `categorias` — id, nombre
- `materiales` — id, nombre, categoria_id, descripcion, cantidad_total, cantidad_disponible, numero_inventario, estado (`disponible`|`mantenimiento`|`baja`)
- `solicitudes` (cabecera del vale, antes se llamaba `prestamos`) — id, alumno_id, maestro_responsable, estado (`pendiente`|`aprobado`|`rechazado`|`activo`|`devuelto`|`vencido`), fecha_solicitud, fecha_aprobacion, fecha_devolucion_estimada, fecha_devolucion_real, aprobado_por, notas (motivo del préstamo)
- `solicitud_items` (renglones del vale) — id, solicitud_id, material_id, cantidad, descripcion (nota libre por renglón)
Alta de solicitudes: solo vía RPC `prestamos.crear_solicitud(maestro_responsable, notas, items jsonb)` (transaccional, valida stock con lock por fila) — el alumno ya no puede insertar directo en `solicitudes` (RLS lo bloquea).
RLS: alumno solo ve/edita sus propias solicitudes/renglones; admin ve y gestiona todo. Enforced con `auth.uid()` contra `profiles.id` y policies por rol.
## Fases de implementación
1. **Backend Supabase** — schema `prestamos`, tablas, RLS, trigger `handle_new_user`, seed de categorías. (bloqueante, va primero)
2. **Scaffolding Astro** — integración React, cliente Supabase, middleware de auth + verificación de dominio/rol, layout base.
3. **Interfaz alumno** — catálogo de materiales, solicitar préstamo, ver mis préstamos y su estado.
4. **Interfaz admin — solicitudes** — bandeja de solicitudes (aprobar/rechazar), marcar devoluciones.
5. **Interfaz admin — inventario** — alta/baja/edición de materiales y categorías.
6. **Reportes** — filtros (fecha, material, alumno, estado), export CSV, vista de vencidos.
7. **Deploy** — Dockerfile + compose para el adapter Node, ruta `prestamos.buglabs.dev` en el túnel de Cloudflare de buglabs.
8. **QA end-to-end** — flujo real de login + alumno + admin en producción.
Fases 3, 4/5 y 6 tocan carpetas de rutas distintas (`src/pages/alumno/*`, `src/pages/admin/*`) y pueden correr como agentes en paralelo una vez completa la fase 2. Fase 7 (infra) también es independiente del código de UI y puede prepararse en paralelo.
## Bitácora
- **2026-08-13** — Plan inicial definido. Se hizo reconocimiento por SSH del homelab buglabs: Supabase self-hosted ya corre ahí (compartido con proyecto "genqbar" en schema `public`), túnel Cloudflare ya gestiona varios subdominios de buglabs.dev, Docker/Compose disponibles. Decisiones de arquitectura tomadas junto con el usuario (ver tabla arriba). Pendiente: confirmación del usuario para arrancar Fase 1 (backend Supabase).
- **2026-08-14** — Plan refinado y aprobado. Ajustes: (1) schema `public.profiles` NO se reutiliza — el sistema de préstamos usa `prestamos.profiles` propio y aislado; (2) sí se agrega `prestamos.audit_log` con trigger para trazabilidad de cambios de estado; (3) v1 sin reglas duras de límite/duración (admin decide caso por caso). Se corrigió la identificación del vecino que usa el schema `public`: no es genqbar (genqbar es un estático sin auth), es otra app de check-in por QR corriendo en el 8080. Google OAuth se confirmó ya habilitado en la instancia de Supabase (`GOOGLE_ENABLED=true`, con CLIENT_ID/SECRET presentes en `~/supabase/docker/.env`), no requiere alta nueva. Se añadieron normas de diseño al plan: paleta UABC (primario `#00723F` verde, secundario `#DD971A` dorado, terciario `#F4F7F5`, neutral `#2D3748`) bajo regla 60/30/10, admin=desktop-first y alumno=mobile-first pero ambos cross-device, minimalista con animaciones ligeras, Inter Variable como tipografía, WCAG AA no negociable. Plan guardado en `~/.claude/plans/inicia-con-la-faze-modular-reef.md`.
- **2026-08-14 — Fase 1 completada (Backend Supabase)**.
- Escrita migración `supabase/migrations/0001_init.sql` con: schema `prestamos`; tablas `profiles, categorias, materiales, prestamos, audit_log`; función helper `prestamos.is_admin()`; trigger `prestamos_on_auth_user_created` en `auth.users` que rechaza emails no `@uabc.edu.mx` (segunda línea de defensa) y crea el perfil; triggers `prestamos_sync_stock` (ajusta `cantidad_disponible` según transiciones de estado) y `prestamos_log_estado` (registra cambios en `audit_log`); 11 policies RLS distribuidas en las 5 tablas usando `is_admin()`; seed de 6 categorías.
- Aplicada por `psql` sobre la instancia de buglabs. Verificado: 5 tablas creadas, 11 policies activas, 3 triggers propios registrados, 6 categorías seed presentes.
- Editado `~/supabase/docker/.env` en buglabs (backup previo con timestamp): `PGRST_DB_SCHEMAS` incluye ahora `prestamos`, `ADDITIONAL_REDIRECT_URLS` incluye `https://prestamos.buglabs.dev/**`.
- Recreados contenedores `supabase-rest` y `supabase-auth` con `docker compose up -d` (un simple `restart` no relee `.env`; ese fue un aprendizaje del proceso). Downtime real: ~5s.
- Corrección aplicada tras primer curl de verificación: faltaba `grant` a `service_role`. Se añadió `grant ... to service_role` + `alter default privileges` (tanto en la DB como en el archivo de migración, para que sea idempotente si se re-aplica). Verificado con `curl` que `GET /rest/v1/categorias` con `Accept-Profile: prestamos` retorna las 6 categorías.
- Estado: backend listo para consumir desde la app. Próximo paso: Fase 2 (scaffolding Astro + React + Tailwind + adapter Node + cliente Supabase SSR + middleware de auth con verificación de dominio).
- **2026-08-14 — Fase 2 completada (Scaffolding Astro)**.
- Instaladas integraciones vía `npx astro add react node tailwind --yes`. Añadido a `package.json`: `@astrojs/react`, `@astrojs/node`, `tailwindcss` (v4, sin config JS — configura en CSS con `@theme`), `react`, `react-dom`. Adaptador Node en modo `standalone`.
- Añadidas deps runtime: `@supabase/supabase-js`, `@supabase/ssr`, `@fontsource-variable/inter`.
- `astro.config.mjs`: se explicitó `output: 'server'` (sin esto, Astro intentaba prerenderizar rutas SSR y el cliente Supabase reventaba). Integraciones: React + Tailwind v4 (vía `@tailwindcss/vite`).
- `tsconfig.json`: añadido `baseUrl: '.'` + `paths: { "@/*": ["src/*"] }` para import alias.
- `src/styles/global.css`: paleta UABC como tokens Tailwind v4 (`@theme` con `--color-primary #00723F, --color-secondary #DD971A, --color-surface #F4F7F5, --color-ink #2D3748, --color-danger #C53030`), Inter Variable como `--font-sans`, componentes `.btn`, `.btn-primary`, `.btn-secondary`, `.btn-ghost`, `.card`, `.input`, `.label`. Tap targets ≥44px. `touch-action: manipulation` y `-webkit-tap-highlight-color: transparent` en botones. `text-wrap: balance/pretty` en tipografía.
- `src/lib/supabase.ts`: helpers `serverClient(cookies)` y `browserClient()`. Ambos apuntan a `db: { schema: 'prestamos' }` para que las queries no necesiten prefijo. Cookies con `httpOnly`, `sameSite: 'lax'`, `secure` solo en prod. Usa el patrón `get/set/remove` (el `getAll/setAll` moderno de `@supabase/ssr` no funcionó con `AstroCookies` de Astro 7 — el método `.getAll()` no existe ahí).
- `src/env.d.ts`: tipos para `App.Locals` (`supabase`, `user`, `profile`) y `ImportMetaEnv` (`PUBLIC_SUPABASE_URL`, `PUBLIC_SUPABASE_ANON_KEY`, `SUPABASE_SERVICE_ROLE_KEY`, `PUBLIC_APP_URL`).
- `src/middleware.ts`: instancia `serverClient`, obtiene user, verifica dominio `@uabc.edu.mx` (rechaza + redirect `/login?error=dominio`), carga `profile` desde `prestamos.profiles`, protege rutas privadas (redirect a `/login` si no hay user; 403 en `/admin/*` si no es admin), redirige a `/` si un user logueado visita `/login`.
- `src/pages/login.astro`: pantalla completa aparte, tarjeta centrada con logo, `<h1>Sistema de Préstamos</h1>`, `<a>` Google (verde primario) que arma `authorize` URL de Supabase, alerta accesible si `?error=dominio|oauth`, nota de dominio institucional al pie. Animación entrada solo detrás de `motion-safe:`.
- `src/pages/api/auth/callback.ts`: intercambia `code` por sesión, verifica dominio por segunda vez y redirige a `/` (o a `/login?error=…` según el caso).
- `src/pages/api/auth/signout.ts`: POST → `signOut` → redirect `/login`.
- `src/layouts/Layout.astro`: HTML base (`lang="es"`, `meta viewport`, `meta theme-color=#00723F`, `color-scheme: light`), incluye skip-to-content link con `focus:not-sr-only`. `title` prop.
- `src/layouts/AppLayout.astro`: sidebar `bg-primary` con logo enlazado a `/`, nav responsive (sidebar en desktop, topbar en mobile), items admin vs alumno según `rol`, botón signout desktop full + icon-only en mobile con `aria-label`.
- `src/pages/index.astro`: dashboard con tarjetas según rol (alumno: catálogo + mis préstamos; admin: solicitudes + inventario + reportes). Placeholders navegables — sus destinos se implementarán en Fases 36.
- `.env` local creado (obtenidas ANON y SERVICE_ROLE keys por SSH de buglabs). `.env.example` versionado con placeholders. `.env*` ya estaba en `.gitignore`.
- Borrado `src/components/Welcome.astro` y `src/assets/*` del starter, `Layout.astro` reescrito para nuestros propósitos.
- Auditoría de UI con skill `web-design-guidelines` (checklist Vercel Labs): resueltos 6 hallazgos accionables — `transition:all` reemplazado por transiciones específicas por propiedad; `role="button"` sobrante en `<a>` de Google removido; `theme-color` y `color-scheme` metas añadidos; `touch-action: manipulation` en interactivos; logo mobile con `aria-label` sobre link y letra `aria-hidden`; `text-wrap: balance/pretty` en tipografía.
- Verificación: `npm run build` sin errores. `npm run dev` (background) → `GET /` sin sesión redirige `/login` (200), `/login` renderiza copy correcto, `?error=dominio` muestra la alerta esperada.
- Aprendizajes registrados: (a) Tailwind v4 requiere `@reference "tailwindcss"` dentro de `<style>` scoped de `.astro` para usar `@apply`; (b) `output: 'server'` es obligatorio en `astro.config.mjs` incluso con adaptador Node — no se infiere; (c) `AstroCookies.getAll()` no existe en Astro 7, usar `get(name)`.
- Estado: base lista. Fases 36 pueden arrancar en paralelo con agentes ahora que hay: middleware con sesión + `profile` en `Astro.locals`, cliente Supabase apuntando al schema `prestamos`, layout con nav responsive, tokens de diseño Tailwind y clases utilitarias `.btn/.card/.input`.
- **2026-08-14 — Fases 3, 4, 5, 6, 7 completadas en paralelo (5 agentes)**.
- Se lanzaron 5 subagentes simultáneos en un solo turno con contratos aislados por carpeta (sin colisión de archivos). Todos terminaron el core aunque tres (Fases 4, 5, 6) marcaron `failed` al final por límite de sesión durante refinamientos post-implementación; el orquestador consolidó y validó que todo compila.
- **Fase 3 (Alumno)** — 5 archivos: `src/pages/alumno/{catalogo,mis-prestamos}.astro`, `src/pages/api/prestamos/index.ts`, `src/components/alumno/{SolicitarModal,FiltroCategorias}.tsx`. Mobile-first. Catálogo agrupa por categoría en SSR, modal `<dialog>` nativo para solicitar, filtro por chips que sincroniza `?cat=` en URL. Mis-préstamos con secciones "Activos" (pendiente/aprobado/activo) y "Historial" en `<details>`. Endpoint POST valida stock antes de insertar.
- **Fase 4 (Admin solicitudes)** — 11 archivos: `src/pages/admin/index.astro` (panel con KPIs), `src/pages/admin/solicitudes/{index,activos,historial}.astro`, `src/components/admin/solicitudes/{AccionesSolicitud,MarcarDevuelto,VerDetalles}.tsx`, `src/pages/api/admin/prestamos/[id]/{aprobar,rechazar,devolver,detalles}.ts`. Desktop-first tabla / mobile cards. Flujo simplificado: `pendiente → aprobado` (que también significa activo) → `devuelto`. El estado `activo` del enum queda reservado. Los endpoints usan `.eq('estado', 'pendiente')` como guard anti doble-click; los triggers de DB manejan stock y audit_log.
- **Fase 5 (Admin inventario)** — 8 archivos: `src/pages/admin/inventario/{index,categorias}.astro`, `src/components/admin/inventario/{MaterialForm,EliminarMaterial,CategoriaForm,EliminarCategoria}.tsx`, `src/pages/api/admin/{materiales,categorias}/[index,\[id\]].ts`. Filtros SSR vía query params. Edición de `cantidad_total` valida que no quede debajo de lo prestado. Manejo de FK violations (23503) con mensaje útil.
- **Fase 6 (Reportes)** — 6 archivos: `src/pages/admin/reportes.astro` (tabs Historial/Vencidos con filtros por rango de fecha/estado/material/alumno), `src/components/admin/reportes/{Autocomplete,AutocompleteMaterial,AutocompleteAlumno}.tsx` (patrón combobox con teclado y aria-*, se extrajo helper compartido `Autocomplete.tsx`), `src/pages/api/admin/reportes/{materiales,alumnos,export}.ts`. Export CSV nativo (sin librería) con BOM UTF-8, escape de `,"` y `\n`, fechas ISO. Marca `// ponytail: LIMIT 500, paginar cuando pase de eso`.
- **Fase 7 (Deploy)** — 5 archivos: `Dockerfile` (multi-stage node:22-alpine, 3 stages, healthcheck vía `node -e http.get('/login')`, USER node), `.dockerignore`, `docker-compose.yml` (puerto `127.0.0.1:8082:4321`, log rotation 10MB×3), `.env.production.example`, `deploy/README.md` con pasos de primer deploy + update + rollback + snippet Cloudflare tunnel. Sin nginx delante, sin pm2. Tamaño de imagen esperado ~180-220 MB.
- **Correcciones post-agentes**: (a) botón "Cerrar sesión" del AppLayout usaba `transition` (all) — cambiado a `transition-colors`. (b) `AutocompleteMaterial` y `AutocompleteAlumno` originalmente tenían `<span class="label">` externo sin asociación al input; el agente 6 detectó la regresión y consolidó en `Autocomplete.tsx` con `<label htmlFor>` propio.
- **Verificación end-to-end en dev**: `npm run build` pasa limpio. Smoke test sin sesión sobre 14 rutas (páginas + API): `/login` → 200; todas las demás → 302 a `/login`. Middleware protege correctamente `/alumno/*`, `/admin/*` y `/api/*`.
- Total: **35 archivos nuevos** (25 páginas/endpoints + 10 componentes React + 5 archivos de deploy). Cero librerías nuevas más allá de lo que quedó en Fase 2. Todos los modales son `<dialog>` HTML nativo.
- **Bugs/hallazgos secundarios** detectados por los agentes (registrados para atender después, no bloqueantes): (i) el middleware ejecuta `getUser()` + fetch de perfil en cada request incluyendo `/api/*`, sin cache — bajo carga alta convendría memoizar por request. (ii) Si `Astro.locals.user` es null, el error de `.id` en el endpoint podría reventar; los endpoints actuales lo cubren con early return pero conviene revisar en la fase de QA.
- Estado: código completo. Falta solo Fase 8 (deploy real al servidor + QA end-to-end con login real). Pendiente confirmación del usuario para arrancar el deploy.
- **2026-08-14 — Fase 8 (Deploy) completada. https://prestamos.buglabs.dev vive.**
- Código publicado en `git.buglabs.dev/LakG/labre-web` (repo privado). Push HTTPS con token efímero generado vía `docker exec -u git gitea gitea admin user generate-access-token` — el token quedó en Gitea para poderse revocar (`Settings → Applications`), nunca vivió en el `remote` local.
- DNS: CNAME `prestamos.buglabs.dev → 3de17b3c-....cfargotunnel.com` (proxied) creado desde el dashboard de Cloudflare.
- En `buglabs`: clonado a `~/labre-web/`, `.env.production` (chmod 600) con las 4 vars reales, `docker compose up -d --build`. Imagen `labre-web-labre-web:latest` construida en ~90s.
- Cloudflared: usuario añadió la regla `- hostname: prestamos.buglabs.dev / service: http://localhost:8082` antes del `http_status:404` en `/etc/cloudflared/config.yml` y reinició el servicio con `sudo systemctl restart cloudflared`.
- **Bug encontrado y arreglado en el deploy**: en el primer build el contenedor daba 500 con "Your project's URL and Key are required". Causa: Vite hornea `import.meta.env.PUBLIC_*` en build time, pero el Dockerfile intencionalmente no expone `.env.production` durante `docker build` (secretos no viajan a layers de imagen). Fix en un commit: `src/lib/supabase.ts` y `src/pages/login.astro` leen ahora de `process.env.PUBLIC_*` con fallback a `import.meta.env.*` para dev; en runtime las vars llegan por `env_file` de compose. El `browserClient` (que no lo usa nadie hoy) se mantiene con `import.meta.env` — si en el futuro un island lo importa habrá que pasarlas como `ARG` en el build stage.
- Verificación end-to-end pública:
- `dig prestamos.buglabs.dev` → IPs de Cloudflare edge.
- `curl -sI https://prestamos.buglabs.dev/login``HTTP/2 200`, `cf-cache-status: DYNAMIC`.
- `GET /` sin cookies → 302 a `/login`.
- `GET /api/prestamos` sin cookies → 302 (middleware protegiendo API).
- Estado final: **sistema en producción**. Falta el paso operativo humano: (a) hacer un primer login real con cuenta `@uabc.edu.mx` para crear el registro en `prestamos.profiles`; (b) promoverse manualmente a admin desde Supabase Studio o `psql`:
```sql
update prestamos.profiles set rol = 'admin' where email = '<tu-correo>@uabc.edu.mx';
```
Después, ese usuario puede empezar a cargar categorías/materiales por la UI y arrancar operación real.
- **2026-08-15 — Iteración UX post-lanzamiento (en producción)**.
- **Motivo del préstamo**: campo del modal de solicitud renombrado a "Motivo del préstamo" y hecho requerido con hint "¿Para qué clase o proyecto?". Se muestra ahora en las tres vistas admin (bandeja pendientes, activos, historial) tanto en tabla desktop como tarjetas mobile.
- **Nav mobile → dock píldora flotante**: reemplazado el topbar horizontal por un dock centrado abajo, íconos SVG inline (heroicons outline) con estilo pill + `backdrop-blur`, respeta `env(safe-area-inset-bottom)`. Item activo con fondo verde primario. Cerrar sesión al final separado por divisor. Alumno: Home/Grid/List/LogOut. Admin: Dashboard/Inbox/Stats/Chart/LogOut.
- **Sidebar desktop colapsable**: botón hamburguesa arriba del contenido (visible solo desktop) alterna la sidebar con `translate-x-full` y transición de 200ms. Estado persistido en `localStorage`, aplicado sin flash mediante `<script is:inline>` bloqueante en `<head>`.
- **Toaster estilo Sileo**: componente React global (`src/components/Toaster.tsx`, `client:idle`) montado una vez en el `AppLayout`. Toasts con `backdrop-blur-xl`, `bg-white/85`, icono circular a color según kind (success primary, error danger, info secondary), animación `translateY + scale + opacity` con `cubic-bezier(.22,1,.36,1)`, auto-dismiss 3.8s, click para cerrar, respeta `prefers-reduced-motion`. Helper `toast()` dispara un `CustomEvent('labre:toast')`. Helper adicional `toastAfterReload()` deja el toast en `sessionStorage` para que sobreviva al `location.reload()` post-acción (el Toaster drena la cola al montar). Cableado en `SolicitarModal`, `AccionesSolicitud` (aprobar/rechazar) y `MarcarDevuelto`.
- **Panel de Estadísticas**: la ruta `/admin/inventario` cambió de "Inventario" a "Estadísticas" (label + ícono `stats` en nav). Agregada arriba una fila de KPIs con 6 tarjetas: solicitadas hoy, aprobadas hoy, rechazadas hoy, devoluciones hoy, en préstamo (total), vencidos (total, resaltado en rojo si > 0). Los cambios de estado se cuentan desde `prestamos.audit_log` filtrando por `estado_nuevo` + rango del día; los totales cuentan directo sobre `prestamos.prestamos`. Las 6 queries corren en `Promise.all`. El rango del día se computa server-side con `todayMX()` (nuevo `src/lib/date.ts`) que usa `Intl.DateTimeFormat` con `timeZone: 'America/Tijuana'` para leer año/mes/día + `longOffset` para obtener el offset actual (con DST). El inventario CRUD original queda intacto debajo.
- **Diagnóstico**: `/api/whoami` devuelve `{user, profile}` de la sesión actual (útil para probar cookies desde el navegador). Endpoint `/api/admin/prestamos/[id]/devolver` retorna ahora un objeto `debug` con `email` y `profile_rol` cuando devuelve 403 (temporal, para trazar un bug reportado por el usuario en el que "Marcar devuelto" devolvía 403; pendiente confirmar la causa con datos reales de prod).
- **Preview local vía Cloudflare tunnel efímero**: se probó con `cloudflared tunnel --url http://localhost:4321` + flag temporal `?preview=alumno|admin` en el middleware (que impersonaba con service_role bajo `import.meta.env.DEV`). El flag se removió antes del deploy — el middleware en prod ya no lo lee. También se removió el `server.allowedHosts` del `astro.config.mjs` que se había abierto solo para el túnel.
- **Deploy**: `git push` → `ssh buglabs "cd ~/labre-web && git pull && docker compose up -d --build"`. Container `labre-web` recreado, healthy en ~15s. Smoke test público: `/login` → 200, `/` → 302, `/api/prestamos` → 302. Downtime real observado: ninguno perceptible.
- Pendiente: usuario debe visitar `https://prestamos.buglabs.dev/api/whoami` en prod y compartir el JSON (o pegar el objeto `debug` que aparece en la respuesta del "Marcar devuelto") para diagnosticar el 403 residual.
- **2026-08-16 — Bug del 403 resuelto: era Astro `security.checkOrigin`**.
- Causa raíz: Astro 5+ activa por default `security.checkOrigin`, que compara `Origin` con `Host` en peticiones POST/PATCH/PUT/DELETE. Detrás de Cloudflare Tunnel esos headers no matchean literalmente, así que Astro corta con 403 y texto plano `"Cross-site POST form submissions are forbidden"` — nunca llega al handler. Por eso los `console.log` del endpoint no salían y `debug` del body no aparecía: Astro respondía antes.
- Fix: `security: { checkOrigin: false }` en `astro.config.mjs`. Los endpoints ya están protegidos por cookies `HttpOnly` + `SameSite=Lax` y flow OAuth con PKCE, así que la protección CSRF de Astro es redundante y solo estorba en este stack.
- Confirmado en prod por el usuario: "Marcar devuelto" ahora funciona. El mismo fix cubre aprobar/rechazar y cualquier otro POST admin que hubiera fallado con el mismo síntoma.
- Limpieza: removido `/api/whoami` (endpoint diagnóstico) y el bloque `debug` que devolvía email+rol en el 403 del endpoint devolver. Ya no aplican.
- Aprendizaje: cuando un endpoint responde 403 con **texto plano** ("Cross-site POST..."), es Astro; cuando responde 403 con **JSON** (`{"error":"no autorizado"}`), es lógica del endpoint. Ese matiz habría acelerado el diagnóstico.
- **2026-08-16 — Auditoría `/impeccable`: "quita el sloop de IA y reforma el diseño"**.
- Se generaron `PRODUCT.md` y `DESIGN.md` (skill impeccable) documentando el sistema de diseño ya implementado ("La Bitácora Digital"). Veredicto tras leer todo el código y capturar screenshots reales (login, catálogo, panel admin, solicitudes, inventario, reportes, mobile) vía un bypass temporal de middleware solo-DEV (`?preview=alumno|admin`, revertido al terminar): el sistema **no** tenía slop visual genérico típico de IA (sin gradientes, sin glassmorphism, sin morado/azul SaaS, sin emojis) — el trabajo previo ya era disciplinado. El slop real encontrado era puntual:
1. El favicon era el cohete default de Astro (boilerplate del scaffold, nunca reemplazado).
2. El "logo" era un cuadrado con la letra "L" — el placeholder más genérico posible, repetido en sidebar, header mobile y login sin ningún glifo propio detrás.
3. `/admin` para no-admins devolvía texto plano sin estilo (`new Response('Acceso denegado', {status:403})`), rompiendo el sistema de diseño.
4. No existía página 404 propia (caía al default de Astro).
5. Íconos "fake": los botones de cerrar modal usaban el carácter `×`, el estado vacío de solicitudes usaba ``, el chevron de historial usaba `` — inconsistente con el resto del sistema que sí dibuja íconos SVG reales (heroicons-outline).
6. Código muerto/inconsistente: `EliminarMaterial` y `RechazarDialog` cambiaban color en hover vía `onMouseEnter/onMouseLeave` en JS (con una custom property `--h` sin usar) en vez de CSS, y el dropdown de `Autocomplete` usaba `shadow-lg` de Tailwind en vez de respetar la regla "sin sombra fuera de dock/toast" del sistema.
- Fix aplicado: nuevo glifo de marca propio (`src/components/BrandMark.astro`) — una ficha/etiqueta con perforación, evoca el ticket de préstamo físico — reemplaza la "L" en sidebar/header/login y es ahora también el favicon (`public/favicon.svg` + `.ico` regenerado). Es un mark de producto, no un intento de escudo institucional UABC (ese sigue pendiente y sin fabricar, por instrucción explícita). Se unificó el color del chip del mark (antes dorado en nav vs. verde en login) a wash blanco/verde consistente. Se agregaron `src/pages/403.astro` y `src/pages/404.astro` con el mismo lenguaje visual del login; el middleware ahora usa `context.rewrite('/403')` en vez de devolver texto plano. Los `×`/``/`` se reemplazaron por SVG inline consistentes con el stroke-style existente. Se limpió el hover JS→CSS y el `shadow-lg` del autocomplete.
- Documentación actualizada: `PRODUCT.md` (Brand Commitments ya no dice "100% tipográfica sin logo"; aclara que el BrandMark es mark de producto, no escudo oficial) y `DESIGN.md` (nueva subsección "Brand Mark" en Components, nueva regla Do/Don't: "todo ícono es SVG dibujado, nunca un carácter unicode").
- Verificación: `npm run build` limpio, detector mecánico de impeccable (`detect.mjs`) sin hallazgos sobre los 15 archivos tocados, screenshots antes/después confirmando visualmente cada fix (incluyendo hover de "Eliminar" y el toggle del historial).
- No se tocó IA de información (paneles Panel vs. Estadísticas se solapan en KPIs pero no se reestructuraron — fuera de alcance de "quitar slop"), ni se generó contenido de relleno para las páginas con poco dato real (1 material de seed) — eso es estado temprano de datos, no slop de diseño.
- Estado: cambios visuales completos y verificados. Servidor de dev quedó corriendo en background (`astro dev status`/`astro dev logs`/`astro dev stop` para gestionarlo). Pendiente: deploy a producción cuando el usuario lo confirme.
- **2026-08-16 — Deploy del rediseño impeccable a producción**. Confirmado por el usuario. `git push` + `ssh buglabs 'cd ~/labre-web && git pull && docker compose up -d --build'`. Container recreado (healthy en ~8s), sin downtime perceptible. Smoke test: `/login` → 200, `/` → 302 (redirige por middleware sin sesión). Cambios ya vivos en https://prestamos.buglabs.dev.
- **2026-08-16 — Reemplazo total de identidad visual: MotherDuck (`/impeccable`)**. El usuario pegó el `DESIGN.md` completo de MotherDuck ("crayon-coded terminal on cream paper", neobrutalista) y pidió que este proyecto adoptara ese estilo. Se trató como redesign de mundo visual completo (no refinamiento) siguiendo `reference/new-work.md` del skill impeccable: el brief venía 100% fijado (colores, tipografía, sombra, radios, componentes documentados), así que se saltó el proceso de generación de conceptos/dice-roll ("el brief gana" — instrucción explícita del skill cuando hay un mundo visual ya pinneado) y se fue directo a comprometer el mundo, construir, e inspeccionar. Sin generación de imágenes disponible en este entorno → build "code-led" (sin comps).
- Contrato de dirección grabado como comentario HTML al inicio de `<body>` en `Layout.astro` (THESIS/OWN-WORLD/STORY/FIRST VIEWPORT/FORM/FINISH), verificado que sobrevive en el build de producción (`dist/`).
- **Reasignación semántica de la paleta** (la traducción real del trabajo — MotherDuck es un sitio de marketing sin estados de status; este es un panel operativo que sí los necesita):
- Verde institucional → **Sky Crayon `#6fc2ff`**, único color de acción rellena (botones primarios, nav activo).
- Dorado institucional → **Duck Bill Orange `#ff9538`**, acento disperso (antes reservado a "ilustración/marquee" en el brief original, reutilizado aquí porque la app no tiene ninguna de las dos cosas).
- Rojo de error → **Coral Sketch**, mismo problema que el sistema anterior con el verde: el fill claro (`#f38e84`) falla contraste AA como texto, así que se separó en `--color-danger` (fill/borde) y `--color-danger-text` (`#a83224`, oscurecido) — fix aplicado con sed dirigido en los 14 archivos que usaban `color: var(--color-danger)` como texto, verificado que no tocó ningún `border-color`/`background` de paso.
- **Mint Sketch `#38c1b0`** se sacó del set decorativo "rainbow" del brief original y se fijó como color de status "positivo" (disponible/aprobado/devuelto) — el brief de MotherDuck nunca necesitó esto porque no tiene estados de negocio.
- **Regla del texto siempre carbón**: ningún fill o borde de color lleva texto blanco o del mismo color — siempre `--color-ink` (`#383838`). Se auditó y corrigió cada instancia de `color: white`/`text-white` en botones de eliminar/rechazar y el chip activo de filtro de categorías.
- **Radio 2px en todo el sistema**: override de `--radius-lg/2xl/3xl` a `2px` en el `@theme` de Tailwind (cascada automática a casi todo el árbol) + `sed` global de `rounded-full` → `rounded-[2px]` en 8 archivos (dock móvil, toaster, badges de estado). Cero píldoras/círculos sobrevivieron.
- **Sombra dura única** `-6px 6px 0 0 var(--color-ink)` con interacción "press" (`translate(-6px,6px)` en hover, vuelve a su posición en active a mitad de camino) aplicada vía clases compartidas nuevas en `global.css` (`.btn`, `.card-raised`, `.card-link`) — cero cambios por archivo necesarios en la mayoría de botones/links porque ya usaban esas clases compartidas. Los 9 `<dialog>` nativos (modales) sí se tocaron uno por uno vía `sed` para añadir borde + sombra ya que no usaban una clase compartida.
- **Reskin manual** (no cubierto por cascada de tokens): `AppLayout.astro` (sidebar pasó de panel verde sólido a panel blanco con borde carbón; dock móvil pasó de píldora flotante con blur a barra inferior plana sin sombra, fiel a la regla del brief "nav buttons are flat"), `Toaster.tsx` (quitado `backdrop-blur-xl`/`bg-white/85`/sombra ambiental → borde + sombra dura), `login.astro`/`403.astro`/`404.astro` (headline uppercase, mismo tratamiento de card elevada), favicon (recoloreado carbón-sobre-crema, regenerado `.ico` vía chromium headless + imagemagick).
- **Nuevo `BrandMark`**: mismo glifo de ficha/etiqueta de la sesión anterior, sin cambios de forma — solo de color (antes verde/dorado institucional, ahora carbón-sobre-azul-cielo o wash blanco translúcido sobre panel blanco).
- Verificación: `npm run build` limpio; detector mecánico (`detect.mjs`) sin hallazgos sobre los 27 archivos tocados; ronda de screenshots batched (desktop 1440px + mobile 390px) sobre 11 páginas vía bypass temporal de middleware solo-DEV (mismo patrón de sesiones anteriores, revertido al terminar) — sin defectos materiales, no hizo falta una segunda ronda de fixes.
- **Revisión de cierre y documentación**: sin subagentes `impeccable-finish-reviewer`/`impeccable-documenter` instalados en este entorno, ambos pases corrieron in-thread siguiendo `reference/degraded/finish-reviewer.md` y `reference/degraded/documenter.md` — disclosure explícito de la sustitución. `DESIGN.md` reescrito completo desde el mundo construido (ya no describe "La Bitácora Digital" verde institucional). `PRODUCT.md` → Brand Commitments actualizado con nota de reemplazo fechada y los valores activos.
- **Deliberadamente NO tocado**: arquitectura de layout (sidebar desktop + dock mobile se mantienen, solo cambió su piel), copy/contenido, lógica de negocio, el escudo oficial UABC (sigue sin fabricarse, por instrucción explícita en `PRODUCT.md`).
- Estado: cambios completos y verificados localmente, **sin commitear ni desplegar** — el usuario ya tiene un deploy previo (UABC verde) en producción; este reemplazo de identidad es mucho más grande y espera confirmación explícita antes de `git push` + deploy.
- **2026-08-17 — Corrección: colores de identidad UABC restaurados dentro del sistema MotherDuck**. El usuario señaló correctamente que el rediseño anterior había sustituido también los colores de marca (verde/dorado UABC) por la paleta literal del brief de MotherDuck (azul cielo/naranja) — un exceso: adoptar un *lenguaje* de diseño (sombra dura, radio 2px, tipografía mono, canvas crema) no implica adoptar los *colores de marca* de otra empresa.
- Fix: `--color-primary` volvió a `#00723F` (verde institucional), `--color-secondary` a `#DD971A` (dorado UABC), en `global.css`. Todo lo demás del sistema neobrutalista (sombra `-6px 6px 0 0`, radio 2px, JetBrains Mono, canvas crema `#f4efea`) se mantuvo intacto — la corrección fue puntual, no una reversión del trabajo de la sesión anterior.
- **Problema de contraste no trivial que surgió del cambio**: verde institucional (`#00723F`) es mucho más oscuro que el azul cielo del brief original (`#6fc2ff`). La regla que se había fijado ("texto siempre carbón, nunca blanco, sobre cualquier relleno de color") dejó de sostenerse — carbón sobre verde oscuro da ~2:1 de contraste (falla AA). Se verificó por cálculo de luminancia relativa que verde necesita texto blanco (~6.2:1) y dorado necesita texto carbón (~4.9:1; blanco sobre dorado falla en ~2.5:1) — son casos opuestos, no intercambiables.
- Se corrigieron 8 puntos que asumían texto carbón sobre fondo primario sólido: `.btn-primary` en `global.css`, el chip del `BrandMark` en sidebar/header móvil/login/404, el ítem de nav activo (sidebar y dock móvil), el chip activo de `FiltroCategorias`, el badge de conteo en `admin/solicitudes/index.astro`, y el ícono "G" del botón de Google en login — todos a texto/ícono blanco. `.btn-secondary` volvió a ser un relleno dorado sólido (como el sistema UABC original) en vez del botón "outline blanco" que había impuesto la regla literal de MotherDuck ("Sky Crayon es el único color de relleno") — esa regla ya no aplica: dorado es un segundo color de marca real de UABC, no un antojo decorativo.
- `DESIGN.md` reescrito: la "Charcoal Text Rule" se reemplazó por la "Text-on-Fill Rule" (el color de texto se decide por contraste real contra cada relleno, no por hábito); toda la prosa que nombraba "Sky Crayon"/"Duck Bill Orange" se corrigió a "Verde Institucional"/"Dorado UABC". `.impeccable/design.json` actualizado en paralelo (colorMeta, CSS de los componentes de ejemplo, narrative). `PRODUCT.md` → Brand Commitments corregido con nota fechada explicando el error y la corrección.
- Verificación: `npm run build` limpio, detector mecánico sin hallazgos, ronda de screenshots (login, panel admin, catálogo, solicitudes, reportes) confirmando visualmente verde/dorado UABC correctos con buen contraste en cada superficie. Middleware de preview revertido de nuevo tras las capturas.
- Aprendizaje para futuras adopciones de brief externo: "usa el estilo de X" nunca implica "usa los colores de marca de X" a menos que se diga explícitamente — la mecánica (sombra, radio, tipografía, densidad) y la identidad de color son ejes independientes, y hay que preguntar o inferir con cuidado cuál se está pidiendo.
- **2026-08-17 — Rediseño de fondo: vale de préstamo multi-ítem (fiel al formulario de papel)**. El usuario mostró la hoja física "Vale de Préstamo" del LSC (UABC) — permite pedir varios materiales en un solo trámite (Laptop, Proyector, Impresora, Switch, Mouse, Teclado, Extensión, Adaptador, Regleta, Bocinas, Herramienta, Otros ×3), cada renglón con cantidad y descripción, más Nombre/Matrícula/Semestre del alumno, Maestro Responsable y Fecha/Hora. El sistema modelaba "1 solicitud = 1 material" — no coincidía. Se rediseñó a "1 vale = N renglones", con decisiones acordadas con el usuario: (1) multi-ítem fiel al papel; (2) el catálogo de materiales/categorías **no se toca** (se mantiene individual con número de inventario y stock, tal como estaba); (3) `semestre` es dato del perfil, se llena una sola vez — pero la UI de onboarding para llenarlo queda **explícitamente fuera de esta ronda** (backlog futuro); (4) `maestro_responsable` es dato por vale, se captura en cada solicitud.
- **Migración `supabase/migrations/0002_solicitudes_multi_item.sql`** aplicada en producción (sin downtime de BD — todo `ALTER`/rename de metadatos + backfill de 5 filas reales): `prestamos.prestamos` → `RENAME TO solicitudes` (preserva ids/FKs/policies existentes intactos); nueva tabla `solicitud_items(solicitud_id, material_id, cantidad, descripcion)` poblada por backfill 1:1 desde las filas legacy; `maestro_responsable` agregado a `solicitudes` (backfill legacy con placeholder, luego `not null`); `semestre` agregado a `profiles` (nullable); `audit_log.prestamo_id` renombrado a `solicitud_id`. Los triggers `sync_stock()`/`log_estado()` se reescribieron para iterar `solicitud_items` de la solicitud afectada en vez de leer `material_id`/`cantidad` de una sola fila. Nueva función RPC `prestamos.crear_solicitud(maestro_responsable, notas, items jsonb)` — `security definer`, transaccional, lockea (`for update`) las filas de `materiales` involucradas en orden `material_id asc` (evita deadlocks), valida cada renglón (material existe, disponible, stock suficiente) y de paso arregla la race condition que tenía el endpoint viejo (select + insert sin lock). El alumno ya no puede insertar directo en `solicitudes` vía supabase-js — se eliminó esa policy RLS; todo pasa por el RPC.
- **Recomendación de dry-run no se pudo seguir literalmente**: el archivo de migración trae su propio `begin;`/`commit;` (mismo patrón que `0001_init.sql`), así que correrlo por `psql` lo aplicó y comprometió de una sola pasada sin ventana para `ROLLBACK` manual — no hubo error, así que no fue necesario, pero es un aprendizaje para la próxima migración: si se quiere un dry-run real hay que envolver el `psql -f archivo.sql` en un `BEGIN`/`ROLLBACK` externo, o quitarle el `begin;`/`commit;` propio al archivo antes de probarlo.
- **Sin ambiente de staging** (dev local y producción comparten el mismo Postgres de buglabs) — se le preguntó al usuario cómo manejar la ventana de riesgo antes de tocar la BD; eligió "migrar ya y trabajar sin pausas hasta el deploy completo" en vez de escribir todo el código a ciegas primero. Aplicó bien: la migración no rompió nada porque el trabajo de código se hizo inmediatamente después, en un solo tramo.
- **4 tracks de código** (3 en paralelo vía Agent tool tras la migración + RPC, 1 trivial hecho directo sin agente por ser 3 líneas):
- **Track A (alumno)** — `src/components/alumno/{SolicitudCart,AgregarMaterial}.tsx` (nuevos, reemplazan `SolicitarModal.tsx`, borrado), `catalogo.astro`, `mis-prestamos.astro`. Decisión de diseño no trivial: Astro hidrata cada `client:*` como raíz de React independiente, así que un `CartProvider` y N `AgregarMaterial` como islands separados NO comparten Context. Solución: `CartProvider` recibe el array completo de `materiales` como prop y renderiza él mismo todo el grid de cards dentro de un único árbol React (`client:load`), con `AgregarMaterial` como hijo normal (no island) leyendo `useCart()`. Carrito con botón flotante "Ver solicitud (N)" + `<dialog>` de checkout (maestro responsable, motivo opcional, renglones editables con cantidad/descripción/quitar) → `POST /api/solicitudes`.
- **Track B (admin solicitudes)** — 11 archivos: 3 vistas de listado, `AccionesSolicitud`/`MarcarDevuelto`/`VerDetalles`, los 4 endpoints de acción, `admin/index.astro`. La columna "Cantidad" separada de las tablas se eliminó en las 3 vistas — la cantidad ahora es por renglón y se muestra apilada dentro de la celda "Material". Se movió `src/pages/api/admin/prestamos/[id]/` → `src/pages/api/admin/solicitudes/[id]/` (`git mv`, coherencia de nombres). De paso se arregló un bug preexistente en `detalles.ts`: el fetch de `audit_log` filtraba por `prestamo_id` (ahora `solicitud_id`) y ordenaba por `created_at` (la columna real siempre fue `at`) — el historial de cambios en "Ver detalles" nunca había funcionado en producción por ese typo.
- **Track C (reportes)** — `reportes.astro` + `export.ts`. El filtro `material_id` pasó a usar `solicitud_items!inner(...)` + `.eq('items.material_id', id)` (trade-off consciente: con el filtro activo, el array de `items` embebido queda acotado solo al renglón que matchea, evita una segunda query). El CSV cambió de "una fila por vale" a "**una fila por renglón**" (estándar para exports de línea de pedido, filtrable/pivoteable en Excel) — se agregaron columnas `Maestro responsable` y `Descripción`.
- **Track D (trivial)** — `admin/inventario/index.astro`: 3 queries de KPIs del día renombradas de `.from('prestamos')` a `.from('solicitudes')`. Hecho directo, sin agente (ponytail: no vale la pena un agente completo para 3 líneas).
- **Verificación**: `npm run build` limpio en cada track y en el árbol final combinado. Lógica de negocio crítica (RPC + triggers de stock + audit_log + RLS) probada **dentro de una transacción `BEGIN`/`ROLLBACK` directo en `psql`** simulando `auth.uid()` vía `request.jwt.claim.sub` (como lo hace PostgREST) — creó una solicitud real con 2 materiales distintos, confirmó que aprobar descuenta stock de ambos, que devolver lo repone, que el audit log registra ambas transiciones con `solicitud_id` correcto — y al hacer `ROLLBACK` no quedó ningún residuo en producción (verificado con conteo antes/después). Las 9 páginas SSR tocadas (catálogo, mis-préstamos, 3 vistas de solicitudes, panel admin, reportes ×2, inventario) se probaron con `curl` contra el dev server reiniciado, usando un bypass temporal `?preview=alumno|admin` en el middleware (mismo patrón ya usado en sesiones anteriores, revertido inmediatamente después — diff de `middleware.ts` quedó limpio) — las 9 devolvieron 200 sin errores en los logs del dev server, incluyendo el filtro `material_id` con el join `!inner` y el CSV export (confirmó que la data legacy migrada aparece correctamente con el placeholder de `maestro_responsable`).
- **Backlog futuro explícito, no incluido en esta ronda**: pantalla de onboarding/completar perfil post-login que pida `semestre` (y posibles otros datos) una sola vez a usuarios nuevos. La columna ya existe en `profiles`, solo falta el endpoint `PATCH` (la policy `profiles_update_self` de `0001_init.sql` ya lo permite) y la UI.
- **Edge case menor, no bloqueante, anotado para si se vuelve a tocar este código**: la RPC `crear_solicitud` no dedupe `material_id` repetidos dentro del mismo array de `items` — si dos renglones apuntan al mismo material con cantidades que combinadas exceden el stock, cada uno se valida contra el disponible total de forma independiente (no acumulativa) al momento de crear la solicitud. En la práctica no ocurre porque el carrito del alumno (`SolicitudCart.tsx`) dedupea por `material_id` incrementando cantidad en vez de crear renglones duplicados; y aunque ocurriera, el trigger `sync_stock` al aprobar sí acumula correctamente sobre la fila real de `materiales`, así que el peor caso es que la aprobación falle por el `check (cantidad_disponible >= 0)` en vez de fallar silenciosamente.
- **Deploy completado**: 2 commits separados (uno para el fix de contraste pendiente de la sesión anterior, otro para el rediseño multi-ítem — se separaron porque venían mezclados en el working tree). Push a `git.buglabs.dev/LakG/labre-web` con token efímero de Gitea (mismo patrón que Fase 8: nunca vivió en el `remote` local, queda revocable a mano desde Gitea → Settings → Applications ya que la CLI de Gitea no tiene comando de revocación). `ssh buglabs 'cd ~/labre-web && git pull && docker compose up -d --build'` → contenedor recreado, `healthy` en el healthcheck. Smoke test público: `/login` → 200, `/` sin sesión → 302, `POST /api/solicitudes` sin sesión → 302 (el middleware protege `/api/*` antes de llegar al handler, igual que el endpoint viejo — no es un 401 porque nunca llega a esa lógica). **Sistema en producción con el modelo de vale multi-ítem.**
- **2026-08-22 — 5 features grandes en un pase: buscador, grid+fotos, unidades individuales, estadísticas separadas, realtime pendientes**. El usuario listó 5 pedidos operativos y pidió arrancar con un plan revisable. Se hizo Fase 1 (exploración) con 2 subagentes Explore en paralelo (frontend + backend), Fase 2 sin Plan agent (contexto suficiente después de la exploración), 4 preguntas cerradas al usuario sobre decisiones que cambiaban el diseño (alcance de unidades, charts, notificaciones, storage), plan escrito y aprobado, luego ejecución en 4 tracks (3 paralelos + 1 secuencial).
- **Decisiones tomadas con el usuario antes de escribir código**: (1) unidades = **flag por material** (`trackeado_por_unidad`), no todos los materiales — evita disruptivo; (2) charts = **recharts** (nueva dep, ~90KB) en vez de SVG a mano — se pidió la librería estándar; (3) notificaciones = badge en nav + Supabase Realtime, **sin browser API ni PWA**; (4) storage = bucket público simple (fotos de material no son sensibles). El usuario pidió expresamente verificar Realtime en la instancia antes de comprometer el plan — se confirmó que `realtime-dev.supabase-realtime` estaba `healthy`, Kong ruteaba `/realtime/v1/*`, y la publication `supabase_realtime` existía pero **vacía** (0 tablas) — necesitaba `alter publication ... add table`.
- **Migración `0003_grid_unidades_realtime.sql`** aplicada limpia en un solo `psql -f` (con `BEGIN`/`COMMIT` propios del archivo, mismo patrón que 0001/0002; sin dry-run porque no hubo error): `materiales.imagen_path`, `materiales.trackeado_por_unidad boolean`, nueva tabla `material_unidades(id, material_id, etiqueta, estado, notas)` con trigger `sync_material_desde_unidad` que mantiene `materiales.cantidad_total`/`cantidad_disponible` sincronizados automáticamente en INSERT/UPDATE/DELETE de unidades, `solicitud_items.material_unidad_id` (nullable, solo para trackeados), RPC `crear_solicitud` reescrita para asignar la primera unidad `disponible` (`FOR UPDATE SKIP LOCKED`) y marcarla `prestado` **al momento de crear la solicitud** (no al aprobar) para blindar contra carreras entre 2 alumnos que piden simultáneo — trade-off: obligó a extender `sync_stock` con una rama para liberar unidades en transición `pendiente → rechazado` (que antes no disparaba nada). Nueva RPC `reasignar_unidad(item_id, nueva_unidad_id)` admin-only con `is_admin()` guard y `audit_log payload jsonb`. `publication supabase_realtime += prestamos.solicitudes` con `DO $$ ... IF NOT EXISTS ... END $$` (idempotente). En la misma migración se creó el **bucket `materiales-fotos` público** vía `insert into storage.buckets` + policies `for select to anon/authenticated using bucket_id=...` y `for all to authenticated using is_admin()` — todo idempotente con `on conflict do update` y `drop policy if exists` primero.
- **4 tracks paralelizados** con contratos aislados por carpeta (sin colisión de archivos entre agentes), 3 en el mismo turno + 1 secuencial (D tocaba `AppLayout.astro` que C ya había editado):
- **A · Buscador catálogo alumno** (`BuscadorCatalogo.tsx` nuevo + `FiltroCategorias.tsx` + `SolicitudCart.tsx` + `catalogo.astro`): filtro client-side por texto (debounce 120ms, normaliza tildes con NFD, sincroniza `?q=` con `history.replaceState`), combinable AND con filtro de categorías. Decisión no obvia: como Astro hidrata cada `client:*` como raíz React independiente, no hay Context compartido entre 2 islands hermanas — se resolvió con un helper `window.__labreFiltrar` que ambas islands invocan (marcado `// ponytail: coordinar via window para evitar Context entre 2 islands hermanas`). El filtro por categoría dejó de setear `hidden` directo y ahora setea `data-hidden-by-cat`; el helper unificado combina ambos flags.
- **B · Grid + imágenes + unidades** (13 archivos): `admin/inventario/index.astro` reescrito como grid `grid-cols-2 sm:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5` con foto `aspect-square object-cover` + placeholder SVG mono-color cuando `imagen_path` es null; `MaterialForm.tsx` con `<input type="file">` + preview (`URL.createObjectURL` + cleanup con `revokeObjectURL`) + checkbox "Rastrear por unidad individual"; nuevo `UnidadesManager.tsx` embebido dentro del mismo `<dialog>` de edición (los `<dialog>` nativos no anidan bien) — solo aparece en `mode=edit` con `trackeado_por_unidad=true` ya persistido, un material recién marcado como trackeado debe guardar primero para poder agregar unidades. Nuevo helper `src/lib/materialImg.ts` con `imgUrl(path)` dual `import.meta.env`/`process.env` (mismo patrón que `src/lib/supabase.ts`). Endpoints CRUD de unidades (`POST/GET/PATCH/DELETE`) con 409 traducidos para: etiqueta duplicada, eliminar unidad prestada, cambiar `trackeado_por_unidad` con datos previos. Reasignar unidad desde `VerDetalles.tsx` con dropdown de unidades `disponible` del mismo material → `POST /api/admin/solicitudes/[id]/reasignar-unidad` → RPC. Upload flow en create: POST material → id → upload al bucket path `${id}/${Date.now()}.${ext}` → PATCH `imagen_path` (3 requests aceptables; alternativa de path temporal + rename no vale la lógica extra). Si upload falla, toast rojo pero material queda creado (no bloquea).
- **C · Estadísticas separada + charts recharts + nav** (4 archivos): `/admin/estadisticas` nueva ruta con los 6 KPIs (movidos de `/admin/inventario`) + 3 gráficas recharts — barras horizontales top-10 materiales últimos 30 días (agrupado en memoria, no en SQL), línea solicitudes/día (relleno con 0 en días sin data para no dejar huecos), donut distribución de estados. `Chart.tsx` wrapper único para los 3 tipos con paleta UABC (`#00723F` primary, `#DD971A` secondary, `#a83224` danger), tooltip con borde 2px carbón + JetBrains Mono, respeta `prefers-reduced-motion` con `animationDuration=0`. Nav admin actualizado: `Panel · Solicitudes · Inventario (box) · Estadísticas (stats) · Reportes` — el icono `box` ya existía en `ICONS` pero no se usaba (candidato natural detectado en exploración). `admin/index.astro` migrado a `todayMX()` (fix de inconsistencia con `/admin/inventario` detectada en Fase 1).
- **D · Badge + realtime** (2 archivos, secuencial tras C): `AppLayout.astro` con SSR count de pendientes cuando `rol='admin'`, badge sobre ícono "Solicitudes" en sidebar (`-top-2 -right-3`) y dock móvil (`-top-1 -right-1`) — chip mono bold 11px, borde 2px carbón, fondo `--color-danger`, texto carbón (no blanco: coral claro `#f38e84` + blanco falla AA con ~2.4:1; carbón pasa ~4.3:1 — la "Text-on-Fill Rule" ya documentada en `DESIGN.md`). Nuevo `BadgeSolicitudes.tsx` `client:idle`, no renderiza nada, monta canal Realtime `solicitudes-pendientes` filtro `estado=eq.pendiente` — en INSERT hace `+1` + toast, en UPDATE **refetch del count** (no delta) porque `replica identity default` de la tabla solo envía la PK en `payload.old` — el estado anterior no llega; marcado `// ponytail: refetch en UPDATE porque payload.old no trae estado en replica identity default; upgrade a delta cuando la tabla tenga replica identity full`.
- **Verificación combinada**: `npm run build` limpio en cada agente y en el árbol final (947ms). Sin conflictos de merge entre los 4 tracks (los contratos aislados por carpeta aguantaron). Smoke test público post-deploy: `/login` → 200, `/` sin sesión → 302, `/admin/estadisticas` sin sesión → 302 (middleware protegiendo la ruta nueva), bucket `materiales-fotos` responde 400 al pedir archivo inexistente (confirma que el bucket existe y responde — si no existiera daría 404 de otro shape). No se hizo QA con sesión real desde el navegador esta ronda — el usuario pidió deploy directo confiando en el build combinado + los checks individuales de cada agente.
- **Aprendizajes registrados**: (a) para carreras de asignación de unidad entre alumnos concurrentes, `FOR UPDATE SKIP LOCKED` en el `SELECT` aguanta hasta el commit — para blindar entre transacciones distintas hay que **cambiar el estado al momento de crear** (no al aprobar), y eso obliga a extender los triggers para las transiciones que antes no importaban (`pendiente → rechazado` para liberar la reserva); (b) Supabase Realtime en self-hosted con `replica identity default` solo emite la PK en `payload.old`, así que cualquier lógica que dependa del estado anterior en UPDATE necesita refetch — o cambiar la tabla a `replica identity full` (más ancho de banda, pero payload completo); (c) contratos de agentes aislados por carpeta escalan bien cuando cada uno reescribe un archivo entero — el conflicto real es cuando 2 agentes editan el mismo archivo con `Edit` string-match, ahí sí hay que serializar; (d) los `<dialog>` HTML nativos no anidan (abrir uno dentro de otro rompe el foco) — cuando se necesita "submodal", mejor embebido en el mismo dialog o pasar a `<div>` posicionado; (e) `CLAUDE.md` en este repo es symlink a `AGENTS.md` — editar el path real (aprendido cuando `Edit` rechazó el symlink).
- **Deliberadamente NO tocado**: (i) cards del alumno en el catálogo no tienen foto todavía — el select SSR ya trae `imagen_path` (agregado por el track B para no romper el shape), pero pintar la miniatura en la card del alumno es un paso siguiente si el usuario lo pide; (ii) onboarding para `semestre` sigue pendiente (mismo backlog que 2026-08-17); (iii) `browser-image-compression` no se instaló — si las fotos que suba el admin pesan mucho, se agrega después.
- **Deploy completado**: 2 commits separados (migración + código, mismo patrón que 0002). Push a `git.buglabs.dev/LakG/labre-web` con token efímero de Gitea (mismo patrón que Fase 8/0002). `ssh buglabs 'cd ~/labre-web && git pull && docker compose up -d --build'` → contenedor recreado, `healthy` en ~26s. **Sistema en producción con las 5 features vivas en https://prestamos.buglabs.dev.**
- **2026-08-24 — Perfil de usuario + onboarding + home reformulado + fix del bug de upload + 5 bugs mobile**. Iteración de UX y bugs sobre la app ya viva en prod. Se hizo Fase 1 con 3 Explore agents (bug upload, home/onboarding, bugs mobile), 4 preguntas cerradas al usuario sobre decisiones grandes (maestro en clase, tabla maestros, foto default, onboarding bloqueante), plan escrito y aprobado, ejecución en 4 tracks paralelos + 1 migración BD.
- **Decisiones tomadas con el usuario**: (1) `maestro_responsable` = **`<textarea>` que el alumno escribe a mano si está en clase**; placeholder muestra el nombre del tutor; si queda vacío, la RPC autocompleta con el tutor guardado — cero infra de horarios, cero fricción; (2) **tabla admin-managed `maestros`** simple (solo nombre + activo), CRUD en nueva subpágina bajo `/admin/maestros`; (3) foto default = **`avatar_url` del OAuth de Google** (viene en `user_metadata`), fallback a iniciales sobre color HSL derivado del email; (4) onboarding = **todo opcional** pero el checkout bloquea con CTA "Completa tu perfil" si faltan matrícula o tutor.
- **Migración `0004_perfil_maestros_avatares.sql`** aplicada limpia en un solo `psql -f` (mismo patrón que 0001/0002/0003): tabla `prestamos.maestros(id, nombre unique, activo, created_at)` con RLS (todos leen, solo admin escribe) + seed `('Sin especificar')`; `prestamos.profiles.tutor_id int` (FK → maestros on delete set null) y `prestamos.profiles.foto_path text`; bucket público `avatares` con policies `for select to anon/authenticated` y `for all to authenticated using (bucket_id='avatares' and (storage.foldername(name))[1] = auth.uid()::text)` — el prefijo de carpeta es el UUID del usuario, así RLS deja escribir solo en tu propia carpeta. RPC `crear_solicitud` reescrita con fallback: si `p_maestro_responsable` viene null/vacío, hace `select nombre from maestros where id = (select tutor_id from profiles where id = auth.uid())`; si tampoco hay tutor, sigue el raise `maestro_responsable_requerido` que ahora cae al bloqueo del checkout.
- **4 tracks paralelizados** — 2 completos limpios (F, C), 2 cortados por límite de sesión pero con la mayoría del trabajo escrito antes de morir (H y P). El orquestador completó los archivos que faltaban directamente en el hilo principal.
- **F · Fixes** (8 archivos, completo): (i) Bug upload — `Dockerfile` con `ARG PUBLIC_SUPABASE_URL/ANON_KEY/APP_URL` + `ENV` al inicio del stage `build`, `docker-compose.yml` con `build.args: {PUBLIC_*: ${VAR}}` (`SUPABASE_SERVICE_ROLE_KEY` deliberadamente fuera — solo runtime), `.dockerignore` intacto (no exponer `.env.production` en la imagen), `src/lib/supabase.ts` con `browserClient()` unificado sobre las constantes que ya usan fallback `process.env ?? import.meta.env`. Verificado post-deploy con `grep "supabase\.buglabs\.dev" /app/dist/client/_astro/*.js` — la URL correcta aparece en `MaterialForm`, `PerfilForm`, y el chunk compartido de supabase; el bug está muerto. (ii) Grid inventario mobile — wrapper `flex gap-2` → `grid grid-cols-2 gap-2`; nuevo prop `compact` en `MaterialForm` y `EliminarMaterial` que renderiza el trigger con `w-full text-sm px-2 minHeight:36px`. (iii) `UnidadesManager` mobile — tabla reemplazada por cards `grid-cols-1 sm:grid-cols-[1fr_auto_auto]`, select estado con `minWidth:130px`, notas full-width abajo. (iv) `Chart` donut — con la skill `dataviz`: quitar labels internos (`label={false}`), agregar `<Legend layout="horizontal" verticalAlign="bottom">` con `formatter` "label (N)" — patrón canónico de la skill ("legend always present for ≥ 2 series"; "selective direct labels — never a number on every point"). (v) `Chart` barras — `YAxis width={100}` fijo + `tickFormatter` que trunca `> 14 chars`; se descartó `window.innerWidth` en render (SSR undefined + recharts no re-renderea `YAxis` en resize) y se descartó rotar labels a `-45°` (viola legibilidad); el tooltip mantiene el label completo, cero pérdida de información.
- **C · Checkout con tutor auto** (2 archivos, completo): `catalogo.astro` hace SSR directo del `profiles.matricula + tutor_id` y join a `maestros.nombre` (el middleware no expone tutor_id explícito — se consulta local); pasa `tutorNombre` y `perfilCompleto: !!(profile.matricula && profile.tutor_id)` como props al `CartProvider`. En `SolicitudCart`, el `<input type="text">` de maestro responsable es ahora `<textarea rows={2} maxLength={200}>` sin `required`, con `placeholder={tutorNombre ?? 'Escribe...'}` + hint verde "se usará: <tutor>" si hay tutor. Guard perfil incompleto en el footer del modal: si `!perfilCompleto`, reemplaza botones por card con CTA "Completa tu perfil" → `/perfil`; el `submit()` early-returns como defensa extra. El `toastAfterReload` post-envío detecta cuando el textarea quedó vacío y hubo tutor, y lo menciona en la descripción ("maestro: X (tu tutor)").
- **H · Home reformulado** (parcial → completado en hilo principal): agente alcanzó a escribir `src/pages/index.astro` (alumno con saludo, CTA "¿Qué vas a pedir hoy?", card de último préstamo activo con estado + preview + fecha humanizada, empty state amigable) y agregar `saludoHora()` + `fmtFechaRelativa()` a `src/lib/date.ts` (ambos con offset MX vía `Intl.DateTimeFormat`, respetan DST). Falló antes de reescribir `admin/index.astro`. El orquestador lo completó con: saludo por hora + 2 KPIs de alerta compactos (Pendientes verde si >0 / Vencidos rojo si >0) + sección "Requieren tu atención" con últimas 2 pendientes en cards grid 2-col + sección "Actividad reciente" con últimas 5 (**tabla desktop, cards mobile — arregla de paso el bug 6**). Los 2 KPIs restantes (En préstamo, Agotados) se movieron a `/admin/estadisticas` que ya los tiene.
- **P · Perfil + onboarding + maestros CRUD** (parcial → completado en hilo principal): agente alcanzó a escribir `src/pages/perfil.astro`, `src/pages/onboarding.astro`, `src/pages/api/profile.ts`, `src/components/profile/{Avatar,PerfilForm}.tsx`, `src/lib/avatar.ts` (con prioridad `foto_path` → `googleAvatarUrl` → iniciales sobre HSL hash del email), y a editar `middleware.ts`/`env.d.ts`/`AppLayout.astro`. Falló antes de crear el CRUD admin de maestros. El orquestador completó: `POST /api/admin/maestros`, `PATCH/DELETE /api/admin/maestros/[id]` (con 23503 → "N alumnos como tutor, reasígnalos"), `MaestroForm.tsx` (adaptado de `CategoriaForm` + checkbox `activo`), `EliminarMaestro.tsx` (adaptado de `EliminarCategoria`), `/admin/maestros` con tabla desktop/cards mobile + subnav Materiales · Categorías · Maestros, y edit de `categorias.astro` para agregar la tab Maestros al subnav existente.
- **Verificación combinada**: `npm run build` limpio (2.24s). Deploy: 2 commits (migración + código), push con token efímero, `ssh buglabs 'cd ~/labre-web && git pull && set -a && . .env.production && set +a && docker compose up -d --build'` — el `set -a; . .env.production; set +a` es nuevo: Docker Compose expande `${VAR}` en `build.args` desde el **shell** (no desde `env_file`), así que hay que exportar las vars del `.env.production` al environment antes del compose. Container `healthy` en 31s. Smoke test público: `/login` → 200; `/perfil`, `/admin/maestros`, `/onboarding` → 302 (middleware protegiendo); bundle client contiene `supabase.buglabs.dev` en los 3 chunks esperados (bug del upload confirmado muerto).
- **Aprendizajes registrados**: (a) los agentes fallando por límite de sesión son un modo de degradación normal cuando el trabajo por agente es grande — la mitigación es dividir mejor los tracks (agentes P y H fueron los más grandes, ambos cortaron); alternativa: preparar prompts para que cada agente escriba archivos incrementalmente y el orquestador pueda completar los huecos con menos contexto; (b) Docker Compose lee `.env` **implícitamente** para expansión de `${VAR}` en el YAML pero NO lee `env_file` para expansión — para vars en `build.args`, o bien renombras `.env.production` a `.env`, o exportas al shell antes del compose (elegimos lo segundo, menos disruptivo con la config existente); (c) `Storage RLS` con path por carpeta = uid es el patrón limpio para "cada quien sube la suya" — `(storage.foldername(name))[1] = auth.uid()::text` en el `using`/`with check`, no necesita RPC ni endpoint intermedio; (d) para RPCs con fallback lógico (como `crear_solicitud` autocompletando el tutor), poner la lógica en la BD y no en el endpoint es más robusto porque el mismo comportamiento aplica si algún día se llama la RPC desde otro cliente; (e) `CLAUDE.md` es symlink a `AGENTS.md` (aprendido en la sesión anterior, sigue vigente — editar el path real).
- **Deliberadamente NO tocado**: horarios académicos automáticos (fuera de alcance explícito — usuario eligió textarea manual); notificaciones al maestro cuando se aprueba un vale (backlog); comprimir avatares antes de subir (si pesan mucho se agrega después); PWA y browser notifications (backlog viejo).
- **Deploy completado**: **sistema en producción con perfil + onboarding + home reformulado + maestros CRUD + upload arreglado + 5 bugs mobile arreglados en https://prestamos.buglabs.dev**.
- **2026-08-25 — v1.6: rol docente + combobox maestros/tutores + sonido notif + fecha 1 día + wrap admin + fix self-heal profile**. Iteración operativa reportada por usuarios reales del laboratorio, más un bug crítico de login descubierto la misma sesión.
- **Bug crítico previo (self-heal)**: usuarios `@uabc.edu.mx` haciendo login vía OAuth entraban con `user` pero **sin fila en `prestamos.profiles`** — la app los trataba como "no logueado". Root cause: coexisten 2 triggers `AFTER INSERT` en `auth.users`: `on_auth_user_created` (del proyecto vecino que usa `public.profiles`) y `prestamos_on_auth_user_created` (nuestro). El del vecino se ejecuta primero por orden alfabético y por razón no diagnosticada el nuestro no dispara consistentemente en producción — verificado que la función `prestamos.handle_new_user()` funciona bien manualmente (insert vía backfill regresó 8 filas de 8 users). Fix aplicado: (a) migración 0005 con policy `profiles_insert_self` (with check `id = auth.uid() and rol = 'alumno'` — impide auto-promoción a admin); (b) middleware.ts con self-heal: si `user` existe y `!profile` y email `@uabc.edu.mx`, hace `upsert` al vuelo con nombre desde `user_metadata.full_name`; (c) backfill manual en prod para los 8 users existentes. Aprendizaje: cuando 2 apps comparten una instancia de Supabase, cualquier trigger `AFTER INSERT` en `auth.users` es riesgoso — mejor no depender del trigger y hacer self-heal server-side desde el middleware. En 0006 se amplió la policy a `rol in ('alumno','docente')`.
- **Decisiones acordadas con el usuario antes de tocar código**: (1) lista de maestros = admin la va agregando manualmente desde `/admin/maestros` (no seed inicial masivo — la lista suele ser estable); (2) separar tutores de maestros = **flag `es_tutor` en la misma tabla** — todos los maestros aparecen en el combobox del checkout; solo los marcados con `es_tutor=true` aparecen en el select de tutor del perfil (mismo CRUD, un checkbox extra); (3) docente = **`profiles.matricula` reusada con label dinámico** "Matrícula" para alumno / "Número de empleado" para docente (cero columna nueva, cero migración de datos); (4) errores sha512/CORS de `beacon.min.js` = **el usuario los desactiva en Cloudflare dashboard** (Analytics & Logs → Web Analytics → toggle off para el dominio), no requiere código.
- **Migración `0006_docente_maestros_v16.sql`** aplicada limpia (`BEGIN`/`COMMIT` propios, mismo patrón que 0001-0005): `profiles.rol` check ahora acepta `'alumno'|'docente'|'admin'`; `maestros.es_tutor boolean not null default false` con `update ... where nombre='Sin especificar'` para marcarlo como tutor fallback (no romper flows existentes); policy `profiles_insert_self` reescrita ahora permite `rol in ('alumno','docente')`; RPC `crear_solicitud` **cambio de firma** de `(text, text, jsonb)` a `(int, text, jsonb)` — recibe `p_maestro_id` en vez de texto libre, y la lógica es: si rol='docente' usa el propio `profiles.nombre` como `maestro_responsable`; si alumno con `p_maestro_id` valida y guarda ese nombre; si alumno sin `p_maestro_id` cae al `tutor_id` del perfil; si tampoco hay tutor, raise `maestro_responsable_requerido`. El drop de la firma vieja es explícito con `drop function if exists prestamos.crear_solicitud(text, text, jsonb)`.
- **Ejecución**: 1 agente Explore para background (falló por límite en la ronda anterior — esta vez no hizo falta explorar), 1 agente en paralelo para el track más grande (perfil + onboarding + `es_tutor` en MaestroForm/endpoints/maestros.astro), y el orquestador hizo el resto directo en el hilo principal (endpoint `/api/solicitudes` con `maestro_id`, `catalogo.astro` con SSR de maestros + `esDocente`, `SolicitudCart.tsx` con `<select>` combobox + guard docente + `maxLength=250` en notas, wrap `break-words` en 4 vistas admin, sonido en `BadgeSolicitudes.tsx`, mover audio a `public/audio/`, fecha default `enDias(1)` en `AccionesSolicitud.tsx`). El agente completó su track limpio, cero conflictos con lo que hice.
- **UX del combobox maestros**: el `<option value="">` inicial muestra "— Usar mi tutor ({tutorNombre}) —" (o "— Elige un maestro —" si no tiene tutor). El fallback backend sigue activo: si el alumno no elige nadie, la RPC usa el `tutor_id` del perfil. Si escoge otro, guarda ese nombre. Doble defensa contra "diferencia de escritura entre humanos" (la razón que dio el usuario para pedir el combobox).
- **UX del docente**: en el checkout, el bloque de maestro simplemente NO se renderiza (`{!esDocente && ...}`); el `perfilCompleto` es `!!matricula` (sin tutor). En perfil/onboarding, semestre y tutor no aparecen; label matrícula → "Número de empleado"; badge del header muestra "Docente". Registro de docente: el admin promueve manualmente vía SQL o desde Studio (`update prestamos.profiles set rol='docente' where email=...`), igual patrón que admin. No hay UI para auto-registrarse como docente — evita abuso.
- **Sonido de notificación**: archivo movido de `src/audio/` (Astro no sirve archivos de `src/` estáticamente) a `public/audio/sonido_notificacion.mp3` — accesible en `/audio/sonido_notificacion.mp3` (verificado 200 `audio/mpeg` post-deploy). `BadgeSolicitudes.tsx` en el handler INSERT: `new Audio('/audio/sonido_notificacion.mp3').play().catch(() => {})` — el catch cubre la política de autoplay (muchos navegadores bloquean sonido sin interacción previa; como el admin ya interactuó al hacer login, en la práctica funciona).
- **Fecha default 1 día**: cambio de una línea en `AccionesSolicitud.tsx:44` (`useState(enDias(7))` → `useState(enDias(1))`). El admin puede cambiarla si quiere; el default solo era muy generoso.
- **Fix del desbordamiento**: `maxLength={500}` → `250` en el textarea del motivo en `SolicitudCart.tsx`, mismo cap aplicado en el backend (`.slice(0, 250)` en `/api/solicitudes/index.ts` como defensa). Wrap con `break-words` (Tailwind = `overflow-wrap: break-word`) agregado en cada renglón donde aparece `Maestro:` o `Motivo:` en las 3 vistas admin (index/activos/historial, tanto tabla desktop como cards mobile) y en el `<dd>` de `VerDetalles.tsx`. El `notas` de `VerDetalles` ya tenía `whitespace-pre-wrap` — quedó intacto.
- **Verificación**: `npm run build` limpio (1.52s). Deploy: 2 commits (migración + código), push con token efímero, `ssh buglabs '... set -a && . .env.production && set +a && docker compose up -d --build'` (mismo patrón que 0004). Container healthy en 23s. Smoke test post-deploy: `/login` → 200; `/audio/sonido_notificacion.mp3` → 200 con `content-type: audio/mpeg` (audio sirve correctamente); BD verifica `profiles_rol_check` incluye docente, 3 maestros existentes con `es_tutor` booleano correcto.
- **Deploy completado**: **v1.6 en producción en https://prestamos.buglabs.dev**. El usuario se encarga de: (a) desactivar Cloudflare Web Analytics para el dominio (bug de sha512/CORS); (b) marcar como docente vía SQL a las cuentas que corresponda.
- **2026-08-27 — v1.7: overlay de carga, lista negra de cuentas, carrito persistente + fix definitivo del upload**. 3 features + 1 cambio UX + 2 bugs residuales. Fase 1 con Bash/grep + logs de Storage en prod (sin Explore agents — bug root cause obvio en los logs), Fase 2 sin Plan agent (contexto claro), 2 preguntas al usuario (modelo de baneos, estrategia fix upload), plan escrito y aprobado, ejecución con migración yo + 3 agentes paralelos.
- **Bug del upload — root cause definitivo (via logs storage de prod)**: `"role":"anon"` + `"error":"new row violates row-level security policy"` (código 42501). Causa real: `browserClient()` en `src/lib/supabase.ts` usa `createBrowserClient` de `@supabase/ssr` — en el browser NO puede leer las cookies de auth porque están seteadas con `httpOnly: true` (correcto por seguridad, JS del browser no las puede leer nunca). Al hacer `.storage.from(bucket).upload()`, la request sale con `Authorization: Bearer <anon_key>` (sin JWT del user), Storage la evalúa como `role: 'anon'`, y RLS rechaza. **Los fixes previos (Dockerfile ARG PUBLIC_*, guard typeof process en supabase.ts) NO tocaban este bug** — solo aseguraban que la URL del Supabase estuviera horneada en el bundle, pero el bundle nunca pudo autenticar contra Storage. Fix elegido: **endpoints proxy server-side** que reciben multipart, validan la sesión con la cookie httpOnly (server sí la lee via `serverClient(cookies)`), y suben con `serviceClient()` (bypass RLS). Cookies siguen httpOnly — cero riesgo XSS.
- **Decisiones tomadas con el usuario**: (1) baneos = **tabla separada** `prestamos.baneos(profile_id, razon, banned_at, banned_by, expires_at, unbanned_at, unbanned_by)` — historial completo + soporte para baneos temporales (aunque UI solo expone permanentes esta ronda); (2) fix upload = endpoints proxy; (3) overlay de carga literal (blur + spinner) según petición del usuario — no ClientRouter (evita side effects en islands y Realtime).
- **Migración `0007_baneos.sql`** aplicada limpia: nueva tabla `baneos` con partial unique index `where unbanned_at is null` (garantiza máximo 1 baneo activo por profile), RLS `baneos_admin_all` (admin CRUD) + `baneos_read_self` (user ve los suyos para /banned), helper SQL `prestamos.is_banned(uid) returns boolean` `security definer stable` que retorna true si existe baneo activo no expirado — invocable por el middleware vía `.rpc('is_banned', {p_uid})`.
- **3 agentes paralelos** con contratos aislados — **los 3 completos limpios en un pase** (a diferencia de v1.6 donde P y H cortaron por límite de sesión):
- **B · Baneos + panel + middleware guard** (9 archivos): `banned.astro` (pantalla completa patrón /403, muestra razón + fecha + expires + quién baneó + botón signout), `POST /api/admin/baneos` (guards admin + no-self-ban + razón 5-500 chars + expires_at futuro opcional, 23505 → 409), `POST /api/admin/baneos/[id]/desbanear` (update idempotente con `where unbanned_at is null`, 404 si ya inactivo), `BanearForm.tsx` (dialog con textarea razón + contador; deshabilitado con tooltip si adminSelf), `DesbanearButton.tsx` (window.confirm simple), `/admin/usuarios.astro` (2 queries paralelas profiles + baneos activos, cruce en memoria, tabla desktop + cards mobile), middleware con nuevo array `BANNED_ALLOWED = ['/banned', '/api/auth/signout']` para escapar del rewrite y evitar loop, RPC `is_banned` solo se llama si `profile && rol !== 'admin'` (evita costo en admin/no-login), nav admin `+ Usuarios`.
- **U · Uploads proxy** (4 archivos): `POST /api/upload/material-foto/[id].ts` (guard admin, valida mime `/^image\//` + `size <= 2MB`, path `${id}/${Date.now()}.${ext}`, sube con `serviceClient().storage.from('materiales-fotos').upload({upsert:true, contentType})`, actualiza `materiales.imagen_path` con locals.supabase), `POST /api/upload/avatar.ts` (guard user autenticado, path `${uid}/${Date.now()}.${ext}` al bucket `avatares`, actualiza `profiles.foto_path`), refactor `MaterialForm.tsx` `uploadFotoFor` para fetch multipart al endpoint (eliminó import de browserClient y del PATCH cliente del imagen_path — el endpoint ya lo persiste, evita doble escritura), refactor `PerfilForm.tsx` mismo patrón. Decisión no obvia: `uploadFotoFor` mantiene el patrón toast+return-null (no throw) del original — preserva la UX de "material creado sin foto" si el upload falla, en vez de bloquear la creación completa. Anotado en comentario: el "Quitar foto" en edit-mode nunca se propagaba a la BD (bug preexistente fuera de scope).
- **X · UX chico** (3 archivos): `Layout.astro` con `<div id="page-loader" hidden>` + estilos `position:fixed backdrop-filter:blur(8px)` + spinner mono animado (respeta prefers-reduced-motion) + script inline que captura clicks en `<a href>` y submits de `<form>` mismo origen (guardas: modifier keys, target=_blank, anchor#, javascript:/mailto:/tel:, cross-origin, defaultPrevented — este último es clave: los forms fetch-managed llaman preventDefault en su onSubmit React, así que el overlay no se dispara falsamente para ellos), `pageshow` limpia el overlay por bfcache. `SolicitudCart.tsx` con useEffect hidratar+persistir `labre:cart:v1` en localStorage + guard `cartHydrated` (evita que la primera pasada del effect pise el localStorage antes de leerlo) + botón Vaciar en footer del checkout con `window.confirm` (solo dentro del bloque perfilCompleto). `admin/inventario/index.astro` eliminado el `<script>` inline de auto-submit debounced que se había agregado en v1.5.
- **Verificación combinada**: `npm run build` limpio (2.74s en dev; 2.64s en el agente B). Sin conflictos de merge. Smoke test público post-deploy: `/login` → 200, `/banned` → 302 (protegido, redirige a login sin sesión), `/admin/usuarios` → 302 (protegido), `/api/upload/avatar` → 302 (middleware protegiendo API antes del handler). Container healthy en ~90s.
- **Aprendizajes registrados**: (a) los "fixes" del bug de upload en sesiones previas (Dockerfile ARG, guard typeof process) NUNCA fueron el fix real — solo eran precondiciones necesarias. El bug real requiere abandonar la idea de que el `browserClient` pueda hablar directo con Storage cuando las cookies son httpOnly. Diagnóstico definitivo llegó por leer logs de `supabase-storage` container donde el error 42501/anon estaba explícito; no era necesario reproducir en browser. (b) Contratos de agentes aislados por CARPETA (no por archivo) escalan mucho mejor — 3 agentes editaron 3 conjuntos disjuntos de rutas/componentes/endpoints sin overhead de coordinación. (c) El uso de `preventDefault` como señal semántica funciona bien: cualquier form que llama `preventDefault()` en su `onSubmit` React no dispara el overlay global, sin necesidad de opt-out explícito por form. (d) `partial unique index` `where unbanned_at is null` es el patrón limpio para "máximo un baneo activo por profile" — evita constraint compleja y da el error 23505 traducible.
- **Deliberadamente NO tocado**: (i) super admin (mencionado por usuario como consideración futura); (ii) baneo automático por rate limiting; (iii) UI para expires_at en baneos (columna existe, se puede exponer si se pide); (iv) compresión de imágenes cliente-side (si las fotos que sube el admin regularmente pasan de 2MB se agrega); (v) el bug preexistente de "Quitar foto" en MaterialForm que no propagaba null a la BD.
- **Deploy completado**: 2 commits (migración + código), push con token efímero de Gitea, `set -a && . .env.production && set +a && docker compose up -d --build` en buglabs (patrón del build args). Container healthy. **v1.7 en producción en https://prestamos.buglabs.dev**. El usuario puede empezar a: (a) crear/actualizar fotos de materiales y perfil (bug arreglado); (b) banear cuentas problemáticas desde `/admin/usuarios`.
- **2026-08-26 — Primera ronda de QA manual con agent-browser (post v1.6)**. Se siguió el proceso de `CLAUDE.md`/`AGENTS.md`: bypass temporal `?preview=alumno|admin|docente` en el middleware (solo `import.meta.env.DEV`), revertido al terminar (`git diff src/middleware.ts` queda limpio).
- **Bug crítico encontrado y arreglado — `process is not defined` rompía la hidratación de 3 formularios en producción**: `src/lib/supabase.ts` leía `process.env.X ?? import.meta.env.X` (orden fijado en la sesión del 24-ago para el bug de upload en Docker). En el browser `process` no existe como global — evaluar `process.env` revienta con `ReferenceError` **antes** de que el `??` pueda caer al fallback. Como el módulo se evalúa completo al importarse (aunque solo se use `browserClient`), esto tumbaba la hidratación de **`PerfilForm.tsx`** (usado en `/perfil` y `/onboarding`) y de **`MaterialForm.tsx`** (admin, alta/edición de material) — los tres quedaban sin JS: los botones "Guardar" hacían un submit nativo del `<form>` (sin `action`, sin querystring) que caía en `/login` en vez de llamar al endpoint. Confirmado con red real: antes del fix, `PATCH /api/profile` nunca se disparaba (submit nativo); con el fix, sí, y persiste correctamente. Fix: guard `typeof process !== 'undefined'` antes de leer `process.env` en las 3 constantes de `supabase.ts` — mantiene la prioridad process→import.meta.env para el server (necesaria por el fix de Docker de esa sesión) sin tocar `process` en el bundle de browser. `avatar.ts` y `materialImg.ts` ya tenían el orden inverso (`import.meta.env` primero) por eso nunca mostraron el bug. **Pendiente: este fix vive solo en el working tree, no se ha commiteado ni desplegado** — el bug sigue viivo en producción hasta que se despliegue.
- **Hallazgo operativo, no bug de código — no había ningún perfil `rol='admin'`** en la base de datos de producción al momento de probar (los 10 perfiles reales eran todos `alumno`/`docente`). Bloqueaba por completo `/admin/*`. Con confirmación del usuario, se promovió `amado.garcia.ramirez@uabc.edu.mx` a `admin` vía `PATCH` directo a PostgREST con `service_role`. No se investigó la causa de cómo se quedó sin admin (posble que nunca se re-promovió tras alguna migración, o que el admin real usaba otro correo ya no presente) — si vuelve a pasar, vale la pena revisar.
- **Incidente de infraestructura durante la sesión**: el túnel Cloudflare de buglabs cayó a mitad de las pruebas (error 1033, "tunnel not connected") — tumbó `supabase.buglabs.dev`, `prestamos.buglabs.dev` y el SSH a buglabs simultáneamente (los tres pasan por el mismo túnel). Se detectó por una request de ~14s seguida de fallos consistentes, confirmado con `curl` externo a los 3 hosts. El usuario lo resolvió revisando el servidor físicamente; no se necesitó ninguna acción de este lado. Aprendizaje: si `supabase.buglabs.dev` empieza a fallar con 530/1033 en medio de una sesión de dev, sospechar del túnel completo antes de asumir un bug de código — afecta a la vez prod, dev local (comparten DB) y el acceso SSH.
- **Error propio durante el QA — datos de prueba escritos sobre perfiles reales**: para probar el guardado de `/perfil` se necesitó una cookie "sticky" (`preview_rol`) además del querystring, porque los `fetch()` que dispara el propio formulario no llevan `?preview=`. La query de impersonación (`.eq('rol','alumno').limit(1)`, sin `order by`) no es determinística entre requests — dos escrituras de prueba consecutivas cayeron en **dos alumnos reales distintos** (Saul Guzman Garcia y Sergio Paolo Piñuelas Manzo), y un intento de "limpiar" con valores `null` cayó en un **tercer** alumno (Eduardo Avitia Castro) que no había sido tocado antes. Se revirtieron a `null` los dos perfiles que sí se escribieron con data de prueba (`QATEST0001`/sem 3/tutor 1); el tercero (Eduardo) ya estaba en `null` cuando se le escribió `null` encima, así que lo más probable es que no se perdiera nada real — coincide con el patrón de otro alumno real (Romell) que también tiene el perfil sin llenar. Aprendizaje para la próxima vez que se necesite probar una escritura contra un bypass basado en rol: **fijar el id exacto del perfil de prueba primero** (`select id from profiles where email = '...'`) en vez de un `.limit(1)` sin orden, precisamente para que esto no vuelva a pasar.
- **Verificado sin hallazgos**: alumno (home, catálogo con buscador+carrito+checkout, guard de perfil incompleto, mis-préstamos), docente (catálogo sin campo de maestro, perfil con label "Número de empleado"), admin (panel con KPIs y actividad reciente, las 3 vistas de solicitudes con wrap correcto incluso con un vale real lleno de emoji spam de un alumno, `VerDetalles` con historial de audit_log correcto, grid de inventario con fotos/placeholder, categorías, maestros con columna `es_tutor`, estadísticas con las 3 gráficas de recharts, export CSV). Un falso positivo: el donut de "Distribución de estados" salía en blanco en un screenshot `--full` (full-page stitched) de agent-browser pero pintaba bien en un screenshot de viewport normal — confirmado que es un artefacto de la herramienta de captura, no un bug de la app (el SVG/paths están completos y correctos en el DOM).
- **Nota de producto, no bug**: en `/admin/maestros`, de los 3 maestros solo "Sin especificar" tiene `es_tutor=true` — por eso el combobox de tutor en `/perfil` solo ofrece esa opción. Si se quiere que los alumnos puedan elegir a "Maria Angelica" o "Monica Cristina" como tutor real, hay que marcarles el checkbox "Es tutor" desde el CRUD.
- **Pendiente de decisión del usuario**: si desplegar el fix de `process is not defined` a producción ahora (bug activo en prod: nadie puede guardar su perfil ni el admin editar materiales) — commit + push + `docker compose up -d --build` en buglabs, mismo patrón de siempre.