# BidBrain project status, part 4: v2.1 (20 to 21 August 2026)

Moved unchanged from CLAUDE.md on 2026-09-18. The standing rules stay in CLAUDE.md, which lists every part of this log.

**v2.1 starts here, 2026-08-20 evening onward.**

  - FOURTH PLATFORM ADDED, Dealer Auction (portal.autotrader.co.uk/dealerauction-login, Auto Trader's trade auction site), 2026-08-20 (Mark: "i want to add the next auction house... this one might be more tricky as it has a 2fa sms 6 digit code sent sometimes, it is also part of the autotrader group and so im not sure if it has an api that might be better to use?"). Checked properly before building: no usable API exists (Auto Trader Connect, the one real official API, syncs a dealer's OWN retail stock OUT to the marketplace, the opposite direction to reading someone else's auction stock; several promising looking search results turned out to be for the unrelated US AutoTrader.com or Canadian AutoTrader.ca), so this was built the same way as every other platform, reading the logged in screens, golden rule 2 unaffected. 2FA is handled the same way any login is, python3 login.py dealerauction manual opens a headed window and waits for Mark to complete it himself, including the SMS step, nothing here can receive or guess that code.
    - Site added to browser.SITES as "dealerauction" (login at portal.autotrader.co.uk/dealerauction-login/, the app itself actually lives at www.dealerauction.co.uk once logged in). login.py's site list updated to match.
    - Scope: the account's sourcing portal bundles three separate things behind one login, "Dealer Auction" (the tab Mark asked for, an aggregate of several underlying networks including a Manheim digital channel), "Private AT listings" (Auto Trader's own classified ads, 53,092 of them, a completely different product), and a fully separate "Manheim" tab (7,417 vehicles, a distinct auction company in its own right). "Dealer Auction" is the tab already selected by default on login, so no extra decision was needed, the other two are out of scope, not touched. bidbrain/readers/dealerauction.py's STOCK_URL bakes in the same network params the "Dealer Auction" tab itself uses, plus salesMethod=bid (the "Auction" filter, found by clicking it and reading the resulting URL, same technique as Motorway's listType=auction), which excludes Buy it now and Make me an offer listings, matching the auction only rule already applied everywhere else. Verified live: 2,372 total in the tab, 1,818 once filtered to auction only.
    - Far richer list cards than Motorway or Carwow ever gave: registration, real NAMA 1 to 5 condition grade, owners, VAT status, retail value, margin, and a time remaining countdown are all directly on the card, so unlike Auction4Cars or Carwow this reader needs NO detail page visit at all, daily_run._read_dealerauction just reads and gates, no enrich_from_detail step exists for this platform.
    - Two decisions confirmed with Mark before building, mirroring how Auction4Cars' own gaps were resolved: (1) No real reserve is ever disclosed anywhere on this platform (checked thoroughly, the list AND a real detail page, all "reserve" text everywhere is just a met/not met status pill, unlike Auction4Cars which turned out to have one hidden in a script). Mark's choice: car.reserve is Retail minus Margin, the platform's own two real figures (never a guess, both read directly), labelled "AT Trade" on the card, his own wording. Verified the arithmetic against real cards: £8,073 Retail minus £2,823 Margin equals £5,250, which matched that same car's real "Starting price" exactly. (2) Location is only ever a "Town, County" string, never exact miles and not a small fixed list like Auction4Cars' depots (potentially hundreds of distinct towns). Mark's choice: a COUNTY level allow list rather than town level, matched via a new _dealerauction_county(location) in pricing.py ("Washington, Tyne And Wear" -> "Tyne And Wear"), reusing the same allowed_locations settings mechanism Auction4Cars already has, gate_failures now branches on which platform to decide whether to match the full location string (Auction4Cars) or just the extracted county (Dealer Auction). Real NAMA grade IS available here though, unlike Auction4Cars, so daily_run._grade_ok_for was NOT extended to this platform, the real grade gates normally.
    - Card wording: _hf_gauge's reserve label mapping extended from a two way (Reserve / Trade value for Carwow) to a lookup covering "AT Trade" for Dealer Auction too, same for the room pill's "over/under" wording and daily_run._reserve_drop_flag's card flag; render._INSIGHT_DROP widened to recognise "AT Trade dropped" alongside the existing two so it still gets its green styling.
    - Three real bugs found and fixed during the build, all caught before shipping by testing against real captured and real live pages rather than trusting the first version: (1) the initial card splitting boundary (a CSS class that turns out to repeat inside a "similar listings" widget nested in some cards) silently dropped or truncated a third of the cards, switched to splitting on each card's own href="#/advert/ID" anchor instead, verified against a real captured page to produce exactly 36 blocks for 36 real cards. (2) The first "Next" pagination button found (analyticslabel="endcaps-next") turned out to belong to a completely different "Recommended Stock" carousel widget elsewhere on the page, clicking it did nothing to the real results, caught live when three attempted pages returned zero new cars; the real pagination control is analyticslabel="paginator-next". (3) Once real pagination worked, that same Recommended Stock carousel was found to repeat some of the same cars across different page loads, so accumulating every page's cars naively counted real duplicates (a live 10 page read showed 355 cars but only 246 distinct registrations); read_all_pages now keeps a car only if its registration has not already been added, not just whether its page contributed any new advert ids. (4) Every single car was rejecting with "Engine could not be confirmed" because car.engine was never set from the parsed derivative and fuel text (the same convention auction4cars.py uses), caught by running the real gate end to end and seeing all 116 real list gate passers reject on the same reason; fixed by setting car.engine = derivative plus fuel at parse time, immediately then showing correct real rejections (banned engines like 1.2 PureTech and the Ford EcoBoost wet belt genuinely catching real cars) and correct held cars once fixed.
    - car.cap_clean deliberately left unset for this platform: Retail here is Auto Trader's own retail valuation, a different scale to CAP Clean (used elsewhere for the value caps and the "looks high against CAP" sanity flag), so storing it there would have silently applied those checks against the wrong kind of figure. Those specific checks simply do not fire for Dealer Auction cars, same as any other car with no CAP reading, an honest gap rather than a wrong number.
    - Wired into daily_run.py (_read_dealerauction, merged into all_cars in both run() and review()) and render.py (a fourth platform tab and section, _platform_class recognising "dealer auction" in car.source, the settings page's hard gate toggle extended to four tabs with the locations field's label, placeholder and note text now swapping between "Allowed depots" and "Allowed counties" depending which of Auction4Cars or Dealer Auction is selected, and the shared add button now resolves whichever platform's array is currently on screen at click time rather than being bound to one fixed array). New synthetic fixture data/fixtures/dealerauction_list_sample.html (never real captured data, same reasoning as the other fixtures), 22 new tests. Verified live end to end: a real 10 page read (max_pages=10) after the fixes returned 246 distinct real cars with sensible reserve and grade coverage, and running the real cars through the full daily_run gate produced correctly reasoned rejections and held cars, no shortlist yet only because Glass's and Cazana were not run in that check. 193 tests pass.
  - RUN NOW BECAME A MODAL WITH PER PLATFORM REFRESH, 2026-08-20 (Mark: "the 'Run' button when clicked open modal with options for refresh 'All, Motorway, Carwow, A4C, Dealer Auction' as that the if the setting change for just one of the auction houses its not a complete repull, also check to see that the repull uses the stored data in the database and only looks for more info to speed the run process up"). With four platforms now live, a settings tweak for just one of them (say a Dealer Auction county) used to mean waiting through a full four platform read, including Carwow's slow, paginated, per car detail enrichment pass, to see it take effect.
    - Clicking Run now no longer starts a run directly, it opens a small modal (render.py, #runmodal-overlay) with a checkbox per platform (Motorway, Carwow, Auction4Cars, Dealer Auction) plus an All checkbox that ticks or unticks the rest together (rmToggleAll) and stays in sync as the other four are toggled individually (rmSync). Closes on Cancel, on a backdrop click, or (unchanged) once the chosen run actually starts. Confirming with anything less than all four sends {platforms: [...]} to /api/run; leaving every box ticked sends the same empty body as before, so the plain full run path is byte for byte unchanged.
    - daily_run.run() gained a platforms parameter (a subset of the new PLATFORM_KEYS list; None or all four means the normal full run, untouched). A smaller set makes it a PARTIAL run: only the chosen platform(s) are actually read live and gated, the platforms left out are carried forward from the last saved run exactly as they were, same status, same pricing, same flags, no re read, no re valuation lookup, no re assessment. The repeat appearance and reserve drop flags are only ever appended to the freshly read cars for the same reason, so a carried forward car's flags are never touched twice. serve.py's /api/run validates the platform names (a typo or an unknown key is a loud 400, never silently ignored) and passes them through to the daily_run.py subprocess as --platforms motorway,carwow. The DealerKit stock read (its own separate on demand button) is not part of this selection, it always runs, same as before.
    - This already gets the "use the stored data, only look for more" half of Mark's ask for free, nothing new was needed: the valuation cache built 2026-08-19 (48 hour reg plus mileage plus source cache for Glass's and Cazana) sits underneath every run regardless of which platforms are selected, so a partial run's freshly read candidates still skip a repeat Glass's or Cazana lookup if they were valued within the last two days, the same as a full run always has.
    - Verified: 193 tests still pass (the change is additive, run(platforms=None) is exactly the old code path). Live, opening the modal, unticking Dealer Auction correctly unticks All while leaving the other three checked, and re ticking it re ticks All. A real python3 daily_run.py --platforms dealerauction run printed "Partial run: refreshing Dealer Auction only. Carrying forward 1545 cars from the other platforms exactly as they were in the last run.", read only Dealer Auction live (no Motorway or Carwow read messages at all), gated and valued 55 fresh candidates on Cazana, and finished with the Motorway, Carwow and Auction4Cars counts in the saved cache untouched to the car (110/2/381 shortlist/held/rejected for Motorway, 80/5/444 for Carwow, 101/5/417 for Auction4Cars, all identical before and after) while Dealer Auction went from nothing to 49 shortlisted, 6 held, 1368 rejected. Reloaded the real cockpit afterward and confirmed the Dealer Auction tab shows real cards with the AT Trade gauge and a live countdown, Motorway and Carwow cards unchanged. Caught and fixed one unrelated live issue while testing this: that evening's earlier full run had stalled mid way through Carwow's pagination (progress stuck on one page for over 8 minutes, a known category of failure for this project, see the browser leak and session death entries above), stopped cleanly via the existing Stop endpoint before it could be mistaken for a hang in the new code.
  - RUN DEALERKIT AND RUN GLASS'S MOVED INTO THE RUN MODAL, 2026-08-20 (Mark: "i think we should move the 'Run dealerkit' & 'Run Glass's' buttons in to the 'Run' modal, also the 'Run Click Dealer' button if thats configured"). The top bar had three separate run buttons crowding it (DealerKit, Glass's, Run now) right after the platform picker modal above was built. Checked whether a Clickdealer button should also move in: no, Clickdealer is not configured for Right Drive, DealerKit fully replaced it for stock on 2026-08-19 (STOCK_FLAGS_ENABLED points at dealerkit.read_in_stock, not clickdealer) and the Clickdealer button was already removed from the cockpit at handover (see the top of this file). The reader, database tables and serve.py endpoint stay in place unused, same as before, nothing added for it.
    - The two buttons kept their ids, classes' underlying behaviour and their own confirm dialogs exactly as they were (runGlass()/runDealerkit(), /api/run-glass, /api/run-dealerkit, all untouched), only their markup moved from the .controls bar into a new "Other checks" section inside #runmodal-overlay, below a divider under the platform checkboxes and the Run button. Each still carries its own red flag dot and hover tooltip when it has something to warn about (a stale DealerKit login, no saved Glass's login), now styled as .runmodal-extrabtn rather than the old top bar .clickbtn/.glassbtn. Clicking either closes the modal (closeRunModal() added to the top of both functions) before its own confirm dialog and POST, same UX as before just reached through the modal.
    - Since these two live inside a normally hidden modal, the single visible Run now button now carries a COMBINED red flag dot and tooltip (run_flag_cls/run_flag_title in render_page, built from dealerkit_notices + glass_notices via the existing _button_flag helper) so a warning is still visible without opening the modal, the bell dropdown still lists everything in full detail as before. setRunning(on, which) was not touched, it already looked up glassbtn/dealerkitbtn by id regardless of where they sit in the DOM.
    - Verified: 193 tests pass. Live, the top bar is now just Run now, the bell and the settings gear; opening the modal shows both buttons under Other checks each correctly carrying their real state (that evening Glass's had no saved login so its button and the combined Run now button both showed a red dot with the right tooltip text, DealerKit's snapshot was recent enough that neither showed one, confirmed against the actual rendered HTML not just a screenshot).
  - CARD GRID MADE RESPONSIVE PAST TWO COLUMNS, 2026-08-20 (Mark: "when i view this page of my 14 inch macbook pro the 2 cards and filter fill the width great, but on my 34 inch ultra wide i think this should be responsive to 5 cards"). The shortlist grid (render.py .grid, auto-fill with a per card minmax) was always genuinely responsive in principle, but .layout, the flex row holding the sidebar and the grid, was capped at max-width:1200px, a leftover from before Right Drive's 34 inch monitor was a consideration. On any screen wider than about 1200px, that cap left the grid area at a fixed circa 948px regardless of how much real screen was available, which floors to exactly 2 columns at the current 360px per card minimum, whether the browser is 1300px wide or 3440px wide. That is why the 14 inch laptop already looked right (its own width is not far past the cap) while the ultrawide was stuck at the same 2.
    - Fix was two numbers, not a media query: raised .layout's max-width from 1200px to 3560px (so the cap stops binding well past a normal desktop, only kicking in on something wider than a real 34 inch monitor, keeping cards from thinning out forever on a genuinely huge screen), and raised the grid's per card minimum from minmax(min(360px,100%),1fr) to minmax(min(480px,100%),1fr) (tried 540px first, landing on exactly 5 columns at 34 inches, revised the same session once Mark asked for 6 instead). The two numbers were solved together algebraically against the 230px sidebar plus 22px layout gap, aiming to land on exactly 2 columns around a 14 inch laptop's real width and exactly 6 around a 34 inch ultrawide's, with auto-fill doing the actual responsive work at every width in between, no breakpoints needed. .fbar (the filter chip row) and .notice (the health banners) had their own matching max-width raised too (1200 to 3560, 1160 to 3520) so they visually line up with the wider grid below rather than staying narrow while the cards stretch past them.
    - Verified live at three real viewport widths via the browser preview tool: 1470px (a 14 inch MacBook Pro) still gives exactly 2 columns, unchanged from before. 3440px (a real 34 inch ultrawide's native resolution) gives exactly 6. 2560px (a 27 inch monitor) lands on 4, confirming the scaling is genuinely continuous rather than only tuned for the two sizes asked about. 193 tests pass (CSS only, no gate or pricing logic touched).
  - CARD INSIGHT FLAGS COLLAPSED TO AN ICON ROW WITH HOVER TOOLTIPS, 2026-08-20 (Mark pasted four real flag lines off a card, for example "Partial service history · lower quality buy" and "Priced on Cazana · run Glass's checks to add it", and asked to "make these icons on the same line with roll over tool tips for text to save space"). Each insight (service history, repeat sighting, Glass's or Cazana state, private plate fallback, VAT, high against CAP, etc, built by _insight_rows) used to render as its own full row, icon plus the whole sentence, so a card with several flags could run to 3 or 4 extra lines of card height. _insight_rows itself, which turns the assessment's flags into (kind, text) pairs, is untouched, only how a card renders each row changed: the text span is gone, each .hf-flag div is now just its icon circle with the full sentence moved to a title attribute (a native browser hover tooltip) and mirrored onto aria-label so a screen reader still gets the full text even though nothing is visible to sighted users except the icon. .hf-flags switched from a column of full width rows to a wrapped row of icons (flex-wrap, 8px gap), cursor:help on each so the hover intent is obvious.
    - Verified live: a real card that used to show three stacked lines of flag text now shows three small icons on one line at the card's foot; read directly off the DOM, each icon's title and aria-label carry the exact original sentence, confirmed against the same text Mark pasted. 193 tests pass (render only, _insight_rows' mapping logic untouched).
  - CARD INSIGHT FLAGS GAINED A CHEVRON TO RESTORE THE ORIGINAL LOOK, 2026-08-20, same session (Mark: "give this a down arrow at the end of the row like valuation detail to restore original look"). The icon row above is hover only, no click path to the full sentences, so a Valuation detail style chevron (the same _HF_CHEV_SVG, same rotate on toggle) now sits at the end of the icon row. Collapsed (the default) it is exactly the icon row from the previous entry; clicking it reveals a second block below, .hf-flags-full, holding the original pre icon row design (icon circle plus the full sentence, stacked one per line, unchanged copy from _insight_rows), then rotates the chevron 180 degrees same as Valuation detail's own toggle. Both blocks are built from the same _insight_rows(a) call so there is only one source of truth for the flag text, never two copies that could drift apart. toggleFlags(btn) in the page JS mirrors toggleVals(btn) exactly (closest, hasAttribute hidden, toggle, rotate).
    - Verified live: found via the DOM that .hf-flags-toggle was, on a filtered page, correctly scoped to the one visible card's own toggle and full block (a first pass wrongly used a bare document.querySelector, which grabbed the first matching element in the whole page rather than the one visible card, a testing mistake not a code bug, caught by checking the toggled text against what was expected and finding it belonged to a different, filtered out car). Scoped correctly to the visible card, clicking the chevron correctly expanded the exact three original sentences Mark pasted, stacked one per line under the icon row, and the chevron rotated. 193 tests pass (render only, _insight_rows untouched, same as the entry above).
  - FLAG ICONS MADE TOPIC RELEVANT, PLUS A RED, AMBER, GREEN TRAFFIC LIGHT, 2026-08-20, same session (Mark: "find & give the icons relevant icons ie service could be spanner, also use a traffic light system to flag view ie red for no service history, green for full"). The icon row's icons were purely generic (!, a tick, an italic i) regardless of what the flag was actually about, and the colour scheme only ever had two shades that mattered (amber warn, green good) plus a neutral grey, no red at all, so a real concern (no service history, a known electrical issue, a valuation that looks wrong against CAP) read the same amber as a mild caution (seen recently, partial service history).
    - _insight_rows(a) now returns (kind, topic, text) instead of (kind, text). topic is a new small set of keys (service, repeat, trend, tag, search, plate, warn, electrical, vat, generic) each mapped in render.py's new _FLAG_ICONS dict to its own small feather style SVG (_flag_svg helper, same inline icon convention as the file's existing COPY/BELL/WRENCH icons): a spanner for anything service history related (reusing the same path as the settings page's own wrench icon), a clock for a repeat sighting, a trending down arrow for a reserve or trade value drop, a price tag for the sold history record, a magnifying glass for a valuation source note, a plate icon for the private plate fallback, a warning triangle for a CAP mismatch, a lightning bolt for a known electrical issue, a percent sign for VAT qualifying, and a plain alert circle for anything that falls through to the generic case.
    - kind is now a genuine three colour traffic light (red, amber, green) plus info (neutral grey, for notes that are neither good nor bad, like which valuation source priced the car or VAT qualifying). Upgraded from the old two tone warn or good: a real money loss on the model's sold history is now red not amber, a CAP mismatch and a known electrical issue are now red not amber, and service history is now properly tiered, none is red, partial or unconfirmed stays amber, full is green.
    - Full service history previously produced NO flag at all (pricing._service_flag returned None, "preferred, nothing to flag" by original design), so there was no way to show it green, silence was the only signal a car had one. Changed _service_flag to always return something, "Full service history." for the full case, still purely an annotation appended to a.flags after the gate has already run, never touches gate_failures or price_car, a car's buyability and price are completely unaffected either way. render._insight_rows recognises this new flag text and colours it green with the spanner icon, alongside the existing red none and amber partial or unconfirmed cases.
    - CSS renamed .hf-flag.warn/.good to .amber/.green to match the traffic light language directly, added .red (reusing the existing --hf-rose colour already used for the under reserve pill, so red does not introduce a brand new colour to the page), .info unchanged. Four existing tests in test_pricing.py that asserted the old (kind, text) shape and warn/good wording were updated to the new (kind, topic, text) shape and red/amber/green/info wording, plus four new tests covering the service history traffic light directly and confirming assess() itself now carries the new flag text for a full service history car.
    - Verified live: repriced the last saved run from cache (python3 daily_run.py --reprice, no live reads, just rebuilds flags and pricing off already read cars) so cached cars picked up the new logic, then found and inspected two real cars directly off the served page: a Seat Ibiza with full service history showed a green spanner icon titled "Full service history", a Dacia Sandero with none showed a red spanner titled "No service history · lower quality buy", both alongside their other flags in their own correct colours (an amber repeat clock, a grey info search icon). 197 tests pass.
  - COCKPIT CONTROLS TIDIED: SHORTLIST IN THE NAV BAR, VIEW AS A DROPDOWN, LOGOS AND SORT ON THE SECTION HEADING, 2026-08-20 (Mark: "move the shortlist icon on to the nav bar to the left of the run button, the 'all, grap fillers, auto only, held back' make this a drop down view option above the filter, move the sort cazana price on the same line as the Auction house title showing amount of cars, and use the auction house logo instead of text, this should allow the top of the filter and top of the first line of images to match"). The slim filter chip row (.fbar) that sat above the sidebar and grid is gone entirely, its three pieces moved to where Mark asked for them, and removing it is what actually closes the alignment gap he wanted, not a separate fix.
    - Shortlisted: was a gold star chip inside .fbar, now render._shortlist_navbtn(summary) puts the same star and count in the top .controls bar, immediately left of Run now (a new toggleShort(btn) in the page JS, click to show starred cars only, click again back to All, syncing the view dropdown below back to "All" when it does). .controls .runbtn kept its own margin-left:auto as a fallback (harmless when both are present, browsers only spend an auto margin once), .navshort carries the real one now that it is first in the right hand cluster.
    - All / Gap fillers / Auto only / Held back: was four chips, now render._viewcat_select(summary) is a single <select id="viewcat"> in the sidebar, placed directly above the Make/Model/... filter controls (the literal "above the filter" reading), styled with the sidebar's existing .filtersel look so it fits straight in. Each option's count lives in a data-label plus data-n pair so applyView's existing per platform tab count recompute can rewrite just the number ("Gap fillers (277)") without needing to know the label text again. currentCat, catOk and the held/section filtering logic underneath are completely unchanged, only the two controls that set currentCat moved and split into onViewCatChange() (the dropdown) and toggleShort() (the nav button), each clearing the other's active state so they behave as one mutually exclusive set the same as the old five chips did.
    - Cazana price sort and the platform logo: each platform's <h1 class="section"> heading (previously "MOTORWAY 110" as plain text) is now wrapped in a new .section-head flex row alongside a render._price_sort_btn() instance, right aligned on the same line. Real makes MW_LOGO/CW_LOGO/A4C_LOGO (already existed, previously used only on the slim held/rejected cards via _platform_pill) now also render in the heading via a new render._platform_heading(key, label, n), no pill wrapper needed since a page background is more forgiving than a photo overlay, Motorway's own captured logo already carries its yellow backdrop from the original site header capture and Auction4Cars' its orange one, Carwow's dark on white reads fine directly on the page. Since more than one section can be on screen at once (the All tab shows all four), .sortdirbtn dropped its old single id in favour of a shared class, painted together by paintPriceSort() via querySelectorAll so every instance always agrees, exactly the same pattern the earlier platform hard gate settings tabs used for their own repeated Add button.
    - A real Dealer Auction logo did not exist yet (Mark's own account was added 2026-08-20, no logo was ever captured for it), so one was fetched live rather than left as a placeholder: the same wordmark Auto Trader's own dealerauction.co.uk marketing site loads in its header, https://www.dealerauction.co.uk/wp-content/uploads/2020/10/logo.svg, a clean vector already on a transparent background. Saved as assets/da_logo.svg (the real captured asset, not fabricated, same convention as every other logo in this file) and assets/da_logo.datauri, wired in as DA_LOGO. Rendered a touch taller than the other three (.section-logo.dealerauction, 24px vs 20px) since its typographic detail is finer and was hard to read at the same size as the plainer Motorway and Carwow wordmarks.
    - Alignment: .content > .platset:first-of-type .section-head gets margin-top:0 (every other section keeps its normal 26px gap between them), so the very first heading row starts level with the sidebar's own first row, both being plain flex children of .layout with the same 20px padding and no extra margin of their own. Measured directly off the DOM rather than judged by eye: the sidebar box and the first section heading's top edge landed at the exact same pixel (78px from the viewport top, both), and the search box (the first real filter field) landed 3px above the first card photo's own top edge (123.8px vs 126.5px), close enough to read as aligned.
    - Verified live: dropdown filtering to Gap fillers showed exactly 277 cards, matching the count in its own option text; the Shortlisted nav toggle showed and hid cards correctly and cleared itself when the dropdown was used instead; clicking any one of the four sort buttons turned all four on together with matching labels, and Reset turned all four off again and reset the dropdown to All; all three existing logos (Motorway, Carwow, Auction4Cars) and the new Dealer Auction one were each checked against a real filtered down card. 197 tests pass (render only, no gate or pricing logic touched).
  - SORT AND VIEW MERGED INTO ONE DROPDOWN, PLUS SAVED AND STARRED FILTER VIEWS, 2026-08-20, same day (Mark: "merge the best opportunity and all filters and move to next to the amount of cars, give the abiltiy to star favorites and they are quick buttons next to view drop down like before , move the shortlist button next to run now"). The wording was ambiguous enough (two dropdowns genuinely merged into one, versus just placed side by side; "star favorites" as a card level thing already existing, versus something new) that it was worth asking rather than guessing wrong twice: Mark confirmed a genuine single merged dropdown, and clarified the star request himself as "bring back one click buttons but only if they are starred as favorites, give ability to save filters as views", which is a real new feature, not just a wording change. The third ask, moving Shortlisted next to Run now, was already done by the previous entry in this file, so nothing further was needed there, just checked it was still true.
    - Sort (Best opportunity, Best match for you, Cheapest reserve) and View (All, Gap fillers, Auto only, Held back) were two separate sidebar dropdowns, now one, render._viewsort_select(summary), built with <optgroup> so the two stay visually grouped, and moved out of the sidebar onto every platform section heading next to its car count, same "one instance per section, all sharing one state" pattern the Cazana price sort already used. Picking a Sort option only ever changes currentSort, picking a View option only ever changes currentCat (the page JS's onViewSortChange(sel)), they stay independent state underneath exactly as before, this is one shared control, not a combined value, the dropdown just shows whichever was picked most recently.
    - Saved views, a genuinely new feature: a saved_views table in bidbrain/db.py (name, criteria as a JSON blob of the whole sidebar filter and sort state, starred, created_at), with list_saved_views/save_view/star_view/delete_view, and three new serve.py endpoints (/api/save-view, /api/star-view, /api/delete-view, plus a GET /api/views to list). Chosen server side rather than localStorage specifically because the cockpit is used from more than one device (the Mac and, once Tailscale Funnel is set up, a phone), and a view saved on one should show on both, the same reasoning already behind bids, hidden_cars and settings living in the database rather than the browser.
    - Sidebar gained a "Saved views" panel at the bottom (render._saved_views_sidebar): a name box plus Save button that POSTs the sidebar's current state (captureCriteria() in the page JS: make, model, year, transmission, body type, the three price ranges, plus the current sort and view) under that name, and a list of every saved view with a star toggle and a delete button. The list is fetched live from /api/views on page load, the same "never bake dynamic state into a server render" pattern the shortlist star sync already uses. Starred views additionally render as gold one click quick buttons (.starviewbtn) next to the merged sort and view dropdown on every section heading ("like before" the dropdown existed, when Gap fillers etc were one click chips), clicking one calls applyCriteria() which sets every field back and re runs applyView(), no dropdown needed for a saved combination.
    - Verified live end to end against the real running server (restarted serve.py, launchd's KeepAlive brought it straight back up with the new endpoints, proven by the PID changing): saved a real view ("Gap fillers test"), it appeared in the sidebar list; starred it, a matching quick button appeared next to all four section dropdowns simultaneously; clicked the quick button, it correctly filtered to the saved 277 gap filler cars and synced the dropdown to show Gap fillers; deleted it, both the sidebar row and every quick button disappeared together. Reset confirmed to clear currentSort, currentCat and all four dropdown instances back to Best opportunity implied default and All. 8 new db round trip tests (empty list, save, starred defaults false, star sticks, unstar sticks, starred sort first, delete removes it), 204 tests pass total.
  - PURCHASES ADMIN PAGE, BUILT for Motorway and Carwow, 2026-08-20 (Mark: "lets start with the motorway and carwow get that setup in a admin page, the link to this page needs an icon next to settings on the nav bar"). purchases_run.py has read Motorway and Carwow's own purchases screens daily into the purchases table since Steven's build, but the only thing that ever looked at it was the accounts email, there was no page to just look at what had actually been bought. Auction4Cars and Dealer Auction do not feed this table at all yet, no reader for their own won screens exists, Mark chose to start with the two platforms already wired up rather than build new readers first.
    - New render.render_purchases(), a small standalone page (purchases.html, same "fetch live from an API on load, never baked stale into a server render" pattern as hidden.html and settings.html, so it always reflects the database, not a run cache). A top summary strip (cars bought, total paid, room against BidBrain's retail estimate for however many of those got one) plus an All / Motorway / Carwow tab set and a table (reg as a yellow plate, vehicle name, platform pill, bought date, paid, retail estimate, and a room column coloured green when the retail estimate clears what was paid, red when it does not). db.list_purchases() is new, everything else (the purchases table, record_purchases, retail_estimate) already existed. serve.py gained GET /api/purchases and GET /purchases.html (rendered fresh per request, same as settings.html).
    - Nav bar gained a shopping bag icon (_BAG_SVG, matching the wrench and bell's inline feather icon style) linking to purchases.html, sitting immediately to the left of the settings gear as asked, both now share the same .settingslink styling with only the first of the pair carrying the wider left margin off the bell.
    - Cancelled or voided sales never reach the purchases table at all, they are already filtered into walked_away by purchases_run.py before recording (a fix from earlier in the project), so nothing extra was needed here to keep a fallen through sale off this page.
    - Caught and fixed before shipping: the page's own JS returned early on an empty result (no purchases yet, genuinely the current real state, nothing has ever populated this table for Mark's account since only com.bidbrain.serve is scheduled via launchd, not the daily purchases run) without ever updating the summary strip past its initial "Loading...", found by testing the real empty state after clearing temporary test rows, not by design review. Fixed so the summary always repaints, reading "0 cars bought" when the table is genuinely empty.
    - Verified live: inserted two temporary purchase rows directly (never through a live platform read), confirmed the table, summary maths (total paid, room, red or green correctly for a loss and a profit case) and the platform tabs all render and filter correctly, removed them, confirmed the real empty state now reads "0 cars bought" instead of hanging on Loading. 204 tests pass (this feature has no dedicated test yet, it is a thin read only view over an already tested table). Still open: purchases_run.py itself is not scheduled for Mark (only com.bidbrain.serve is, per the "still to do" note at the top of this file), so the page will stay empty until either that scheduling is done or he runs it by hand; Auction4Cars and Dealer Auction purchases are not covered, next step if Mark wants all four platforms in the same page.
  - FIRST REAL MOTORWAY PURCHASES CAPTURE, PLUS TWO GENUINE READER BUGS FOUND AND FIXED, 2026-08-21 (Mark: "can we fill the cars bought for motorway now"). purchases_run.py had never actually run for Mark's account (only com.bidbrain.serve is scheduled, see the entry above), so the new purchases page was genuinely empty. read_all() and main() gained a platforms filter (a set of "Motorway"/"Carwow", None means both, same shape as daily_run.run()'s own platforms parameter) so python3 purchases_run.py --baseline --motorway could capture just Motorway without touching Carwow, --baseline so this first ever capture seeds history without emailing accounts about 25 cars bought over the past weeks. New CLI flags --motorway/--carwow, purchases_run.py --baseline --motorway is the exact command run live.
    - The first live run stored 25 real purchases, all correctly gated past the cancelled/voided filter (24 cancelled or withdrawn Motorway sales seen in the same read, correctly routed to walked_away instead, none reached the purchases table), but every single row's name came back as the literal word "Delivered" instead of the real vehicle name. Root cause found by inspecting a real row's raw text directly rather than guessing: reg.read_purchases assumed the line right after the registration is always the vehicle name, but a fully delivered car's row inserts an extra "Delivered" status badge there first (real row text: reg, Delivered, Peugeot 3008 GT Line BlueHDi S/S Auto, Delivery requested, Checks complete, price, date), a genuine structural difference for that delivery stage, not a timing or hydration race (a hydration theory was tried first, added a wait_for_function settling until every row shows a price, which is harmless and still kept, but did not fix it, proving it wrong before committing to it). The real fix: skip over any status word(s) immediately after the reg, however many, to find the actual name.
    - Because the bad names were already written to the database from the first bad run, and the original self heal logic only backfilled a blank name (never touched one already wrongly set to a status word), a second run with the reader fixed still did not correct the 18 affected rows, caught by re running and checking the database directly rather than assuming the fix worked. db.record_purchases's self heal rewritten to compare against a shared _BAD_PURCHASE_NAMES set (the known status words) on both the old stored name and the new one: only replaces the stored name when the old one is blank or a known bad word AND the new one is a real name, otherwise always keeps the old one, so a good name can never be downgraded by a later bad read either.
    - Separately, and pre existing, not something this session introduced: Motorway purchases have never had a bought_date at all, read_purchases only ever built a won_text key (never renamed to what record_purchases actually reads) and its own capture regex only matched an "ago" suffix, missing the absolute date format ("18 Jun 2026") seen live entirely. New _parse_won_date(text, today) turns either the relative ("8 hours ago", "3 days ago") or the real absolute format into a plain YYYY-MM-DD string, the same shape Carwow's own bought_date already uses, returning "" rather than guessing on anything unrecognised. record_purchases's self heal extended the same way as the name (fills in a blank bought_date on a later read, never overwrites one already set, purchase dates do not change once bought).
    - Verified live end to end: re ran python3 purchases_run.py --baseline --motorway twice more after each fix, checked the database directly both times rather than trusting the run's own summary line, confirming first 0 of 25 names still bad then 0 of 25 missing a bought_date. The real purchases page now shows all 25 Motorway buys with correct vehicle names, real bought dates (2026-08-21 down to 2026-07-27) and a total of £165,844 paid, sorted newest first. 5 new tests for _parse_won_date (hours ago, days ago, the absolute DD Mon YYYY form, and two never guessed cases), 212 tests pass total.
  - RETAIL ESTIMATES TRIED VIA LIVE CAZANA, PAYMENT BREAKDOWN ADDED, ROUNDING RULE STILL UNRESOLVED, 2026-08-21, same day (Mark: "retail est is the cazana 115% round to the next either 295,495,795,995 try fill these in ill check to see if your right"). A one off backfill script (not committed, a scratchpad tool) read each purchase's real current mileage off its own still live Motorway listing page, looked up Cazana for that reg and mileage, calculated 115%, and rounded to the nearest of the four given endings, cycling every £1000. Filled all 24 missing estimates and verified live, e.g. AY66WFW Cazana £7,070 to 115% £8,130.50 to nearest £7,995.
    - Mark corrected four of these (AY66WFW to £8,495 not £7,995, HG17NRF to £8,295 not £7,795, YG67UKZ to £13,995 not £14,495, and confirmed KF66GJZ's already existing £9,505 should actually be £9,995) with a new instruction, "use the purchase price aswel then round up to next 1 or 2 95 to create our retail prce". Tried to fit a formula to the four corrected examples first rather than guess a third time: neither a fixed £ margin nor a fixed % margin over purchase price holds across all four, and YG67UKZ's correction is actually below the plain 115% calculation, which "round up" alone cannot explain. Asked Mark to walk through one example step by step rather than guess again. Still open, nothing in the rounding logic has been changed based on the four corrections, retail_estimate still holds the "nearest of the four endings" values from the first pass.
    - Mark separately flagged the Paid figure itself: "if you click the price it shows winning bid, fee, transport, vat total show these on report". Investigated live rather than guessed how: a purchase row's context menu has a "Purchase summary" option leading to /purchase-summary/<vehicle id>, whose Payment details section (once Motorway has finished reviewing the seller's documents, blank with "Payment details will be available once..." before that) gives Vehicle price (the winning bid), Motorway fee (base plus its own VAT (20%) line), Transport fee (same), a Motorway Protect fee when taken (same), and Total paid; the components sum exactly to the total, proving the reading is right (bid 7577.00 plus fee 359.00 plus transport 167.00 plus protect 44.99 plus vat 114.20 equals the real total paid 8262.19 exactly).
    - purchases table gained five real columns (winning_bid, motorway_fee, transport_fee, protect_fee, vat_total, all nullable REAL) plus cazana_115 (the raw unrounded Cazana times 1.15, added when Mark separately asked mid session to see it next to retail_estimate so he could compare and send real examples). New motorway.read_purchase_breakdown(page, vehicle_ref), _money_after(lines, label) and _vat_total_from_lines(lines) (money always split "£X,XXX" then ".XX" across two lines on this page, VAT (20%) appears once per fee type so every occurrence is summed, never just the first). Wired into purchases_run.py's read_all so a new purchase gets its breakdown the same run it is first seen (skipped, not guessed, while still pending review, self heals on a later run via record_purchases's same never-overwrite-once-set pattern already used for name and bought_date). purchases.html widened (1000px to 1280px, table wrapped in its own horizontal scroll box) with Winning bid, Fee, Transport, VAT, Total paid, Cazana 115% and Retail est. all shown side by side.
    - One real bug caught and fixed before it shipped, found by testing against a car (AP67LJZ) already proven by hand to have a full breakdown, not by trusting the first attempt: the literal text "Payment details" appears twice on the purchase summary page, once as a Quick Links sidebar nav item (renders immediately) and once as the actual section heading above the real figures (loads a moment later, or never if payment is still pending). Waiting on that ambiguous text matched the sidebar link and read the page before the real breakdown had loaded, returning nothing for all 25 rows on the first backfill attempt. Fixed by waiting on "Vehicle price" instead, a label that only ever appears in the real breakdown, so a timeout waiting for it is itself the correct "not available yet" signal, no separate check needed.
    - Verified live end to end: re ran the backfill after the fix, 15 of 25 purchases got their full breakdown (matching real totals already on file exactly), the other 10 genuinely still awaiting Motorway's own document review, confirmed by checking one directly rather than assuming. cazana_115 populated for all 25 from the values already read during the retail estimate pass, cross checked afterward against a fresh reg times 1.15 calculation to catch a transcription slip (one digit typed wrong on AY66WFW, 8130.05 instead of 8130.50, caught by verifying against the formula rather than trusting the paste). 9 new tests for the money and VAT parsing (synthetic lines, never a real capture, real ones carry the seller's personal details) plus a self heal round trip for the new fields, 223 tests pass total.
  - RETAIL ESTIMATE FORMULA CONFIRMED, 2026-08-21, same day: "its roughly £500 above the 115% cazana retail". Tested Cazana retail times 1.15 plus a flat £500, rounded to the nearest of the four dealer price endings, against all 16 real cars Mark had corrected or pasted across the last two exchanges (his own four earlier corrections plus 12 more he pasted straight off the report): 12 of 16 landed on his exact figure. Also tested his own suggested alternative, a flat 120% of Cazana with no added margin, against the same 16: only 6 of 16, clearly the worse fit, so 115% plus £500 was accepted as the rule, the 4 remaining misses put down to his own real world judgement on those specific cars (he had already called it "roughly", not exact).
    - New purchases_run.dealer_retail_price(cazana_retail): the confirmed formula as real code, not another one off script. All 25 purchases' retail_estimate recomputed and stored with it (a temporary, uncommitted script, matching the pattern already used for the two earlier one off backfills this session).
    - Wired into the permanent pipeline, not just applied once: motorway.read_purchase_breakdown now also reads the mileage already sitting at the top of the same purchase summary page (new _mileage_from_lines(lines)), so purchases_run.main() can do a live Cazana lookup and call dealer_retail_price() for any Motorway purchase _retail_map() (BidBrain's own recent assessment history) does not cover, with no extra page visit needed for the mileage. Only tried when a payment breakdown was actually read (so a real mileage is known); never guessed otherwise. cazana_115 and retail_estimate both added to db.record_purchases's self heal set (_PURCHASE_MONEY_FIELDS), same never-overwrite-once-set behaviour as the payment breakdown fields, record_purchases's INSERT and UPDATE both rewritten to build their money columns from that one shared list rather than a hand written column for each, so a future addition to the set does not need the SQL touched in two places.
    - Verified against real data, not a synthetic case: all 25 purchases' retail_estimate recomputed from their already stored cazana_115 and re displayed on the live purchases.html, spot checked against several of Mark's own pasted figures (AY66WFW now reads £8,495, HG17NRF £8,295, S33DFB £10,495, all exact matches). 5 new tests (the formula against 4 of the confirmed cars, plus a None in, None out check), 2 more for the new mileage line reader, 230 tests pass total.
  - PAYMENT BREAKDOWN'S THIRD STATE FOUND, MARGIN REPLACES ROOM, PHOTO AND STATUS ADDED TO THE REPORT, 2026-08-21, same day (Mark: "change 'room' to margin and calculate from the 'winning bid', i see there is missing data if you click the 3 dots next to date won and go to purchase summary full data is there", then mid session "also add an image to the line small/medium and the status's"). The 10 purchases still showing no breakdown after the previous session's fix were not all genuinely pending document review as assumed, Mark could see real figures on some of them by hand. Checked KF66GJZ's real purchase summary page directly rather than trust the earlier read: there is a THIRD page state neither of the two already handled covers, an "Estimated breakdown" (Motorway's own heading text, shown once a car is bought but not yet paid for) carrying the exact same field labels as the confirmed one but a grand total labelled "Total to pay", not "Total paid", with Motorway's own copy underneath reading "This vehicle has not been paid for yet. The final breakdown will be updated post payment." read_purchase_breakdown only ever looked for "Total paid", so this state's genuinely real figures (verified live: KF66GJZ's £6,210 vehicle price plus £329 Motorway fee, £267.70 transport, £44.99 Motorway Protect and £128.34 VAT sum exactly to its own £6,980.03 Total to pay) were being silently discarded, the whole read returning None as if nothing were there.
    - Fixed to try "Total paid" first, then fall back to "Total to pay", flagging the row with a new estimated key. Since Motorway's own words say these figures can still move before the real payment lands, storing them the same immutable way as a confirmed breakdown would have been wrong, a genuinely updated confirmed figure landing later would have found the row already locked. New purchases.payment_estimated column (INTEGER, added via the usual idempotent _migrate ALTER). db.record_purchases's money handling split into two groups: _PURCHASE_PAYMENT_FIELDS (winning_bid, motorway_fee, transport_fee, protect_fee, vat_total) now locks for good only once a row's stored payment_estimated is 0 (confirmed), otherwise a later read replaces the whole breakdown wholesale rather than patching just the blanks, since an estimate's own figures can genuinely all shift together; _PURCHASE_OWN_FIELDS (retail_estimate, cazana_115, and the new photo_url below) keeps the old simple fill once, never overwrite behaviour unchanged, these are BidBrain's own calculations, unrelated to Motorway's payment confirmation state.
    - Room renamed to Margin on the report (render.render_purchases), and recalculated from the winning bid itself rather than the full amount paid, per Mark's instruction: fees, transport and VAT are cost of sale, not part of what the car made against its own asking price. Falls back to the total paid figure only for a Carwow purchase or a still pending Motorway one where no winning_bid is known yet.
    - Photo added to each row (Mark: "add an image to the line small/medium"), a small 64 by 44 thumbnail. First tried reusing the photo already fetched for that car during its own daily run (purchases_run._retail_map() extended to also return a reg to photo_url map from the same last_run.json cache it already reads for the retail figure), but that came back empty for all 25 real purchases: every one of them had long since rotated out of the daily cache (checked directly, zero overlap between the 25 purchased regs and the 381 regs in the current cache), a car bought weeks ago simply is not in a run from today. Fixed properly by reading the car's own exterior photo straight off the same purchase summary page read_purchase_breakdown already opens (motorway.read_purchase_photo(page), no extra page visit), matched only on an "exterior" imgix path via a new _PURCHASE_PHOTO_RE, deliberately never a bare imgix fallback: that same page also carries the seller's own identity documents (V5, driving licence) on similarly named imgix hosts (mw-documents.imgix.net, checked live), and a looser match risked embedding a scanned ID instead of the car. The daily run cache is kept as a fallback for the rare gap this live read cannot fill (a still pending purchase, or a failed page read). Verified live: all 25 real purchases now carry a genuine exterior photo (motorway-photos-*.imgix.net/.../exterior_front_driver-*.jpg, the exact same host and path style the cockpit's own daily cards already embed successfully).
    - One real bug caught before calling this done: the thumbnails rendered as blank grey boxes on first live check even though the src attributes were correct and the same URLs loaded fine on their own. Diagnosed by comparing against the cockpit's own working photo grid rather than guessing (identical CDN host, identical path pattern, loads instantly there), which ruled out a CDN or hotlink block. The actual cause was the img tag's own loading="lazy" attribute: harmless on a real browser scrolling a normal page, but the automated verification browser never registered the row as "near the viewport" so the fetch never fired at all (complete:false, naturalWidth:0, indefinitely). Removed loading="lazy" from render.py's thumb markup, a small admin table with at most a few dozen rows has no real need for it anyway; confirmed live afterward that every thumbnail now loads (naturalWidth:300 on inspection).
    - Status column added, showing the platform's own status word plus, while a Motorway breakdown is still only an estimate, an amber "Estimated breakdown" note right there on the row, so a figure that could still move is visibly flagged rather than presented identically to a confirmed one.
    - Verified live end to end: re ran purchases_run.py --motorway twice (once for the breakdown and rounding fix, once more after adding the photo reader) and checked the database directly both times. Payment breakdown coverage rose from 15 to 23 of 25 (the KF66GJZ fix alone recovering 8 rows, all correctly flagged payment_estimated=1), the remaining 2 genuinely still at "will be available once all seller documents have been reviewed". All 25 rows carry a real photo. The live purchases.html page shows correct Margin figures (spot checked KF66GJZ: £9,795 retail est. less its £6,210 winning bid, a £3,585 margin, matching the page exactly) and correct status text including the amber Estimated breakdown note. 9 new tests (the Total to pay parsing path, a full estimated to estimated to confirmed to locked round trip through record_purchases proving an estimate keeps refreshing right up until the real confirmed figure lands and then locks for good, and the exterior versus document photo matching), 239 tests pass total.
