Work

Hacer Aldea

A shared map of what is happening in a small town and who is doing it, open from a link, a QR code or a WhatsApp message, without an account. Built as one TypeScript system that a coding agent can hold in its head.

Verified: checked against the repository or the live deployInferred: my reading of that evidenceIllustrative: simplified for this page

Context

Verified

In the small Mexican towns this was designed for, community life already works: WhatsApp groups, flyers, Instagram, word of mouth. What does not work is legibility. Information is duplicated, disappears within hours and is invisible outside your own groups.

The product plan makes four promises:

  • A visitor knows what is happening near them today within ten seconds.
  • An organiser says it once, in WhatsApp, and it stays findable.
  • A local steward keeps their town legible in twenty minutes a week, from a phone.
  • A newcomer understands the place in one evening.

It is explicitly not a social network, a marketplace, a tourism guide or a chatbot. AI only removes friction inside deterministic workflows.

Constraints

Verified
  • No accounts for visitors. Everything public opens from a link or a printed QR code.
  • Previews matter more than pages. Links travel through WhatsApp, so each one has to preview with the real event, not a blank app.
  • Many towns from the first commit. One network, several communities, each in its own time zone, with no data leaking between them.
  • WhatsApp's own rules. A business number cannot read groups, so the flow is forward-to-publish. Business verification and message templates take weeks and cannot be hurried by code.
  • Only publish what a source made public. Seed data carries no phone numbers, because a plausible Mexican mobile is somebody's real number.
  • Agents write much of the code. One language, one process, one schema, and boundaries enforced by tooling, so an agent can hold the whole system in context and cannot quietly cross a line.

My part

Verified

This is my own project. I shaped the product, made the architectural calls recorded in its decision log, and directed the Claude Code agents that wrote most of the code. I reviewed what merged. All 50 commits to date come from my studio machine.

The repository's AGENTS.md sets the rules the agents work under: change a documented decision only by writing a new one, never spread a database row into a response, and treat pnpm check (types, lint, format, migration drift and tests) as the definition of done.

Architecture

A modular monolith in a pnpm workspace. Select a box for its rules, or trace a request through the system.

Verified boxes and boundaries Illustrative layout
Hacer Aldea, system drawing
A browser and a WhatsApp flyer bot talk to one server. The server uses a core package and a database package; only the database package talks to Postgres. The app uses the core package and never the database package. A separate community atlas mirrors places into the database every night.one process, one deployimportsimportsimportstypes onlyPhone or browsera link, a QR code, no accountFlyer botWhatsApp, separate repoAppExpo + Expo Routerapps/appServerHono on Node 24, one processapps/serverCoreZod schemas, enums, pure rulespackages/coreDatabase packageDrizzle schema, migrations, queriespackages/dbPostgresmanaged, server-only accessCommunity atlasseparate site, owns places

Server

apps/server
Does
Serves the public JSON API under /api and the web build, injecting each route's Open Graph tags so shared links preview properly.
May use
packages/core and packages/db.
Never
Imports the app. Spreads a database row straight into a response: every response goes through an explicit DTO mapper.
Why
Link previews are a must, and a static single-page app cannot produce them, so the server renders the meta tags.

Select any box in the drawing to see its rules.

Drawn from the project's docs/ARCHITECTURE.md and the no-restricted-imports rules in its ESLint config. The import boundaries are enforced by the linter, not by convention.

Decisions and trade-offs

From the project's decision log, in my words. Open one to see what it cost and what lost.

