The right-hand panel now answers the question the stack list provoked — what
does a client actually install? Capacitor, iOS and Android lead "Built with",
and a new SVG diagram shows the two ways in: customers through the installed
app, staff through the admin dashboard behind Cloudflare Access.
Costs were half-quoted. The stack named Mapbox, Twilio, Resend and Sentry and
priced none of them, so each now states the shape of its bill rather than a
figure that would be wrong at every scale. App store accounts are listed too,
in the client's own name for the same reason the hosting is.
Currency was the serious one. Every figure is US dollars and the panel wrote
them as "$59", but this launches in Mexico, where "$" is the peso — the Spanish
column understated the running cost roughly seventeenfold to the only reader
who matters. money() now prints "$59" in English and "59 USD" in Spanish.
The rest of the Spanish was a literal translation of dense English: peninsular
vocabulary in a Mexican market, two stray vosotros forms among the tuteo, and
calques that were not grammatical — "aplicada en ninguna parte", "todo lo
discontinuo" for dashed lines. Reviewed all 126 strings. The architecture note
was 120 words in one block covering five topics; it is now two paragraphs, one
idea each, and the PostGIS sentence went because "What it does" already makes
that promise in language a client can act on.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The showcase was a Barcelona market: Catalan names, +34 numbers, euro
rates and "Carrer Example 12" on every job. Presented to a Mexican
client, all of that reads as somebody else's product.
City comes from NEXT_PUBLIC_CITY_* as before, now Ciudad de México at
19.4326/-99.1332, with MAPBOX_COUNTRY=mx. The seed's fallbacks were
Barcelona literals, so an unset env quietly seeded a different city
than the app rendered — they now agree.
Two db tests pinned the Barcelona centre as a hardcoded constant, which
is why the deck returned zero cards on the first run here: every pro was
a continent outside the radius. They read the same env as the seed now,
so the trap cannot recur.
Money: formatCents defaults to USD/en-US, and the nine hardcoded euro
signs across the card, search rows, quote strip and forms are dollars.
The rate NUMBERS are unchanged and still read high for CDMX — that is a
pricing decision, not a currency one, and is left alone deliberately.
Seed people are Mexican, addressed on real Roma/Condesa streets rotated
by index rather than one placeholder repeated. Phones moved to +52 55,
which moves the demo login to +525500000000 / 000000.
Also in here, from the same session:
- Sending a job now confirms. The mutation always succeeded; the sheet
just closed with no receipt, which from the customer's side is
indistinguishable from a dead button. Dismissing that receipt resolves
as 'sent', so the card does not return to the deck.
- Media moves to DigitalOcean Spaces, with the public origin derived
from bucket and region instead of a second env var to keep in sync.
- Managed-Postgres TLS: DATABASE_CA_CERT takes a path or inline PEM.
- The client-facing project panel beside the running app.
- Two profiles removed and four renamed to match their photos.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Closes the funnel. Before this the product could match two people and then
stopped: `quotes`, `bookings` and `reviews` had tables and state machines and
nothing that wrote a row, the entry deck's right swipe was wired to an empty
handler, and every address resolved to the city centre.
Jobs tab and chat
- message router: thread, send, markRead, unreadTotal. A thread is a MATCH, not
a job — one job with three interested pros is three private conversations.
- Current/Past segments derived from ACTIVE_JOB_STATUSES, job detail listing the
pros who accepted, and the conversation itself with attachments.
Hiring from the deck
- A right swipe on the entry deck opened nothing. It now resolves "which job?"
through a sheet — sign in, pick an open job, or post one — and calls the same
deck.swipe the per-job deck does, so the open-request cap and row lock apply
exactly once. Swipes are vetoable so closing the sheet returns the card.
Geocoding
- ST_Distance and ST_DWithin rank and filter every deck, and both operands were
placeholders. Addresses now resolve through Mapbox (permanent=true, which is
what licenses storing the coordinates), the server resolves points rather than
trusting client-supplied lat/lng, and every stored point records how it was
obtained. A `city`-precision base cannot reach the verification queue.
Quote -> booking -> review
- The commercial chain, minus payments. Accepting a quote is the only place a
booking is created; confirming completion is what unlocks reviews and moves
the pro's completed_jobs.
- Reviews publish double-blind with no sweeper: each is written with
published_at already set to its embargo deadline and every read filters
published_at <= now(), so it publishes itself. The second review pulls both
forward. A silent counterparty cannot bury a bad review by never replying.
State machine changes, both deliberate
- booked -> matched: a cancelled booking is not a cancelled job.
- scheduled -> awaiting_confirmation: in_progress is optional, so a pro who
never tapped Start can still say the work is done.
Test suite
- api tests ran files in parallel against one database and failed roughly one
run in three on whichever file lost the race. Serialised, and three fixtures
that grabbed "the first client" pinned to the seeded accounts.
Also includes work from a parallel session: admin verification queue, pro
public profile and reviews read path, notification sending, denormalised stats
recompute, search, and observability.
318 tests passing; typecheck and lint clean across 7 packages.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>