` tags, which have no way to attach the app's `Authorization: Bearer` header. These two endpoints read that header directly and were deliberately never added to the auth-bypass allowlist used for browser-loaded media (images/audio) — a full life-list export is genuine personal data, not a low-sensitivity media file, so bypassing auth for it would mean anyone with the URL could download it with no login. When auth was enabled, the plain link click sent no credentials, the server correctly rejected it with 401, and the browser downloaded that rejection body as if it were the file.
- This is the exact same root cause already fixed once before in this codebase for the database backup download (`settings.backup.download`) — followed that proven pattern exactly rather than inventing a new one: a real `fetch()` with the Bearer token attached, reading the server's actual `Content-Disposition` filename, then triggering the save via a Blob URL instead of a bare anchor href.
- Verified end-to-end against the real server with auth enabled: confirmed the exact bug first (no auth header → 401 → literal `{"detail":"Not authenticated"}` body, byte-for-byte matching the user's uploaded files), then confirmed the fix (a real token → 200 → real `Content-Disposition` filename → real exported species data) for both the CSV and JSON export.
- Added real error surfacing in the UI too — a `fetch`-based download can genuinely fail (expired session, network error) in ways a plain link click never could report; previously such a failure was invisible.
### New: "+N more" tile on the dashboard's "Seen Today" grid now opens a popup instead of linking to the full Life List
- Per request. Shows every species detected today (not just the visible 8) in the same photo-grid tile style as the main grid — refactored the tile rendering into a shared component so the popup's grid can never visually drift from the dashboard's. Each tile links to that bird's profile page, same as the main grid; closes via the X button, clicking outside, or Escape.
## 0.1.13.h — 2026-06-28
### Fixed: BirdProfile hero photo regressed on wide desktop screens after the mobile crop fix
- A fixed 380px height was tuned to look right on mobile (confirmed correct via a real screenshot) but produces an increasingly extreme banner ratio as viewport width grows — confirmed by direct math: ~3.16:1 at 1200px, climbing to ~5.15:1 at a reported 1958px-wide desktop screen, the same class of crop problem already root-caused once this session for the dashboard's live card, just now showing up at the opposite end of the width range. Switched to a `clamp(380px, 48vw, 460px)` responsive height — re-verified the floor preserves the exact mobile height already confirmed correct (every common phone width still resolves to 380px), while the ceiling caps the worst-case desktop ratio at 1958px down to ~4.26:1 instead of letting it scale unbounded with a single fixed number.
### Changed: Dashboard's daily/weekly Summary card columns now stretch to equal height
- Reported directly with a screenshot: the bird-card column, the chart column, and the rarest-species column each sat at their own natural content height inside a grid row that was already correctly stretching all three columns equally — the grid track was right, but none of the columns' own content was set up to fill the height the grid gave it, so the three visibly ended at different heights despite occupying the same row. Made each column a full-height flex column with its main content area set to `flex-1` (the chart) or pushed to the bottom via `mt-auto` (the detection/recording stat tiles), the same fix pattern already proven correct for `PairedRow` earlier this session — confirmed the relevant compiled CSS (`items-stretch`, `h-full`, `flex-1`, `mt-auto`) are all real, standard rules before considering this done.
### Changed: Statistics' Rarest Species + Busiest Day grouped into one shared collapsible row
- Same `PairedRow` treatment already applied to the other four chart pairs this session. Both halves were previously independently conditionally rendered (a deployment might have rarest-species data but no busiest-day record yet, or vice versa) — preserved that through the refactor via a new `singleColumn` option on `PairedRow`, so a row with only one real half to show doesn't render an empty second grid column.
## 0.1.13.g — 2026-06-28
### Fixed: a third, distinct source of mobile horizontal scroll — found via real DevTools output from the user's own running page, not guesswork
- The user provided the actual `Element.scrollWidth > viewport` diagnostic output, which is what made this fixable directly: it listed `page-hero -mx-7 -mt-7 px-7 pt-7 pb-5 mb-6` (confirming the device was running a build that predates the previous fix, not that the previous fix was wrong) and, separately, a bare `text-sm truncate` with no other classes — that second one pointed straight at the dashboard's recent-comments list.
- Root cause: the row above each truncated comment body (author name + "on" + the bird species name, e.g. "on Black-and-white Warbler") had zero width constraint on any of its three spans. A long, unbroken species common name with no natural wrap point could force that row wider than its container — and because nothing in an unconstrained row can shrink, that width increase propagated to the row's parent `div` despite it already having `min-w-0`, dragging the *correctly*-constrained `` below it along to the same overflowing width. This is exactly why DevTools flagged the `
` itself: the actual cause was its unconstrained sibling row above it, not the truncated element.
- Fixed by constraining both dynamic-length spans individually (`truncate min-w-0` on each, `maxWidth: '40%'` on the author name specifically so a long username and a long species name can't both claim unlimited space), with the static `"on"` separator kept `flex-shrink-0` so it's never itself squeezed.
## 0.1.13.f — 2026-06-28
### Fixed: recording title card's displayed duration could diverge from the actual recording
- Real, fully root-caused bug. The device computed `duration_seconds` for its metadata sidecar from wall-clock elapsed time (`recording_end - recording_start`), entirely separate from the actual number of audio samples written to the WAV file moments later. `get_stereo_recording()` can legitimately return fewer samples than requested — the rolling buffer may not have accumulated enough yet (e.g. right after startup), or for two-device setups its per-channel buffers can drift to slightly different lengths and get clamped to the shorter one — and neither case was ever reconciled back into the reported duration. The server then stored whatever the device claimed with zero verification.
- **Device-side root-cause fix**: `duration_seconds` is now derived from the real number of samples actually written to the WAV file (`len(audio_out) / sample_rate`), not the wall-clock estimate. Verified with an isolated test reproducing the exact confirmed failure mode (buffer returning fewer samples than requested) — old behavior would report 30.0s, new behavior correctly reports 28.4s, matching the real file.
- **Server-side cross-check (defense-in-depth)**: the upload endpoint now independently re-verifies the device's reported duration against the real uploaded WAV file via `soundfile`, the same trusted approach the separate manual-upload path already used — corrects and logs a warning on any mismatch over 50ms, so even a future device-side regression can't silently reach the title card. Verified end-to-end: a device falsely claiming 8.0s against a genuinely 5.0s file is correctly stored as 5.0s.
- **Retroactive repair**: new `POST /api/v1/recordings/repair-durations` endpoint re-reads the real WAV file for every already-uploaded recording (or one specific recording via `?recording_id=`) and corrects any stored `duration_seconds` that diverges from it by more than 50ms. Supports `?dry_run=true` to preview corrections without writing. Skips archived recordings (audio already deleted) and missing files. Verified end-to-end: dry_run reports without writing, a real run corrects and persists, archived recordings are never touched, and re-running after a fix correctly reports zero further corrections.
### Fixed: a second source of mobile horizontal scroll, found after the first round of `min-w-0` fixes
- Real, confirmed via direct arithmetic on the actual CSS rather than guesswork. `.page-hero`'s negative margin (`-28px` each side) was hardcoded to match `.page-root`'s desktop padding, but `.page-root` switches to `16px` horizontal padding on mobile (`max-width: 640px`) with no matching override on `.page-hero` — so on mobile the hero bled `28px` past its container while only `16px` of padding existed to absorb it, a persistent `12px`-per-side overhang past the real viewport edge on every single page load (the hero renders unconditionally at the top of every page, unlike the previous live-card bug which only showed up conditionally). Added the missing mobile breakpoint override, matching `.page-root`'s own mobile value exactly. Also removed redundant Tailwind utility classes (`-mx-7 -mt-7 px-7 pt-7 pb-5 mb-6`) that were layering the identical desktop-only values on top of the same element from the JSX side — confirmed identical via the compiled output (`1.75rem` = `28px`) — removing them eliminates a second, fragile source of the same number that could have silently undone this fix later depending on stylesheet ordering.
### Changed: Today's/This Week's Summary now appears above the stat cards row
- Per request. Dashboard order is now: live detection card → Seen Today → Daily/Weekly Summary → stat cards (Recordings/Species/Detections/Last Detection) → chart + top species → recent recordings.
## 0.1.13.e — 2026-06-28
### Changed: paired Statistics charts now share a single collapsible row instead of two independent cards
- Reported directly, with a screenshot: when only one card in a 2-up row was collapsed, the earlier `items-stretch` fix correctly matched its box height to its still-expanded neighbor — but that just produced a mostly-empty, awkwardly-stretched card next to a full chart, which read as broken rather than intentional.
- Replaced that approach for all four genuine 2-up pairs (Detections by Month + Hour of Day; Seasonal Distribution + Day of Week; Confidence Distribution + Time of Day; Top Active Hours + Detection Summary) with a new `PairedRow` component: one shared header spans the full row width with a single toggle, and both charts' bodies show or hide together. A collapsed row now shrinks to one compact header bar with zero empty space, instead of leaving a stretched, mostly-blank card. Standalone full-width cards (Top Species, Life List Growth, Confidence by Species, the Species×Month Heatmap, Rarest Species, Busiest Day) were already correct on their own and are untouched.
- Each `PairedRow` gets its own real `cardState` entry (e.g. `stats:row-monthly-hourly`), independent of the two charts it contains, and still respects the global expand/collapse default and the per-row override exactly like every other card — visually verified against the actual compiled CSS (not just inspecting the code) that a collapsed row renders as a single bar with no leftover height, and an expanded row correctly shows both charts side by side under the shared header.
## 0.1.13.d — 2026-06-28
### Fixed: dashboard had a small horizontal scroll on mobile, usually when the live detection card was showing
- Real bug, reported directly (no screenshot needed — root-caused from the description alone: "usually when the large recent recordings header is active" pointed straight at the live card's "Also in this clip" row). The species-name span in that row had `truncate` but no `min-w-0` — in a flex row, `truncate`'s `overflow:hidden` only actually clamps content once the element is allowed to shrink below its intrinsic content width, and a flex item's default `min-width` is `auto`, not `0`. A long joined species list (e.g. "House Finch, American Robin, Scarlet Tanager") could force that span — and therefore the row, the card, and the page — wider than the viewport, exactly producing the reported horizontal scroll on mobile whenever a live detection had multiple other species in the same clip.
- Audited every other `truncate` usage in the same file for the identical latent pattern (`truncate` + a flex-shrink role with no `min-w-0`) rather than just patching the one that had visibly manifested. Found and fixed two more real instances (the Top Species sidebar list and the dashboard's own top-species row) that hadn't yet produced a visible symptom only because single species names are rarely long enough to trigger it — same underlying bug, lower probability, fixed consistently rather than left as a latent risk. Confirmed every remaining `truncate` instance in the file is either already correctly guarded by `min-w-0` on the actual shrinking element, or sits inside an absolutely-positioned, width-pinned container where the bug can't occur at all.
## 0.1.13.c — 2026-06-28
### Fixed: live card's "Xs ago" badge too small; species/scientific name truncated on mobile ("Americ…", "Turdus migrator…")
- Real bugs, reported from a real screenshot. The "ago" badge was `text-xs`, bumped to `text-sm`. The name/scientific-name column used a forced single-line `truncate`, which on a narrow mobile viewport (where the 180px photo panel eats a large fraction of the available width) clipped real names mid-word. Removed the forced truncation and switched both lines to `clamp()`-based responsive font sizing that scales down on narrow screens and allows wrapping onto a second line instead of being cut off — reads far better for longer names than an ellipsis ever could. The photo panel itself is now also responsive (`clamp(110px, 28vw, 180px)`) so the text column gets proportionally more room on small screens instead of staying fixed at a size tuned for desktop.
### Fixed: BirdProfile hero photo had the same crop bug as last version's dashboard live card
- The hero header used a hardcoded `objectPosition: 'center 22%'` on a roughly 3.3:1 banner (260px tall against a typically 700px+ wide card) — the same root cause already fixed once on the dashboard's live card this session: no single fixed crop bias survives an extreme banner ratio across arbitrary source photo framing. Confirmed directly from a reported screenshot showing the bird's head and eye pushed off the top edge entirely. Unlike the dashboard card, this hero is intentionally a dramatic full-bleed poster treatment matching the rest of the page's design language, so switching to a side-by-side panel (like the dashboard fix) would have been the wrong call here. Instead: increased height (260px → 340px) to give the crop more room, and switched to true vertical centering, the most robust default across arbitrary photo compositions.
### Fixed: Photo Gallery thumbnails sometimes rendered as thin, mostly-cropped-out slivers
- Real bug, reported with a screenshot showing a thumbnail cut down to a sliver of just the bird's head. Root cause: `aspect-square` and a hardcoded `maxHeight: 200` directly conflicted — `aspect-square` wants the tile exactly as tall as it is wide, but `maxHeight` clamped it short-and-wide instead once the real rendered tile width exceeded 200px (confirmed by calculating realistic 2-column mobile grid tile widths, which were well over 200px), and `object-cover` then cropped the source photo down to whatever fit that squished, non-square box. `aspect-square` alone is sufficient and correct; removed the conflicting `maxHeight`.
### Changed: collapsing one card in a paired Statistics row no longer leaves the row looking visually broken
- Reported directly: collapsing one card next to its still-expanded neighbor left a short card sitting at the top of a tall grid row with empty space below it — visually read as a layout glitch even though nothing was technically wrong with either card. Root cause: `items-start` on the grid prevented the row from stretching the shorter card to match its taller neighbor. Switched both Statistics grid containers to `items-stretch` (the CSS Grid default), and updated `UnifiedCard`'s `stripe` variant to be a flex column internally so a collapsed card's own background and header actually fill that stretched row height gracefully, rather than just leaving dead space inside a taller box. All Statistics cards already default to expanded (`ChartCard`'s own `fallbackOpen` default is `true`, confirmed no call site overrides it), satisfying "all stats cards expanded by default" directly.
### Changed: set deliberate per-card expand/collapse defaults across Bird Profile and My Profile
- Previously several cards had no explicit `fallbackOpen` at all and silently inherited whichever global default happened to be set. Set explicitly, by request: Seasonal Range Map, About, Taxonomy, Seasonal Presence, Conservation, and Example Calls & Songs now default open (headline/core content); External Resources and Notes now default closed (secondary/situational); Photo Gallery, Observation Heatmap, Recordings, and user Photos keep their previously-established deliberate states (open/closed respectively, based on either explicit prior request or genuine loading cost).
### Improved: Field Guide (regional library) load time
- Root-caused a real per-row cost in `get_regional_library`: a synchronous `os.path.exists()` filesystem stat call on every matched `BirdProfile` row, unbatchable, running on every single page load for up to 1000-2000 species. Confirmed this check was unnecessary — not just slow — by finding that a second loop later in the very same function (appending life-list birds missing from the regional checklist) already trusted `photo_path` without any existence check at all; the two loops were inconsistent with each other. `photo_path` only changes during enrichment, a rare deliberate background event, not on every request, so re-verifying it synchronously in the request path was paying a real, avoidable cost. Benchmarked the isolated cost of 400 such calls on this environment's local disk (0.67ms — negligible here), but production runs on Unraid array/network-mounted storage where `stat()` latency can be meaningfully higher than local SSD; removing the check is a safe, real fix regardless of the exact magnitude in any specific deployment. The actual `/photo` endpoint still does its own real existence check before serving a file, so the only failure mode if a file is ever deleted out-of-band is a broken `
` icon, not a crash or security issue.
## 0.1.13.b — 2026-06-28
### Fixed: live detection poster card's photo was cropped awkwardly, cutting off the bird's head
- Real bug, reported with a screenshot. Root cause: the photo was stretched full-bleed across the entire card width with a hardcoded `objectPosition: 'center 25%'` copied from BirdProfile.jsx's hero header without re-checking it suited this card's very different shape — on a wide desktop viewport this card is roughly 6:1+ width:height, an extreme banner ratio no single fixed crop bias can handle well for a portrait-oriented bird photo. Confirmed by extracting the actual visible region from the reported screenshot and measuring its real aspect ratio (2.5:1 even before accounting for the full page width) — no `object-position` tweak fixes a ratio that extreme.
- Redesigned the layout instead of re-tuning the crop percentage: photo now sits in a fixed 180×180 square panel on the left, with species name, confidence, clip duration, device, and the "Also in this clip" row laid out to its right — giving every photo a sane, predictable 1:1 crop target regardless of the card's overall width or the source photo's own orientation. Visually verified against the actual reported photo (extracted from the screenshot) before and after, confirming the bird's head, eye, and beak are now fully in frame.
## 0.1.13.a — 2026-06-28
### New: unified per-card expand/collapse system across all four card-bearing pages, with a global default + per-card override setting
- Previously three separate, inconsistent mechanisms existed: BirdProfile.jsx's `CollapsibleCard` (a hardcoded `defaultOpen` prop per usage, zero persistence — every page load reset every card regardless of what the person had toggled last time), Settings.jsx's own bespoke `Section` + a private `chirp_settings_collapsed` localStorage key (real persistence, but always-collapsed-by-default with no way to flip that), and Statistics.jsx's `ChartCard` / UserProfile.jsx's `Section` (no collapse capability at all — every card was always fully expanded).
- New `lib/cardState.js`: one localStorage-backed store for the whole app. Two layers, in priority order — a per-card override once a person has explicitly clicked a specific card's header (remembered forever after, regardless of anything else), falling back to a global default ("Expanded" or "Collapsed", configurable in Settings → Display Preferences → "Card expand/collapse default") for any card that's never been individually touched. A `fallbackOpen` escape hatch lets specific cards request a deliberate per-card starting state independent of the global default — used for the Photo Gallery (explicitly requested to default open) and for cards with a real cost to loading by default (Observation Heatmap loads Leaflet + external tiles; Recordings and the user-submitted Photos card both fetch/render on mount) — all three keep their previously-deliberate behavior even though the underlying mechanism changed.
- New `components/UnifiedCard.jsx`: one shared component, three visual variants (`bordered`, `stripe`, `plain`) matching the three distinct looks already in use across the four pages exactly, so converting each page only required swapping which variant it asks for — no visual changes to any existing card's appearance. `Settings.jsx`'s real jump-to-section feature (scrollIntoView by DOM id) was a real constraint here: those exact DOM id strings predate this change and other code depends on them, so they're preserved exactly via a separate `domId` prop, distinct from the new cardState key.
- One-time migration (`migrateLegacySettingsCollapse`, run once at app boot alongside the existing theme-application side effect) folds any of a person's real existing Settings collapse preferences into the new unified store under `settings:`-namespaced keys, rather than silently resetting them — a read-and-merge, not a destructive migration, confirmed idempotent (re-running it doesn't clobber a real new-system override) by direct test.
- Verified with 9 real logic tests against the actual module (global default, override-beats-default, fallback-vs-override priority, reset behavior, migration correctness and idempotency) using a minimal localStorage polyfill — not just a successful build.
### Fixed: dashboard "Listening now" card got permanently stuck on "Loading waveform…"
- Real bug, reported with a screenshot. Root cause: the waveform fetch's `.catch(() => {})` silently swallowed any failure with no fallback UI state — and a real, plausible failure mode exists for a recording that's only seconds old: `get_waveform` 404s if the WAV file isn't fully flushed to disk yet, a genuine race for the most-recent recording specifically (the live card's entire reason for existing).
- Replaced the waveform entirely with a poster-style redesign per request: the top-confidence species' real photo as a full-bleed background (always available the instant the recording row exists, no file-flush race), confidence, clip duration, device name, and — only when genuinely true, computed from data already on the recording rather than any new fetch — which other distinct species were also detected in the same clip, shown as overlapping avatar chips with a "View →" link through to the recording. No backend changes needed; every field used (`top_species_photo_url`, `detections[].bird_photo_url`, `duration_seconds`, `device_name`) already existed on `RecordingOut`.
### Changed: dashboard stat cards no longer single out "Detections" with a solid accent fill
- Per feedback that the bright accent-colored card looked out of place. All four stat cards (Recordings, Species, Detections, Last Detection) now share one consistent tint instead of three different icon colors plus one solid-fill "featured" treatment.
### Changed: Dashboard's daily/weekly Summary card header now states and controls the active period directly
- Previously read as a plain "Summary" title with a separate pair of pill buttons to its right — functionally fine, but didn't make the card's actual subject ("today" vs "this week") clear from the header alone. The header now reads "Today's Summary" or "This Week's Summary", with the time-period word itself the clickable control that switches periods — removes the separate redundant button pair entirely in favor of one integrated control.
### New: real `:active` press feedback on cards and buttons (previously `:hover`-only, which never fires on touch)
- `.card-hover` (used by stat cards, the live detection card, and the recordings grid — confirmed by checking every usage) gets a genuine press-down scale on `:active`, with matching overrides for the themes (`paper`, `plate`, `fieldnotebook`) that deliberately opt out of all card motion. `.btn-primary`/`.btn-secondary`'s `:active` rule previously just canceled the hover lift back to neutral on press (`transform: none`) — changed to an actual press-down scale instead, since canceling-to-neutral was a missed opportunity for real tactile feedback, not a deliberate "no feedback on press" choice.
## 0.1.12.f — 2026-06-28
### Fixed: GBIF range map tab had no legend or attribution at all
- `ebird_st` already had a real, exact-RGB-matched 4-color legend (confirmed against `SEASON_COLOURS` in the backend rendering code); the genuine gap was `gbif`, which had no entry in the frontend's `LEGENDS` table and no attribution line. Fixed with an honest gradient legend rather than a fabricated discrete swatch list — confirmed via GBIF's own published map styles documentation that `classic.poly` (the style Chirp actually requests) is a real yellow→red density ramp, not a categorical map like AAB/BirdLife/S&T, so a "Breeding/Non-breeding" swatch legend would have misrepresented what's actually rendered. Also stopped the frontend from requesting `source=wikipedia` from the legend endpoint when viewing the `ebird_st`/`gbif` tabs — wasted work, since neither tab ever reads that response.
### New: two-finger pan + pinch-to-zoom for range maps on touch devices
- Previously only mouse drag/wheel were wired up; touch had zero handlers. Two-finger drag now pans (mirrors the existing zoom>1 gate on mouse-drag panning), pinch now zooms using the same [1,4] clamp the wheel handler already uses. Deliberately not single-finger pan — that needs to keep scrolling the page normally on mobile. Researched `touch-action` semantics properly before implementing: confirmed via the W3C pointerevents spec discussion (issue #159) that "let 1 finger scroll, let JS own 2 fingers" is a known, unresolved cross-browser ambiguity, and that `touch-action` is evaluated by the browser at the moment a touch begins, not reactively mid-gesture — so a conditional value that only flips to `none` once two fingers are already down would not reliably catch the first pinch attempt in every browser. Used the standard, reliable pattern instead: `touch-action: none` permanently scoped to just the map widget itself (the same trade-off real interactive map/canvas components use), not the whole page.
### Fixed: hero photo header — attribution badge too large/intrusive, scientific name unreadable against photo
- Reported directly from a real screenshot. The attribution badge (`top-3 right-3`, `bg-black/60`, full-size text, unconstrained width) was wrapping to two lines and covering part of the bird's face. Shrunk to a small, truncated, much lower-opacity badge. The scientific name had no background treatment at all and was genuinely illegible against busy photo backgrounds (confirmed in the reported screenshot — "Buteo platypterus" was invisible against branches). Both the common and scientific names now sit on a translucent dark backing strip plus a subtle text-shadow, so they stay legible regardless of what's behind them in the photo. Visually verified with a real rendered mockup built from the actual reported photo (not just inspecting the code) before shipping.
### Improved: more bird photos, gallery expanded by default, fixed blurry thumbnails
- Raised the iNaturalist extra-photo fetch from 5 photos (`taxon_photos[1:6]`) to 12 (`[1:13]`) — the real, binding cap on "more photos," not the frontend's display slice, which was never actually reached. `PhotoGallery`'s `defaultOpen` flipped from `false` to `true` as requested (the user-submitted `BirdPhotoGallery` — a different feature with its own upload dropzone — intentionally left collapsed by default, since always showing an upload prompt on every profile wasn't part of this request).
- Root-caused the actual blurry-thumbnail bug rather than guessing: confirmed via iNaturalist's own published photo-size documentation that `square_url` is a fixed 75×75px crop, while `medium_url` is 500px and `large_url` is 1024px. The thumbnail fallback chain previously dropped straight from `medium_url` to `square_url` when a given photo entry lacked a medium size — silently serving a 75px image that then gets CSS-upscaled 2-3x across the gallery's ~150-220px grid tiles, which is always visibly blurry. Fixed the fallback order to try `large_url` before ever touching `square_url`, for both the gallery thumbnails and the main per-bird `photo_url` (which every other view — Life List, Regional Library, Dashboard — already reads from, so this one backend fix improves photo quality everywhere without needing per-component changes).
## 0.1.12.e — 2026-06-28
### Fixed: switching range map tabs (or reloading the page) re-fetched/re-rendered a map from scratch even when it had already been fetched moments earlier
- Root cause: the on-demand `/range-map/fetch` endpoint had no concept of "is there already a cached file for this source" at all — it unconditionally queued a full background re-fetch (Playwright/GeoTIFF render) on every single call. The frontend's `handleFetchSource` already had a `force` parameter (used by the explicit refresh button), but it was never actually passed to the network call — both `api.js`'s `fetchRangeMap()` and the backend route itself were missing `force` entirely, so there was no way to distinguish "user explicitly asked to refresh" from "user is just clicking back to a tab they'd already loaded."
- Fixed in three coordinated places: (1) the backend route now checks for an existing, non-trivial cached file at the exact same path the background fetch itself writes to (confirmed identical sanitization/path logic by direct comparison, not assumption) and returns immediately without queuing any work if found and `force` wasn't passed — and if that source isn't the profile's currently-active one, it re-points `range_map_url`/`range_map_source` at the existing file synchronously, so switching back to a previously-fetched tab is instant; (2) `api.js`'s `fetchRangeMap()` now actually forwards `force` instead of silently dropping it; (3) `BirdProfile.jsx`'s poll loop now short-circuits immediately on a `"cached"` response instead of waiting through a pointless 2-second poll interval for data that's already known.
- Verified end-to-end against the real server in the exact reported scenario: pre-seeded a cached S&T file, simulated switching to a different tab (which changes `range_map_source` in the DB), then called the fetch endpoint again for the original source with no `force` — confirmed `"cached"` status in 68ms with the profile correctly re-pointed at the existing file, no background task queued. Separately confirmed `force=true` still queues a real fetch regardless of cache state, and a species with no cached file at all still queues a real fetch without needing `force`.
### New: Dashboard redesign — layered stat cards, a live waveform card for the most recent detection, and a "Seen today" species photo grid
- Stat cards now sit at slightly different elevations (subtle per-card vertical offset) instead of four identical flat boxes, and the Detections stat — the one that best represents "the system is actively working" — is now a solid accent-filled "featured" card instead of an outlined one, using the theme's existing `--btn-primary-text` token for correct contrast (confirmed populated by all themes, not assumed).
- New live card appears whenever the most recent detection was within the last 5 minutes: shows a real waveform (not decorative bars) from the actual most recent recording via the existing `/recordings/{id}/waveform` endpoint at a small point count, plus the real top species name, photo, and confidence from that recording's actual detection data.
- New "Seen today" photo grid sourced from a new `species_today` field added to `/recordings/statistics/dashboard-summary`'s daily/weekly summary — previously that endpoint computed the full per-species list internally but only ever returned the single most-common bird plus a bottom-5 rarest list, discarding the rest. Each tile shows a real photo (when the species has one) and an honest "NEW" badge driven by a genuine per-species `MIN(trigger_timestamp)` lookup — a species is only flagged new if its first-ever detection at this location falls within that summary period's window, not a fabricated indicator. Grid caps at 9 visible tiles with a real "+N more" tile linking to the Life List for the rest.
- Note: this dashboard work resumed from a prior in-progress edit that had been left with an incomplete component (`LiveDetectionCard`'s function body cut off mid-implementation, no closing brace) — caught immediately via a real brace-balance check and a real `vite build` before continuing, rather than assuming the file was in a clean state.
## 0.1.12.d — 2026-06-28
### Fixed: "Refresh All Data" couldn't see S&T/GBIF maps fetched via the per-bird tab, causing unnecessary re-fetches
- Diagnosed from a real ~2-hour production log Josh provided, not assumption. The log showed the same bird's eBird S&T map being fully re-rendered three separate times by the on-demand per-tab endpoint (`chirp.api.birds`) while a bulk `enrich-all` run was active elsewhere in the same window — each write going to a path like `/app/data/media/birds/acadian_flycatcher/range_map_ebird_st.png`, while the bulk pass's own write for the *same* bird, earlier in the same log, went to `/app/data/media/birds/birds/Acadian_Flycatcher/range_map_ebird_st.png`.
- Root cause confirmed by reading all three directory-construction sites in the codebase: `_prefetch_secondary_range_maps` (added in 0.1.12.b/c) used a third, incompatible convention — `media_dir/birds/Title_Case_Name/` — never reused anywhere else. The on-demand S&T/GBIF endpoint (`_fetch_range_map_bg`) and the existing AAB/Macaulay/Wikipedia downloader (`_download_range_map`, already serving most birds' primary tab in production) both use `media_dir/lowercase_safe_name/`, confirmed identical by direct comparison test. `_prefetch_secondary_range_maps` was the outlier, not the other two — fixed it to match them exactly, using the same `re.sub` sanitization `_download_range_map` already uses, rather than introducing a fourth convention or assuming a different existing helper (`_species_media_dir`, used for photos/calls) was the right one to reuse — confirmed by direct test that helper does *not* lowercase and would have produced a fourth mismatching path.
- Effect of the bug: the bulk recency check (`profile.range_maps_prefetched_at` + `os.path.exists` on the expected file) could never find a file the on-demand tab had just written, since it was checking an entirely different directory — so every "Refresh All Data" run re-fetched eBird S&T (and GBIF) from scratch for any bird whose map tab had ever been opened manually, even seconds earlier. This was the opposite of the intended "skip if already fresh" behavior the recency check exists for.
- Note: `profile.range_map_url` is what the serving endpoint (`get_range_map`) actually reads to display a tab, and that was never affected by this bug — it's set correctly by whichever fetch path runs. The bug was purely about the bulk *recency check* and *wasted re-fetches/orphaned files on disk*, not about anything being served incorrectly to a person looking at a profile page.
- Verified end-to-end against the real, fixed method (not a reimplementation): called `enrich_profile(force=True)` through the actual production entry point, confirmed the BirdLife file landed in the directory that now matches the on-demand convention exactly, then called it again with `force=False` immediately after and confirmed `range_maps_prefetched_at` stayed unchanged — i.e. the second call correctly skipped re-fetching rather than redoing the work.
- A real, pre-existing, separate quirk was also traced (not a bug, but worth a note for whoever reads `_resolve_range_map` next): it's fully gated by a global `range_maps_enabled` setting that, on a fresh/never-configured `AppSettings` row, defaults to disabled — this caused a misleading "nothing happened" result in an early test pass before `AppSettings` was created with the flag explicitly set, and is the kind of thing worth checking first if range maps ever appear to silently do nothing on a fresh install.
## 0.1.12.c — 2026-06-28
### New: "Refresh Range Maps Only" is now real (was a disabled next-version placeholder in 0.1.12.b)
- Added `maps_only` parameter to `BirdEnricher.enrich_profile()`/`_do_enrich()`. Skips Wikipedia article fetch, iNaturalist photo/sounds/conservation-status, and seasonal frequency — the genuinely non-map-related steps — but deliberately keeps scientific-name resolution and eBird taxonomy/species-code lookup, since `_resolve_range_map` and the eBird S&T/Macaulay prefetch both depend on those identifiers already being populated. Skipping them too would have silently produced no map at all for any bird not already fully enriched once — confirmed this dependency by reading `_resolve_range_map` and the secondary prefetch step before writing the skip logic, not assumed.
- A `maps_only` pass does **not** set `enrichment_complete = True` (confirmed every read site of that flag first — it's used to decide whether a bird still needs a full enrichment pass; a maps-only run correctly leaves that decision untouched either way) but does update `last_enriched`, since a real pass did run and commit.
- Caught and fixed a real regression in my own first draft before shipping it: an early version of this change called `_resolve_range_map()` unconditionally on every `_do_enrich()` pass instead of only when the map was missing or a refresh was forced — that function has no internal "skip if already resolved" guard of its own (it always tries AAB → Macaulay → Wikipedia from scratch), so the unconditional call would have made *every* routine, non-refresh enrichment re-run AAB's Playwright session on every bird, every time. Restored the original `not profile.range_map_url or force` outer guard (with `maps_only` always forcing it, since that's the point of the button) before testing.
### New: `POST /api/v1/birds/enrich-all/maps-only` endpoint
- Shares the exact same progress-tracking dict, concurrent-run lock, and per-bird iteration loop as `/enrich-all` (refactored both into one `_run_bulk_enrich()` helper rather than duplicating the thread/locking machinery) — only the `enrich_profile()` call differs by mode. The status dict gained a `mode` field (`"full"` | `"maps_only"`) so the same `/enrich-all/status` polling endpoint serves both buttons.
- The two endpoints share one lock, not two independent ones: starting a maps-only run while a full refresh is active (or vice versa) correctly returns `409`, the same as two clicks of the same button. Verified live: triggered maps-only, confirmed a concurrent full-refresh attempt got `409`, confirmed `mode` was reported correctly while running, then ran a full refresh immediately after the maps-only run finished to confirm the shared lock releases cleanly between different-mode runs in sequence.
### Settings: the "Next version" placeholder button is now a real, working button
- Both buttons now disable while *either* kind of run is active (matching the shared backend lock), each show their own live progress label and animation while their own mode is running, and each show a mode-appropriate completion message. Progress bar and percentage are shared UI driven by whichever mode is currently active, labeled "(maps only)" when relevant.
- Verified end-to-end against the real built bundle: confirmed the new button text and endpoint call are present in the compiled output, confirmed the old "Next version" badge text is completely gone, and ran the full maps-only → full-refresh sequence live against the actual server.
## 0.1.12.b — 2026-06-28
### Fixed: "Refresh All Bird Data" never actually refreshed eBird S&T or GBIF range maps
- Traced via real code reading, not assumption: `_prefetch_secondary_range_maps()` (the function "Refresh All Data" calls per bird to eagerly cache every map tab) only ever covered BirdLife and iNat Geomodel. eBird Status & Trends and GBIF were each only reachable through their own single-profile, on-demand `POST /{profile_id}/range-map/fetch?source=...` endpoint — i.e. only by a person manually clicking that specific tab on one bird at a time. Confirmed by grepping every call site of the underlying fetch functions before writing any fix.
- Added both to the same prefetch step, following the exact pattern already established for BirdLife/geomodel: gated by the same per-source `range_map_sources` setting (S&T defaults to off, matching its existing "(testing)" checkbox default in Settings; GBIF has no checkbox today and was already unguarded at its endpoint, so stays unguarded here too), skipped if already cached recently unless forced, never overwrites the primary displayed map/tab — only pre-caches each source's own file so opening that tab is instant.
- Neither addition touches `profile.range_map_url`/`range_map_source` (the primary tab), matching existing BirdLife/geomodel behavior exactly — this is purely about every *tab* being fresh after a refresh, not changing which one is shown by default.
### New: real progress tracking + progress bar for "Refresh All Bird Data"
- The bulk refresh previously fired a detached background thread and returned once, with the UI showing a static "✓ Queued N birds" message with no further feedback — no way to tell if it was still running, how far along it was, or which bird was currently being processed.
- Added a `GET /api/v1/birds/enrich-all/status` polling endpoint backed by a real in-process progress dict (same plain-module-level-dict pattern as the existing `_enriching_now` set in `bird_enricher.py` — appropriate for this single-process SQLite/NullPool deployment), tracking `running`, `total`, `done`, `current_name`, `started_at`, `finished_at`, and `errors`.
- Added a concurrent-run guard: a second `POST /enrich-all` while one is already running now returns `409` instead of starting a second thread against the same rows. Verified with a live test (seeded birds, real concurrent POST, confirmed 409).
- Settings now polls this endpoint (every 2s while running, same `setInterval`/`clearInterval` pattern as the existing `GeomodelDownloadPanel`), checks on mount too (so reloading the page mid-refresh still shows the live bar instead of losing it), and renders a real percentage progress bar plus the currently-processing bird's name — not a fake/static spinner.
- Full flow verified end-to-end against the real running server with `TestClient` and 5 seeded birds: confirmed real percentage climb (0→20→40→60→80→100%), real `current_name` updates per bird, clean finish state, and the 409 concurrent-run guard.
### Changed: Settings card renamed "Bird Data and Range Maps"
- Was "Data Management" — renamed for clarity now that this card explicitly covers every range map source, not just generic data housekeeping.
### Added (UI placeholder, not yet functional): "Refresh Range Maps Only" button
- A range-maps-only refresh (skip Wikipedia/iNat/eBird-taxonomy/sounds/frequency, only re-run `_resolve_range_map` + the secondary prefetch step) would need a new parameter threaded through `enrich_profile()`/`_do_enrich()`, a new endpoint, and matching frontend wiring — assessed as real, non-trivial work, not a quick add. Per explicit instruction, deferred to next version rather than rushed. The button is present now but disabled, clearly labeled "Next version" with an explanatory tooltip, rather than silently missing or silently broken.
## 0.1.12.a — 2026-06-28
### Fixed: device-side OLED button watcher crash on gpiod 2.x
- `chirp-device/chirp/display.py`'s Argon ONE button watcher used the gpiod 1.x Python API (`gpiod.Chip("gpiochip0")`, `.get_line()`, `LINE_REQ_EV_FALLING_EDGE`), which gpiod 2.0 removed entirely in a full rewrite of the bindings. `requirements.txt`'s open-ended `gpiod>=1.5.0` pin let 2.x install silently, so the button watcher crashed with `FileNotFoundError` on `gpiochip0` on any device that picked up gpiod 2.x (confirmed via a real production traceback; root cause confirmed against the actual installed gpiod 2.5.0 source before patching, including verifying every new API call against the real package — `request_lines`, `LineSettings`, `Edge.FALLING`, `Bias.PULL_UP`, `wait_edge_events`, `read_edge_events`, `release`).
- A second, independent bug compounded this: `_try_start()` only caught `ImportError`, so the `FileNotFoundError` from the broken v1 call died silently inside an already-started daemon thread, and the sysfs fallback never ran. Fixed by opening the gpiod line request synchronously (on the calling thread, before spawning the watcher thread) so any real failure is caught and correctly falls through to the sysfs fallback path.
- `chirp-device/requirements.txt`: pinned `gpiod>=2.0.0,<3.0.0` to match the rewritten v2 API now in use and prevent a future major-version bump from silently breaking this the same way.
- Confirmed on the real device (`gpiodetect`/`gpioinfo`) that `gpiochip0 [pinctrl-rp1]` is in fact the correct chip owning GPIO 27 on this Pi 5 — the crash was an API mismatch, not a wrong chip name.
### Config: ReSpeaker + Coral TPU deployment (AudioMoth deferred)
- `chirp-device/config.yaml`: `detection.birdnet.backend` set to `"coral"` (Coral USB Accelerator now plugged in). `respeaker.mode: auto` and `bat_echolocation.enabled: false` were already correctly set for ReSpeaker-only operation with AudioMoth dormant-but-ready; no changes needed there. Coral model path (`/home/pi/chirp-device/models/coral/...`) is a fixed convention matched by `install-coral.sh`, not a config key — confirmed no separate path setting was needed.
### Noted, not fixed (pre-existing, out of scope this session)
- `pyflakes chirp/respeaker.py` reports `undefined name 'np'` at line 480 — this is `numpy` referenced only inside quoted type-annotation strings (`"np.ndarray"`) on a function that imports `numpy` lazily inside its own body. Harmless at runtime (annotations aren't evaluated), confirmed pre-existing via diff against the originally uploaded zip (byte-identical, not introduced this session). Flagging honestly rather than silently fixing unrelated code or silently ignoring a real lint finding — worth a one-line fix (hoist the import or use `from __future__ import annotations`) in a future session.
## 0.1.11.z — 2026-06-26
### Investigated and rejected: Sibley and Audubon as AAB replacements
- Sibley's range maps only exist in paid books/apps — no public web source exists to fetch from at all.
- Audubon's field guide explicitly attributes its range maps to a licensed, copyrighted Kaufman field guide ("All rights reserved") — and checking two real species pages (including a known long-distance migrant) found no actual map image rendered on the page anymore, just descriptive text. Nothing to build against, legally or technically.
- Also checked USGS's BBA Explorer: a legacy 2014-era tool with only one example species map in its entire gallery, not a real per-species source.
### Fixed: a real, currently-live bug found while investigating — disabling AAB didn't actually disable AAB
- While checking why Macaulay Library (already partially integrated as AAB's silent fallback) wasn't more reliable, traced a real bug: unchecking "All About Birds" in Settings writes to the granular per-source `range_map_sources` dict, but the actual enrichment function only ever checked a different, single global flag — never that dict. Unchecking AAB had zero effect on whether AAB actually ran. Fixed and verified with a real test.
- Gave Macaulay its own independent gate in the same fix, rather than only ever running as AAB's invisible fallback with no way to control or even identify it as the active source.
### New: Macaulay Library as a real, full frontend tab
- Added a dedicated camera-icon tab, its own refresh path (a simple Cornell CDN lookup keyed by eBird species code — no Playwright session needed, unlike AAB), its own loading/error states, and its own attribution line and legend handling (Macaulay has no embedded color-coded legend at all, unlike AAB's illustrated maps).
- Broadened the existing AAB/S&T cross-contamination guard to cover Macaulay too, rather than writing a parallel set of checks.
### New: drag-and-drop map tab ordering in Settings
- Added a `range_map_source_order` setting (backend column, migration entry, API plumbing) and a real native-HTML5 drag-and-drop reorderable list in Settings — no new dependency needed for a simple 4-5-item list. Verified the setting round-trips correctly through a real update-then-fresh-GET test.
- `BirdProfile.jsx`'s map tabs now sort by this order instead of a fixed array order; any tab id missing from a saved order (e.g. a newly-added source on an older config) falls back to the end rather than being silently hidden.
### Changed: Field Guide icon is now an open book
- Swapped the nav icon from a location pin (which never fit a reference/lookup feature) to `BookOpen`, already available in the icon library and unused elsewhere in the app.
### Improved: all 11 themed logos now have animated sound waves; Terminal and Oscilloscope redesigned
- Rendered the current Terminal and Oscilloscope logos directly to confirm the complaint before changing anything: Terminal's ASCII-character art was confirmed illegible at actual small tab-icon scale (just scattered green dots, not the clear bird the same art showed at large preview scale) — character art fundamentally doesn't scale down. Oscilloscope's 8px pixel grid read as a vague amber blob with a barely-visible single-pixel eye.
- Redesigned both: Terminal is now a bolder monospace-block bird (thick rectangular strokes, not literal characters) that reads clearly at both large and small scale; Oscilloscope moved to a coarser, more deliberate 12px grid with a much larger, high-contrast eye, modeled after a classic 16-bit sprite rather than an organic pixel-blob. Verified both at simulated small icon scale before finalizing, not just at large preview size.
- Added a real animated sound-wave element to all 11 logos (previously zero had one), each styled to match its own theme rather than reusing one generic wave: flat geometric strokes for Cornell, loose pencil lines for Field Notebook, blocky VU-meter bars for Terminal, soft blurred watercolor washes for Paper, sharp two-tone strokes for Mono, engraved cross-hatch for Plate, retro equalizer bars for Oscilloscope, confident contour lines for Aviary, glowing red strokes echoing the eye color for Dracula, radar-sweep concentric arcs for NEXRAD, and glowing UV-purple strokes for Moth Trap.
- Caught and fixed a real bug while reviewing the finished file: Terminal's and Oscilloscope's keyframe names were the only two not namespaced with each logo's unique per-size id, unlike every other logo — a real collision risk if two instances of the same logo render at different sizes on the same page simultaneously (e.g. header + Settings drag-list). Fixed to match the established convention.
- Threaded a real `animate` prop through `ChirpLogo.jsx`'s dispatcher to every themed logo, previously hardcoded to static-only.
- Also caught two stale references while reviewing Settings.jsx for consistency: the Macaulay entries in both the tab-order list and the enable/disable checklist were still using a borrowed eBird S&T icon and a "no separate tab yet" comment, both incorrect now that Macaulay has its own real tab and icon from this same session.
## 0.1.11.y — 2026-06-26
### Fixed: real root cause of the eBird S&T missing-breeding-band bug (probably)
- A real three-way screenshot comparison (BirdLife, iNat, S&T for the same species) made the bug concrete for the first time: the breeding band was nearly absent from the S&T render while both other sources agreed on a broad breeding presence. Not migration biology — a real compositing bug, finally visible.
- Researched the actual GDAL/ebirdst convention rather than continuing to guess: real per-band season names live in the non-standard `TIFFTAG_GDAL_METADATA` tag (code 42112, an XML blob keyed by band "sample" index) — not the baseline `ImageDescription` tag (270) this code has been reading since the very first version of this feature. Confirmed directly: built a test file with real GDAL band-naming XML and verified `page.description` returns a useless auto-generated JSON string while tag 42112 holds the real names — this matches every production log's `descriptions=['']` finding exactly.
- Added real tag-42112 parsing, with `page.description` and the positional order-assumption kept as fallbacks in that order. Caught and fixed a real bug in my own first draft while testing it: the metadata is keyed by per-band "sample" index, but the confirmed-real file structure has only ONE physical page containing all 4 bands (channels-last) — keying by page index instead would have only ever checked sample 0 and silently missed samples 1–3. Fixed by building the description list after the true band count is known, not before.
- Added explicit, unambiguous logging for both outcomes (tag found vs. genuinely absent) — the next real S&T render's log will state directly whether this fixes the underlying file, rather than requiring another round of indirect inference from band labels.
- Considered switching this whole pipeline to a shelled-out R script using the official `ebirdst` package, which knows the real band-naming convention with certainty and needs no guessing. Weighed honestly: real costs (new R + GDAL/GEOS/PROJ runtime in the Docker image, a Python↔R subprocess bridge, an entire rendering pipeline rebuilt from scratch) against a fix that's specific, already written, and verifiable right now in this sandbox — versus an R path I can't test here at all (no R runtime, no CRAN access). Decided to ship the targeted fix and treat the R rewrite as the real fallback if the next production log shows tag 42112 is genuinely absent on actual eBird files too.
## 0.1.11.x — 2026-06-26
### New: tutorial now explains what the seasonal likelihood percentages actually mean
- Traced the real source before writing anything: the underlying number behind every frequency bar (Field Guide and Bird Profile alike) is the fraction of eBird checklists in your region that reported the species that month — the same metric the Merlin app shows as its blue bars, fetched from eBird's bar chart API with iNaturalist's monthly histogram as a fallback. `current_likelihood` isn't a separate metric; it's just that same array's current-month value, pre-extracted for convenience.
- The genuinely non-obvious part, confirmed from the actual ranking code rather than assumed: the Field Guide's Very Likely/Likely/Possible/Rare labels are NOT fixed percentage cutoffs — they're a relative rank against every other species shown for that month. The same raw frequency can earn a different label in different months (a modest frequency might be "Very Likely" in a quiet winter month, "Possible" during peak migration). Added this explanation to the Field Guide tutorial section.
- Also confirmed the Bird Profile's own Seasonal Presence chart works differently — no cross-species ranking at all, just that one species' raw frequency scaled against its own yearly peak. Added a shorter note there clarifying the distinction and cross-referencing the fuller Field Guide explanation, rather than duplicating it.
- Separately confirmed "Detected Here" is an independent flag (Chirp's own real detections) from the regional eBird frequency percentage — a species can show as both "Detected Here" and "Rare" at the same time, which the tutorial now states explicitly rather than leaving as an implicit assumption.
## 0.1.11.w — 2026-06-26
### Fixed (definitively this time): eBird S&T render corruption — confirmed 100% reproduction rate from real logs
- A real log showed the exact same `shape=(1, 11484, 4)` corruption happening for every single species checked (Red-eyed Vireo, Indigo Bunting, Broad-winged Hawk, repeatedly) — not intermittent, not load-related. The "fix" shipped two sessions ago for a different 4D scenario (multiple overview/pyramid pages) was real and still correct for that case, but never addressed this one — confirmed by the log's own absence of the "filtered N overview pages" line, meaning there was genuinely only one page involved, not several.
- Traced precisely: a single GeoTIFF page with real multi-band data, read by `tifffile` in **channels-last** layout `(H, W, bands)` — a standard GDAL convention for multi-sample-per-pixel TIFFs — produces shape `(1, H, W, bands)` after stacking. The existing 4D handler (`data[:, 0]`) was written for a *different* 4D case and assumed the wrong axis was the spurious one, slicing out a single row of the real height dimension and discarding the rest — reproduced the exact corrupted shape from the log byte-for-byte with this precise mechanism, not a guess.
- Fixed by checking *which* axis is actually the singleton before squeezing, rather than assuming. Verified against both real 4D cases this code now needs to handle correctly: the original separate-pages scenario from three sessions ago (still works), and a genuine multi-sample-per-pixel TIFF built with `tifffile` itself (now correctly recovers all 4 real bands at the real 5562×11484 resolution instead of a corrupted 1-pixel-tall sliver). The previous code's own size-sanity-check correctly caught the corrupted output as too small to use and reported `Range map fetch FAILED` — an honest, correct signal, just not specific enough to find the real cause without this trace.
- AAB's 403 errors in the same log are the same persistent Cloudflare block described several sessions ago, not a regression — that assessment holds.
## 0.1.11.v — 2026-06-26
### Fixed: full sweep of the hardcoded-pastel-text contrast pattern
- Did the full sweep requested after the last fix: found 61 hardcoded hex colors (8 distinct values) across 16 component files, all light pastels meant for dark backgrounds, all confirmed via real contrast math to fail badly (1.0–1.8:1) against every light theme. Computed darker replacements for each, individually verified to pass 4.5:1+ against Cornell's cream (the lightest theme, so the others pass with more margin).
- Since these are inline `style={{ color: '#hex' }}` literals, not Tailwind classes, the existing class-based override mechanism couldn't fix them directly. Added 8 new shared CSS variables instead (`--pastel-green`, `--pastel-red`, etc.), defined once with the original light value (correct for dark themes, confirmed 8.4–12:1 there) and overridden to a darker tier for the 5 light themes only. Converted all 61 call sites to reference these variables.
- Also extended the existing class-override mechanism to the 6 `badge-*` CSS classes (green/blue/purple/yellow/red/teal), found to have the identical problem.
- Caught a real CSS syntax error while writing the new comment block: a literal `*/` inside the comment text (from referencing "text-gray-*") prematurely closed the comment itself, breaking the entire stylesheet build. Reworded to avoid it.
### Improved: 4 of the 11 themed logos
- Oscilloscope: fixed a crude unblurred glow rectangle into a real soft phosphor bloom, added a highlight pixel for dimension.
- Mono: replaced the flat vertical light/shadow split with a diagonal one (more cinematic noir lighting) and added rim-light edge highlights — caught and fixed a draft that briefly introduced a third grey tone, which would have violated the theme's own strict two-color swatch definition.
- Terminal: cleaner ASCII proportions and a subtle phosphor-glow accent dot.
- Field Notebook: added ruled notebook page lines, a second construction guideline, and a small handwritten-style "sp.?" annotation for more genuine field-journal character.
### Range map code review — 2 real bugs found and fixed
- A band could be silently DROPPED from the eBird S&T render entirely (not mislabeled — genuinely missing data) when some bands had real season names from file metadata but others didn't, since the no-real-names-found bands were never added to the season mapping at all. Confirmed with a test: 2 of 4 real bands vanished from a render despite containing real abundance data. Fixed to fall back to the positional guess per-band, only for whichever specific bands lack a real name, with a precise warning naming which bands are affected.
- The basemap reprojection code read geo-referencing tags from `tif.pages[0]` (literally the first page in file order) while the actual pixel data uses a size-filtered page list (confirmed two sessions ago that overview/pyramid pages can appear at any position) — these could be different pages, silently misaligning the basemap if an overview page happened to come first. Fixed to use the same page reference for both. Verified directly: built a test file with deliberately wrong geo-tags on the first (overview) page and confirmed the fix reads the correct page's tags instead.
- Cross-checked the GeoPackage binary header parser (envelope size table, flag bit layout) against the authoritative OGC specification directly — confirmed correct, no bug found there. Considered and declined to "fix" a theoretical mixed-endianness WKB edge case, since real-world GDAL output (confirmed via `gpkg_ogr_contents` in the actual schema) never produces it.
## 0.1.11.u — 2026-06-26
### Fixed: more low-contrast text on light themes — Recording Detail and Submit-to-iNaturalist
- Confirmed from a real screenshot (Cornell Lab theme): the bird name next to its photo was invisible, the confidence label ("86% High") was too faint, and the "Submit to iNaturalist" button text was barely legible. Last session's fix was correctly scoped to `BirdProfile.jsx`'s map area specifically, but this is a different file with the same underlying problem — not a regression.
- Computed real WCAG contrast ratios rather than eyeballing it: `text-gray-100` (the bird name) measured 1.0–1.2:1 against light-theme backgrounds, even worse than the `-200`/`-300`/`-400` shades an earlier session already fixed — it was simply missing from that fix's class list. Added it. The confidence-label colors (`text-chirp-400`/`text-cyan-400`/`text-yellow-400`) measured 1.25–1.71:1; mapped the high-confidence tier to the theme's own `--accent-600` and the others to `--text-secondary`, since this app has no separate semantic blue/amber scale to draw from.
- `InatSubmitButton.jsx` used a hardcoded `#86efac` (light mint-green, meant for a dark background) in 6 places — replaced all of them with `var(--accent-600)`, the same theme-aware darker-accent variable already used elsewhere (`LifeList.jsx`, `RegionalLibrary.jsx`) for exactly this "colored text that needs to read on any theme" case.
- Found the same hardcoded-pastel pattern in the `badge-green`/`badge-blue`/`badge-yellow`/`badge-purple`/`badge-red`/`badge-teal` CSS classes (also failing badly, 1.3–1.7:1) — flagging this but not fixing it here, since it's a broader, separately-scoped cleanup beyond what was reported this time.
## 0.1.11.t — 2026-06-26
### Fixed (for real this time): iNat geomodel "always one color" — it's not a bug
- Got the actual schema from a real production database via the debug endpoint: `fid, geom, taxon_id, parent_taxon_id, name, rank, iconic_taxon_id, iconic_taxon_name, geomodel_version`. There is no score, probability, or likelihood column in this product at all — confirmed directly, not guessed.
- This settles something three sessions of column-name guessing couldn't: iNaturalist's geomodel range product is a binary presence polygon per taxon ("the species occurs somewhere within this shape"), not a per-cell abundance gradient. A uniform-color render was never a fallback standing in for missing data — it's the correct, complete representation of what this data actually contains.
- Removed the entire score-detection/normalization premise from the renderer: no more guessing column names, no more degenerate-variance detection, no more neutral-color fallback pretending a gradient might exist. Every polygon now renders in one confident color, with a legend that says "Estimated range" instead of implying a High/Moderate/Low scale that was never real. Fixed the matching frontend legend overlay to use the exact same color as the backend render.
- Verified against a GeoPackage built with the literal real-world column list and sample values from the debug output, not a guessed approximation.
## 0.1.11.s — 2026-06-26
### Fixed: iNaturalist/BirdLife map text unreadable on light themes
- The map tab attribution lines, refresh hints, and copyright captions used Tailwind's hardcoded `text-gray-600`/`text-gray-400` classes, completely independent of the active theme's color variables. These read fine on dark backgrounds but were low-contrast and washed out against light themes like Cornell Lab's warm cream background.
- Fixed all 8 occurrences in the map tab area to use `var(--text-tertiary)`/`var(--text-secondary)`, the same theme-aware variables used everywhere else for this kind of muted caption text. Scoped to the map area specifically that was reported; the rest of this file has the same hardcoded-grey pattern in ~46 other places, which is a separate, larger cleanup not attempted here.
### New: 11 hand-drawn themed logos, one per "Distinct Look" theme
- Cornell Lab, Field Notebook, Terminal, Paper, Mono, Plate, Oscilloscope, Aviary, Dracula, NEXRAD, and Moth Trap each now get their own logo artwork matching that theme's real visual identity, instead of every theme reusing the same orange gradient bird: a flat geometric carnelian-red mark for Cornell, a loose pencil sketch with cross-hatching for Field Notebook, genuine monospace ASCII art with a blinking cursor for Terminal, a soft blurred watercolor wash with fine ink linework for Paper, a hard noir light/shadow split with no gradients for Mono, fine parallel-hatch engraving in a decorative frame for Plate, blocky 8px-grid pixel art for Oscilloscope, confident stippled-shading museum-plate linework for Aviary, a cape-winged bird with a glowing red eye and fang-tinted beak for Dracula, a stencil-cut silhouette with a targeting-reticle eye and HUD corner ticks for NEXRAD, and a UV-blacklight glow halo with moth-dappled spots for Moth Trap.
- Rendered every one to PNG and visually checked before writing the final component, rather than shipping unverified SVG.
- Wired in at the `ChirpLogo` component itself (not at each call site) so every existing usage — Dashboard hero, Login screen, Tutorial demo — automatically gets the right themed logo with no changes needed at the call sites. Watches the `[data-theme]` attribute via `MutationObserver` so the logo swaps live if the theme changes without a page reload. Themed variants are static by design (a pencil sketch shouldn't wing-flap); themes without a custom logo keep the original animated gradient bird unchanged.
## 0.1.11.r — 2026-06-26
### Fixed: eBird S&T corrupted shape and scrambled legend colors — two more real bugs
- A real production log showed the S&T renderer producing shape `(1, 11484, 4)` instead of the correct `(1, 5562, 11484)`. Root cause: real eBird GeoTIFFs (Cloud-Optimized GeoTIFF format) embed reduced-resolution overview/pyramid pages alongside the genuine band page(s) — last session's page-stacking fix assumed every page was a same-sized band, and stacking mismatched shapes together either crashed or, for certain layouts, silently produced a corrupted combined shape. Fixed by filtering to only full-resolution pages before stacking, confirmed against a synthetic file combining both structures.
- Separately, every band was mapping to "breeding" regardless of its real season — `"breeding"` is a substring of `"nonbreeding"`/`"prebreeding_migration"`/`"postbreeding_migration"`, and the alias-matching loop checked it first, so every non-breeding-family description matched the wrong, shorter alias before reaching its own correct one. Fixed by checking longer aliases first. This is very likely the real explanation for the scrambled legend-vs-map colors reported this session.
- The iNat geomodel's uniform color in the same report is most likely the neutral-color fallback (added two sessions ago) correctly firing, not a new bug — the real database schema still doesn't match the guessed column-name candidates. The existing `/api/v1/settings/geomodel/debug` endpoint can confirm this directly against the real file.
### Fixed: Statistics page showed 20 species instead of the real count
- Found the actual root cause: `full_statistics()` never returned a `total_species` field at all. The frontend's fallback to `top_species.length` silently reported the chart's query LIMIT (capped at `max(20, heatmap_limit)`, defaulting to 20) as if it were the real species count. Added a real, unlimited count query. Verified end-to-end with 80 real species in a test database — confirmed the bug reproduces exactly (20) and the fix resolves it (80).
- Also found and fixed a related inconsistency: comment counts included comments on soft-deleted recordings, which aren't cascade-deleted from the database. Excluded those for consistency with every other stat on the page.
### Fixed/improved, smaller items
- All four phases of the "tap to enhance" tutorial demo now last the same duration (1200ms each) — the phase right before the sparkle/enhance moment was previously much shorter than the others.
- The ultrasonic-mic info box in Settings now collapses by default, leaving just the warning headline visible until expanded.
- Fixed copy that referenced "the threshold below" in a description attached to a toggle whose related threshold control doesn't exist on screen until that same toggle is switched on — now states the actual setting name and default directly.
- The daily digest send-time picker now shows the equivalent local time (using the configured timezone) alongside the raw UTC hour, in both the caption and each dropdown option, computed against the real current date so DST is reflected correctly.
- Bird profile hero photos now crop biased toward the top of the frame rather than dead-center, since bird photography conventionally frames the subject in the upper-to-middle portion — this won't be correct for every photo without real object detection (a much larger feature, not implemented here), but is a meaningful improvement over centering for the common case.
## 0.1.11.q — 2026-06-26
### Fixed: eBird S&T file selection — definitively confirmed from a real log
- A full file listing in a production log settled what last session could only theorize about: the selected file was `.../seasonal/brwhaw_abundance_full-year_mean_3km_2023.tif` — the single-layer full-year product, not a real multi-band seasonal file.
- Root cause: the highest-priority scoring check matched `"seasonal"` against the file's full path, including its *directory* (`seasonal/`), not just the product type in the filename itself — a full-year file sitting in a folder called `seasonal/` won the top score despite explicitly saying "full-year" in its own name. The exclusion for "full-year" existed but only applied at the lowest-priority fallback tier, which this file never reached because it already won earlier.
- Fixed by rejecting "full-year" universally, before any other scoring, so it can never outscore a real seasonal file regardless of which directory it's listed under. Added an explicit, clearly-logged fallback to a full-year file specifically for species where no real seasonal file exists at all — a single-layer map is still better than none, but this is now a deliberate, visible choice rather than an accidental one. Verified both outcomes end-to-end against the real filenames from the log.
### Fixed: iNat geomodel crash — `ValueError: could not convert string to float: 'species'`
- A real traceback showed `score_col` had matched a taxonomic rank column (containing text like "species", "genus") instead of a numeric score — confirmed this was `"rank"`, one of the broader candidate names added last session specifically to find more real score columns, which turned out to be too generic and collided with a common, unrelated column purpose.
- Removed the riskiest overly-generic candidates (`rank`, `value`, `weight`, `density`, `occurrence`, `prediction`) and kept only names specifically about a numeric likelihood. Also added a defensive `try`/`except` at the actual conversion site, so even if a future guess collides with another non-numeric column, one bad value can no longer crash the entire render — it falls back to the neutral placeholder for that row instead. Verified both fixes directly against the exact crashing value from the traceback.
## 0.1.11.p — 2026-06-26
### Fixed: iNat geomodel map only ever shows yellow, never green
- Traced the math precisely: the color for each hexagon comes from normalizing its score against the min/max of all hexagons for that species. If the score column couldn't be matched in the file's schema (a real possibility — column names are guessed against a fixed candidate list, since there's no way to inspect the actual schema without the real downloaded file), every hexagon silently got an identical hardcoded placeholder score. With every score identical, the normalization always lands on 0 — which is the first color in the gradient: yellow. Confirmed this exact mechanism end-to-end with a synthetic GeoPackage that has no matching score column at all.
- Fixed two ways: rendering now uses a clearly neutral grey-blue (not on the yellow→green gradient at all) when there's no real score variance to show, so it's never confused with genuine "low likelihood" data again; and added clear, always-on logging of the resolved schema (which column names actually matched) so this is diagnosable directly from production logs going forward instead of requiring a separate manual debug call. Also expanded the column-name guess list with more plausible real-world names, in case the real schema simply isn't covered by the original short list.
### Investigated: eBird S&T "only one layer" — likely real per-species data, now more diagnosable
- The reported `bands=1` is consistent with eBird's own documentation: seasonal files have one layer per season that passed expert review, and a season can be entirely omitted if it failed review — so this could be genuinely correct for an individual species. But seeing it for two different common, well-studied species in the same session is suspicious enough to take seriously.
- Couldn't fully resolve this without a real downloaded file to inspect (no network access to eBird's servers from this environment), but made it properly diagnosable: the exact filename selected is now logged directly alongside the band count, instead of only appearing in a separate, easy-to-miss log line at download time.
### Fixed: a real regression — AAB tab 404ing instead of falling back to Wikipedia
- Confirmed from a log: the exact AAB cross-contamination fix from two sessions ago had its own bug. When the cached map was S&T-sourced, it cleared `range_map_url` entirely — which prevented execution from ever reaching the existing Wikipedia-fallback logic further down in the same function (only reachable via a non-empty URL), so a species that should have successfully fallen back to Wikipedia (and had, on an earlier request before the S&T tab was visited) instead hit an early 404.
- Fixed by going straight to the same fresh-Wikipedia-lookup fallback the "dead AAB URL" case already uses, rather than wiping the URL and hoping something else picks up the pieces. Verified end-to-end: the exact regression scenario (S&T-contaminated cache, failed AAB refresh, a real Wikipedia map available) now correctly redirects instead of 404ing, and the genuine dead-end case (no Wikipedia available either) still degrades to a clean 404 as before.
## 0.1.11.o — 2026-06-26
### New: prettier map source icons, sleeker Dashboard
- Redesigned all four map source icons with real gradients and depth (binoculars, leaf+magnifier, globe, bar chart) instead of flat single-color line icons. Caught and fixed a latent bug while polishing these: each icon's gradient `id` was derived from its `size` prop, so two instances at the same default size would collide on the same SVG `id` and silently break each other's gradient. Fixed with React's `useId()` instead.
- Dashboard polish: each stat card icon now has its own tinted gradient background instead of all four sharing one flat color; a small live-pulse indicator appears next to the greeting when the last detection was genuinely within 5 minutes (data-driven, not decorative); active period/summary toggle buttons get a subtle theme-aware glow; rarest-species rows and the most-common-bird card got entrance animation and a touch more depth that the rest of the page already had; the empty "no recordings yet" state got a gentle floating icon and a warmer message instead of a static glyph.
### Reliability assessment: which map sources are actually most trustworthy
- Did an evidence-based pass rather than a guess: counted real fixes needed per source across the full changelog history, and checked each source's underlying architecture.
- iNat Geomodel is the most reliable — no browser scraping, no API key, a direct stable file download. Its bugs were crash-level and are now fixed and well-tested, not the recurring "the website changed shape" problem the scraped sources keep hitting.
- eBird S&T is structurally the most robust (a real documented REST API, no scraping at all) but is the least battle-tested of the four — it was only wired up two sessions ago, and the basemap reprojection is still unverified against a real downloaded file since this environment can't reach eBird's servers to test one.
- AAB has a real, recurring failure mode (Cloudflare blocking server-side downloads of its own hotlinked URLs) that keeps getting worked around rather than actually fixed — the fragility is structural.
- BirdLife is the least reliable of the four: most total fixes needed, and distinctively, two wrong root-cause assumptions before landing on the real fix for the same bug — a sign the site is hard to reason about reliably from outside. Also the slowest in practice (~10-16s per fetch, confirmed in production logs).
- No new, currently-unresolved map errors found beyond what's already documented in-code as a known verification gap (the S&T basemap, noted above).
## 0.1.11.n — 2026-06-26
### Fixed: AAB tab could show the eBird S&T map after visiting that tab
- Confirmed real: the AAB tab's image request had no way to tell the backend which source it actually wanted — the endpoint just served whatever `range_map_url`/`range_map_source` happened to be cached, regardless of which tab asked. Once the S&T tab successfully populated those fields (its own independent background fetch), switching back to AAB kept showing that same S&T-sourced image.
- Added a `source=aab` parameter the frontend now sends; when the cached source is `ebird_st`/`gbif`, the backend forces the same re-resolution path the explicit refresh button already used, rather than serving the mismatched file.
- Caught a real gap while testing this: if that re-resolution itself fails to find a usable AAB image, the original code would still fall through and serve the stale S&T file anyway — closed that by refusing to serve it in that case too, returning a clean 404 instead of the wrong map.
- Verified all three paths end-to-end against the real endpoint function: refresh fails → clean 404, not the S&T image; refresh succeeds → serves and persists the new AAB image; already-correct AAB cache → completely unaffected. Also confirmed the underlying S&T data in the database is never destroyed by an AAB tab request, so switching back to S&T afterward still works.
## 0.1.11.m — 2026-06-26
### New: original icons for each map source toggle, replacing generic emoji
- Replaced the 🗺/🌿/🐦/📊 emoji on the AAB/iNat/BirdLife/S&T tab pills and the Settings map-sources checklist with small, purpose-built SVG icons (binoculars, a leaf with a magnifying glass, a globe, a bar chart).
- These are original glyphs, not reproductions of any organization's actual logo — the relevant domains aren't reachable from this build environment to source real brand assets, and recreating a trademarked mark from memory wouldn't be accurate or appropriate regardless. Each icon evokes that source's general focus instead.
- Rendered all four to PNG for visual verification before shipping — confirmed each reads clearly at a small size and is visually distinct from the others.
## 0.1.11.l — 2026-06-26
### New: real spectrogram thumbnails for list/grid views
- The recordings list grid and Dashboard's recent-recordings cards were fetching the exact same full-resolution spectrogram PNG used on the detail page, for what's displayed at a fraction of that size. A `dpi` parameter for a lighter thumbnail variant already existed in the generator but no caller had ever actually passed anything but the default — a half-wired feature, not a new one.
- Wired it up: `?thumb=true` on the spectrogram endpoint serves a dedicated, separately-cached lower-DPI, capped-size variant. Generated proactively at upload time and during the threshold-change background regeneration (added last session), alongside the full-resolution version — never generated lazily on first grid view.
- Verified end-to-end with a real 9-second test recording: thumbnail is 72.7% smaller (116KB → 32KB) and still clearly legible at actual display size.
### Fixed: recording deletion orphaned spectrogram files on disk
- Found while wiring up thumbnails: deletion only removed the single DB-tracked spectrogram path, not the other per-threshold cache variants that already existed before this session (or the new thumbnail files). A pre-existing gap, made marginally worse by adding a second file per threshold.
- Fixed both cleanup sites (manual delete, auto-purge) to glob-match every spectrogram variant for a recording rather than just the one tracked path. Verified end-to-end with 4 simulated variant files (2 thresholds × full/thumbnail) — all correctly removed, where only 1 of 4 would have been before.
## 0.1.11.k — 2026-06-26
### Fixed: eBird S&T map showed only one color, no basemap
- Confirmed two real bugs in the GeoTIFF reading, not just a rendering choice. First: `tifffile`'s `asarray()` only reads the first page/series by default when a file's bands are written as separate series rather than one combined series — reproduced this directly, confirmed it silently drops every band after the first with no error.
- Second: even when bands were read correctly, they were labeled by fixed index ("band 0 = breeding") regardless of which season actually survived review for that species — confirmed via eBird's own documentation that bands correspond to whichever seasons passed review, not a fixed global order. Now reads each band's real description from the file's metadata first, only falling back to the documented order (logged explicitly as an assumption) when no real names exist.
- Added a real coastline basemap, correctly reprojected from lat/lon into the file's actual native projection (read from the GeoTIFF's own georeferencing tags via `pyproj`, not assumed) rather than the flat grey background it had before. Verified the transform math and pixel placement directly against known reference points using a hand-built synthetic GeoTIFF — confirmed land/ocean colors land exactly where expected. Could not verify against a real downloaded eBird file (no network access to test against); every step degrades cleanly to the previous flat-background behavior if the file's tags don't match what's expected, rather than risk a confidently-wrong basemap.
- Fixed the file-selection scorer to require "abundance" in the filename explicitly — it could previously match a same-named seasonal-mean file for a different product (occurrence, count, proportion-population) with a different band structure entirely.
### Fixed: changing the confidence display threshold caused page hangs
- Root cause confirmed: changing this setting deleted every cached spectrogram PNG immediately, making every single recording a cache-miss at the same moment. The next person to view any recording, or scroll a list loading thumbnails, would hit the slow synchronous generation path one at a time — the real cause of reported hangs, not a hypothetical.
- Replaced the immediate delete with a background task that proactively regenerates every recording's spectrogram right after the setting changes, so the cache is warm again before anyone actually views them. Old spectrograms stay valid and servable until each replacement is ready — no window where nothing is cached.
- Verified end-to-end with a real generated WAV file and real `AudioProcessor` call: produces a real spectrogram PNG, and a second run correctly skips already-cached files rather than redoing the work.
- Noted but didn't implement in this pass: list-view thumbnails currently fetch the same full-resolution spectrogram PNG used on the detail page, already lazy-loaded via `IntersectionObserver` but not size-reduced. A dedicated thumbnail size is a real opportunity, scoped separately to avoid rushing a second new caching/generation path in the same session as the one above.
## 0.1.11.j — 2026-06-26
### Optimization audit
- Added 3 composite indexes targeting the actual hot filter/join patterns used repeatedly across the statistics and listing endpoints (`deleted_at`+`trigger_timestamp`, `haikubox_detection_id`+`deleted_at` on recordings; `common_name`+`recording_id` on detections) — SQLite can't intersect two separate single-column indexes as well as it can use one purpose-built composite. Verified idempotent against both fresh and pre-existing databases.
- Audited for N+1 query patterns; found one loop-with-a-query-inside in the bulk "enrich all species" background task, but left it alone since the per-bird cost (external API calls, Playwright) already dwarfs one extra `SELECT` by orders of magnitude — batching it would add complexity for no measurable gain.
- Lazy-loaded `SSHTerminal` (and its `xterm` dependency), which was statically bundled into every page load despite only being used when someone explicitly opens a device's SSH panel. Confirmed via real build output: main JS chunk dropped from 1,354 kB to 1,066 kB (~21%), with `xterm` correctly split into its own 290 kB chunk that only loads on demand.
- Found and removed two dead, unrouted component files (`BirdLibrary.jsx`, `LikelyBirds.jsx`) — confirmed both were superseded by `RegionalLibrary.jsx` (which independently reimplements the same likelihood-ranking logic against the same fields) and confirmed via `grep` that nothing in the frontend or backend still referenced either. Build succeeds unchanged after removal.
- Ran `vulture` for dead-code detection. Found one real bug at 90% confidence: `User.id`'s default was `lambda: str(uuid.uuid4()) if True else ""` — dead conditional logic that always evaluated the same way regardless of the condition. Fixed to use the existing `new_uuid()` helper for consistency with every other model. Verified end-to-end that real, valid UUIDs are still generated correctly.
- Fixed a stale docstring/comment listing `range_map_source`'s possible values as only `"wikipedia"`/`"ebird_st"`/`"gbif"`, missing `"aab"`/`"macaulay"` — both real, actively-assigned values confirmed in an earlier session's fix.
- Noted but didn't act on: `xterm` itself is deprecated in favor of `@xterm/xterm` (confirmed via `npm view`) — a real finding, but a riskier migration (different import paths, possible breaking changes between v5 and v6) that needs its own dedicated, tested pass rather than folding into this audit.
## 0.1.11.i — 2026-06-26
### Fixed: BirdLife legend over-matching — pulled from a reference panel, not the species' real legend
- A production log for Warbling Vireo showed 11 of the 12 possible BirdLife categories extracted for one species — checked against multiple independent sources for its actual range (a simple breeding/non-breeding migrant) and confirmed that's implausible.
- Root cause: extraction searched the entire page (`document.querySelectorAll('body *')`), which would also match a static reference/glossary panel listing most possible categories, not just the actual species-specific legend on the map.
- Fixed by finding the real "Legend" heading first and scoping the search to its container; added a sanity check that discards an unscoped match if it finds more than 6 categories, since no real species plausibly shows more than half the possible range states at once.
- Verified with `jsdom` against a simulated page containing both a fake 11-category reference panel and a real 2-category species legend near a "Legend" heading — correctly scopes to the real one and ignores the reference panel entirely. Also verified the full Python-level chain across four cases: scoped result (trusted regardless of count), unscoped-and-implausible (discarded), unscoped-but-plausible (still trusted), and nothing found.
### Investigated: adapting the `ebirdst` R package — not worth it
- Checked the package's own documentation directly. It's a thin wrapper around the same `st-download.ebird.org` REST API `range_map_service.py` already calls — same endpoints, same GeoTIFF format, no capability Chirp's existing Python implementation doesn't already have natively.
- Bridging it via `rpy2` would mean adding a full R runtime to the container for zero functional gain. Not recommending it; the existing native implementation (now wired up as a real tab in `0.1.11.h`) is the better path.
- One real takeaway: confirmed the `2023` data version string already hardcoded in `range_map_service.py` is current, and noted the citation requirement (Fink et al.) for future attribution-line polish.
## 0.1.11.h — 2026-06-26
### New: eBird Status & Trends as a 4th map source — was already built, just never wired up
- Found that a complete eBird S&T implementation already existed (`fetch_ebird_st_map`: real GeoTIFF download + rendering with seasonal colour compositing), along with full trigger/status/reset endpoints, a Settings UI key field, and even a frontend fetch+poll system — all with zero callers anywhere. Confirmed via `grep`.
- Wired it up as a real, visible 4th tab: added it to Settings' Map Sources list (off by default — "testing," needs a separate S&T key), added the tab pill, error/loading states, and a fetch-in-progress indicator, reusing the existing dead `handleFetchSource` logic instead of duplicating it. Legend uses the exact RGB values from the backend's `SEASON_COLOURS` so it matches what's actually rendered.
- Caught and fixed a real bug in the existing dead code while wiring it in: the fetch guard would have silently no-op'd the refresh button since it treated "already on this source" as "nothing to do," with no way to force a re-fetch.
### Fixed: map tabs could imply a refresh with no visible feedback
- `aabLoading`/`geomodelLoading`/`birdlifeLoading` were all already tracked but never rendered anywhere — confirmed via `grep`, same dead-state pattern as the eBird S&T system above.
- Added a real loading overlay: the previous map stays visible (dimmed) with a "Refreshing map…" spinner on top, instead of the view blanking or flickering. Caught BirdLife's refresh button missing the loading-state set entirely while wiring this in.
## 0.1.11.g — 2026-06-26
### Fixed: backup download — real ImportError, not just a UX issue
- Confirmed from a traceback: `from .config import Settings` in `app_settings.py` (which lives in `app/api/`) resolved to the nonexistent `app.api.config` — needed `..config` to reach `app/config.py`. A typo from the session that added this filename logic.
- Verified the fix end-to-end: the endpoint now runs and produces the correct `chirp_v{version}_{date}.db` filename instead of crashing with a 500.
### Fixed: European Starling's Wikipedia fallback queried the wrong title
- Confirmed: Wikipedia's real article for this species is "Common starling," not "European starling" — Chirp's common name is a redirect title on Wikipedia, not the canonical one. The MediaWiki API doesn't follow redirects to fetch the real content unless `redirects=1` is explicitly passed, which this call never did — so it silently got nothing and fell through to a much worse stripped-down guess ("Starling").
- Fixed by adding `redirects=1` to both the range-map image query and the article-extract query (the second one defensively, same root cause). Confirmed via MediaWiki's own documentation that this is exactly the documented behavior, not a guess.
### Fixed: a broken AAB map hid the iNat/BirdLife tabs entirely
- Reported: when AAB fails, the whole map section — including the tab buttons themselves — collapsed to "not available," with no way to switch to the two independent, unrelated sources.
- Root cause: AAB's image error was the only one wired to collapse the entire outer section; iNat and BirdLife's errors already correctly stayed within the tabbed view. Fixed AAB to match — it now shows its own inline "try iNat or BirdLife" message inside the tab view instead of hiding the tabs.
- Also fixed a related gap: clicking back to the AAB tab after a previous failure never reset its error state, unlike iNat/BirdLife, which already did.
## 0.1.11.f — 2026-06-26
### Fixed: range map dead-ends when a previously-working AAB URL stops working
- Confirmed from a log: European Starling's `range_map_url` was a real AAB CDN asset that worked before but now 403s server-side. The serving endpoint had no fallback for "the cached URL itself is dead" — it just redirected the browser to the same broken URL, which fails there too.
- Fixed by falling through to a fresh Wikipedia lookup (the same one `_resolve_range_map` already uses during enrichment) when the proxy fetch confirms the URL is dead, and persisting the correction so future requests don't hit the same dead end. A genuine dead-end (no Wikipedia page either) now returns a clear 503 instead of silently redirecting to a known-broken URL.
- This only covered the Wikipedia case for `range_map_source` syncing in `0.1.11.e`; this AAB-CDN-URL case is a separate, non-Wikipedia code path that needed its own fix.
- Verified end-to-end with the exact European Starling scenario (stale AAB URL, simulated 403, real fallback to a Wikipedia URL) and the genuine-dead-end case (no fallback available, confirmed a clean 503 rather than a crash).
## 0.1.11.e — 2026-06-26
### Fixed: legend still wrong for some maps — `range_map_source` going stale
- Confirmed from a log: American Robin's AAB capture failed (Cloudflare 403), fell back to a Wikipedia redirect, but the older redirect code path (separate from last session's `_resolve_range_map` fix) never updates `range_map_source` — it stayed `"aab"`, so the legend endpoint kept serving an empty AAB legend for a map that was actually Wikipedia.
- Fixed by syncing `range_map_source` at both serving paths (the Wikimedia redirect and the locally-cached `.svg` case, since `.svg` itself is a reliable Wikipedia signal). Verified end-to-end: a profile with stale `range_map_source="aab"` but a real Wikipedia URL now gets corrected and the legend endpoint correctly returns the real SVG legend instead of `no_legend_cached`.
### Fixed: BirdLife 404 detection ran before the page actually rendered
- American Robin's BirdLife screenshot was a literal 404 page, despite the detection logic itself being correct (confirmed by testing the exact visible text). Root cause: the check ran immediately after `domcontentloaded`, before BirdLife's client-rendered SPA had painted anything — reading an empty shell, not the real 404 text.
- Added a wait (with one retry) before checking. Also discovered the resolution priority itself was backwards: BirdLife's `en_HBW` cross-walk (which fixed European Starling) returns "American/San Lucas Robin" for American Robin — which 404s — while the plain guessed slug ("american-robin...") is correct. Neither strategy is reliably better, so now the guess is tried first and the `en_HBW`/search fallback only runs if it actually 404s.
- Verified against both real cases directly: American Robin's real factsheet text isn't flagged as a 404, European Starling's `en_HBW` fallback still resolves correctly, and the source confirms the guess is tried before the fallback.
## 0.1.11.d — 2026-06-26
### Fixed: wrong/missing range map legends for AAB and Macaulay maps
- Root cause: the legend endpoint fell through to a Wikipedia SVG legend unconditionally whenever `aab_legend` was empty, even when the actual map shown was AAB or Macaulay — producing real colors for the wrong map entirely.
- Fixed by gating that fallback on `range_map_source` actually being Wikipedia/GBIF. Also discovered `range_map_source` was never set during normal enrichment at all and fixed that gap.
- Stale data from before this fix needs the map's own refresh icon (not a browser refresh) to re-fetch correctly.
### Fixed: Life List still showing 88 species after disabling Haikubox
- Found a second, separate instance of the Haikubox-exclusion bug, in `full_statistics()` (the entire Statistics page) rather than the Dashboard query fixed previously.
- Fixed at the shared `_base()` query, covering every chart on that page in one fix. Verified end-to-end with a synthetic Haikubox-only species — correctly excluded from `top10_names` and `cumulative_species`.
### New: day-of-week activity can be normalized by hours actually recorded
- Added `day_of_week_normalized` (detections per hour recorded) alongside the existing raw counts, with a UI toggle. Excludes Haikubox's synthetic 3-second placeholder duration from the denominator.
- Verified end-to-end with synthetic data (10hrs/50 detections vs 1hr/20 detections across two days) — correct per-hour values, Haikubox correctly excluded.
### Fixed: backup download was actually a JSON error, not a real backup
- Root cause: the plain `` link never sent the auth token this endpoint requires, so with auth enabled the "backup" being saved was a 403 JSON error body with a browser-guessed filename.
- Fixed with a real authenticated fetch+blob download, named `chirp_v{version}_{date}.db`.
### Fixed: Cornell (and other light theme) headers nearly invisible
- Four plain page headers (Devices, Settings, LikelyBirds, RecordingDetail) used raw `text-white` with no theme-aware variable — fine on dark themes, nearly invisible on light ones.
- Fixed all four; confirmed BirdProfile's photo-overlay header was correctly using white-on-dark-scrim and left it alone.
### New: more animation on Dashboard and Tutorial
- Added staggered entrances for list rows/cards/notes, hover micro-interactions, and tutorial header/step/nav entrance animations. Added two more stagger tiers and a pop-in keyframe to support it.
### New: two more themes — NEXRAD and Moth Trap
- NEXRAD: real BirdCast/weather-radar bird-migration aesthetic (verified against the actual green→yellow→red reflectivity convention), with a genuine rotating sweep-line animation.
- Moth Trap: nocturnal field-recording UV-blacklight aesthetic, true black with a violet glow.
- Caught and fixed two of my own typos (a malformed hex value, a JS comment using `#` instead of `//`) before they shipped.
## 0.1.11.c — 2026-06-25
### New: iNat geomodel now has a refresh button, matching AAB and BirdLife
- Added `?refresh=true` to the geomodel endpoint plus a matching UI button — previously the only way to bypass a stale cached render (e.g. from before `0.1.11.b`'s star-burst fix) was deleting the file by hand.
- Button stays visible even in the error state, since refreshing is the actual recovery action.
- Checked the new state's declaration order for the same temporal-dead-zone bug caught in `0.1.11.a` — not present here, since `geomodelSrc` doesn't depend on `mapView`.
### Fixed: AAB legend colors all rendered as the same dull grey
- Two compounding bugs from `0.1.11.b`'s same fix: the "real color" check only rejected pure black, so inherited grey body text passed through as if it were real styling; and the new vividness check meant to catch that had a regex typo (`\\d+` instead of `\d+`), so it silently matched nothing — including genuinely vivid real colors.
- Fixed both, plus added a same-color-for-every-label sanity check that discards the whole result if every category resolves to one identical color.
- Verified against the actual extracted JS with `jsdom` (grey rejected, real distinct colors accepted, identical-color case discarded) and an end-to-end Python run confirming the fallback palette.
## 0.1.11.b — 2026-06-25
### Fixed: iNat geomodel map rendered as a star-burst of triangles
- Root cause: iNat stores each taxon's range as many disjoint hexagons, often grouped per row as one MultiPolygon. The WKB parser flattened every sub-polygon into one ring, so `ax.fill()` drew one self-intersecting path connecting unrelated hexagons across the globe.
- Fixed by returning a list of separate rings instead of one flattened ring.
- Verified: a synthetic 3-hexagon MultiPolygon now parses into 3 separate rings, and a ~40-hexagon synthetic dataset renders clean with zero artifacts.
### Fixed: BirdLife legend panel still visible in the saved screenshot
- Added a content-based hide (find the panel by its actual "Show IBAs/KBAs" + "Show protected areas" text) since the panel's class name is almost certainly CSS-module-hashed.
- Caught a real bug before shipping: `querySelectorAll('div')` returns the outermost matching ancestor first — which could be the whole map container, not just the legend. Fixed to select the innermost match instead; confirmed with `jsdom`.
- Hide pass now runs twice with a short wait, in case BirdLife's own animation was still in flight.
### Fixed: AAB legend colors didn't match the real documented convention
- Confirmed via Cornell-affiliated training material (and independently by Sibley Guides): Breeding=red/orange, Migration=yellow, Non-breeding=blue, Year-round=purple (a blend, not its own color) — the old fallback palette didn't match this.
- Switched to reading real DOM colors first, falling back to the corrected palette only when nothing real is available.
- Caught two more bugs while building this: a substring collision ("breeding" matches inside "nonbreeding," fixed by checking longest labels first) and a stray Python-style `#` comment left inside a JS string, which would have thrown a real syntax error — caught by extracting and running the literal JS through `node --check`.
## 0.1.11.a — 2026-06-25
### Fixed: iNat geomodel map crashed on every request
- Production log showed `AttributeError: 'numpy.ndarray' object has no attribute 'ptp'` — removed in NumPy 2.0, which `requirements.txt` allowed.
- Fixed with the standalone `np.ptp()`. Verified by reproducing the crash directly and by rendering a real synthetic GeoPackage end-to-end through `render_range_map()`.
### Fixed: BirdLife legend showing wrong colors for some categories
- Root cause: the swatch-color search accepted the first nearby non-white element, which in BirdLife's two-column grid could grab an unrelated row's color instead of the right one.
- Fixed by requiring a real swatch shape (small, no children, no text) before accepting a color. Verified with `jsdom` against a simulated 4-row grid — zero cross-row contamination.
### Fixed: legend panel and toolbar buttons baked into the saved screenshot
- LEGEND, EXPAND, zoom, and share/download icons were all still visible — the dismiss pass wasn't catching the always-present toolbar.
- Added class-name, button-text, and positional (icon-only buttons near a map corner) hiding. Verified with `jsdom`; caught and fixed an off-by-one in my own test where a button sitting exactly at the original threshold was missed.
### New: AAB map tab now has a refresh button, matching BirdLife's
- Added `?refresh=true` to the `/range-map` endpoint and a matching UI button.
- Caught a real bug while wiring this in: the new refresh state was referenced before its own `useState` declaration — confirmed this throws a real `ReferenceError` and fixed the ordering before it shipped.
- Also added an `onLoad` handler to clear the loading spinner on success, fixing a small pre-existing gap shared by the BirdLife refresh path too.
## 0.1.10.z — 2026-06-25
### Fixed: AAB legend extraction — same root cause as BirdLife's, found proactively
- Asked directly whether AAB's legend was actually being extracted in production. There was no logging at all confirming the answer either way (unlike BirdLife's path, which already logs its outcome) — so the honest answer was "unverified," not "yes."
- Checked, and it had the identical flaw `0.1.10.y` just fixed for BirdLife: the selectors guessed class names containing "legend"/"range-map"/"maps", but a direct fetch of a real AAB `/maps-range` page confirmed the legend text sits in a plain element with none of those words anywhere in its class name — AAB's Cornell Lab stack, like BirdLife's, almost certainly uses framework-generated class names rather than literal English ones.
- Confirmed via the same direct fetch that the legend text itself *is* present and in the expected format (`Year-roundBreedingMigrationNonbreeding`, concatenated with no separator) on the actual `/maps-range` page for European Starling — so the page and format assumptions baked into the original extraction logic were correct; only the selector strategy was wrong.
- Fixed the same way as BirdLife: scan the full page body for the concatenated legend string by content instead of guessing a containing element's class name first. The old class-name selectors are kept as a secondary fallback in case some AAB page variants do use a literal "legend" class.
- Added logging after extraction (`AAB legend for {species}: N entries` / `none extracted`) so this is actually visible in production logs going forward — its absence is exactly why this bug could ship unnoticed in `0.1.10.w`.
- Verified with `jsdom` against three realistic simulated DOM structures: the confirmed real concatenated-string format, a single-category year-round-resident case (confirming no false match against a longer descriptive paragraph that also happens to contain "year-round"), and a multi-span structure where each category is its own element rather than one text run. All three extract correctly. Also re-verified the Python-level wrapper logic directly: correct category ordering, graceful `[]` (not a crash) when nothing matches or `page.evaluate()` raises, and confirmed the class-name fallback path still works as a safety net when the body scan finds nothing.
## 0.1.10.y — 2026-06-25
### Fixed: BirdLife legend never extracted — wrong root cause assumption, again
- Real production log confirmed `0.1.10.x`'s URL fix worked (the real BirdLife world map for European Starling loaded correctly, `200 OK` instead of `503`), but the legend still came back empty: `could not extract legend from DOM with any known selector`, `0 legend entries`. The frontend kept showing the old hardcoded 7-swatch fallback list as a result — including "Possibly extant," which obviously doesn't apply to a common, well-established species like European Starling.
- Root cause: the production screenshot shows BirdLife's map legend is collapsed behind a "LEGEND" button by default — it isn't in the DOM (or isn't populated) until that button is clicked. The previous version's dismiss/cleanup pass *did* click that same button, but only *after* extraction had already run and failed against the still-collapsed panel — so the click that would have opened it happened one step too late to matter, then immediately closed it again before the screenshot.
- Fixed the ordering: open the legend panel first, extract, then close/hide it before screenshotting — not the reverse.
- Also replaced the class-name-based selectors (`.legend-item`, `[class*='legend']`, etc.) with direct text matching against the 12 known category labels. BirdLife's DataZone is a Trifork-built React app; production class names there are almost certainly CSS-module-generated hashes, not literal strings containing "legend" — which is exactly why every class-name guess found nothing. Matching on the labels' actual visible text is far more durable against that kind of styling, and color is read from whichever nearby element (the label itself, a sibling, a parent, or a child) has the first non-transparent computed background — handling the dot-plus-label-span pattern common in real legend rows.
- Verified with `jsdom` against two realistic simulated DOM structures (not just Python-level mocks): a CSS-module-style legend panel with `LegendItem_abc123`-style class names, confirming all 4 real categories extract correctly while a long paragraph that happens to mention "Resident" in passing is correctly ignored; and a wrapper-div-plus-leaf-span structure, confirming the extraction correctly dedupes to the leaf element's swatch color rather than double-counting or grabbing the wrong element's background.
- Also verified the underlying Python logic directly: spelling normalization (Reintroduced/Re-introduced, Nonbreeding/Non-breeding collapse to one canonical label), graceful `None` (not a crash) when `page.evaluate()` raises, and confirmed the known-labels list is passed to the browser correctly.
## 0.1.10.x — 2026-06-25
### Fixed: BirdLife search-based resolution didn't work in production — replaced with eBird's own taxonomy cross-walk
- Real production log from the running server showed `0.1.10.w`'s fix only got halfway there: `BirdLife search: no factsheet links found for 'Sturnus vulgaris'` — the search-page scrape fell back to the guessed slug, which still 404'd exactly as before. BirdLife relaunched their entire DataZone platform in April 2025 (built by Trifork) as a heavily client-rendered SPA; the selector guess for reading search results didn't match its real markup, and this couldn't be fully verified from this environment's sandboxed network access.
- Root cause traced to something better than a search scrape: eBird officially maintains an `en_HBW` locale as a documented cross-walk between the eBird/Clements taxonomy (what Chirp/BirdNET uses) and the BirdLife/HBW taxonomy specifically for this purpose — querying eBird's existing taxonomy API with `locale=en_HBW` for a species returns BirdLife's own common name directly. For *Sturnus vulgaris* this correctly returns "Common Starling," confirming the original diagnosis was right; the implementation just needed a more reliable resolution path than scraping an unfamiliar, JS-heavy search UI blind.
- This is now the primary resolution strategy, ahead of the site-search scrape (kept as a fallback for species with no `en_HBW` entry or when no eBird API key is configured) and the guessed-slug (final fallback). Reuses the `ebird_species_code`/`ebird_api_key` infrastructure already in place elsewhere in the enricher — no new API integration needed.
- Caught and fixed a real bug while wiring this in: the standalone wrapper functions used by the `/range-map/birdlife` endpoint create a `BirdEnricher` via `__new__` (bypassing `__init__`) for backward compatibility, which meant `self.db` didn't exist as an attribute at all on that path — accessing it to read the eBird API key would have raised `AttributeError` rather than gracefully falling through. Fixed by having both wrappers properly construct the enricher via `__init__` when a db session is available (now passed through from the endpoint, which already had one), and by guarding the attribute access with `getattr` so the no-db path still degrades gracefully instead of crashing.
- Verified with mocked eBird API responses: confirmed the European Starling → Common Starling slug resolves correctly, confirmed the resolution chain only falls through to search when eBird genuinely returns nothing, confirmed species with no `en_HBW` entry and network failures both degrade to `None` (triggering the next fallback) rather than raising, and confirmed the `AttributeError` fix with three separate no-db/no-key scenarios that would have crashed before the fix.
## 0.1.10.w — 2026-06-25
### Fixed: BirdLife range map 404s — wrong root cause assumption
- Reported: some species (e.g. European Starling) showed BirdLife's own 404 page screenshotted and served as if it were the range map.
- Root cause confirmed directly against the live site: BirdLife's IUCN-based English names frequently differ from Chirp/BirdNET's (European Starling vs. BirdLife's "Common Starling" — same *Sturnus vulgaris*). The slug was guessed from Chirp's common name and never checked against the page it actually landed on, so a name mismatch silently 404'd and got screenshotted anyway.
- Fixed two ways: (1) the real factsheet URL is now resolved via BirdLife's own search, keyed on scientific name rather than guessing from the English common name — scientific names are consistent across sources, common names often aren't; (2) added an explicit check for BirdLife's "can't find this page" 404 content before screenshotting at all, as a safety net regardless of how the URL was resolved, so a 404 page can never again be saved as if it were a real map.
- Verified with synthetic Playwright-page mocks: confirmed the European Starling case now resolves to `common-starling-sturnus-vulgaris` instead of 404ing, confirmed 404 pages are correctly detected and real factsheets are not false-flagged, and confirmed graceful fallback to the guessed slug when search itself fails to load.
### New: BirdLife and geomodel range maps are now pre-fetched during enrichment
- Previously both were 100% lazy — never touched until the person opened a bird's profile and clicked that specific map tab, so the first view always had to wait for a fresh Playwright capture or a fresh render. Now fetched eagerly during the same enrichment pass that already runs on "Refresh All Data" and on new-species detection, so both are cached and ready before anyone looks.
- Respects each source's individual enabled-toggle from Settings (a disabled source is never fetched). Skips species pre-fetched within the last 30 days unless forced, so a repeated "Refresh All Data" doesn't re-run Playwright on every bird every time — only profiles never pre-fetched, or whose cached file has gone missing, are actually re-captured.
- Verified end-to-end against a real database: settings toggle respected (geomodel skipped when disabled), legend persisted to the profile, staleness correctly skips a second call within the window, and `force=True` correctly bypasses that skip.
### New: per-species range map legends, replacing one fixed global swatch list
- Reported: legend colors/categories didn't always match what a given species' map actually showed, since both the BirdLife and AAB legends were one hardcoded 4-to-7-color list applied to every species regardless of what that species' real map displayed.
- BirdLife's real page can show up to 12 possible legend categories (Resident, Breeding, Non-breeding, Re-introduced, Introduced, Origin uncertain, Possibly extinct, Extinct, Possibly extant, Passage, Season uncertain, Assisted colonisation), and any given species only renders the subset that applies to it. Now read directly from that species' live DOM at fetch time — exact swatch colors and labels, not an approximation — and cached per-profile (`birdlife_legend`).
- AAB/Birds of the World pages render a real per-species legend string too (e.g. "Year-round Breeding Migration Nonbreeding" for Arctic Tern, only the applicable categories present). Read the same way and cached per-profile (`aab_legend`), with colors assigned from AAB's documented fixed 4-color convention since the legend text isn't reliably paired with its swatch element in the DOM the way BirdLife's is.
- New `birdlife_legend`, `aab_legend`, and `range_maps_prefetched_at` columns added to `BirdProfile`. The hardcoded lists are kept as a fallback only for the case where extraction genuinely found nothing for that species (or for profiles enriched before this version) — not as the default path anymore.
- `/range-map-legend` now takes a `source` param so the frontend requests the legend scoped to whichever map tab is actually open; previously it only ever returned the Wikipedia SVG-parsed legend regardless of which source was being viewed.
- Verified with synthetic DOM-read mocks covering: a 4-category species, a 1-category species (e.g. a true year-round resident), selector fallthrough when the first selector matches nothing, case-insensitive dedup of repeated categories, and the "nothing matched at all" case returning `None` (no legend cached) as distinct from "matched but empty."
- Caught and fixed a real bug during testing: AAB label formatting was emitting "Nonbreeding" instead of "Non-breeding" for any page where the source text had no hyphen — fixed by mapping category order directly to a canonical display label instead of reformatting the matched substring.
## 0.1.10.v — 2026-06-24
### Fixed: Dashboard species/detection counts didn't match the Life List
- Reported and reproduced exactly: Dashboard showed 88 species / 11,266 detections while the Life List showed 80 species / 11,253 detections — an 8-species, 13-detection gap.
- Root cause: `get_life_list()` in `birds.py` already excludes species detected *only* via Haikubox (a documented, intentional rule — a species with zero real Pi/device detections was never actually heard by this Chirp install), but the Dashboard's `total_species`/`total_detections` stats in `recording_stats()` had no such exclusion at all, so Haikubox-only species inflated both counts there.
- Fixed by applying the same exclusion subquery `get_life_list()` already uses to the Dashboard's detection base query — `total_species`, `total_detections`, and `top_species` now all correctly exclude Haikubox-only species, matching the Life List exactly. The "Detection activity" chart is a separate, independent query untouched by this change, since it answers a different question (when did activity happen) than the headline stats (what counts as a species we've detected).
- Verified directly with a synthetic dataset reproducing the exact reported numbers (80 real species + 8 Haikubox-only species): confirmed the bug reproduces the precise 88/80 and 11,266/11,253 gaps before the fix, and confirmed `total_species`, `total_detections`, and zero leakage into `top_species` all match the Life List exactly after it.
## 0.1.10.u — 2026-06-24
### Haikubox/Pi separation — closed remaining gaps
- **Main `/recordings` feed leak fixed** — Haikubox rows were only excluded from the main feed while the toggle was *off*; with it on, there was no exclusion at all. Now unconditional. Verified with a mixed dataset (3 Pi + 5 Haikubox): main feed → 3 Pi only, Haikubox feed → 5 Haikubox only, both toggle states.
- **Haikubox-only species no longer pollute Life List / Field Guide / likelihood scoring** — fixed in all four places: Life List endpoint, the CSV/eBird + JSON export helper, Field Guide's `locally_detected` flag, and the periodic eBird regional-sync job. A species needs at least one *non*-Haikubox detection to count; species detected by both Pi and Haikubox are unaffected. Verified with a three-way dataset (Haikubox-only / Pi-only / both).
- **Bird profile pages confirmed already Haikubox-clean** — the per-species Recordings list reuses the now-fixed main feed, plus a dedicated "📡 N Haikubox detection(s)" link already exists separately. Added defensive indicator logic to the play button as a safety net.
## 0.1.10.t — 2026-06-24
### Fixed: the real Haikubox bug — wrong field names
- Real payload capture showed the actual Haikubox API keys are `cn`/`sn`/`dt`/`spCode`/`wav`/`offset` — not `commonName`/`timestamp`, which every prior version checked for. Every detection silently failed validation and was miscounted as dedup (`0 new, 161 already seen, DB row count 0 -> 0`); this was never data loss, the rows had never been written.
- **Timestamp double-conversion fixed** — `dt` carries an explicit UTC `Z` suffix; old code stripped it and then applied a device-timezone offset on top, shifting every timestamp and dedup key. Now detects explicit UTC/offset markers and only applies the device-timezone interpretation when genuinely naive.
- Fixed by mapping the real field names with fallback to the old names for forward-compatibility. Verified against real captured detections: first sync → `3 new, 0 already seen, DB row count 0 -> 3`; second sync on the identical payload → `0 new, 3 already seen, DB row count 3 -> 3` (genuine dedup).
## 0.1.10.s — 2026-06-24
### Found the real Haikubox issue: field-validation failure mislabeled as dedup
- Fresh logs gave the decisive evidence: `0 new, 161 already seen ... actual DB row count: 0 -> 0` — zero before and after, never populated.
- `_import_detection` returns `False` for three different reasons (missing fields, bad timestamp, genuine dedup) and the caller treated all three identically as "already seen," with no logging on the validation-failure path.
- Added explicit WARNING-level logging for validation failures, labeled "NOT a dedup skip," plus a one-time raw payload sample per sync. Verified by simulating a mismatched-field payload and reproducing the exact false-positive pattern, then confirming a correct payload still dedups normally with no false warning.
## 0.1.10.r — 2026-06-24
### Investigated: Haikubox data-loss diagnosis gap
- Traced a full debug log's fluctuating payload counts (141→141→140…139…141) — normal behavior, since the Haikubox API only returns a rolling 24-hour window (`DETECTION_WINDOW_HOURS = 24`); confirmed no `db.delete()` exists anywhere in `haikubox_sync.py`.
- Every sync in the log showed "0 new" for ~1h45m straight — only possible if real rows existed throughout; the log never actually captured a drop-to-zero moment.
- Ruled out species/timestamp dedup-key collision (key includes exact timestamp) and confirmed a startup re-sync already runs on every service start.
### Added: diagnostic to catch the disappearance in the act
- Every sync now records a direct count of `Recording` rows with `haikubox_detection_id` set, compared against the previous cycle's ending count (not the dedup counter, which only reflects what the import lookup found).
- Verified across a 4-sync sequence with a deliberate deletion between syncs 2 and 3: correctly logged `WARNING: Haikubox row count DROPPED between sync cycles: 10 -> 6` only on sync 3.
## 0.1.10.q — 2026-06-24
### Fixed: real cause of the "list mode cuts off content" bug
- `RecordingsList.jsx` was the only page making its own root a second, competing scroll container (`h-full` + own `overflowY: 'auto'`) instead of relying on `` in `App.jsx`. Fixed by resolving the scroll container via `closest('main')`, preserving scroll-position save/restore. Verified with 100 real recordings — all rows in DOM, pagination controls fully visible.
### Fixed: two more toolbar buttons with mismatched heights
- Grid/list toggle was using padding-derived height instead of explicit `34px`, same root cause as the earlier calendar-button fix. All four toolbar buttons now measure exactly 34px.
### Added: always-visible Haikubox link on the device card
- Previous link only showed when `total_recordings > 0`. Added a second, unconditional link next to the serial-number field so toggle/sync state is reachable even with zero detections.
### Haikubox data loss: still open
- Confirmed on the reporting deployment that every auto-cleanup trigger (`auto_delete_recordings_days=0`, `auto_archive_recordings_days=0`, `storage_max_dir_gb=0.0`) was already inactive — ruling out the 0.1.10.o fix as the cause of the original 139-detection loss. Root cause still unconfirmed; awaiting a live capture.
## 0.1.10.p — 2026-06-24
### Fixed: spectrogram cache writes are now atomic
- Reported regression: "Enhanced" spectrogram on a new recording rendered corrupted/blocky. Could not reproduce with synthetic audio, but found and fixed a real race: every cache write in `audio_processor.py` wrote directly to its final path, so concurrent requests could both pass the exists-check before either finished writing.
- Fixed by writing to a temp path and atomically `os.replace()`-ing into place, across every cache write (bird/bat spectrograms, enhanced audio, clips, bat downshift output).
- Two real bugs caught while building this: temp suffix appended *after* the extension broke format-inference in `soundfile`/`matplotlib` (fixed: insert before extension); using PID for "uniqueness" failed because threads share a PID (fixed: UUID per call).
- Verified with 5 rounds of 12 concurrent requests + 3 rounds of 8 concurrent requests on a 60s recording — 0 errors, 0 corrupted files, 0 leftover temp files.
### Haikubox data loss: previous fix confirmed inactive on reporting deployment
- The 0.1.10.o cleanup-exclusion fix is correct and stays in, but confirmed not to be what caused the original 139-detection loss — its trigger conditions were never active there. Root cause recorded as still open.
## 0.1.10.o — 2026-06-24
### Fixed: real data-loss bug — automatic cleanup could delete Haikubox detections
- Neither `_prune_by_age` nor `_prune_by_size` excluded Haikubox-sourced recordings, which have no audio file (`wav_path` empty by design) — deleting one frees zero disk space while destroying real detection history.
- Also closed two related gaps: `/settings/purge-empty-recordings`'s `no_server_detections` mode, and the per-recording auto-purge inside `process_recording_bg` (latter confirmed unreachable today, fixed defensively).
- Fixed by excluding `Recording.haikubox_detection_id IS NOT NULL` from all four code paths. Verified with a mixed dataset: a 45-day Pi recording correctly pruned, a 60-day Haikubox recording correctly untouched.
### Investigating: spectrogram-corruption regression
- Reported: "Enhanced" spectrogram on newly-acquired recordings renders corrupted; pre-upgrade recordings unaffected. Confirmed real and reproducible, but not yet reproducible with synthetic test audio.
- Added diagnostics: `enhance_audio()` now logs input/output stats (sample counts, peak amplitude, RMS, NaN/Inf checks) and replaces any NaN/Inf output with silence before caching, rather than silently caching a corrupted file.
## 0.1.10.n — 2026-06-24
### Fixed: second Docker build failure from the VERSION-file change
- 0.1.10.m fixed the backend stage's `COPY VERSION ./VERSION` but missed that the frontend-builder stage is a separate, isolated stage receiving only `COPY frontend/ .` — `VERSION` one level above was never present in that stage's filesystem.
- Fixed by adding an explicit `COPY VERSION ./VERSION` to the frontend-builder stage (after `npm install`, before `npm run build`, to preserve layer-cache behavior), and making `stamp-sw-version.mjs` search multiple candidate paths instead of assuming one fixed depth.
- Verified against both real layouts: a faithful simulation of the Docker frontend stage's filesystem, and a real local `chirp-server/frontend/` checkout — both resolved correctly with no per-environment config.
## 0.1.10.m — 2026-06-24
### Fixed: real Docker build failure from the previous version-sync fix
- 0.1.10.l moved canonical `VERSION` to the repo root, verified at the time only via direct Python imports and `npm run build` — missing that `docker-compose.yml`'s build context is `chirp-server/`, one level below the repo root, so `COPY VERSION ./VERSION` couldn't see it (`"/VERSION": not found`).
- Fixed by moving `VERSION` into `chirp-server/` (the real build context) and updating both `config.py`'s `_read_version()` and `stamp-sw-version.mjs`'s path math to match.
- Verified by simulating the real container directory layout (`/app/VERSION` + `/app/app/config.py`) and running the real `_read_version()` against it.
## 0.1.10.l — 2026-06-24
### New: archive recordings — delete just the audio, keep everything else
- New `Recording.archived_at` field, deliberately distinct from soft-delete (`deleted_at`, which hides from stats — archiving must not). Deletes the WAV + clips only; spectrogram, all `Detection` rows, notes, and stats contribution are untouched.
- New `POST /{id}/archive` and `POST /{id}/unarchive` endpoints — verified end-to-end (files deleted, spectrogram/detections preserved, `has_audio` flips correctly).
- Playback, WAV download, and iNaturalist submission correctly disabled on archived recordings with specific error messages instead of generic "file not found."
- Fixed a related bug surfaced by this: the spectrogram block used `has_audio === false` to mean only "Haikubox, never had audio" and hid the spectrogram — now distinguishes that from "archived, spectrogram still on disk."
- Added fallback: if a confidence-threshold change invalidates the cached spectrogram key post-archive, serves any other cached variant rather than 404ing.
- New independent `auto_archive_recordings_days` setting (Settings → Storage & Data), wired into the cleanup cycle as a new first pass; respects the existing "keep flagged" (label/notes) protection.
- New "Archive"/"Unarchive" button and "Archived" badge on the recording detail page.
### Optimization audit
- Added a missing index on `Recording.device_id` (3 equality filters) and `Recording.archived_at` via a new generic, idempotent index-migration step (`_migrate_indexes()`), parallel to the existing column-migration system.
- Fixed `dashboard_summary()` querying `AppSettings` three times per load instead of once. Caught a `None`-vs-"not provided" sentinel bug in the first fix attempt via the empty-database edge case.
### Fixed: version number not staying in sync
- `main.py`'s `FastAPI(version=...)` was an independently hardcoded literal (despite a comment claiming otherwise) — bumping it never touched what `/health`/`VersionBadge.jsx` displayed. Two separate `VERSION` files existed, one never read at all, plus a third independent copy in `package.json`.
- Fixed by making the repo-root `VERSION` the single real source: `main.py` reads it directly, the redundant file was removed, and `stamp-sw-version.mjs` reads the same canonical file and writes it into `package.json` too. Verified: one edit propagated correctly to the FastAPI object, `/health`, `sw.js`'s cache name, and `package.json`.
### Smaller fixes
- Removed obsolete `version: "3.9"` from `docker-compose.yml`.
- Fixed the Recordings page date-filter button's cramped spacing against its icon/chevron.
- Updated the in-app Getting Started guide (stale "11 color themes" → 20 + custom builder; stale Haikubox description → reflects the real integration).
## 0.1.10.j — 2026-06-24
### New: date range filter on the main Recordings page
- Same filter already shipped on the Haikubox page (24h/7d/30d/3mo/all presets + custom range), extracted into a shared `DateRangeFilter.jsx` component (`useDateRangeFilter` hook + dropdown UI) so both pages share identical, tested logic. Backend: matching `start`/`end` query params on `GET /recordings`. Resets pagination to page 1 on change.
### Fixed: real auth bug causing "page size doesn't do anything" / stuck pages
- `recordings.list()` and `haikuboxList()` each had their own hand-rolled `fetch()` bypassing the shared `request()` helper — neither handled a `401 Not Authenticated` response. Compounded by `RecordingsList.jsx`'s `.catch(() => {})` silently discarding the failure with no indication of being logged out.
- Fixed by giving both functions the same 401-handling as `request()` (clear stale token, redirect to `/login`), and replacing the silent catch with a real error state (visible message + working Retry button). Verified by simulating a 401 against old vs. new logic side by side.
## 0.1.10.h — 2026-06-23
### Devices page: removed generic settings card for the virtual Haikubox device
- The Haikubox device was rendering through the same `DeviceCard` component as a real Pi, showing irrelevant BirdNET/inference-backend controls. Filtered it from that list — it already has its own dedicated status panel further down the page. Underlying `Device` row untouched.
### New: enhanced spectrogram now matches the enhanced audio toggle
- "Enhanced" previously only swapped audio playback while the spectrogram stayed on the original. Added a matching enhanced spectrogram from the same noise-reduced audio, with the same on-disk caching strategy, reusing an already-cached enhanced audio file if one exists.
- Measured generation time directly: 370–600ms typical, with a one-time 1.4–1.5s outlier on the first request after restart (matplotlib font-cache init, not per-recording). Added a "Generating enhanced spectrogram…" loading state to cover that case.
- Lifted the `enhanced` toggle state from `AudioPlayer` up to the shared parent so audio and spectrogram respond to one toggle together. Verified end-to-end with a real browser.
## 0.1.10.g — 2026-06-23
### Found the actual root cause of "Haikubox detections not appearing": service-worker caching, not a data bug
- Backend traced exhaustively end-to-end — sync, dedup, toggle, summary/list endpoints, settings round-trip — everything worked correctly; the 115 real detections existed and were correctly served the whole time.
- Real cause: `frontend/public/sw.js`'s `const CACHE = 'chirp-v1'` was a hardcoded literal that never changed across any release from `0.1.9.x` through `0.1.10.f`. The `activate` handler's cache-cleanup logic only triggers when the name changes — it never did, so browsers could serve stale JS/CSS indefinitely. **A normal hard refresh does not fix this** (the SW intercepts requests before they reach the network).
- Fixed two ways: (1) cache name now includes the real app version (`chirp-v0.1.10.g`); (2) added `stamp-sw-version.mjs` as an npm `prebuild` step that auto-rewrites the cache name from `package.json`'s version on every build, eliminating the manual-edit step that caused this.
- If still seeing stale data after updating: DevTools → Application → Service Workers → Unregister, then reload.
## 0.1.10.f — 2026-06-23
### New: date range filter on the Haikubox Detections page
- Presets (24h/7d/30d/3mo/all time) + custom range, filtered server-side via new `start`/`end` params on `GET /recordings/haikubox` (required for correctness with pagination). Composes correctly with the species deep-link filter.
- Handles aware-vs-naive timestamp mismatch: incoming filters may arrive timezone-aware while `trigger_timestamp` is stored naive-UTC; endpoint converts and strips before comparing.
- Verified end-to-end against a 115-detection synthetic dataset: every preset, custom range (empty + overlapping cases), and clear-filter all produce correct results via real UI interaction.
### Investigating: reported Haikubox detections not appearing after enabling
- Reproduced the 115-detection sync scenario synthetically end-to-end and every step worked correctly. Could not reproduce the reported symptom — root cause found two releases later (0.1.10.g, service-worker caching).
## 0.1.10.e — 2026-06-23
### New: master on/off toggle for the entire Haikubox feature
- `haikubox_enabled` (Settings → Account & Advanced → API Keys, default off) hard-hides every Haikubox surface immediately when disabled — devices, recordings, detections, profile links, dedicated page — rather than just pausing sync:
- Sync loop checks the toggle fresh every poll cycle (takes effect within 15 min, no restart); stops touching `last_seen`/`status` while off.
- `GET /recordings` excludes Haikubox rows entirely when disabled (previously never filtered at all).
- Both dedicated endpoints return empty/zeroed results when disabled.
- `GET /devices` excludes the virtual device row when disabled.
- Manual "Sync Now" now returns a clear error when disabled instead of silently no-op'ing.
- Configured serial number persists independently of the enabled state; re-enabling immediately restores everything (nothing is deleted by disabling).
- Found and fixed: an existing DB upgrading to this version lands on `haikubox_enabled = None` (SQLite's `ALTER TABLE ADD COLUMN` doesn't backfill defaults) — every check treats `None` as falsy via `getattr(..., False)`, so upgrades default to safely hidden. Verified against a pre-existing test database through enable/disable/re-enable cycles.
- Added a clearer Devices-page distinction between "never configured" vs. "configured but disabled."
## 0.1.10.d — 2026-06-23
### Found and fixed: Haikubox device showing "offline" despite working sync
- Traced the full sync pipeline end-to-end with mocked API responses; ruled out the "auto-delete, no audio file" theory directly (Haikubox recordings never pass through `process_recording_bg`).
- Real cause: `Device.is_online()` used one fixed 2-minute staleness window for every device, but Haikubox only updates `last_seen` once per 15-minute sync cycle — so it showed offline ~13 of every 15 minutes even with sync working perfectly. Fixed with a platform-aware threshold (~30-minute window for Haikubox, matching the same ~4x-interval grace period the Pi's threshold implicitly used). Verified at 90s/15min/40min staleness against the real `is_online()` and `list_devices()` endpoint.
- Also fixed: the sync loop's failure paths never called `_update_device_status`, so a real outage left `last_seen` stale with no active offline signal.
### New: dedicated Haikubox Detections page
- Separate `/haikubox` page rather than filtering logic added to the main Recordings list (Haikubox rows were never hidden from `GET /recordings` — confirmed, never the bug). Simpler, lower-emphasis layout: no audio player, no spectrogram thumbnails, explicit "no audio attached" framing.
- Three entry points: a banner link on Recordings (only shown when Haikubox detections exist), a redirected link on the Devices page's Haikubox card, and species-filtered deep links from bird/bat profile pages (correctly absent on bat profiles, since Haikubox is bird-only).
- New endpoints: `GET /recordings/haikubox` (paginated, optional `species` filter) and `GET /recordings/haikubox/summary` (counts + per-species breakdown). Verified with real requests and browser screenshots.
## 0.1.10.c — 2026-06-23
### Recording detail page: Recording Details card moved below Notes
- Straightforward reorder, no functional changes.
### Settings page reorganized into 5 functional groups, cards default-collapsed
- 17 cards previously one flat list — reorganized into **Appearance**, **Detection**, **Notifications**, **Storage & Data**, **Account & Advanced**, matching the existing jump-to dropdown (simplified from 8 tabs to the same 5 groups). Data Management and Database Backup & Restore moved out of the old "Experimental" cluster into Storage & Data.
- Every card now starts collapsed by default; collapse state extended to a three-state model (never-touched / explicitly collapsed / explicitly expanded) so existing stored preferences keep working.
### Six new themes
- **Cornell Lab** — built on the Lab's actual 2015 Pentagram-redesign brand identity; Carnelian red (#B31B1B) is Cornell's real trademarked color; Nunito/Source Serif 4 substituted for the Lab's commercial Avenir/Mercury typefaces (no free release).
- **Field Notebook** — real repeating-gradient ruled-paper texture, aged-paper background, pencil-grey ink, Kalam accent font for headers only (body stays legible sans).
- **Dracula** — built from the official published Dracula spec (github.com/dracula/dracula-theme, MIT), exact hex values — not the unpublished paid "Dracula Pro" palette.
- Plus three from an earlier round this session: Plate, Oscilloscope, Aviary. All six verified visually via real browser screenshots.
### Found and fixed: accessibility bug affecting every light theme
- Tailwind's `text-gray-200` through `text-gray-400` were hardcoded directly in JSX across 32+ call sites in 7 files. Measured real WCAG contrast: 1.1–2.4:1 against light-theme backgrounds, failing even the 3:1 minimum for large text (`text-gray-500` marginal at 4.2–4.6:1).
- Fixed centrally via a CSS override scoped per light theme rather than touching 32+ JSX sites — specificity confirmed to win without `!important`.
### Custom theme builder
- Pick 5 colors (background, card, text, accent, border); every other variable (hover states, secondary/tertiary text, focus rings, shadows, full accent ramp) derived algorithmically via real HSL math (`lib/themeColors.js`).
- Two bugs caught before shipping: a wildcard `[data-theme]` CSS selector that would have corrupted every built-in theme the moment a custom one was created (fixed by scoping to the specific theme id); a derived `text-tertiary` blend that only hit 2.9:1 contrast (recalculated to 4.2:1+ across 5 test palettes).
- Applies via a dynamically-injected `