Verified
  • ADR-025

    Recurring events expand in wall-clock time, not UTC

    Problem
    A weekly class is “Wednesdays at 18:00”, a calendar fact. Adding 7 × 24 hours to a stored instant is right until a clock change, then every later occurrence is an hour wrong. It also quietly depends on the machine's time zone, so CI, the container and a laptop could disagree.
    Decision
    Expansion steps calendar dates in the community's own IANA zone and converts each one to an instant. Nothing reads the ambient time zone. “Until” is stored as a calendar date, not a timestamp.
    Cost
    A class keeps its duration across a clock change rather than its end time. The difference is one hour, once a year, in a country that stopped observing DST in 2022.
    Rejected
    Storing occurrences as UTC instants and stepping them; it fails west of Greenwich and twice a year.
  • ADR-028

    The atlas owns places; this app mirrors them read-only

    Problem
    A separate, already-launched atlas site had curated places, promised URLs and an owner claim flow. This app had the recurrence engine and installability. The same café edited in both would diverge within a month.
    Decision
    Keep both, give places one writer. A nightly job copies the atlas feed in. A database trigger refuses any edit to a mirrored row unless the sync is the one writing.
    Cost
    Places can be up to a day stale, and the public feed has no descriptions or photos, so mirrored places show a name, a kind, a point and a link out.
    Rejected
    Merging the two, which would have thrown away either the launched site or the engine. A live proxy, which would have broken joins and added a network hop to the busiest screen.
  • ADR-027

    Database tests run against real Postgres, and CI proves they ran

    Problem
    The schema is the thing under test. A mock cannot catch a missing constraint, a cascade that does not cascade, or a transaction that leaves an orphaned row.
    Decision
    Tests apply the migrations to a real Postgres and truncate between tests. They skip locally without Docker, but a dedicated test fails in CI if the database URL is missing, so a suite cannot stop running while the pipeline stays green.
    Cost
    Slower tests and a database in CI. A migration-drift check runs too; its first version passed against a schema it should have rejected, so it is now verified in both directions.
    Rejected
    Mocked repositories, which only prove the mock agrees with itself.
  • Architecture

    Link previews need a server, so the web build is served, not hosted

    Problem
    Most links will be shared in WhatsApp groups. A link that previews as a blank app is a link nobody taps. A static single-page app cannot give each event its own title and image.
    Decision
    The same Hono process that serves the API serves the Expo web build and injects each route's Open Graph tags.
    Cost
    A long-running server instead of free static hosting. It also suits webhooks and cron, which the plan needs anyway.
    Rejected
    Static hosting on a CDN with a generic preview for every page.
  • Architecture

    A small typed fetch wrapper instead of Hono's end-to-end client

    Problem
    The plan called for Hono's typed client. In practice the type-only import dragged the server's whole module graph, Drizzle and Node built-ins included, through the mobile bundler just to erase it.
    Decision
    The app declares its response shapes beside a small fetch wrapper, next to the server's DTO mappers that produce them.
    Cost
    Two places to keep in step. Revisit if they drift in a way tests do not catch.
    Rejected
    Keeping the typed client and fighting the bundler.

Try ADR-025

The same weekly class, expanded two ways. Switch to a zone with a clock change, or read the end date the wrong way, and watch the difference.

“Wednesdays at 18:00, until 25 November”Illustrative, runs in your browser
Six weekly occurrences expanded two ways
#Stored instant + 7 × 24 hStepped in wall-clock timeInstant stored (UTC)Shown?
1Wed 21 Oct, 18:00Wed 21 Oct, 18:002026-10-22 00:00Zyes
2Wed 28 Oct, 18:00Wed 28 Oct, 18:002026-10-29 00:00Zyes
3Wed 4 Nov, 17:00Wed 4 Nov, 18:002026-11-05 01:00Zyes
4Wed 11 Nov, 17:00Wed 11 Nov, 18:002026-11-12 01:00Zyes
5Wed 18 Nov, 17:00Wed 18 Nov, 18:002026-11-19 01:00Zyes
6Wed 25 Nov, 17:00Wed 25 Nov, 18:002026-11-26 01:00Zyes

Adding a fixed week to the stored instant shows 4 of 6 classes an hour off after the clocks change. Stepping calendar dates in the community's zone keeps every one at 18:00. The end date is compared as a calendar day in the community's zone, so the last class is kept.

A re-implementation of the rule with the browser's Intl API. The production code uses date-fns-tz and rrule in packages/core/src/rules/time.ts, and its tests run against both Denver and Mexico City.

What was built, and when

From the commit history. Two short bursts, a month apart.

Verified
  1. 1 Sep 2026

    Workspace, the master plan frozen at v1.0, core rules and time handling, schema and first migration.

  2. 2 Sep 2026

    Occurrence materializer, public API, the app, link previews and a smoke script. Search, calendar files, QR codes, i18n, submissions and stewardship. Docker image and a public deploy.

  3. 3 Sep 2026

    Audits against the existing atlas, then the read-only mirror (ADR-028).

  4. 10–12 Oct 2026

    Guest-facing screens, flyer submissions stored for review, over-the-air updates and a TestFlight build lane.

The Today screen on a phone: a search box, filters, and tomorrow's events with time, place and price, one marked as repeating every Saturday.
Today, on the pilot deploy. Sample content, screenshot taken 16 October 2026.
A month calendar for October 2026 with dots marking days that have activities, most of them from weekly recurring events.
The calendar. Most dots come from recurring events expanded by the ADR-025 rules. Sample content.

Result

Verified

A pilot deploy is running with seeded sample content. On 16 October 2026 I loaded it, and the server returned the community's own title and description in the page's link-preview tags. The last recorded local run passed 57 database tests, 120 server tests and 9 ingest tests. A debug iOS build runs in the simulator, and the TestFlight lane exists.

Not done

  • Member accounts, private responses and agreements.
  • Persistent listings and notices, and the steward queue for them.
  • The WhatsApp intake changes on the live bot.
  • Native notifications, store builds and a production launch.

What I would do differently

Inferred

Pick the system of record before the second surface exists. By day three there were two Hacer Aldeas holding places. ADR-028 settled it cleanly, but it is a decision I would rather have made before writing the places table.

Test the contract between repositories, not only each repository. The flyer bot was posting to an ingest route that did not exist yet on this side. A contract test shared by the two would have caught it the day the bot was written.

Start the slow, non-engineering work on day one. WhatsApp business verification and template approval run in sequence and take weeks. The plan itself names them as the longest non-engineering dependency, and no code shortens them.