Todos los proyectos
Live · clienteProyecto 03
ClienteConstrucciónMobile-first

milibrodeobra

El ingeniero residente carga el día una vez, parado en la obra, con fotos. El mensaje de WhatsApp sale idéntico al que escribía a mano, los seguidores abren la página por link sin instalar nada y el libro de obra se escribe solo.

Next.js 16React 19SupabaseZodTailwind 4Vercel

El libro de obra de papel lo lee quien lo tiene en la mano. Este se escribe desde el teléfono, en la obra, y lo abre gente que nunca instaló nada.

El reporte diario se carga una vez. De ese mismo estado salen el WhatsApp que el grupo ya sabe leer, la página que se comparte por link y el libro de obra. En uso real en obras desde junio de 2026.

Vistas del reporte de apertura en 5 semanas275
De ellas, personas externas al ingeniero26
Previsualizaciones del crawler de WhatsApp: el link, pegado en el grupo19
Ventana del 14 de julio al 17 de agosto de 2026, una obra activa, medido con una tabla de eventos propia, sin analytics de terceros. Si el ingeniero pega el texto en vez del link, esas lecturas no se ven.
56reportes diarios reales en esas 5 semanas: 35 aperturas y 21 cierres
320pruebas: 62 unitarias y 258 comprobaciones de navegador
17migraciones aplicadas sobre producción, sin base de datos local
01 · Cómo funciona

El día de un ingeniero residente, de la obra al libro

Pulsa los pasos o deja que avance. Cada paso ilumina la pieza de la que habla.

En la obra
desde el celular
Reporte
una carga al día
Estado
una fila por día
WhatsApp
el texto de siempre
Seguidores
link, sin cuenta
Libro y tareas
derivados
Paso 1 de 5 · avanza solo
Llega a la obra y abre el reporte de hoy
Con un toque trae el de ayer: personal por disciplina, contratistas, equipos, consumibles. Solo edita lo que cambió. Los días de ejecución salen del reporte anterior más uno.

Lo que recibe el grupo. El formato es el que ya leían: solo cambia quién lo teclea.

Buenos días para todos
CECO: 4120 - Constructora del Este
Obra Asociada: Casa quinta, Lomas
Días de ejecución: 118 días hábiles
Personal en obra:
Disciplina: Albañilería
Material Pendiente por despacho:
**Cemento 20 sacos
Ver reporte completo: milibrodeobra.com/…
# ejemplo inventado · ** = prioridad alta
02 · Arquitectura

Un frontend, un estado y una base que también es la de desarrollo

No hay API separada ni cron. Las páginas leen con la clave anónima bajo RLS; las mutaciones son server actions que validan con Zod y escriben con el service role.

Topología · Vercel + Supabase
Borde
Vercel
edge y CDN · rutas públicas force-dynamic
sin cron
HTTPS · el origen del link se lee de los headers, no de una variable
App
Next.js 16
App Router · React 19 · Server Components
49 server actions
Zod al borde · requireAcceso(obra, nivel)
lib/acceso.ts
anon key + RLS para leer · service role para escribir
Datos
Supabase Auth
cuenta por usuario · suscripción y prueba por usuario
Postgres
14 tablas · snapshot JSONB por día
17 migraciones
Storage
bucket fotos · URL firmada por foto
Modelo de datos · 14 tablas, el estado del día en una
obras
CECO, empresa, ingeniero, avance · lectura pública por RLS
1 → n · un reporte de apertura y uno de cierre por día
reportes diarios
tipo apertura / cierre · snapshot JSONB con 11 campos · clave única (obra, fecha, tipo)
del snapshot salen las tres salidas y se derivan
fotos
Storage · comprimidas en cliente · apertura/ y cierre/
tareas
4 columnas · asignación por persona · comentarios
seguidores
link regenerable · el token vive fuera de obras
Lo que se consulta de forma cruzada o tiene ciclo de vida propio sí está normalizado: actividades como motor del avance, eventos para la auditoría de uso, reportes_pago con estado. El libro de obra no tiene tabla: se construye leyendo los reportes.
03 · Un ejemplo de punta a punta

Un día de obra, de las 7 de la mañana al libro

Un caso inventado con números pequeños, para ver qué pasa en cada tramo antes de hablar de los retos.

