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>
The deck had two answers, which is a lot to hang on a swipe: send this pro a job
right now, or lose them. Three more, in one fixed row (DESIGN.md §6.8):
rewind · ✗ · watch · ✓ · ask
Size is the hierarchy — the two that end the card stay 64px, the three that do
not are 44px, never below the §8 floor. Rewind is disabled rather than hidden
when there is nothing to undo, so the row never changes length and the big pair
never moves out from under a thumb.
Rewind is local. The entry deck writes no swipes — a `swipes` row is job-scoped
and there is no job there — so the card leaving was only ever an index move.
Watch: "tell me when this one is free"
- `pro_watches` snapshots the pro's availability AT WATCH TIME, because the
trigger is a change, not a state. Without it a sweep would notify every
watcher on every run, since "available" stays true for as long as they stay
available.
- Deliberately the narrow version: a pro with is_accepting_jobs = false is
invisible everywhere (eligibleProAtAnyDistance requires it), so a watch can
only be placed on somebody already free and fires on the away-and-back cycle.
"Free at a time that suits me" needs pro_availability — seeded since M1, read
by nothing — to become a real calendar. Flagged rather than faked.
Ask: a question, before there is a job
- This is the first way to reach a pro who has not agreed to anything. Chat was
gated behind message → match → accepted request → job, and that gate is what
made a pro's inbox worth opening, so the cap is not decoration:
MAX_OPEN_ENQUIRIES unanswered at a time, one thread per pair so it cannot be
walked around, answered threads stop counting, stale ones fall out, and the
pro can close one.
- `enquiries` is its own table, not a match with a null job: a match means a pro
said yes to specific work, and collapsing the two would put rows in `matches`
that no quote, booking or review could hang off.
- `messages` now belongs to a match OR an enquiry, with a CHECK making the
illegal state unrepresentable. One message table, so one chat screen.
283 tests passing; typecheck and lint clean across 7 packages.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>