All projects
Live · clientProject 03
ClientConstructionMobile-first

milibrodeobra

The resident engineer enters the day once, standing on site, with photos. The WhatsApp message comes out identical to the one they used to type by hand, followers open the page by link without installing anything, and the logbook writes itself.

Next.js 16React 19SupabaseZodTailwind 4Vercel

The paper site diary is read by whoever holds it. This one is written from a phone, on the job site, and opened by people who never installed anything.

The daily report is entered once. From that same state come the WhatsApp message the group already knows how to read, the page shared by link and the site diary. In real use on job sites since June 2026.

Opening report views in 5 weeks275
Of those, people other than the engineer26
WhatsApp crawler previews: the link, pasted in the group19
Window from July 14 to August 17, 2026, one active job site, measured with an in-house events table, no third-party analytics. If the engineer pastes the text instead of the link, those reads are invisible.
56real daily reports in those 5 weeks: 35 openings and 21 closings
320tests: 62 unit tests and 258 browser checks
17migrations applied against production, with no local database
01 · How it works

A resident engineer's day, from the job site to the diary

Tap the steps or let it play. Each step lights up the piece it talks about.

On site
from the phone
Report
one entry a day
State
one row per day
WhatsApp
the usual text
Followers
link, no account
Diary and tasks
derived
Step 1 of 5 · plays on its own
Arrives on site and opens today's report
One tap brings in yesterday's: crew by trade, contractors, equipment, consumables. Only what changed gets edited. Execution days come from the previous report plus one.

What the group receives. The format is the one they already read: only who types it changes.

Good morning everyone
CECO: 4120 - Constructora del Este
Project: Casa quinta, Lomas
Execution days: 118 working days
Crew on site:
Trade: Masonry
Material pending dispatch:
**Cement 20 bags
Full report: milibrodeobra.com/…
# made-up example · ** = high priority
02 · Architecture

One frontend, one state and a database that is also the dev database

No separate API and no cron. Pages read with the anon key under RLS; mutations are server actions that validate with Zod and write with the service role.

Topology · Vercel + Supabase
Edge
Vercel
edge and CDN · public routes force-dynamic
no cron
HTTPS · the link origin is read from the headers, not from a variable
App
Next.js 16
App Router · React 19 · Server Components
49 server actions
Zod at the edge · requireAcceso(obra, nivel)
lib/acceso.ts
anon key + RLS to read · service role to write
Data
Supabase Auth
account per user · subscription and trial per user
Postgres
14 tables · JSONB snapshot per day
17 migrations
Storage
photos bucket · signed URL per photo
Data model · 14 tables, the day's state in one
job sites
CECO, company, engineer, progress · public read via RLS
1 → n · one opening report and one closing report per day
daily reports
type opening / closing · JSONB snapshot with 11 fields · unique key (obra, fecha, tipo)
the three outputs come from the snapshot and are derived
photos
Storage · compressed on the client · apertura/ and cierre/
tasks
4 columns · assigned per person · comments
followers
regenerable link · the token lives outside obras
What gets queried across sites or has its own lifecycle is normalized: actividades as the progress engine, eventos for usage auditing, reportes_pago with a status. The site diary has no table: it is built by reading the reports.
03 · An end-to-end example

A day on site, from 7 in the morning to the diary

A made-up case with small numbers, to see what happens at each stage before getting to the challenges.

1
07:05 · on site
Marcos opens today's report
He taps Use yesterday's data. His 4 masons, 2 electricians from the contractor, the mixer and the consumables ledger appear. He marks one mason as absent and lowers the cement: 10 bags on site, 90 used out of 100 assigned.
2
07:12
Two photos and a pending item
He photographs the formed slab; each photo carries an activity and a comment. He adds **Cemento 20 sacos to pending material, high priority. Saving validates the report and writes it as the day's state.
3
07:13 · WhatsApp
The group gets the usual text
WhatsApp opens with the message already loaded: greeting, CECO, execution day 118, crew by trade, the pending item with its two asterisks and, at the end, the link to the report. Marcos just taps send.
4
13:00 · office
The boss opens the link
No account, no install. She sees the photos, the consumable in red at 90% and the pending item. Since she follows the site, it also shows up on her home screen. She assigns a task to purchasing: dispatch 20 bags, "to do" column.
5
17:30 · closing
The closing is derived from the opening
The closing report brings in the day's activities as they are; Marcos edits what was not finished and writes a requirement. Tomorrow that requirement shows up first in the opening's alerts, until the material arrives.
6
end of month
The diary is already written
Day, weather, workers by trade, activities and remarks: one line per shift, derived from the saved reports. Filtered by month and printed. Nobody had to write it up.
04 · The challenges

