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>
Greenfield scaffold for Linkder, a swipe-to-hire marketplace for local
professional services.
- pnpm/turbo monorepo: apps/web, packages/{shared,db}
- Postgres 16 + PostGIS via docker compose (ports 5442/6389 to avoid
clashing with other local stacks)
- Drizzle schema, 23 tables, geography(Point,4326) with GiST indexes
- Domain core in packages/shared: integer-cent money, status transition
graphs, deck ranking weights, cancellation policy — 46 unit tests
- Deck query: filtering in Postgres on the GiST index, ranking in JS so
the weights stay tunable — 18 integration tests against a seeded DB
- Deterministic seed placing pros at known distances, including three
that must NOT appear on a deck (out of radius, unverified, away)
- Next.js 15 app shell with a working swipe deck
- CI: typecheck, lint, test, build against live postgres+redis
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>