1
07:05 · en la obra
Marcos abre el reporte de hoy
Toca Usar datos de ayer. Aparecen sus 4 albañiles, 2 electricistas del contratista, la mezcladora y el ledger de consumibles. Marca a un albañil como ausente y baja el cemento: 10 sacos en obra, 90 consumidos de 100 asignados.
2
07:12
Dos fotos y un pendiente
Fotografía la losa encofrada; cada foto lleva actividad y comentario. Agrega **Cemento 20 sacos a material pendiente, prioridad alta. Guardar valida el reporte y lo escribe como estado del día.
3
07:13 · WhatsApp
El grupo recibe el texto de siempre
Se abre WhatsApp con el mensaje ya cargado: saludo, CECO, día 118 de ejecución, personal por disciplina, el pendiente con sus dos asteriscos y, al final, el link al reporte. Marcos solo pulsa enviar.
4
13:00 · oficina
La jefa abre el link
Sin cuenta ni instalación. Ve las fotos, el consumible en rojo al 90 % y el pendiente. Como sigue la obra, también le aparece en su inicio. Asigna una tarea a compras: despachar 20 sacos, columna "por hacer".
5
17:30 · cierre
El cierre se deriva de la apertura
El reporte de cierre trae las actividades del día tal cual; Marcos edita lo que no se terminó y escribe un requerimiento. Mañana ese requerimiento aparece primero en las alertas de la apertura, hasta que llegue el material.
6
fin de mes
El libro ya está escrito
Día, clima, trabajadores por rama, actividades y observaciones: un renglón por jornada, derivado de los reportes guardados. Se filtra por mes y se imprime. Nadie lo pasó en limpio.
04 · Los retos

Cinco veces en que la solución obvia estaba mal

Cada reto sigue el mismo patrón: lo que cualquiera haría primero, por qué no sirve, lo que se hizo y el número que lo demuestra. El detalle técnico queda plegado.

01

El reporte no es un documento, es un estado

Lo obvio
Normalizar: una tabla por sección del reporte y un join por cada render.
Por qué falla
Dentro del reporte hay listas de personas por disciplina, contratistas con cuadrilla, un ledger de consumibles con tres cantidades, pendientes con prioridad, equipos con estado y notas libres. Las columnas fijas se quedan cortas la primera semana, y cada sección nueva sería una migración con datos en vivo, porque la única base del proyecto es la de producción.
■ formato viejo, en la base■ formato nuevo, en el código
actividades: string[]
z.preprocess →
{ texto, estado }
consumibles: { usado, total }
z.preprocess →
{ enObra, consumido, asignado }
fotos: caption
z.preprocess →
actividad + comentario
Se traduce al leer. El formato nuevo se escribe la próxima vez que el ingeniero guarda ese día.
Lo que se hizo
Una fila por día con una columna snapshot JSONB y clave única (obra, fecha, tipo). El esquema del snapshot vive en Zod, 11 campos, y es la única fuente de verdad para las tres salidas. Cuando el modelo cambió, se añadió un z.preprocess que traduce el formato viejo al leer.
3 → 0tres cambios de modelo en producción con datos reales dentro, cero migraciones de datos. Los reportes de junio se abren con el código de septiembre.
Ver detalle técnico

El reporte se lee y se escribe siempre entero, nunca por secciones. Un día es una fila, "usar el de ayer" es copiar un objeto y el historial de una obra es un select ordenado por fecha. La validación queda en el borde: la server action parsea antes de escribir y nada llega a la base sin pasar por ahí.

Trade-off: el JSONB no se agrega con SQL. Cualquier vista transversal (el libro, los indicadores del inicio, las alertas de stock) se deriva en código leyendo snapshots. Para una obra con cientos de días es barato; para un portafolio de obras habría que materializar.
02

El WhatsApp tiene que salir idéntico al que ya leen

Lo obvio
Diseñar un mensaje mejor, más corto y más claro.
Por qué falla
Gerencia y cliente ya sabían leer ese bloque: dónde está el personal, qué significa ** delante de un material, cómo se lee el ledger. Un formato "mejor" es un formato nuevo que aprender, y nadie del otro lado iba a instalar nada ni cambiar de costumbre.
Lo que se hizo
renderWhatsApp(estado, ctx) es una función pura de estado a texto: recorre el snapshot en el orden del mensaje original y reproduce cada convención, los asteriscos de prioridad, el "Días de ejecución: N días hábiles", las notas entre paréntesis. El resultado va en un link wa.me/?text= que abre WhatsApp con el mensaje cargado. Es una plantilla determinista, sin ningún modelo en medio.
1 → 3un estado, tres lectores servidos: el grupo recibe su texto, el jefe abre un link sin cuenta, el archivo de la obra se arma solo.
Ver detalle técnico