Five times the obvious solution was wrong

Each challenge follows the same pattern: what anyone would do first, why it does not work, what was done and the number that proves it. The technical detail stays folded.

01

The report is not a document, it is a state

The obvious approach
Normalize: one table per report section and a join for every render.
Why it fails
Inside the report there are lists of people by trade, contractors with their crews, a consumables ledger with three quantities, pending items with priority, equipment with a status and free notes. Fixed columns fall short in the first week, and every new section would be a migration on live data, because the project's only database is production.
■ old format, in the database■ new format, in the code
actividades: string[]
z.preprocess →
{ texto, estado }
consumibles: { usado, total }
z.preprocess →
{ enObra, consumido, asignado }
fotos: caption
z.preprocess →
actividad + comentario
Translated on read. The new format is written the next time the engineer saves that day.
What was done
One row per day with a JSONB snapshot column and a unique key (obra, fecha, tipo). The snapshot schema lives in Zod, 11 fields, and is the single source of truth for the three outputs. When the model changed, a z.preprocess was added that translates the old format on read.
3 → 0three model changes in production with real data inside, zero data migrations. June reports open with the September code.
Technical detail

The report is always read and written whole, never by section. A day is a row, "use yesterday's" is copying an object and a site's history is a select ordered by date. Validation stays at the edge: the server action parses before writing and nothing reaches the database without going through it.

Trade-off: JSONB does not aggregate with SQL. Any cross-cutting view (the diary, the home screen indicators, the stock alerts) is derived in code by reading snapshots. For one site with hundreds of days it is cheap; for a portfolio of sites it would need materializing.
02

The WhatsApp message has to come out identical to the one they already read

The obvious approach
Design a better message, shorter and clearer.
Why it fails
Management and the client already knew how to read that block: where the crew is, what ** in front of a material means, how to read the ledger. A "better" format is a new format to learn, and nobody on the other side was going to install anything or change a habit.
What was done
renderWhatsApp(estado, ctx) is a pure function from state to text: it walks the snapshot in the order of the original message and reproduces every convention, the priority asterisks, the "Execution days: N working days" line, the notes in parentheses. The result goes in a wa.me/?text= link that opens WhatsApp with the message loaded. It is a deterministic template, with no model in between.
1 → 3one state, three readers served: the group gets its text, the boss opens a link with no account, the site's record builds itself.
Technical detail

The other two outputs read the same snapshot. The page at /obras/[id]/[fecha] is public by link, with photos, clickable indicators and colour-coded stock alerts; the reports table has public read via RLS and every write goes through the service role. The diary (lib/libroObra.ts) is built from the saved reports, one line per day, filterable by month and printable.

03

"Today" is not the server's today

The obvious approach
Take the date with new Date() on the server and format it.
Why it fails
Vercel runs in UTC and Venezuela is at UTC-4. From 8 at night the server is already in tomorrow. The report came in through WhatsApp: a closing done at night was saved with the next day's date; in the afternoon it worked. It does not show up locally or in the tests.
Caracas · UTC-4
16:00
today
18:00
today
20:00
today
22:00
today
00:00
today
02:00
today
Server · UTC
20:00
today
22:00
today
00:00
tomorrow
02:00
tomorrow
04:00
today
06:00
today
At 4 in the afternoon it works. From 8 at night, a closing report was saved with the next day's date.
What was done
A single hoyISO() helper with ZONA_OBRA = "America/Caracas", used in both forms, in the progress calculation and in the subscription check. It was reproduced before fixing: a local server with TZ=UTC at 10 at night returned the 14th; with the fix, the 13th. Verified in production at that same hour.
20:00the hour from which every closing was born with the wrong date. The opening form had the same latent bug: openings are done in the morning.
Technical detail

Project rule written after this bug: never compute "today" from the server clock. If the app ever gets used outside Venezuela, ZONA_OBRA becomes a column of obras.

04

Measure whether the report gets read, without third-party analytics

