# Stream G: the complete airport page
(written 2026-09-11 by the away-plan finalising session, from `AWAY-PLAN.md` section 4; the airport page's own placeholder view is the task list)

> **Status 11 Sep:** not started. Wave one of the away period, beside R2 and M. Runs as `/stream-g`.

**Runs:** autonomously, in the one working tree beside Stream R2 (discussions and the page
consolidation) and Stream M (standardise and merge). Staging only; nothing on production. Never
deploys; rian holds the deploy and migration tokens.

## Goal
Turn the airport page's `TemplateGaps` panel from a list of what is missing into a list of what
is filled in: opening hours with an honest provenance, featured products by family, the
shopping-feature filters, a comparison preset, and the category and category-at-airport pages
the structure proposal already sets thresholds for. Every part lands as the shopper would see it
on staging and as the crawler would read it in the server body.

## Working assumptions this lane proceeds under (rian's to overturn; `AWAY-PLAN.md` §3)
- **Opening hours are a new source class**, collected from airport operator sites where their
  robots allow, left for hand population where they do not, with the provenance shown. Each
  operator domain is its own host with its own robots file read before it is added; permission
  never transfers between domains, exactly as retailer domains work. *If rian says no collection
  of hours at all:* G1's collectors are switched off by deleting their registry entries; the
  table, the hand path and the provenance stay as they are.
- **All six page types ship on 18 Sep where the coverage thresholds are met**, with beauty
  switching on as its coverage crosses the bar (`decide-page-types-18-sep` is still rian's to
  answer; this lane proceeds on the first option). *If rian chooses the buffer week:* G7 is
  reverted before it is deployed, one commit; nothing else in the lane depends on it.
- **The per-airport view is where the provenance lands** (the review area's stated order, third
  item). This lane builds the read and the component; mounting it on `/collectors` is Stream M's
  file during wave one and Stream R3's page in wave two, so G raises an issue with the exact
  import line rather than editing their files.

## What already exists, so you build on it
- `AirportDetail` (`models/hubs.py`) carries `guide: AirportGuide | None` with `hours: str | None`
  and `checked_at`; `services/airport_guides.py` holds the written guides in code (Heathrow so
  far); `seo.airport_facts_html` already prints "Opening hours" when the guide has them.
- `web/src/lib/airportTemplate.ts` names fourteen parts with `present()` checks; the `hours`,
  `featured`, `categories`, `filters` and `compare` parts are the ones this lane fills. The
  `TemplateGaps` panel is browser-only and owner-facing; `tests/test_seo_airport.py` pins that
  the server body never draws it.
- `/airports/{slug}` is server-rendered in `main.py` through `seo.airport_body(detail, tab)`;
  the shelf tabs are the first place a query parameter selects server content, pinned by
  `TestAirportBody`. `urls.airport_tab` and `lib/urls.ts` mirror each other.
- `services/robots_policy.py` and `collectors/robots.py` are the one robots implementation:
  by-name matching, wildcards, any matching Disallow is a no, 404 open, 401/403 refusal, 5xx
  stop. Hours collectors import them; they never grow a second parser.
- `services/coverage.py` and the structure proposal (`lib/structure.ts`, `/structure`) carry the
  coverage thresholds a page type must clear before it is built.
- `services/featured.py` picks featured products site-wide; `award_picker.py` chooses medals.
- The comparison tool is `/savings` (`SavingsPage.tsx`), which compares the shopper's chosen
  airports; `/trip` redirects into it.

## Tasks, in order (ids on /plan; record each with `main/scripts/plan-set.py`; commit prefix `G:`)
1. **G1 Opening hours, collected where robots allow.** First, one robots.txt read per airport
   operator domain for the nineteen public airports (this is the whole network budget of the
   task; announce the list in `.logs/runs/hours-robots-<date>.md` with the verdict per domain:
   allowed path, disallowed, unreadable). Group the allowed domains by platform; **one collector
   per operator platform, never per airport**, under a new package `services/hours/` with a
   `HoursCollector` protocol in `base.py` that reuses the fetch port and `robots_policy` (a
   refusal raises `SourceBlocked` and is recorded, never retried in the same run). Schema-only
   migration, one head, revised from the current head (see Rules): `airport_hours(id, location_id
   FK, source_kind collected|hand, entered_by FK accounts NULL, observed_at, source_url NULL,
   text, detail JSON NULL, created_at)`; the newest row per location per kind is the current
   one, nothing is deleted. `app.cli hours collect [--airport IATA]`, registered from a new
   `cli_hours.py` through one `register(sub)` line in `cli.py`. Where the operator disallows or
   has no readable page, the airport is **left for hand population and the run log says so**.
   Fixtures: one saved operator page per platform in `tests/fixtures/`, a pure parser test per
   platform, the three robots outcomes. Not a single fetch beyond robots and the allowed pages.
2. **G2 Hours by hand.** `app.cli hours set <IATA> --file <path> [--by <username>]` writes a
   `source_kind='hand'` row with the date and the account; the same shape as a collected row so
   the page does not care which it got. The reader resolves hand over collected (a human value is
   never overwritten by a machine; a later collected row is stored, not shown, while a hand row
   exists). `AirportGuide.hours` is now read from the table, the code constant in
   `airport_guides.py` becoming the seed for the hand row at Heathrow (a one-off, idempotent
   `backfill hours_seed` in `BACKFILLS`). Tests on in-memory SQLite: hand beats collected, a
   second collect stores and does not show, `--check` prints and writes nothing.
3. **G3 Provenance per airport.** A read-only `GET /api/airports/{iata}/hours` (in a new
   `routers/airports.py`, classified under the public reads when the site is open and members
   otherwise, like the airport detail) answering `{kind: collected|hand|none, observed_at,
   entered_by_username, source_url}`; `AirportDetail.guide.hours_provenance` carries the same
   for the page. A component `components/AirportHours.tsx` renders the hours with their date
   and, in the owner-facing `TemplateGaps` panel, the provenance line ("collected 14 Sep from
   <host>" / "entered by rian 12 Sep" / nothing at all where neither exists). Write the shape into
   `main/docs/COLLECTORS.md` under a new "Opening hours" section and raise one issue for Stream M
   (`--owner M`) naming the exact import to mount the component as a per-airport row on
   `/collectors`, and one for Stream R3 (`--owner R`) so V2 carries it. The server body prints
   the date beside the hours; never the word "verified", never a rate.
4. **G4 Featured at this airport, by family.** A new `services/airport_featured.py`: for one
   location, the best-value comparable per family (spirits, wine and champagne, fragrance,
   skincare, confectionery, tobacco where visible), the same picker rules `featured.py` uses
   (comparable only, image preferred, medal as a tie-break), returned on `AirportDetail.featured`
   and drawn as one row per family on the page and in `seo.airport_body`. The `featured` part
   of the template becomes present when the list is non-empty. Tests on a seeded SQLite: a
   family with no comparable product yields no row rather than a guessed one.
5. **G5 Shopping-feature filters.** The storefront's own filter chips (award winners, travel
   exclusives, comparable only) on the airport page's full list, as query parameters the API
   already accepts on `/api/products`; every filtered address points back at the clean airport
   address with `rel=canonical` in the server head so the crawler indexes one page. `urls.ts`
   and `urls.py` move together (`test_seo_airport.py::TestAirportBody` pins the pair). The
   filter row stays as short as a real store's: build-time toggles belong in Settings.
6. **G6 A comparison preset.** A link on the airport page opens `/savings?from=<IATA>` with that
   airport preselected as the departure and the shopper's saved airports (or none) as the
   others; `SavingsPage.tsx` reads the parameter once on mount and never rewrites the shopper's
   saved set. One line in `lib/urls.ts`, a test on the parser.
7. **G7 Category and category-at-airport pages.** `/airports/<airport>/<category>` server-rendered
   (`seo.airport_category_body`, mirrored by a `pages/AirportCategoryPage.tsx`), built only where
   the structure proposal's coverage threshold for that pair is met, read from
   `services/coverage.py` at request time (a pair below the bar answers 404 and is absent from
   the sitemap; nothing is typed). Beauty categories switch on as their coverage crosses the
   bar with no code change. The site-wide category pages follow the same rule under
   `/categories/<category>` only if `lib/structure.ts` names that address; if it does not,
   G7 lands the airport pair alone and raises an issue naming the address question for rian.
   Every new address goes into the sitemap through the existing generator, and into
   `tests/fixtures/open_routes.json` and `tests/test_site_routes.py`. No existing address moves.

**Not in this stream:** guides for JFK, Hong Kong, Buenos Aires and Paris (Adam's copy), price
charts, the terminal and shop model (Stream A's A18 proposal), and any change to the header,
footer or brand.

## Owns
New: `main/app/services/hours/*`, `main/app/cli_hours.py`, `main/app/models/hours.py`,
`main/app/routers/airports.py`, `main/app/services/airport_featured.py`, the `airport_hours`
migration file, `main/web/src/components/AirportHours.tsx` and `.css`,
`main/web/src/components/AirportFeatured.tsx`, `main/web/src/components/AirportFilters.tsx`,
`main/web/src/pages/AirportCategoryPage.tsx` and `.css`, `main/tests/test_hours_*.py`,
`main/tests/test_airport_*.py`, `main/tests/fixtures/hours_*`.
Existing: `main/app/services/airport_guides.py`, `main/app/services/coverage.py`,
`main/app/models/hubs.py`, `main/app/services/seo.py` (the airport and category functions
only), `main/app/main.py` (the `/airports/*` and category routes only), `main/app/routers/catalog.py`
(the `/airports*` routes only), `main/app/services/urls.py`, `main/web/src/pages/AirportPage.tsx`
and `.css`, `main/web/src/pages/AirportsPage.tsx`, `main/web/src/pages/SavingsPage.tsx` (the
preset only), `main/web/src/lib/airportTemplate.ts`, `main/web/src/lib/airports.tsx`,
`main/tests/test_seo_airport.py`, `main/tests/test_site_routes.py`.

## Shared, append your own lines only
`main/app/services/access.py` (add your route keys to the class dicts; never restructure),
`main/app/cli.py` (one `register` line, one `BACKFILLS` entry), `main/web/src/App.tsx` (your
route lines), `main/web/src/lib/urls.ts`, `main/tests/fixtures/open_routes.json`,
`main/docs/COLLECTORS.md` (your "Opening hours" section), `main/docs/RUNBOOK.md` (your
commands), `main/CHANGELOG.md` Unreleased, `import/progress.json` through `plan-set.py`,
`.logs/handoff.md` prepend-only. Re-read a shared file immediately before editing it, make the
edit in one write, commit within the same task; never hold an uncommitted change to a shared
file across tasks.

## Must not touch
Stream M's column: `services/lines.py`, `services/merges.py`, `services/normalize.py`,
`models/catalog.py`, `services/collector_view.py`, `routers/collectors.py`,
`pages/CollectorsPage.tsx`, `DATA-MODEL.md`. Stream R2's column: `routers/discussion.py`,
`routers/items.py`, `routers/todos.py`, `routers/plan.py`, `routers/quote.py`,
`models/discussion.py`, `pages/DiscussionPage.tsx`, `StructurePage.tsx`, `QuotePage.tsx`,
`TodoPage.tsx`, `PlanPage.tsx`, `pages/plan/*`, `components/SiteHeader.tsx`, `web/src/vendor/*`.
The retail collectors under `services/collectors/*` and `ingest.py`; `services/featured.py`;
`docker-compose.yml`, `deploy/`, `.env`, `.app.env`, `.bw-auth.env`; the live database
(rehearse on `dfp-devdb`, 127.0.0.1:5433, in a fresh copy of the newest `backups/dfp-nightly-*.dump`);
production in any form.

## Rules
- **Robots before any operator domain is added, and any matching Disallow is a no.** A refusal
  is recorded and the airport goes to hand population; never a second parser, never a retry
  in the same run, never a browser-like identity. The network budget of this lane is the robots
  reads and the allowed hours pages, nothing else; announce each run in `.logs/runs/`.
- **Migrations: one head.** Run `alembic heads` immediately before writing and immediately
  before committing; if another lane's new revision is in `alembic/versions/`, chain your
  `down_revision` onto it and re-rehearse up/down/up on `dfp-devdb`. Never `alembic merge`.
  Commit the migration file in the same task it was written.
- **A GET never writes; a human value is never overwritten by a machine; empty beats guessed.**
- **No address that exists today moves.** New addresses are added; redirects are rian's call.
- **Dates, never rates.** "Hours collected 14 Sep" is the ceiling of the public wording.
- No em dashes and never "free" or "cheap" in anything a person reads on the page;
  `test_house_style.py` walks your new files.
- Everything else per `OVERNIGHT-RULES.md` and `AWAY-PLAN.md` §2: never deploy (say "green and
  ready" and name the after-deploy commands in order), own files only, `main/check.sh` green
  before every commit, handoff ≤25 lines, `/plan` updated, decisions on the running list with
  `--blocks` and `--weight` and the assumption you proceeded under.