Las otras dos salidas leen el mismo snapshot. La página en /obras/[id]/[fecha] es pública por link, con fotos, indicadores clicables y alertas de stock por color; la tabla de reportes tiene lectura pública por RLS y toda escritura pasa por el service role. El libro (lib/libroObra.ts) se construye a partir de los reportes guardados, un renglón por día, filtrable por mes e imprimible.

03

"Hoy" no es el hoy del servidor

Lo obvio
Tomar la fecha con new Date() en el servidor y formatearla.
Por qué falla
Vercel corre en UTC y Venezuela está en UTC-4. A partir de las 8 de la noche el servidor ya está en mañana. El reporte llegó por WhatsApp: un cierre hecho de noche se guardaba con la fecha del día siguiente; de tarde funcionaba. No aparece en local ni en las pruebas.
Caracas · UTC-4
16:00
hoy
18:00
hoy
20:00
hoy
22:00
hoy
00:00
hoy
02:00
hoy
Servidor · UTC
20:00
hoy
22:00
hoy
00:00
mañana
02:00
mañana
04:00
hoy
06:00
hoy
A las 4 de la tarde funciona. Desde las 8 de la noche, un reporte de cierre se guardaba con la fecha del día siguiente.
Lo que se hizo
Un único helper hoyISO() con ZONA_OBRA = "America/Caracas", usado en los dos formularios, en el cálculo de logros y en la validación de suscripción. Se reprodujo antes de arreglar: servidor local con TZ=UTC a las 10 de la noche devolvía el día 14; con el fix, el 13. Se verificó en producción a esa misma hora.
20:00la hora a partir de la cual cada cierre nacía con la fecha equivocada. El formulario de apertura tenía el mismo bug latente: las aperturas se hacen de mañana.
Ver detalle técnico

Regla del proyecto escrita a partir de este bug: nunca calcular "hoy" con la hora del servidor. Si la app llega a usarse fuera de Venezuela, ZONA_OBRA pasa a ser una columna de obras.

04

Medir si el reporte se lee, sin analytics de terceros

Lo obvio
Poner un script de analytics y mirar el panel.
Por qué falla
La pregunta no era cuántas visitas, sino si alguien fuera del ingeniero abría los reportes, y cuál de los dos. Un panel genérico no distingue al ingeniero logueado, a un lector externo repetido y al crawler que WhatsApp manda cuando alguien pega un link en un chat.
Reporte de apertura
275
vistas
19
previews de WhatsApp
26 vistas de personas externas, con un lector repetido y un bloque de 4 equipos de la misma oficina.
Reporte de cierre · con las fotos
60
vistas
0
previews de WhatsApp
Todas del propio ingeniero o de pruebas. Nadie de afuera vio una foto.
Lo que se hizo
Una tabla eventos propia registra page_view y acciones con IP, user agent y usuario o UUID anónimo. La auditoría de 5 semanas dio el contraste: el reporte de apertura tenía audiencia externa y previews de WhatsApp casi diarios a la misma hora; el de cierre, ninguna. La causa era mecánica: el texto de WhatsApp del cierre no incluía la URL, solo el texto. Ahora los dos textos terminan con el link al reporte.
0previsualizaciones del crawler de WhatsApp sobre un reporte de cierre en 5 semanas. Las fotos, que solo existen en la web, no las veía nadie de afuera.
Ver detalle técnico

El origen del link se deriva de los headers x-forwarded-host y x-forwarded-proto (lib/url.ts), no de una variable de entorno, así que el mismo código produce la URL correcta en producción, preview y local.

Límite del dato: si el ingeniero pega el texto en vez del link, esas lecturas son invisibles. La medición se repite dos semanas después del cambio buscando previews del crawler sobre /cierre/.
05

De una contraseña compartida a una puerta única

Lo obvio
Una contraseña en una variable de entorno: un solo usuario real, cero fricción.
Por qué falla
Cuando llegaron cuentas por usuario, obras con dueño y seguidores en solo lectura, convivían tres mecanismos: la contraseña legacy, la cuenta real y dos gates de suscripción, uno de ellos global. Y puedeEditarObra caía en la contraseña compartida si no había sesión: quien la tuviera podía editar cualquier obra saltándose al dueño.
acción
Dueño
Seguidor
Link
Nadie
Leer el reporte
✓
✓
✓
✓
Ver obra y tareas
✓
✓
·
·
Cargar reportes
✓
·
·
·
Compartir, cerrar tareas
✓
·
·
·
Editar exige además suscripción vigente del usuario que está logueado, no de una fila global.
Lo que se hizo
Un módulo, lib/acceso.ts, responde quién sos, qué sos en esta obra y si tu suscripción está al día. Páginas y server actions lo usan a través de requireAcceso(obraId, nivel). La contraseña compartida se eliminó de los tres entornos de Vercel. El gate global se retiró sin borrar la tabla que lo sostenía, porque tenía datos del cliente.
1 módulodetrás de todas las páginas y las 49 server actions. Renovar una suscripción pasó de un PATCH a mano con la clave de servicio a un botón en /admin.
Ver detalle técnico