The obvious approach
Drop in an analytics script and look at the dashboard.
Why it fails
The question was not how many visits, but whether anyone other than the engineer opened the reports, and which of the two. A generic dashboard does not tell apart the logged-in engineer, a repeat external reader and the crawler WhatsApp sends when someone pastes a link in a chat.
Opening report
275
views
19
WhatsApp previews
26 views from people outside the engineer, with one repeat reader and a block of 4 devices from the same office.
Closing report · with the photos
60
views
0
WhatsApp previews
All from the engineer himself or from tests. Nobody outside saw a photo.
What was done
An in-house eventos table records page_view and actions with IP, user agent and user or anonymous UUID. The 5-week audit gave the contrast: the opening report had an external audience and near-daily WhatsApp previews at the same hour; the closing report, none. The cause was mechanical: the closing's WhatsApp text did not include the URL, only the text. Now both texts end with the link to the report.
0WhatsApp crawler previews on a closing report in 5 weeks. The photos, which only exist on the web, were seen by nobody outside.
Technical detail

The link origin is derived from the x-forwarded-host and x-forwarded-proto headers (lib/url.ts), not from an environment variable, so the same code produces the right URL in production, preview and local.

Limit of the data: if the engineer pastes the text instead of the link, those reads are invisible. The measurement is repeated two weeks after the change looking for crawler previews on /cierre/.
05

From a shared password to a single gate

The obvious approach
A password in an environment variable: one real user, zero friction.
Why it fails
When per-user accounts, sites with an owner and read-only followers arrived, three mechanisms coexisted: the legacy password, the real account and two subscription gates, one of them global. And puedeEditarObra fell back to the shared password when there was no session: anyone who had it could edit any site, bypassing the owner.
action
Owner
Follower
Link
Nobody
Read the report
✓
✓
✓
✓
See site and tasks
✓
✓
·
·
Submit reports
✓
·
·
·
Share, close tasks
✓
·
·
·
Editing also requires an active subscription for the logged-in user, not a global row.
What was done
One module, lib/acceso.ts, answers who you are, what you are on this site and whether your subscription is current. Pages and server actions use it through requireAcceso(obraId, nivel). The shared password was removed from the three Vercel environments. The global gate was retired without dropping the table behind it, because it held client data.
1 modulebehind every page and all 49 server actions. Renewing a subscription went from a hand-made PATCH with the service key to a button in /admin.
Technical detail

A site's follow link lives in its own table, obra_links_seguimiento, not as a column of obras. The reason is in the SQL: obras has select using (true) because reports are shared by link, so a token there would be exposed to any future select("*") with the anon key. In the separate table, with RLS on and no policies, only the service role reaches it.

Behaviour change in its favour: server actions now also require an active subscription, which before only the pages checked.

"The message your group receives is the same as always. Nobody on the other side has to install anything or change a habit."
The filter for every decision was the same: does this save the engineer typing while standing on site, or does it change something for the people who already read the report? The first gets in. The second is dropped even if it is "better".
05 · Decisions

The dev database is production, and the rules follow from that

An engineer entering real reports while the product is being built. Additive migrations, fixtures prefixed test-* that are created and deleted by script, a list of protected sites that aborts the cleanup if one is named, and a JSON backup before mutating data.

WHY
Why the report as a JSONB snapshot and not normalized tables?
Because the report is always read and written whole, never by section. Normalizing meant a dozen child tables, a join per render and a migration on live data for every new section. What did get normalized is what is queried across sites or has its own lifecycle: tasks, activities, events, payments.
Go deeper

Fourteen tables in total, with the snapshot in just one. A flag like silenciado on a consumable travels in the next day's clone because it is in the JSON, not because an FK guarantees it. It is desired behaviour that you have to know about.

There is no referential integrity inside the snapshot. The price of reading and writing whole is that nothing inside the JSON points to another row.
WHY
Why Next.js with server actions and no separate API?
The product has one client, the phone's browser, and a single frontend. A separate backend duplicated types, deploys and CORS to serve exactly one app. Pages read with the anon key under RLS; mutations run with the service role, validate with Zod and call revalidatePath on what changed.
Go deeper

Public reports are force-dynamic: renewing a subscription or silencing an alert shows on the next reload without a redeploy. There is no cron or scheduler; execution days, stock alerts and the diary are computed on read.

