# Changelog All notable changes to this project are documented here, newest first. **Format:** loosely follows [Keep a Changelog](https://keepachangelog.com/) — each entry is a version, a date/time, and a short list of what changed and why. **Versioning** follows [Semantic Versioning](https://semver.org/) (`MAJOR.MINOR.PATCH`): MAJOR for breaking changes (e.g. a data format change that isn't backward-compatible), MINOR for new capabilities, PATCH for fixes that don't add anything new. **To ship a change:** update this file, then bump `package.json`'s version to match using `npm version patch|minor|major` — that command bumps the number correctly for you (you don't have to compute it by hand), and creates a matching git commit + tag in one step. Tag format is `vX.Y.Z`. --- ## [1.9.1] - 2026-10-07 UTC ### Changed - **Easier-to-scan dashboard.** Prices, the chart and the readings come first; "Search dates" and the admin Users panel moved below them. The readings table is now a scrollable window with a sticky header and quieter, tighter notes instead of a 60-row wall. - **Calmer controls.** Secondary buttons no longer turn red on hover; red is kept for Remove. Every form's main button shares one style, password and email fields match the other inputs, and keyboard focus has a visible ring. ### Fixed - **Phones:** the header no longer squeezes the title into a narrow column or makes the page scroll sideways, and the chart keeps legible labels (it scrolls sideways inside its card instead of squashing). - The "Forgot password?" banner no longer shows, empty, when there are no requests (fixed earlier today, recorded here). --- ## [1.9.0] - 2026-10-07 UTC ### Added - **Password resets by email.** Each account can have an email address (set by the admin in the Users panel, or by the user via **Email** in the header). "Forgot password?" on the login screen now emails a one-time link (`/reset.html`) that's valid for an hour; only a hash of it is stored, and it stops working once used or when the password changes any other way. Accounts without an email still fall back to flagging the admin, as in 1.8.0. The login screen's answer is the same either way. - Mail goes through Nyx's shared relay (`smtp-relay`, see `~/docker/smtp-relay/README.md`) from `flightprice@victorialanetempleton.com`. Configured by env vars in `docker-compose.yml`: `SMTP_HOST`, `SMTP_PORT`, `MAIL_FROM`, `MAIL_REPLY_TO`, `APP_BASE_URL` (the address used in the link). --- ## [1.8.0] - 2026-10-07 UTC ### Added - **"Forgot password?" on the login screen.** There's no email on file, so it can't reset anything by itself -- it flags the account for the admin (`POST /api/password-reset-request`). It answers the same way whether or not the username exists, and is throttled to 10 requests an hour. - **Reset requests shown to the admin.** A banner at the top of the dashboard names who asked, and their row in the Users panel is marked with when they asked and a **Dismiss** button next to **Reset password**. The flag clears itself when the admin resets the password, when the user changes it, or when the user logs in successfully (they remembered it). --- ## [1.7.1] - 2026-08-04 UTC ### Fixed - **Sessions now survive a server restart.** They were in-memory only, so anyone logged in got bounced back to `/login.html` every time the container restarted -- a deploy, a host reboot, or (as happened several times while building the auth system itself) restarting to pick up new server code. `lib/sessions.js` now mirrors the session map to `data/sessions.json` (gitignored, like `data/users.json`) and reloads it on startup, dropping anything already expired. No change to the 30-day sliding expiry itself -- this just makes it durable. --- ## [1.7.0] - 2026-08-04 UTC ### Added - **"Checked X ago" indicator** -- shown above the price chart for the selected range, and on each row in the "Search dates" card. Turns red if a range hasn't been checked in 36+ hours. Recorded at the *start* of a scrape attempt (`lib/store.js#recordChecked`), not after a successful reading, so it reflects whether the daily check actually ran, not just whether it found a price -- this is what would have surfaced the cron-PATH bug (see the 1.6.0-era investigation) on the dashboard directly instead of it going unnoticed for two days. - **Self-service password change** -- a "Change password" button in the header opens a small form wired to the `PATCH /api/me/password` endpoint that already existed but had no UI. Previously the only way to change a password was asking the admin for a reset. --- ## [1.6.0] - 2026-08-04 UTC ### Added - **Real per-user accounts with login**, replacing the single shared API token -- the dashboard is being exposed outside this machine, and a shared secret prompted via a browser `prompt()` wasn't a fit for that anymore (the 1.4.0 entry below already flagged this as the thing to revisit). The whole app, including just viewing prices, now requires logging in at `/login.html`. - Passwords are hashed with scrypt (`lib/users.js`); sessions are random opaque tokens held in memory (`lib/sessions.js`, 30-day sliding expiry, cleared on server restart -- same tradeoff `scrapeInProgress` already makes). - An `admin` role can manage accounts from a "Users" panel that appears on the dashboard: add a user, reset anyone's password (no old password needed -- the new one is shown once, for the admin to relay directly), or remove a user. The last remaining admin can't be removed. - `scripts/manage-users.js` -- terminal equivalent of the above (`add|reset|list|remove`), and the account-recovery path if the only admin ever gets locked out, since it doesn't require being logged in. - Login has a per-username lockout (5 failed attempts, 15 minutes) against brute-forcing, given this is now reachable from the open internet. - `PRICE_API_TOKEN` is gone -- removed from `server.js`, `docker- compose.yml`, and `.env`. --- ## [1.5.0] - 2026-08-02 UTC ### Added - Track up to **3 date ranges concurrently** instead of just one. Each tracked range gets its own independent price history, shown via a new tab bar above the stats/chart/table -- switch ranges without reloading. - "Search dates" card now lists each tracked range as an editable row (label + dates + Save + Remove) plus an "add a range" form shown whenever you're under the 3-range limit. - Backend: `route.pinnedDates` (single object) replaced by `route. searchDates` (array of up to 3 `{id, depart, return, label}`); readings now carry a `rangeId` linking them to a specific tracked range (`null` = general ~12-month automatic sampling, unchanged from before). `PATCH /api/route` replaced by `POST /api/route/dates`, `PATCH /api/route/dates/:id`, `DELETE /api/route/dates/:id`. `POST /api/scrape-now` accepts an optional `{rangeId}` to scrape just one range instead of every tracked range. - The daily scrape (`scripts/scrape-prices.js`, unchanged schedule/cron) now loops over every tracked range sequentially -- same "don't hammer either site" posture as before, just repeated per range. Existing data migrates automatically and transparently on first read: the previously- pinned range and its history become "range 1", nothing is lost. - `scripts/add-price.js` gained `--range-id` for manual entries against a specific tracked range. --- ## [1.4.0] - 2026-08-02 13:49 UTC ### Added - The API token is now remembered in the browser (`localStorage`) after the first prompt, instead of asking again on every write action (setting pinned dates, clearing them). Cleared automatically if the server ever rejects it (401), so a stale/wrong cached token doesn't get stuck. - This is a shared-secret-remembered-locally model, not real per-user accounts — acceptable while the dashboard stays private, but worth revisiting with proper login/sessions if it's ever exposed to multiple distinct users with different access levels. --- ## [1.3.0] - 2026-08-02 13:40 UTC ### Added - Pin a specific depart/return date range (e.g. Thanksgiving week) that the scrapers check instead of their own automatic ~10-month sampling. New "Search dates" card on the dashboard with a label + two date inputs; setting or clearing it triggers an immediate scrape (~1-2 min) so you see a result right away, and it applies to every scheduled run after that. Backend: `route.pinnedDates` in the data file, `PATCH /api/route` to set/clear it (token-gated, same pattern as `POST /api/prices`), `POST /api/scrape-now` + `GET /api/scrape-status` to trigger and poll a background scrape without holding an HTTP request open for the full runtime. `fetchGoogleFlightsFare`/`fetchKayakFare` both accept an optional `datePairs` override. - Verified live: pinning Thanksgiving week (2026-11-25 to 2026-12-01) surfaced a real result from Google Flights (£589) while Kayak genuinely found no results for that exact range -- confirming the "one scraper failing doesn't block the other" behavior on a real failure, not just in theory. --- ## [1.2.1] - 2026-08-02 13:28 UTC ### Fixed - Deleted the 4 placeholder readings (Jul 20-30) that were manually fabricated while building the airline/baggage feature, sharing one invented "Aer Lingus" assumption applied uniformly. Sitting next to the first real scrape (Aug 2), they made the table look like the airline and baggage policy were hardcoded/broken. History now starts clean from the first genuine reading. - Renamed the "Change" column to "vs previous" and added a tooltip — it compares against the previous *recorded check*, not necessarily yesterday, since checks don't run every single day. - Added tooltips to "Price" (per person, round-trip) and "Travel dates" (the specific sampled itinerary dates that price was found for, not the single cheapest date in the whole year) clarifying what those columns actually mean. - The header subtitle now says "per person" explicitly instead of leaving the price basis implicit. --- ## [1.2.0] - 2026-08-02 13:24 UTC ### Added - The version number is now visible in the running app, not just in repo files: `GET /api/prices` and `GET /api/stats` include a `version` field (read from `package.json`), the dashboard shows it in a small footer, and a new `GET /changelog` route serves this file so it's viewable from the running app too. --- ## [1.1.1] - 2026-08-02 13:15 UTC ### Changed - README now points at this changelog and states the current version — no behavior change, hence a patch bump rather than minor. --- ## [1.1.0] - 2026-08-02 13:13 UTC ### Added - Version control (git) and this changelog. `v1.0.0` is tagged on the commit immediately before this one — that's the baseline everything below was already built on. --- ## [1.0.0] - 2026-08-02 Baseline. Version control didn't exist yet while this was built, so this entry is a retrospective summary rather than a real-time log — precise timestamps start from 1.1.0 onward. Roughly in the order it happened: ### Added - Initial app: Express server, flat-file JSON store (`data/prices.json`), dashboard (`public/`) charting price history for LBA → BOS with all-time high/low and day-over-day change, `POST /api/prices` (token auth) and `GET /api/prices` / `GET /api/stats`, and `scripts/add-price.js` for manual entry. - Airline, booking-link, and baggage/seat-fee fields on each reading, shown in the dashboard (including small airline logo icons on the chart and table, sourced from a public logo CDN keyed by IATA code). - `docs/daily-routine-prompt.md` — a prompt for an optional, not-currently- deployed Claude Code cloud routine that would search the web and email a digest via Gmail. - **Google Flights scraper** (`lib/providers/google-flights.js`) — queries the same base64-encoded protobuf endpoint Google Flights' own web client uses internally (schema vendored from the MIT-licensed [fast-flights](https://github.com/AWeirdDev/flights) project), so no browser is needed for this source. - **Kayak scraper** (`lib/providers/kayak.js`) — loads Kayak's real search page in a real (headless) Chromium browser via Playwright and captures its own internal results API response, rather than parsing rendered HTML. - `scripts/scrape-prices.js` — runs both scrapers daily, records the cheaper price, and notes the other's price for comparison. - Daily scheduling via cron inside the `web` container (`scripts/daily-routine.cron`, `scripts/run-scrapers.sh`), with a random 0-9 minute jitter so requests don't land at an identical time every day. - Multi-stage `Dockerfile`: a light `cli` stage (no browser) for the Claude Code CLI container, and a `scraper` stage (Playwright + Chromium) for the `web` container. ### Changed - Fixed a dark-mode theming bug where the page background/text colors were defined on a class one level below ``, so `body`'s own background/color rules silently fell back to browser defaults — producing black-on-black and white-on-white in places. Theme variables now live on `:root`. - Compacted the "Airline" column in the history table (full descriptive name + code was causing horizontal scroll) down to just the logo icon + IATA code, with the full name available on hover. ### Removed - A per-adult passenger-count picker and the API endpoint that backed it (`PATCH /api/route`) — the app now assumes a single traveler; double the number yourself when pricing for two. - A Travelpayouts + Kiwi Tequila API integration (cached fares + a live cross-check) — built, then removed at the user's request in favor of the Google Flights / Kayak scraping approach above.