Bitácora: 5 features en producción (buscador, grid+fotos, unidades, estadísticas, realtime)

This commit is contained in:
2026-08-22 18:11:10 -07:00
parent 544bbf38a9
commit b2bc5f8d1b
+13
View File
@@ -214,3 +214,16 @@ Fases 3, 4/5 y 6 tocan carpetas de rutas distintas (`src/pages/alumno/*`, `src/p
- **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. - **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. - **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.** - **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.**