# Changelog

Newest first. The running version is shown in the app footer.

## Unreleased

- An airport page's **Opening hours** now come from a table rather than a typed constant: collected from the airport operator's own site where its robots allow it (Heathrow, Dublin and Toronto Pearson read on the first run), or entered by hand where they do not, and the line carries the date it was collected or entered. The placeholder view names which it was and who entered it; a shopper and the crawler see the hours and the date and nothing more. `GET /api/airports/{iata}/hours` answers the same.

- **Featured at this airport**: one row of cards per family (spirits; wine, champagne and beer; perfume; skincare and makeup) with the best value at that airport, the bottles it is the cheapest of our airports for, picked by the home page's own rules. A family with nothing to compare draws no row. The crawler's copy of the page carries the same rows.

- The airport page's full list has the storefront's own chips: **Comparable**, **Award winners** and **Travel exclusives**, each a link with its own address (`?awarded_only=true` and the like, the names `/api/products` takes), kept across the category chips, the sort and the pages. Every filtered address names the clean airport page as its canonical, so search engines index one page per airport. `GET /api/airports/{iata}` takes the same three parameters.

- **Compare from here** on every airport page opens My savings on that airport (`/savings?from=LHR`), beside the airports you saved. The link never changes your saved set: the airport leads the comparison for that visit only.

- **Category at airport pages**: whisky at Heathrow, gin at Zurich, at `/airports/<airport>/<category>`, built only where the pairing holds enough published products to be a real list (the bar is read at request time, so a category switches on as its coverage crosses it, beauty included). Each carries the standing line, the airport's other category pages, the exclusives in that category there, and the full list with the shopping-feature chips, sort and pages; the airport page links to the ones it has. In the sitemap; a pair under the bar is a real 404. The site-wide category address is a question for rian on the running list.

## 0.46.0 — 2026-09-11 — the airport page in one screen: facts up top, terminals as rows, three shelves as tabs

- The top of an airport page is now one compact panel: the summary of the duty free there, the standing facts beside it (operator, terminals, opening hours, the map, which shops we read, when we last checked), and the four counts. The stat cards, the shops sentence and the guide's own heading are folded into it.

- Where to shop is a row per terminal, showing its airlines and the first line of what is there, opening in place when you want the rest. Four terminals of prose no longer push the prices off the screen.

- The products are one shelf at a time instead of four grids stacked: **Best value here**, **Travel exclusives** and **All products** are tabs, and the shelf is in the address (`?tab=`), so any of the three can be linked or bookmarked. The category chips and the sort belong to the full list, where they apply. A filter, a sort or a page number selects the full list on its own. A shelf with nothing on it is not offered. The crawler's copy of the page draws the same shelf for the same address.

- The placeholder view is one panel of rows now — what is missing, who fills it, what is needed — instead of a card in every empty place, which made the page longer than the page.

## 0.45.0 — 2026-09-11 — Heathrow's duty free guide, an airport template that shows its own gaps

- An airport page can now carry the written half the structure proposal asks for: where the duty free is, terminal by terminal, the specialty and luxury shops around it, services like reserve and collect, a link to the airport's own map, and a dated line saying when a person last checked the locations. Heathrow is written from Adam's guide: World Duty Free in all four terminals, T5's Harrods and Prada, T3's second store inside the Flight Connection Centre, T2's two gate locations, T4's prestige beauty, and Reserve & Collect. The crawler's copy of the page draws the same block from the same words.

- New demo setting, **Placeholder view** (on by default): on any airport page, every part of the template nobody has filled draws a card where it will go, saying what it is, what we need, and who fills it — a person writes it, we build it, the collectors fill it, or it waits until after launch. Fourteen parts are checked, from the shops we read to the category-at-airport pages and the comparison tool. The airports list marks which airports have a guide written. Turn the setting off and the page is exactly what a shopper sees: nothing invented, and the server's copy never draws a placeholder at all.

- The home page's "Tracking N products across N shops" line is gone.

- The airports page is the airports we price and nothing else. The notes for us — which shops are catalogues rather than terminals, which airports we cannot read and why — are out; they live in `/sources` and the running list.

## 0.44.0 — 2026-09-11 — a footer ready for launch, pages that open at the top, loading outlines instead of jumps

- Links now open their page at the top. The site used to keep the window where it was, so a link near the bottom of one page (the footer, a settings sample link) opened the next page far down, sometimes on a bare footer while the page loaded. Back and forward still return to where you were. A second cause of a jump is fixed too: after following a "See it on a sample page" link, changing airports on the home page scrolled back to the sponsor tile.

- The footer is rebuilt for launch: the logo sits straight on the navy (no white box), then the promise and the social links; Shop (all products, drinks, beauty, travel exclusives, award winners, my savings); Airports (the six with the most products, named from the data, and "All 19 airports"); Read (articles, our price data). Under it, the copyright and the two sentences a price comparison owes its readers: prices are public shelf prices, checked and dated, and we are not affiliated with the retailers or brands. The "Coming soon" and "Behind the scenes" columns are gone.

- Pages that load show an outline of what is coming, in the real layout, instead of a line of text that the page then pushes down: product cards on the catalogue, its departments and shelves, Exclusives and Awards; rows on My savings; cards on the airports list; the hero, stat cards and a row of cards on an airport or brand page; the article list and an article. A change of filter keeps the last answer under the same veil the home page uses.

- The department menu loads its lists (airports and their flags, brands, shelves, articles) in the background once the page has settled, so the first hover opens a full panel rather than "Loading the airports...".

- The newsletter form: Tab from the name goes to the last name when there was no space in it, and straight to the email when there was; the home airport and interests appear as soon as the email box is chosen, not on the first letter.

- The home page's "What's coming" section is gone now that the articles section is real. In `/settings`, the "Editorial sample articles" switch is gone too, and each unbuilt feature's switch now says what it still controls (its links in the navigation and the Articles menu, and its teaser band).

## 0.43.0 — 2026-09-11 — airports set in two places, a newsletter form that grows, awards higher on the home page