WHY
Why Google's native button and not redirect-based OAuth?
With the redirect, Google's consent screen announces the Supabase project's domain, not the app's. For an engineer signing in for the first time that looks like phishing. The official fix is a custom domain on Supabase: Pro plan plus add-on, 35 USD a month. The native flow draws the button in an iframe, resolves on the same page and Google names our domain. It costs nothing.
Go deeper

Google Identity Services returns an id_token that is passed to signInWithIdToken. If Google's script fails to load, it falls back on its own to the redirect path. The rule for who can sign in lives apart from the OAuth transport (lib/altaGoogle.ts, with the data gate injected) so it can be tested without Google: if a profile with that email already exists and is not linked, it links itself; if it points to another Auth user, it is not overwritten.

Gotcha: locale in renderButton translates nothing; what translates the button is ?hl=es in the script URL. And the button fails silently if the origin is not authorized, so the code checks that Google drew something before replacing the fallback.

WHY
Why a subscription with reported payment and manual confirmation?
Clients pay by local mobile transfer or in USDT. International gateways do not accept Venezuelan merchants and PayPal on a low ticket loses a meaningful share to the fixed fee. For a product with few clients, a well-run manual charge is worth more than a badly fitted gateway.
Go deeper

A new account starts with a 30-day trial, no card. In /suscripcion the user sees how many days are left, where to pay and a form to report the payment: reference, bank, date and a mandatory screenshot. The admin reviews in /admin and confirms or rejects with a reason the user reads in their history.

Confirming adds a month on top of the current expiry, not on top of today: whoever pays early does not lose the days they had left. proximoVencimiento also handles the January 31 plus one month case, which JavaScript sends to March.

Pending: the monthly price is not decided and the app does not show it. The PayPal integration exists in an undeployed branch, with no webhook, so today a payment is credited only if the person does not close the tab.
milibrodeobra
WhatsApp by hand + paper diary
Writing the day
preload yesterday's and edit what changed
type the whole block every morning
History
one row per day, filterable by month
lost in the chat; the paper stays on site
Photos
with activity and comment, on the day's page
loose in the chat, with no site date
Who reads it
the group, anyone with the link and the followers
the group, and the diary whoever holds it
Install
nothing: a link
nothing
What does not change: the group keeps receiving the same text. The comparison is about workflow, not figures.
06 · What I would do differently

Five decisions I would make the other way today

I did
Shared password in an environment variable as the first auth
Today I would
Per-user accounts from the first deploy, even if the first user was just one. The password outlived the arrival of multi-user and for weeks allowed editing any site without being its owner.
I did
Day closing coupled to the task board
Today I would
Derive the closing from the same day's opening from the start and leave the board as the invisible progress engine. The engineer said it plainly: "hooking it to the board was a bad idea, I need it to be dead simple". It was rewritten twice.
I did
Two subscription gates: one per user and another in a global row
Today I would
A single per-user gate and renewal in /admin from the day the subscription existed. Every renewal meant touching two tables and, if one was forgotten, the client was left half blocked.
I did
"Today" computed from the server clock
Today I would
ZONA_OBRA explicit in the first commit and, better, as a column of obras. It cost a bug reported by the client and a night reproducing it with TZ=UTC.
I did
Closing WhatsApp text without the link to the report
Today I would
End every shareable text with the URL from the start and measure from day one. It took 5 weeks of auditing to discover that nobody saw the closing photos.
07 · What's next

Open debt, by name

open
Monthly price and months per payment
The app shows no price. Today each confirmed payment adds exactly one month.
open
Sign in with Google and open sign-up
Branch ready and tested, with a 30-day trial per account. Deploy and three variables still missing.
open
PayPal webhook
So a payment is credited even if the person closes the tab. The branch exists, not deployed.
open
Public roadmap
A page with what is coming and what already shipped, so people using the app know what to expect and ask for what is missing.
open
One vocabulary across the app
The landing page says "site diary"; some internal screens still use the old name.
optional
Voice or free-text input
An adapter that produces the same snapshot as the form. Pluggable, without touching the core.
08 · If you take away three things

Recap

1
The daily report is entered once from the phone, on the job site. The WhatsApp group gets the same text as always and nobody on the other side installs anything.
2
The report is a state, not a document: one row per day, validated with Zod. From there come the WhatsApp message, the shareable page, the site diary and the tasks. The model changed three times with real data inside and no row was migrated.
3
An in-house table measures whether anyone reads, and the data changed the product: the closing had no audience because its text carried no link. Now it does.
Links
View live