El link de seguimiento de una obra vive en su propia tabla, obra_links_seguimiento, y no como columna de obras. La razón está en el SQL: obras tiene select using (true) porque los reportes se comparten por link, así que un token ahí quedaría expuesto ante cualquier select("*") futuro con la clave anónima. En la tabla aparte, con RLS activo y sin policies, solo lo alcanza el service role.

Cambio de comportamiento a favor: las server actions ahora también exigen suscripción vigente, cosa que antes solo miraban las páginas.

"El mensaje que tu grupo recibe es el mismo de siempre. Nadie del otro lado tiene que instalar nada ni cambiar de costumbre."
El filtro de cada decisión fue el mismo: ¿esto le ahorra tecleo al ingeniero parado en la obra, o le cambia algo a la gente que ya lee el reporte? Lo primero entra. Lo segundo se descarta aunque sea "mejor".
05 · Decisiones

La base de desarrollo es la de producción, y de ahí salen las reglas

Un ingeniero cargando reportes reales mientras el producto se construye. Migraciones aditivas, fixtures con prefijo test-* que se crean y se borran por script, una lista de obras protegidas que aborta la limpieza si se la nombra, y backup JSON antes de mutar datos.

WHY
¿Por qué el reporte como snapshot JSONB y no tablas normalizadas?
Porque el reporte se lee y se escribe siempre entero, nunca por secciones. Normalizar eran una decena de tablas hijas, un join por render y una migración con datos en vivo por cada sección nueva. Lo que sí se normalizó es lo que se consulta cruzado o tiene ciclo de vida propio: tareas, actividades, eventos, pagos.
Profundizar

Catorce tablas en total, con el snapshot en una sola. Un flag como silenciado en un consumible viaja en el clon del día siguiente porque está en el JSON, no porque una FK lo garantice. Es un comportamiento deseado que hay que conocer.

No hay integridad referencial dentro del snapshot. El precio de leer y escribir entero es que nada dentro del JSON apunta a otra fila.
WHY
¿Por qué Next.js con server actions y sin API separada?
El producto tiene un cliente, el navegador del celular, y un solo frontend. Un backend aparte duplicaba tipos, deploys y CORS para servir exactamente una app. Las páginas leen con la clave anónima bajo RLS; las mutaciones corren con el service role, validan con Zod y llaman revalidatePath sobre lo que cambió.
Profundizar

Los reportes públicos son force-dynamic: renovar una suscripción o silenciar una alerta se ve en la siguiente recarga sin redeploy. No hay cron ni scheduler; los días de ejecución, las alertas de stock y el libro se calculan al leer.

WHY
¿Por qué el botón nativo de Google y no OAuth por redirección?
Con la redirección, la pantalla de consentimiento de Google anuncia el dominio del proyecto de Supabase, no el de la app. Para un ingeniero que entra por primera vez eso parece phishing. La solución oficial es un dominio propio en Supabase: plan Pro más add-on, 35 USD al mes. El flujo nativo dibuja el botón en un iframe, resuelve en la misma página y Google nombra nuestro dominio. Cuesta cero.
Profundizar

Google Identity Services entrega un id_token que se pasa a signInWithIdToken. Si el script de Google no carga, cae solo al camino por redirección. La regla de quién puede entrar vive aparte del transporte OAuth (lib/altaGoogle.ts, con la puerta de datos inyectada) para poder probarla sin Google: si ya hay perfil con ese email sin enlazar, se enlaza solo; si apunta a otro usuario de Auth, no se pisa.

Gotcha: locale en renderButton no traduce nada; lo que traduce el botón es ?hl=es en la URL del script. Y el botón falla en silencio si el origen no está autorizado, así que se comprueba que Google haya dibujado algo antes de reemplazar el respaldo.

WHY
¿Por qué suscripción con pago reportado y confirmación manual?
Los clientes pagan en pago móvil o en USDT. Las pasarelas internacionales no aceptan comerciantes venezolanos y PayPal a un ticket bajo pierde una porción relevante en comisión fija. Para un producto con pocos clientes, un cobro manual bien hecho vale más que una pasarela mal encajada.
Profundizar