- Airports are chosen in two places only: the picker on the home page and the dropdown under "Set your airports" in the header. Every other "Set my airports" button (the home page's savings heading and closing band, the Your airports card in the menu, the product page, the savings page with fewer than two airports, the airports page) opens that same dropdown where the shopper is, instead of sending them to another page; the home picker's "Browse all airports" tile opens it too. The savings page no longer carries its own picker or suggested pairings, and the airports page's "Add to my trip" buttons are gone (a card still says when its airport is on the trip).

- The newsletter form starts with two fields, a name and an email. Leaving the name splits it into first and last name (everything after the first space becomes the last name), and starting the email brings in the home airport and the interests. The same form in the footer and after articles works the same way.

- On the home page, the award-winning bottles now come before the articles (the billboard still follows them), and each winner wears its medal, the competition's own artwork, on its photo. The "By category" tiles are gone.

## 0.42.0 — 2026-09-11 — a sticky skyscraper beside articles, native tiles on the product and airport pages, rectangles on Exclusives and Awards

- An article's sponsor placement is now a sticky skyscraper (300 by 600) in its own column to the right of the text, staying in view as the reader scrolls. When it is off, the text is centred, as before; on screens too narrow for two columns it is left out. It replaces the magazine rectangle after the text.

- The product page's tile among the related bottles and the airport page's shelf tile are now native, like the one on the home page's savings: drawn as a product card, labelled Sponsored, and taking one card's place. The product page shows seven related bottles and the tile (eight without it). The airport shelf shows 23 bottles and the tile per page (24 without it), and its pages stay contiguous, so no bottle falls between two pages.

- Travel exclusives and Award winners each have a new placement: a 300 by 250 rectangle at the top right, beside the page's introduction (under it on a phone). When it is off, the introduction runs alone.

- `/settings` lists the new placements with their "See it on a sample page" links.

## 0.41.0 — 2026-09-11 — the articles section, a first article, and every sponsor placement one click from settings

- Articles look like a publication now, taking their cues from the Professor sites (the Olive Oil Professor's blog) in Duty Free Professor's own colours: section labels between rules ("— From the Professor —"), serif headlines, a small-capital "date · author" line on every piece. The articles page leads with the newest piece large, picture beside the words, then a grid. An article opens on a centred head (its section, headline, standfirst, "11 Sep 2026 · Duty Free Professor · 3 min read"), the picture at full width, then an unhurried serif reading column with a dropped first letter, spaced-capital subheads and centred pull quotes; after it, a short note on who publishes the site, the sponsor placements, and more to read.

- A piece with no author account takes the house byline, "Duty Free Professor"; one with an author names them. Cards and the article API now carry the author's name and a reading time. A piece with no picture gets a drawn stand-in on its card rather than an empty frame.

- A first article, "How to tell whether a duty-free price is really a saving", a buying guide under the house byline with its own drawn picture (a departures board of the checks), published on staging for Adam to read and keep, change or replace. Its source is `import/articles/is-it-really-a-saving.md`; editorial pictures ship under `/media/`.

- On `/settings`, every sponsor placement has a "See it on a sample page" link that switches it on in this browser and opens a real page of its kind scrolled to it: the home page, the airport with the most products, the top featured product, the newest article. The note above them describes the native tile as well as the banner boxes.

## 0.40.0 — 2026-09-11 — a quick savings page, a native sponsor tile, the billboard lower down

- "All my savings" answers in tens of milliseconds: a two-airport route took 0.6 to 2.3 seconds on staging (Madrid and Barcelona the slowest), because every price observation in the catalogue was ranked to find each shop's latest, and the database re-ran the route's "stocked at two or more stops" list once for every price row. Now that list is fetched once and only those bottles at the route's shops are ranked: 1 to 80 ms across ten routes, the same results in every sort and filter tried. Equal prices and equal savings now always fall in the same order.

- The savings on the home page fill the grid: with the sponsor tile off, two full rows of eight cards under the headline (it showed seven and an empty place).

- The sponsor tile on the savings shelf is now a native placement, drawn like a product card and labelled Sponsored: a square image in the card's photo box, the brand name, a headline, a description and a call to action. These are the assets of a responsive display ad (Google Ads' responsive display ads, IAB native), so an advertiser's existing files and words fit: a square image 1200 by 1200 (at least 300 by 300), a headline of up to 30 characters, a description of up to 90, a brand name of up to 25. Switched on from `/settings` without creative, it shows a sample ad at those limits with the image box naming its spec.

- The home billboard moved below the award-winning bottles, and when it is off its empty band no longer leaves a gap.

## 0.39.0 — 2026-09-11 — the trip savings hold still: placeholders, a refresh veil, and a quicker answer

- The home page's savings section no longer disappears while it loads. On a first visit it draws stand-ins of the cards' own shape (the headline and seven cards, shimmering) that are exactly the size of what replaces them. When your airports change, the last savings stay in place, dimmed under a shimmer, with "Finding the savings at ATH, BCN" until the new ones land. Measured with the browser's layout-shift score: 0.05 before, 0.0005 after.

- Choosing a single airport no longer empties the section: the savings stay site-wide until a second airport makes a route (the header menu's featured card follows the same rule).

- Product cards keep one height whatever they hold, everywhere they appear: the name takes two lines (longer names end in an ellipsis), the stocked line one, the shop bars four rows. A grid no longer changes size when its cards change.

- The answer comes faster: pricing the featured cards ranked every price observation in the catalogue to find each listing's latest, about 450 of the 600 ms the request took on staging, and up to six seconds for some airport sets; it now ranks only those products' observations, 130 to 220 ms, with identical results. Two shops at the same price now always list in the same order.

- The headline saving strongly prefers an award winner with a photo: one takes the big card whenever no more than one other featured card saves more than twice its dollars (a single luxury bottle's gap may tower over it; two may not). Tuned on staging's savings for 438 airport sets: award winners with photos now headline 11% of them, up from 5%. The headline also shows its saving as a percentage, "Save $43.47 (37%)".

- The line of airport codes under "The savings on your trip" ("ATH · EZE · BCN · BOG - shop by shop, cheapest first") is gone, and so is the sentence under "The biggest savings right now", so the heading is the same height either way.

## 0.38.0 — 2026-09-11 — the search answers as you type

- The header search suggests as you type, from the second letter: airports (flag, city, airport name, code), brands with a page (and how many products each has), and products (a picture or the monogram, the category, how many shops stock it), with the letters you typed marked, then "See all results". Airports and brands answer on every key; products arrive once typing pauses, in about a third of a second.

- The arrow keys move through the suggestions and Enter opens the one chosen; Enter with none chosen searches as before (a code or a city typed whole opens that airport). Escape closes the list, and a second Escape clears the box. A click anywhere else closes it.

- A new read, `GET /api/search/suggest?q=`, matches products exactly as the products page does (the words in the name or the brand, accents ignored, visible shops only) and ranks them for typing: names that begin with the words, then brands that do, then the most widely stocked. It carries no prices, which is what makes it fast: the products page's query spends about half a second per search pricing the whole catalogue. It is a storefront read like `/api/products`, members-only until the site opens.

## 0.37.0 — 2026-09-11 — the header's second pass: Articles, the profile circle, a compact airport picker

- The reading pages have their own department: Articles, after Beauty, opens a panel with Reviews & tasting notes, Buying guides and News (each with its line of what it is for), "All articles", and the three newest articles once any are published. Reviews, Buying guides and News leave the row; Price tracker, Exclusives and Awards stay.

- The Drinks and Beauty panels say "Popular brands", not "Popular houses".

- The search box reads "Search product, brand, or airport", and means it: an airport code or a city typed whole ("LHR", "Paris") opens that airport's page, and a search that names airports ("spain", "heathrow") lists them above the products it found.

- The top row ends with the airports button and a profile circle: the account's initials (a picture later), opening onto Account, Admin and View as for those who may, then Build Discussion (the review page) with the discussion pages this account may open beneath it (the proposal, the site structure, the to-do list, the demo settings, the data sources, the build plan), then Sign out. Signed out, the circle leads to sign in. My savings moved to the end of the nav row. The footer's "Behind the scenes" column lists the same pages from the same list.

- On a phone the airports button shows the plane alone, so the logo, the button, the profile circle and the menu share one line.

- The home page's "Where are you flying?" card runs the full width and holds exactly two rows of airports, however wide the screen, the last tile leading to every airport; the hint and the buttons share its last line.

- The home page's closing band now meets the footer, with no strip of page between them.

## 0.36.0 — 2026-09-11 — the header leads with departments: Airports, Drinks, Beauty

- The nav row now starts with three departments, as in Adam's prototype: Airports, Drinks and Beauty. Each is a link to its own page and opens its own panel on hover, or from the small arrow beside it (click or keyboard; Escape closes it). Products and "Airports we price" leave the row; Airports takes their place. The rest of the row (Reviews to Awards) is unchanged.

- The Drinks and Beauty panels have four columns: the department's shelves with our own drawn glass and bottle marks and their counts, its ten leading houses (a whisky house no longer sits among fragrance houses), the Professor's picks (medal winners for drinks, travel exclusives, bottles priced at two or more airports), and a featured saving from the same selection as the home page, dated by the latest collection. Where the department has no featured saving, the column invites the shopper to set their airports.

- The Airports panel lists every visible airport with its flag, grouped by region. The old panel showed twelve of nineteen, alphabetically, and left out Paris, the biggest shop.

- "All drinks" and "All beauty" are real pages: `/products?family=liquor` and `/products?family=beauty`, headed with the department's name, their filter chips limited to its shelves. The products API takes a `family` filter, and each brand in `/api/brands` carries the family most of its products sit in.

- On a phone, the departments lead the menu drawer as collapsible sections, ahead of the other links.

- The server-rendered pages carry the same header, so nothing moves when the page comes alive; an airport page marks Airports, as the app does.

## 0.35.0 — 2026-09-10 — the site gets its own sign-in: accounts, levels, View As, members-only mode

- The site has its own sign-in. Every visitor signs in with a username or email and a password before seeing anything (members only while we build; the storefront opens to the public later by one setting); the shared password on the dev host is retired by the deploy steps below. Three accounts at launch: the owner, Adam and Mark. Adam and Mark see the client pages (the review page, the proposal, the structure page, the to-do list, the settings) and comment, rank, toggle and complete under their own names; the plan, the data sources page and the account administration are the owner's. A comment is now the signed-in person's, never a typed name; each person edits their own comments. The owner can view the site as anyone (read-only, or acting) from the account menu, and manage people and levels on `/admin`.

- Sign-in pages: `/login` (with "Forgot password?"), `/forgot`, a welcome link to choose a first password, a reset link, and `/account` to change a password or sign out. Eight wrong passwords in a row lock an account for a quarter of an hour; ten attempts from one address in a quarter of an hour are refused for a while.

- A mail provider for invites and reset links, inert until `MAIL_PROVIDER=resend`, `RESEND_API_KEY` and `MAIL_FROM` are set (in an app-only env file, never the shared one); until then the welcome and reset links come from the CLI.

- Every route now needs to be classified before it answers: a page or an API read without a signed-in account answers with the sign-in page or a 401 while the site is members only; a write from another site is refused.

- The app's own file-serving route (the one that answers a stray `/logo.png`) now serves only files under the built site's folder. Until now a request path with `..` segments could read any file inside the container to anyone past the gate; found in the accounts plan's review on 10 Sep, fixed with a test.

- The four wine competitions' silver and bronze medals (and the Asia competition's gold) now draw as badges on cards and product pages like every other medal, from the competitions' own artwork; until now they fell back to a text tag.

- The home page's airport picker no longer moves the card below it when the airports arrive: the collapsed list's rows are drawn at their final height while loading. Measured on a phone profile: layout shift 0.017 to 0.001.

- Medal matching is stricter about variants: a competition entry no longer lands on a bottling it does not name (a plain entry never sits on the Rye Cask Finish, the Cask Strength or the Peated), and entries written with a typographic apostrophe now match their brand. After deploy: `python -m app.cli awards --rebuild` (review sample in `.logs/verification/awards-matching-review-2026-09-10.md`).

- **Before this deploy** (`main/docs/RUNBOOK.md`, Accounts): nothing collecting; `docker exec dutyfreeprofessor-db pg_dump -U dfp -Fc dfp > backups/dfp-$(date +%F)-accounts.dump`; `ACCOUNT_OWNER=rian` and `SITE_ACCESS=members` into `.app.env`, the two owner sign-in lines from the earlier note deleted (they are ignored), the file tightened with the `setfacl` recipe. **After it, in this order, inside the container:** `backfill accounts`, `backfill levels`, `accounts create --username adam ...` and `mark` on level `admin`, `accounts set-password --username rian`, `accounts invite-link --username adam` and `mark` (printed once; send them yourself), `backfill authors --map Adam=adam --map Mark=mark --map rian=rian`, then `awards --rebuild`. Then the curl list and the browser round trip from the runbook, `srv-gw security-audit`, and, owner only, `srv-gw id-gate --site dutyfreeprofessor.demoing.info --mode public`. Migration #4 rides (`b5c6d7e8f9a0`): the account tables, the overrides table and seven who-columns; the container applies it on start.

## 0.34.0 — 2026-09-10 — the structure review lands: airport addresses by name, Perfume, the brand floor, the price of record

- Product page titles and medal lines no longer carry a dash: "Santa Teresa 1796: duty-free prices at 3 shops" and "Double Gold, NYISC 2024", as the house style reads everywhere else.

- The structure page has been rebuilt around the review: each of the five questions carries what was settled, two new sections list what the review decided and what is still open, the airport page is specified as the duty free situation only (shops by terminal, specialty shops, perhaps a map), the brand page gains Mark's templated fields, the family reads Perfume throughout, the addresses show the reviewed shapes, and the page rows that shipped on 5 Sep read live. The to-do ask for airport write-ups is reworded the same way (applied to the live list; the row keeps its comment). The feature board's airport card matches.

- The shop's own price in its own currency is the price of record: it leads each row of a product's price table in bold, and the US dollar figure beside it is labelled as our conversion at the exchange rate held on the date checked. The product's structured data no longer states a US dollar price range; each shop's offer is in the shop's currency, and a range appears only when every shop quotes the same currency.

- A brand page now needs three or more published products priced, between them, at two or more airports, the floor the review settled; a brand under it has no page, no sitemap entry and no link from its products, and the brands index, the page and the sitemap all apply the one rule.

- The category is Perfume, not Fragrance, wherever a shopper reads it: navigation, filters, breadcrumbs and category links; cologne and eau de toilette stay beneath it. After deploy: `backfill perfume` (renames the stored category; safe to repeat).

- Airport addresses lead with the airport's common name, then its code, then its city, as the structure review asked: `/airports/heathrow-lhr-london` where it was `/airports/lhr-london`; JFK is the one airport known by its code, `/airports/jfk-new-york`. Every earlier address, the bare code included, redirects to the new one and keeps its filters. The names are a declared table (twenty-one airports), so an airport nobody has named yet keeps its earlier address; the API now sends every airport link ready-made so no page rebuilds one.

- The structure proposal has been reviewed: Mark's points and Adam's replies now sit on the page beside the part of the structure each one is about, with rian's answers under them. Settled by that round: the family is renamed Perfume, a brand needs three products across two or more airports before it gets a page, launch publishes the comparable set with the rest opening as price history accrues, and the shop's own price in its own currency is the price of record.

- Comments can be edited in place wherever a comment thread appears (the structure page, the quote, the feature board, the to-do list, the running list). The wording changes; the author never does, and an edited comment is marked as edited, so nobody's words can be quietly reattributed. No migration: the table already carried the timestamp this uses.

## 0.33.0 — 2026-09-05 — the heavy build lands: fresh prices, brands and merges, airport and brand pages, articles and email capture, the running list

- A shop that verify has blocked (an uncleared size, identity, currency or parse failure) now disappears from the public site until a human clears the check: its prices leave every card and price table, its airport page and its products' sitemap entries go with them, and the counts on the home page and the data page shrink to match; a moved price or a retailer's refusal changes nothing, those rows keep their date. The same rule now covers the trip comparison (`/api/trip`, the stops offered, the suggested routes) and the coverage list, which had kept showing such a shop and linking to its airport page.
- Article pages are served: `/articles` lists the published pieces and `/articles/<slug>` serves one with its content in the HTML like a product page (Article and breadcrumb structured data, a real 404 for a draft or unknown slug), published articles join the sitemap and the RSS feed, and an airport's published write-up appears on its airport page. After deploy: import Adam's hand-ins and publish them (`main/docs/RUNBOOK.md`, Content).
- The medal in a card's corner and the medals listed on a product page now follow the written strategy: each competition's latest result, the highest level first, ties to the newer year, then the competition nearest the traveller's chosen airports; a bottle that won Double Gold in 2024 and Silver in 2025 shows the Silver. `GET /api/products/{id}` accepts `at=` so the order can follow a traveller's airports.
- The structure page marks the airport and brand pages as built and deploying this weekend, and the server-rendered header matches the new browse panel's closed state.
- The header has a browse panel: beside Products, a small toggle (or resting the mouse on it) opens the shelves by category with their product counts, the twelve biggest brands with a page, and the airports we price, every row a link to a real page. It is keyboard-usable (Enter opens, Tab moves through, Escape closes), closes on a click outside or on navigation, opens nothing over the page's layout, and on a phone the same three lists sit as collapsible sections at the foot of the menu drawer, which now scrolls. The brands column appears by itself once the brands table is live; categories will group by family (drinks, beauty) once the API says which is which.
- The home page leads with the headline saving: the featured list's top pick printed large (the bottle, the saving in dollars, the cheapest shop and price, the dearest price, how many shops were compared) above the rest of the featured cards; the travel exclusives moved up to directly under the savings, on their own band, with the live count in the heading and an Exclusive mark on each bottle. On a product page the saving is printed as large as the price. Gold labels (categories, brand lines, kinds) use the darker gold everywhere they are text, so every small label passes WCAG AA.
- Instagram and YouTube links in the header's nav row and under the footer tagline, shown only once the site is given the profile URLs (they arrive from the server's environment, the same place the Organization markup will state them), so no empty icon ships before Adam and Mark hand them over.
- The sample editorial block is gone from the home page; the latest articles appear there by themselves once the first one is published. The newsletter is a real form: the full one on the home page's newsletter band, a compact one at the top of the footer on every other page; both post to `/api/subscribers`.
- The product page's brand name now links to the brand's own page when it has one (and to a catalogue search when the house is too thin for a page), in the served HTML and in the app alike; the browse panel's categories column is grouped by family (Drinks, Beauty, Confectionery, Tobacco) now that `/api/stats` says which family each category belongs to; and a traveller with saved airports sees the medals on a product page ordered for those airports, the page keeping its content while that order loads.

- Brand pages: `/brands/<slug>` for every brand with two or more products priced at the airports shown (the slug is the brands table's, so "Moët & Chandon" and "MOET CHANDON" are one page): the products with prices across airports, which airports stock the brand and how often each is the cheapest, category filters for houses with several, sort and paging. Server-rendered with CollectionPage, ItemList and Brand structured data, breadcrumbs and sitemap entries; an alias slug redirects to the house; a thin or unknown brand is a 404. `GET /api/brands` and `GET /api/brands/{slug}` back it. The product API now carries the brand's slug (`brand_slug`) on every card and product, and only for a brand that has a page, so a brand name links to its page when there is one and to a catalogue search when there is not; the product page's link switches over next.
- Articles are stored, not built: migration #5 (`a4b5c6d7e8f9`, schema only) adds `articles` (three kinds: article, airport write-up, category intro; draft or published) and `subscribers`. `python -m app.cli articles import <file | --upload id | --todo id>` takes Adam's Markdown, text or Word hand-ins from `/todo` straight into drafts (Word via `mammoth`; embedded images are counted and left out for the shared Drive), idempotent on slug, `articles publish <slug>` makes a piece public; `GET /api/articles`, `/api/articles/{slug}`, `/api/articles/airport/{iata}` and `/api/articles/category/{name}` serve published text only, with the body rendered to safe HTML by one renderer. Article pages (`/articles`, `/articles/<slug>`), the latest-articles block and the airport/category text blocks exist as components and switch on when Stream B wires the page route and Stream C places them. After deploy: `alembic upgrade head`; no backfill; import Adam's files with the commands in `main/docs/RUNBOOK.md` (Content).
- Email capture: `POST /api/subscribers` stores first name, last name, email, home airport and interests with the consent sentence and its timestamp (no IP, no user agent, the address never logged), answers the same for a new and an already-listed address, ignores bot submissions that fill the hidden field, and is rate-limited per visitor and overall. The list leaves the database only through `python -m app.cli subscribers export` in the container (owner only); there is no read route. The form component (`SubscribeForm`) is ready for the home page and footer.
- Sponsor slots: a `SponsorSlot` component for the IAB sizes (728x90, 970x250, 970x90, 300x250, 336x280, 300x600, 160x600, 320x50, 320x100) that reserves its box at the size's aspect ratio so nothing shifts, shows Adam's creative from `public/sponsors/` (versioned file names, registered in `web/src/lib/sponsors.ts`) when there is one and nothing when there is not, and can only ever show a static image; positions follow Stream C's brand pass.
- Medals: the rules for which medal a bottle shows are now written down (the one-page strategy on the review page, decision card 5, and in `award_picker.py`): each competition's latest result stays on the page and in the structured data, the highest level leads, ties go to the newer year, then the competition nearest the traveller's chosen airports (JFK: New York), then the judges' score; a pin by competition, year and medal is designed in and waits for the `overrides` table. `python -m app.cli awards --rebuild` now reconciles by that natural key instead of deleting and reinserting, so award ids no longer change on a rebuild. The matcher is stricter: every brand word must be on the bottle and category words in a brand name no longer count, which removes other producers' medals from Charles Heidsieck, Beefeater, Johnnie Walker, Jacob's Creek and Wente rows, and two different entries landing on one bottle in one competition-year now store nothing. After deploy: `python -m app.cli awards --rebuild` (review sample in `.logs/verification/awards-matching-review-2026-09-05.md`). The card corner and product page pick up the new rules once `catalog_queries` calls the picker (issue for B).
- Brands are a table and duplicates are folded: migration #3 (`f3a4b5c6d7e8`, schema only) adds `brands` (one row per house, whatever the shops call it), `products.brand_id`, the size as the shop states it (`size_value`/`size_unit`), a per-vertical attribute (a fragrance's concentration) that keeps an EDP and an EDT apart without ever splitting a product a feed says nothing about, `merged_into_id`, `product_merges`, `merge_candidates` and `reverifications`. The match key now folds the brand, so "Moët & Chandon", "MOET ET CHANDON" and "Don Julio®" lead to one product; a merged product's old address answers under its survivor (301). After deploy, in this order: `backfill implausible_sizes`, `backfill brands`, `backfill sizes`, `backfill categories`, `rederive`, `backfill merges` (on the 5 Sep dump: 2,215 brand rows, 1,032 products categorised, 40 duplicate groups folded, the rest queued for the Mon 21 review).
- Beauty widening: every Avolta store walks its drinks and beauty trees separately (no more alphabetical cap that once dropped whisky at Heathrow); beauty is read one page deep and a beauty family's product page is fetched only for the forty targeted lines (`import/beauty-targets.json`, from `.logs/runs/beauty-candidates-2026-09-05.md`); the four stores whose robots forbids paging re-read the product URLs already held so page-2 bottles keep their history; Dublin walks its beauty tree for the targeted lines; the Shopify shops now read their Spanish shelves (Licores, Vinos, Rones...) and their beauty shelves in full. A size no single item comes in (a mist declared at 7,624 ml by its retailer) is now refused at collection and cleared from rows already stored.
- The crawl surface: `robots.txt` now states the site's Content Signals (search yes, AI answers yes, AI training no) and addresses the training crawlers by name; `/feed.xml` is an RSS feed of products newly added to the catalogue; `/llms.txt` briefs AI assistants on what the site is and where its pages and machine-readable surfaces are, with live counts; `/data` is a public description of the dataset (what is collected, how, what a row holds, coverage, contact) with schema.org Dataset markup; and `python -m app.cli_pages indexnow` tells the IndexNow engines which pages changed after a collection. After deploy: mint an `INDEXNOW_KEY` in `.app.env` (the key file is served at `/<key>.txt`).
- Airport pages: `/airports/<iata>-<city>` (for example `/airports/lhr-london`) for every airport shown on the site, with the counts (products priced, sold at other airports too, cheapest here, travel exclusives), the products where that airport is the cheapest of ours, its categories as filters, its travel exclusives and the full product list with sort and paging. The page arrives with its content in the HTML like a product page, carries CollectionPage, ItemList and Airport structured data, breadcrumbs and a sitemap entry, and is linked from the airports index and from every airport row in a product's price table. A bare code or stale words redirect; a hidden airport is a 404. `GET /api/airports` and `GET /api/airports/{iata}` back it.
- The reader now links to its own identity page, `https://bot.dutyfreeprofessor.com` (a separate static site), in every request; the page states the exact user-agent string, the pacing and robots behaviour, how to block or slow the reader, and the contact address.
- The Issues and to-dos tab now ranks by what the plan is waiting on: each item names the plan tasks it gates and a weight, and urgency is computed from the live task statuses on every request (in progress or due within days → needed now; gated work already done → went ahead under the assumption; otherwise before its task, or later). A "Before you go further" list at the top holds only what rian must settle now; informational items fold away under "Later".
- The build-plan page gained a second tab, Issues and to-dos: what only rian can decide, what he must do or check, and the issues to address, each with a comment thread and decide / done / dismiss / reopen actions. Items come from `import/items.json`, which sessions maintain through `main/scripts/items.py`; rian's actions are stored in the new `owner_item_states` table (migration `e2f3a4b5c6d7`, schema only, no backfill) and merged on every request. The old `/issues` page now redirects there; the two markdown registers are generated from the list.
- Seoul (The Shilla Duty Free) collector on the rendered fetch: server-rendered product pages read as a browser receives them, the discount price in USD with the list price crossed out, the barcode from the page's reference number, stock from what the page actually shows. Discovery walks the home page and each product's related-product rail under a per-run render budget, because the store's category grids are drawn by scripts its robots.txt forbids us to fetch. After deploy: `python -m app.cli collect --source shilla-icn` inside an announced window.
- Singapore (iShopChangi) collector on the rendered fetch: the targeted set only (drinks stocked at two or more of the collected shops plus the beauty brands shared by Paris and Athens), the duty-free departure price in SGD, one row per size, category from the page's own breadcrumb. The source publishes no barcodes, so its rows match other shops on brand, name and size. After deploy: `python -m app.cli collect --source ishopchangi-sin` inside an announced window.
- `python -m app.cli verify`: re-reads a sample of published listings from the retailer through the same collector and records a verdict per listing (pass, price moved, gone, size or identity or currency mismatch, parse failure, refused, review). One host at a time, robots re-read, crawl delay honoured, a shop abandoned at its first refusal. A correctness failure blocks that shop's publication until a human clears it with `verify-clear`; `verify-status` shows the rolling pass rate and the only date the public site may show per shop ("Spot-checked"). Plain-language explanation in `main/docs/QUALITY.md`.
- Migration #2 (schema only): `verification_runs`, `verification_checks`, `audit_snapshots`, `rejected_observations`, and `collection_runs.skip_counts` (the per-reason split of skipped listings). From this deploy every listing a collector or ingest refuses is stored with its reason and payload instead of only logged, and the audit's snapshots go to the database. After deploy: `alembic upgrade head`; no backfill.
- The rendered fetch: a browser sidecar (Playwright's Chromium in its own isolated container, no secrets, no database access, every request outside the retailer's own hosts refused) for the sources that draw their prices with JavaScript, behind the same rules as every other read: our own bot identity, robots.txt re-read each run, a ten-second floor between renders, a per-run render budget, and a challenge page treated as a refusal. After deploy: the compose file gains the `browser` service and a second network; `srv-gw deploy --build` builds it.
- `python -m app.cli audit`: a no-network data audit, nightly. Sixteen measures of the shapes a misread leaves behind (a location whose currency disagrees with its prices, a shop where every row is in stock, a size no bottle comes in, a spread no third shop corroborates), each against a threshold that starts at the value measured on 2026-09-05 and lives beside it in the code, plus the lists the dated human review starts from (brand spellings the fold has not joined, the merge candidates the rules would not settle alone, the largest spreads and discounts, sample rows with retailer links). Snapshots go to `.logs/verification/` and, after migration #2, to `audit_snapshots`. A merged product counts in no measure; duplicates are read by the merge rules themselves (what `backfill merges` would fold, so zero after it); three thresholds are re-pinned to the post-migration numbers (duplicate groups 0, brand folds 0, unexplained large formats 2), and the Mon 21 checklist gains a section asking, pair by pair, whether a merge candidate is one product or two.
- The build-plan page (`/plan`) now opens with the sixteen promises of the accepted quote, each one's state derived from the tasks that deliver it (delivered only when every task is done), then the list of things only rian can do in date order, then a day-by-day roadmap to Cannes with delivery and Cannes marked. Every task carries a target date; the production-environment steps (egress test, sizing, DigitalOcean provisioning, first deploy, domain and Cloudflare, go-live checklist) are now dated tasks of their own.
- The structure proposal page (`/structure`) now frames its figures as the sixteen airports collected today with nineteen at launch, reads the published-pages count live instead of stating one, reflects the structured data that actually ships, and carries re-measured beauty counts. One sentence is marked for rian to complete before it goes to Mark.
- Structured data corrected for the validators that matter: every URL in it is absolute and built from `PUBLIC_BASE_URL`, barcodes are typed by width (gtin8/12/13/14), availability is stated only when a stock flag was actually observed, each shop's price appears as its own offer in its own currency, the home page declares the Organization, and the retired SearchAction is gone. Unknown products and mistyped addresses now return a real 404, HEAD requests work, pages carry ETag and Last-Modified (so crawlers get 304s), and HTML and JSON are marked no-store so a CDN can never show an owner's change late. After deploy: set `PUBLIC_BASE_URL` in `.app.env`.
- Audit defect list fixed: a barcode/size contradiction is now refused rather than creating a wrong-size product; each price carries the exact minute it was read and the FX rate it was converted with; Dubai stock is unknown unless the API states it; a location's currency follows its collector; runs whose process died are marked as errors; image attribution says barcode or name match, and a name match must agree on age statements. After deploy: `backfill stuck_runs`, `backfill locations`, `backfill orphan_tiles`, `backfill image_provenance`.
- Product pages now arrive with their content in the HTML: the name, brand, size, medals, the verdict and the full price table are rendered on the server with the same markup the app draws, so search engines, AI assistants and anyone with JavaScript off read the page. The app then takes over in place (no skeleton, one fewer request; layout shift measured at 0). Brand and category on a product page are now links. Text responses are gzip-compressed by the app itself.
- Every collected listing now keeps the fragment it was parsed from (`raw_records`, facts only: no descriptions or imagery) and names its category family (`products.vertical`), so a rule change can be re-run without a re-fetch. After deploy: `python -m app.cli backfill vertical`.
- Migration #1 (schema only): `accounts`, `raw_records`, `price_observations.source_kind`, `sources.identity_mode` + `permission_record`; a newly collected airport now starts hidden. After deploy: `alembic upgrade head`, then `python -m app.cli backfill accounts`.
- Every collector now reads its host's robots.txt through one shared policy (`robots.py`): a group addressed to DutyFreeProfessorBot by name is honoured, wildcard rules apply, the host's crawl delay is respected, and a host that refuses the robots read itself is treated as a refusal. Four query-blocked Avolta stores become page-1-only; the four Shopify shops and Dubai currently refuse our identity and show their last-seen dates.

## 0.32.0 — 2026-09-03 — Paris (Extime) collector

- New collector `extime-paris` for CDG/Orly: sitemap-sharded discovery, RSC
  payload parsing for size and GTIN, duty-free pricing, per-variant emission.
- Product families deduped: 12,430 catalogue URLs are 6,637 real products.
  Halves both the recorded observations (which were doubling) and the crawl.
- Shared fetcher now requests gzip: 659KB -> 89KB per page, all collectors.

## 0.31.0 - 2026-09-03

- New /structure page: the site structure proposal opened for comment, with
  the four decisions at the top, the measured catalogue evidence behind them,
  and a comment thread on every section. Same thread mechanism as the feature
  board and the quote, so replies all land in one place. Noindexed. Its footer link
  appears only with curator mode on, so it stays out of the client's way until
  it is ready to be shown.

## 0.30.0 - 2026-09-03

- Brand alignment, first pass. The Duty Free Professor logo replaces the
  wordmark in the header and footer. The palette moves to the navy taken from
  the logo (#003F6C), with every contrast pair re-checked. The display face
  becomes Libre Baskerville, the open Baskerville revival, chosen to sit with
  the baskerville-urw used across the other Professor sites; Fraunces is gone
  and its files removed. Inter stays for body text.

## 0.29.3 - 2026-09-02

- Quote page: timeline starts Thursday, the design milestone moves to Monday
  as the brand alignment pass plus the structure proposal, and the 11th now
  reads as brand alignment feedback incorporated. Logo and font requests
  dropped from what is needed, since those are in hand. The rate note now
  explains the reduction as a fit with this phase, and says plainly that later
  rounds are likely to sit closer to the standard rate.

## 0.29.2 - 2026-09-02

- Quote page: shorter intro naming the four groupings; the out-of-reach
  features moved directly under the price box as an expandable list in the
  same card format as the costed lines, without prices; and the whole page
  now says soft launch rather than launch.

## 0.29.1 - 2026-09-02

- Quote page revised: presets renamed (The Essentials, The Safe Bet, The
  Ambitious Version); brand alignment and crawlable pages became optional but
  marked Recommended, leaving three always-included lines; the airport and
  brand pages now depend on the structure proposal and follow it on and off;
  the three collectors carry a deeper reduction with the reason stated; and a
  "Not possible before the soft launch" section replaces the one-line
  exclusion note, with automated collection at its head.

## 0.29.0 - 2026-09-01

- New /quote page: the launch proposal as something the client can tailor.
  Five core lines plus eleven optional ones, each with both the standard rate
  and this project's rate, a switch, and its own comment thread. Presets set a
  starting point; the total follows every toggle; a request button freezes the
  basket and sends it. Selections and requests persist server-side
  (quote_selections, quote_requests). Noindexed, like the review page.
- The comment thread moved into a shared component, so the board and the quote
  page use the same one and the client only learns one way to reply.

## 0.28.0 - 2026-08-31

- The feature board becomes the plan of record: every card carries a scope
  chip (Launch scope / After launch / Later / In discussion) and the board
  groups by scope. All of the client's requested features joined as cards
  (crawlable content pages, airport hub pages, brand pages, official brand
  images, sponsor banners, producer enhanced listings, store partner portal,
  crowdsourced price updates, manually checked prices; traveler accounts
  absorbed the alerts card). New "In build" status for work in flight.

## 0.27.0 - 2026-08-31

- Performance pass driven by PageSpeed Insights: fonts self-hosted (kills the
  render-blocking Google Fonts chain and the third-party dependency), a
  product-page loading skeleton that mirrors the real layout (the pending-to-
  loaded swap was most of the 0.41 layout shift), the LCP product image
  preloaded from the server-injected head, immutable cache headers for hashed
  assets and fonts, and route-level code splitting (main bundle 92 to 82 KB
  gzipped; shopper path stays eager).
- Accessibility: gold text and buttons and soft ink meet WCAG AA contrast
  (new gold-ink and gold-strong tokens); footer headings no longer skip
  levels.

## 0.26.3 - 2026-08-31

- Product schema fills three of Google's recommended fields: description (the
  same factual sentence as the meta description), offers.url, and
  offers.availability driven by the site's own buyability rule. The remaining
  aggregateRating/review warning stays deliberately: we hold no reviews and
  will not fabricate them.

## 0.26.2 - 2026-08-28

- The review page's video player can be closed, and stays closed for that
  browser until reopened from the "Watch the video walkthrough" button in its
  place. The page is read many times; the video only needs watching once.

## 0.26.1 - 2026-08-27

- Collector health now follows location visibility like every other surface:
  a source is listed only where prices it collected are on display, and
  configured-but-never-run collectors appear only when nothing is hidden.
  The Heinemann coverage card no longer mentions previously gathered prices.
  Pre-descope explainer pages moved out of the served static directory.

## 0.26.0 - 2026-08-27

- Rian's review pass folded in: his rankings became the board's dashed
  suggestions, his working comments were rewritten into each card's copy in
  one consistent voice, then removed so the page is fresh for the client.
  Heinemann reworded to "a relationship to build?"; a new hosting decision
  ("Where this lives, and what it costs to run": rian hosts and runs it,
  $50/mo starting point, live discussion invited) joins the urgent three.

## 0.25.0 - 2026-08-26

- Review page slimmed to its final shape: five email-style big-picture lines,
  ONE feature board, and five decisions. The board merges "what's working"
  with the ranking: every card shows status (Live/Partial/Planned), what it
  is, how far it is today, what more time buys, four tiers (Essential for
  launch / Next priority / Later / Not interested) with rian's suggestion
  pre-marked as a dashed hint until the client confirms, and its own comment
  thread. Comments now attach to features as well as decision items (nullable
  item_id + feature_key, one-target check constraint).
- Board refined after rian's skim: the tier question is now purely launch
  scope ("does the launch need it?" - Needed for launch / Soon after / Later,
  maybe / Not interested); status badges are filled chips (Built / Part-built
  / Not built) with filter buttons; the name gate is gone (the client is the
  only ranker); decisions retitled "For your comment" with the first two
  marked urgent and the rest "No rush"; a general "Anything else" thread
  closes the page.
- The board's question now depends on status: built rows ask about effort
  (Good as is / A bit more before launch / Go heavy here / Leave it out),
  unbuilt rows ask about timing (Before launch / Soon after / Later, maybe /
  Not interested) - one tap always answers "where does the time go". Added a
  Design and branding row; "More time buys" lines rewritten to stand alone
  (SEO honestly says not to spend more there yet).
- Demo settings grew a "Sample features" panel: per-browser switches for every
  unbuilt surface (announcement bar, editorial samples, and each feature stub's
  nav/footer/coming-soon presence), with "All off - built only" and "All on -
  full vision" bulk buttons. Instant, local, layered under the server flags.

## 0.24.0 - 2026-08-25

- Client review page restructured to the client-facing pitch: big picture,
  what's working, coverage (room to grow and the limits), the code and the
  machine, bigger ideas — bullets throughout.
- New "Pick the demo" ranking: every feature (live and planned) gets
  Must-have / Nice-to-have / Later buttons; the latest choice per feature is
  stored server-side (feature_priorities table, upsert) so the ranking is
  visible without a meeting.
- Positioning pass over everything the client reads: portability acknowledged
  once with honest ramp-up friction; the environment and operation presented
  as a hosted, tended service; no cost-of-collection talk. Discussion items
  updated to match; Heinemann relationship and competitions-database questions
  added as decisions.

## 0.23.1 - 2026-08-25

- Repair: purged 165 pre-variant configurable-tile listings that the 0.22.0
  per-size re-crawl had superseded but not replaced (they kept stale ambiguous
  prices; 14 barcode-less ghosts duplicated real products, e.g. a "1942 Tequila
  70cl" beside the true Don Julio 1942 750ml row). A tile whose page could not
  be re-read keeps its old listing, honestly dated.
- The airport picker, suggested routes and trip comparison now respect location
  visibility (the trip service had been missed in 0.23.0's filter).
- The client review page rewritten for a skimming reader: short sections, a
  one-line body per feature, one flat built list. Medal artwork retouched to
  true transparency (several files carried an opaque white tile) and medal
  URLs are now versioned so CDN and browser caches pick up artwork changes.

## 0.23.0 - 2026-08-25

- Coverage is now configuration: each location has a visibility switch
  (`app.cli locations`). Hidden locations keep collecting — history accrues —
  but appear nowhere on the site: catalogue, product pages, stats, category
  counts, coverage page, sitemap and page metadata all follow the visible set.
  The demo shows the four airports from the client's brief.
- Feature switches (`FEATURE_*` in .app.env, injected into the SPA shell):
  my-airports, the featured-savings home section, in-card price bars, and the
  teaser slots each toggle without a code change. Demo config switches off the
  merchandising layer (featured cards, bars); everything stays built.
- Award medals render as a small badge overhanging the card's top-left corner;
  a missing artwork file falls back to the text tag instead of a broken image.
  (Previously the photo box's full-bleed rule stretched the medal to fill it.)
- The client review page's built list is split into "what the proof of concept
  promised" and "built beyond the promise".

## 0.22.0 - 2026-08-25

- Multi-size products are now collected as one listing per size the shop's own
  page prices, so a magnum at one store can never be compared against the 75cl
  at another. Dropped-decimal slugs ("-15l" meaning 1.5L) parse correctly.
- The home page will not headline a spread over 3x unless at least three shops
  corroborate it; product pages still show every observed price.

## 0.21.0 - 2026-08-25

- Featuring algorithm v2: blended dollar+percentage saving score, award-winner
  bonus with a guaranteed floor, photo preference as a tie-breaker, champions
  always shown.
- Award winners' cards show the competition's medal artwork in the corner; the
  duplicate Save tag over the photo is gone.

## 0.20.x - 2026-08-25

- The home page's savings section is cards only: eight comparison cards picked
  by the agreed featuring rules (dollar champions, percentage champions, award
  winners prioritised, best-of-rest fill).
- Multi-size tiles whose product page does not sell our size are now skipped
  instead of inheriting the cheapest variant's price.

## 0.19.0 - 2026-08-25

- Product cards show the price comparison inline: up to four shops with
  price-proportional bars, cheapest emphasised, and a save/buy-at footer.
  Scoped to the shopper's airports when set.

## 0.18.x - 2026-08-25

- Server-rendered SEO layer: schema.org Product markup with medals and live price
  ranges, unique titles and meta descriptions, descriptive product URLs with 301s,
  generated sitemap.xml, robots.txt; review/settings/sources pages noindexed.
- HTML entities from one feed decoded at ingest and repaired in stored names.
- New York JFK and Buenos Aires Ezeiza added to collection (config only).
- Review page: search-engine readiness added to the built list.

## 0.14.0 - 2026-08-23

- The discussion page became the client review page: a walkthrough-video slot, the
  realities and decisions in priority order with a comment thread on every item (typed
  name, no account -- the shared password already gates the site), an honest what's-built
  list with extent, and the planned features. Curator controls (add, resolve) moved
  behind a demo setting so the client sees a clean page.
- Madrid joined coverage: its storefront renders server-side again and the existing
  collector works on it -- 133 prices on first collection.

## 0.13.0 - 2026-08-23

Quality-audit release: seventeen independent checks over the data, the code and the copy,
then fixes for everything confirmed.

- **Prices.** The Avolta collector took the first price in a tile, which on multi-size
  products was the smallest variant's — Grey Goose 1L showed at $18.73. It now reads the
  typed final price, records promotional was-prices, and the ingest quarantines any price
  under half the product's cross-shop median instead of publishing it. The wrong rows are
  deleted. "Cheapest" now ignores sold-out shelves whenever anything is in stock.
- **Identity.** Barcode-less sightings of a bottle no longer create a duplicate product
  per airport per run (327 duplicates merged); a barcode whose size disagrees with the
  product is treated as a retailer labelling error and refused; restricted-circulation
  barcodes (GS1 02/2x) no longer drive cross-retailer joins; multipack sizes are the pack
  total; junk feed strings are no longer categories.
- **Medals.** The matcher now requires brand agreement, keeps expression words and ages,
  and refuses ambiguity. 422 medals stand, each defensible, replacing 486 of which about
  a third were on the wrong bottle.
- **Compliance.** Heinemann's robots.txt now disallows its search endpoint, so the global
  catalogue collector refuses to run; every platform collector re-checks robots on every
  run. Existing catalogue data remains but no longer refreshes.
- **Honesty.** "Comparable" now means more than one real airport (the online catalogue
  no longer inflates it); search ignores accents and treats % and _ literally; stale
  persisted airports are pruned; the savings view is shareable by URL; the mobile
  airport picker actually opens; unknown pages return a 404 page.

## 0.12.x - 2026-08-23

- The trip became a persistent airport set ("which airports will you be at"), scoping the
  whole site: catalogue, exclusives, awards, related rails and each product page.
- Adam's wireframe navigation, announcement bar, header search and editorial home page.
- Mexico City prices were being recorded in the wrong currency (USD quoted, stored as
  pesos); collectors now read the currency the page declares.
- Exclusives and Awards became real pages; explainer pages are served by rule and a
  missing page 404s instead of rendering the app shell.

## 0.11.4 - 2026-08-23

- Corrected the coverage page: it still listed Avolta's airports as blocking us, which is
  no longer true and contradicted the app's own data. "Not yet covered" now means only
  what we genuinely cannot read, and airports we could add with configuration alone are
  stated separately.

## 0.11.3 - 2026-08-23

- Collections commit in batches instead of holding one transaction for an entire run.
  Concurrent collectors touching the same products were deadlocking against each other;
  short transactions rarely do. A run that fails part-way now also keeps what it had
  already collected.

## 0.11.2 - 2026-08-23

- Fixed the rest of the concurrency problem: two airports stocking the same bottle would
  both try to create it, and the barcode uniqueness constraint killed one run outright.
  Product and listing creation now tolerate a concurrent creator, as retailer and
  location already did.
- A dead category page no longer ends a whole airport's collection.

## 0.11.1 - 2026-08-23

- Fixed: collectors for the same retailer running concurrently would collide creating
  the shared retailer row, and every process but one lost its entire run. Row creation
  now tolerates a concurrent creator.
- A storefront that serves category pages but no products in the HTML now reports that
  plainly instead of a silent "ok, 0 prices".

## 0.11.0 - 2026-08-22

- Avolta added: Heathrow, Athens, Madrid, Barcelona, Zurich, Hong Kong, Toronto and
  Mexico City. Avolta runs one platform across 168 airports, so this is the largest
  single source of per-airport prices available.
- Each Avolta storefront's own robots.txt is read and its requested crawl delay honoured
  (30 seconds at some airports, 60 at others), never faster than our own setting. The
  paths they disallow are never touched.

## 0.10.0 - 2026-08-22

- Sydney and the Danish/German BorderShop added, both publishing barcodes on every
  product. Ten shops now collect.
- Shops running the same commerce platform (Keflavik, Sydney, BorderShop) share one
  parameterised collector instead of a near-duplicate module each; adding another is a
  few lines of configuration. Each was checked against its own robots.txt first.

## 0.9.1 - 2026-08-22

- Products without a photograph now show a generated brand mark instead of an empty
  box. Most of this catalogue is travel-retail exclusives that no open image database
  carries, so the placeholder needs to look deliberate rather than broken.

## 0.9.0 - 2026-08-22

- Airports is now a real page listing every shop we price, with how much of its range we
  hold and how fresh it is — plus the airports we cannot cover and the honest reason for
  each.
- Data sources page rebuilt around coverage and barcode quality, since barcode coverage
  is what makes cross-shop matching trustworthy.
- Trip page suggests routes that actually have overlap instead of making you guess.
- A single delisted product page no longer aborts a whole collection.

- Keflavik (Reykjavik) added. It publishes barcodes on essentially every product, which
  makes it one of the few sources able to anchor reliable cross-shop matching.
- Product pages rebuilt: a proper hero with the photo, medals and a plain verdict
  ("cheapest at X, save Y"), plus related bottles worth comparing.

## 0.8.1 - 2026-08-22

- Fixed: the data-sources page hung while a collection was running. Listing sources was
  a GET that created database rows, which deadlocked against the collector holding them.
  It is now read-only, and a collector can be paused before its first run.

## 0.8.0 - 2026-08-22

- Dublin and Cork added via ARI's The Loop, enumerated through their sitemap because
  their robots.txt disallows the faster category path. Takes the genuine duty-free tier
  where the site shows both duty-free and duty-paid prices.
- Product images now fall back to a strict name search when a barcode misses, which
  lifts coverage well beyond barcode-only lookups. Still openly licensed, still credited.
- Default "Featured" sort leads with products that have a photo, a medal and a real
  cross-shop comparison, so the catalogue's first screen looks finished.
- A "was" price equal to or below the current price is discarded rather than shown as a
  saving.

## 0.7.0 - 2026-08-21

- A real home page: editorial hero with the trip search as the main action, live savings
  from a real route, category and award browsing, and honest teasers for what's coming.
- Every future feature (airports, price tracker, reviews, guides, map, alerts) now exists
  in the site and explains what it will do rather than 404ing.
- Filters rebuilt for shoppers: a search field, a category rail, three refinements and a
  sort. Build-time toggles moved to a separate Demo settings page.
- Filters and trip selections live in the URL, so any view can be linked.
- "Decisions & realities" replaces the terse discussion list: what needs deciding, the
  real-world constraint behind it, and a suggested compromise.

## 0.6.0 - 2026-08-21

- Trip search: pick the airports on a route and see what is stocked at more than one of
  them, ranked by how much the choice is worth. This is the question the original brief
  asked -- buy at departure, or wait until you land.
- A running "To discuss" list inside the app, seeded with the open questions.
- Ecuador storefront dropped; it returns 404.

## 0.5.0 - 2026-08-21

- Five more airports: Montreal, Panama, Bogota, Guayaquil and San Salvador, through one
  Shopify adapter. Adding another Shopify shop is now configuration, not code.
- Product photography from Open Food Facts, matched on barcode and credited on the page.
  Retailer photography is still deliberately never collected.
- Barcodes are check-digit validated. Some shops put sequential internal SKUs in the
  barcode field, which would have joined unrelated bottles together.
- "With photos" filter.

## 0.4.0 - 2026-08-21

- Products with a price of zero (gift-with-purchase items and unavailable lines) are no
  longer ingested, and existing zero-priced rows were removed. They were sorting to the
  top of "cheapest" and made the comparison look wrong.
- Competition medals now use the competitions' own medal artwork rather than a text
  badge, for all four spirits and four wine competitions.

## 0.3.1 - 2026-08-21

- More retailer packaging labels ("beverage can", "PET bottle", "Multipack") are treated
  as formats rather than categories, and aperitif spellings fold into one category.

## 0.3.0 - 2026-08-21

- Consumer category vocabulary (Whisky, Cognac & Brandy, Rum, Gin, Vodka, Tequila &
  Mezcal, Liqueurs & Aperitifs, Wine, Champagne & Sparkling and more), taken from the
  client's own wireframe, replacing whatever label each retailer feed happened to emit.
  Categories are resolved from each retailer's own category tree first and the product
  name second, because many bottles never state what they are.
- Travel-retail exclusives are now captured and filterable.

## 0.2.2 - 2026-08-21

- Product pages no longer show raw country codes ("AE", "WOR") where a feed supplies a
  code instead of a country name.

## 0.2.1 - 2026-08-21

- Backfilled products collected before the category fix, so packaging words no longer
  appear as categories on existing rows.

## 0.2.0 - 2026-08-21

- Competition medals: winners exported from the competition network and matched onto
  duty-free products by brand and name, conservatively enough that a wrong medal is
  unlikely.
- Category labels no longer show packaging words ("Bottle", "Gift Box") as categories.
- Dubai collection now stops at the end of the liquor section instead of walking the
  whole 33,000-product catalogue.

## 0.1.0 - 2026-08-21

- First working slice: Dubai Duty Free and Heinemann global collectors, barcode-based
  cross-retailer product matching, USD conversion, browse/search, and the per-airport
  price comparison table.
- Per-source kill switch and collection health, so a collector can be paused without a deploy.