La cuenta nueva arranca con 30 días de prueba sin tarjeta. En /suscripcion el usuario ve cuántos días le quedan, a dónde pagar y un formulario para avisar que pagó: referencia, banco, fecha y captura obligatoria. El admin revisa en /admin y confirma o descarta con motivo, que el usuario lee en su historial.

Confirmar suma un mes sobre el vencimiento vigente, no sobre hoy: quien paga antes de tiempo no pierde los días que le quedaban. proximoVencimiento corrige además el caso 31 de enero más un mes, que JavaScript manda a marzo.

Pendiente: el precio mensual no está decidido y la app no lo muestra. La integración con PayPal existe en una rama sin desplegar, sin webhook, así que hoy un pago acredita solo si la persona no cierra la pestaña.
milibrodeobra
WhatsApp a mano + libro de papel
Escribir el día
precargar el de ayer y editar lo que cambió
teclear el bloque entero cada mañana
Historial
una fila por día, filtrable por mes
se pierde en el chat; el papel se queda en la obra
Fotos
con actividad y comentario, en la página del día
sueltas en el chat, sin fecha de obra
Quién lo lee
el grupo, quien tenga el link y los seguidores
el grupo, y el libro quien lo tenga en la mano
Instalar
nada: un link
nada
Lo que no cambia: el grupo sigue recibiendo el mismo texto. La comparación es de flujo de trabajo, no de cifras.
06 · Qué haría distinto

Cinco decisiones que hoy tomaría al revés

Hice
Contraseña compartida en una variable de entorno como primer auth
Hoy haría
Cuentas por usuario desde el primer deploy, aunque el primer usuario fuera uno. La contraseña sobrevivió a la llegada del multiusuario y durante semanas permitió editar cualquier obra sin ser su dueño.
Hice
Cierre del día acoplado al tablero
Hoy haría
Derivar el cierre de la apertura del mismo día desde el inicio y dejar el tablero como motor invisible del avance. El ingeniero lo dijo con claridad: "conectarlo al tablero fue mala idea, necesito que sea súper sencillo". Se reescribió dos veces.
Hice
Dos gates de suscripción: uno por usuario y otro en una fila global
Hoy haría
Un solo gate por usuario y la renovación en /admin desde el día que existió la suscripción. Cada renovación exigía tocar dos tablas y, si se olvidaba una, el cliente quedaba a medio bloquear.
Hice
Fecha "hoy" calculada con la hora del servidor
Hoy haría
ZONA_OBRA explícita en el primer commit y, mejor, como columna de obras. Costó un bug reportado por el cliente y una noche reproduciéndolo con TZ=UTC.
Hice
Texto de WhatsApp del cierre sin el link al reporte
Hoy haría
Cerrar todos los textos compartibles con la URL desde el inicio y medir desde el primer día. Tardó 5 semanas de auditoría descubrir que las fotos del cierre no las veía nadie.
07 · Lo que viene

Deuda abierta, con nombre

abierto
Precio mensual y meses por pago
La app no muestra precio. Hoy cada pago confirmado suma exactamente un mes.
abierto
Entrar con Google y registro abierto
Rama lista y probada, con 30 días de prueba por cuenta. Falta desplegar y tres variables.
abierto
Webhook de PayPal
Para que el pago acredite aunque la persona cierre la pestaña. La rama existe sin desplegar.
abierto
Creación de roadmap público
Una página con lo que viene y lo que ya salió, para que quien usa la app sepa qué esperar y pida lo que falta.
abierto
Un solo vocabulario dentro de la app
La landing dice "libro diario"; algunas pantallas internas todavía usan el nombre viejo.
opcional
Entrada por voz o texto libre
Un adaptador que produzca el mismo snapshot que el formulario. Enchufable, sin tocar el núcleo.
08 · Si te llevas tres cosas

Recap

1
El reporte diario se carga una vez desde el celular, en la obra. El grupo de WhatsApp recibe el mismo texto de siempre y nadie del otro lado instala nada.
2
El reporte es un estado, no un documento: una fila por día, validada con Zod. De ahí salen el WhatsApp, la página compartible, el libro de obra y las tareas. El modelo cambió tres veces con datos reales dentro y ninguna fila se migró.
3
Se mide con una tabla propia si alguien lee, y el dato cambió el producto: el cierre no tenía audiencia porque su texto no llevaba link. Ahora lo lleva.
Enlaces
Ver en vivo