From a4d37cad8cf4dbe0681fd87e4e7ac64c15733e30 Mon Sep 17 00:00:00 2001 From: CalebLovell Date: Wed, 22 Jul 2026 16:42:08 -0500 Subject: [PATCH 1/2] Add reusable dev-data seed script for scripts/birds.db Generates ~4 weeks of realistic Wisconsin-backyard-bird detections (dawn/dusk activity clustering, weighted species frequency, a couple of rare recent migrants, guaranteed last-hour activity) so the web-ui dashboard has real data to build against locally. Co-Authored-By: Claude Sonnet 5 --- scripts/seed_test_data.py | 205 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 205 insertions(+) create mode 100644 scripts/seed_test_data.py diff --git a/scripts/seed_test_data.py b/scripts/seed_test_data.py new file mode 100644 index 00000000..57384dbd --- /dev/null +++ b/scripts/seed_test_data.py @@ -0,0 +1,205 @@ +#!/usr/bin/env python3 +"""Seed scripts/birds.db with realistic-looking fake detections for local dev. + +Generates ~3-4 weeks of history plus today, with dawn/dusk activity +clustering, a mix of common/rare species, and a handful of detections in +the last hour so "today"/"last hour" stats aren't empty. Matches the exact +schema scripts/createdb.sh creates. + +Usage: + python3 scripts/seed_test_data.py # wipes and reseeds + python3 scripts/seed_test_data.py --append # adds on top of existing rows + python3 scripts/seed_test_data.py --days 14 # shorter history +""" +import argparse +import os +import random +import sqlite3 +from datetime import datetime, timedelta + +DB_PATH = os.path.join(os.path.dirname(__file__), 'birds.db') + +# (common_name, scientific_name, relative frequency weight) +REGULAR_SPECIES = [ + ('Northern Cardinal', 'Cardinalis cardinalis', 10), + ('Blue Jay', 'Cyanocitta cristata', 8), + ('American Robin', 'Turdus migratorius', 10), + ('Black-capped Chickadee', 'Poecile atricapillus', 9), + ('American Goldfinch', 'Spinus tristis', 7), + ('House Finch', 'Haemorhous mexicanus', 7), + ('Mourning Dove', 'Zenaida macroura', 6), + ('Downy Woodpecker', 'Dryobates pubescens', 5), + ('White-breasted Nuthatch', 'Sitta carolinensis', 5), + ('Song Sparrow', 'Melospiza melodia', 6), + ('Carolina Wren', 'Thryothorus ludovicianus', 5), + ('Tufted Titmouse', 'Baeolophus bicolor', 5), + ('European Starling', 'Sturnus vulgaris', 6), + ('Red-winged Blackbird', 'Agelaius phoeniceus', 4), + ('Dark-eyed Junco', 'Junco hyemalis', 4), + ('American Crow', 'Corvus brachyrhynchos', 5), + ('House Sparrow', 'Passer domesticus', 6), + ('Common Grackle', 'Quiscalus quiscula', 4), + ('Red-breasted Nuthatch', 'Sitta canadensis', 3), + ('White-throated Sparrow', 'Zonotrichia albicollis', 3), + ('Common Loon', 'Gavia immer', 1), +] + +NOCTURNAL_SPECIES = [ + ('Barred Owl', 'Strix varia', 3), + ('Great Horned Owl', 'Bubo virginianus', 2), + ('Eastern Screech-Owl', 'Megascops asio', 1), +] + +# Only shows up in the most recent few days, to leave something for +# "new/rare species" features to have real data to test against. +RECENT_RARITIES = [ + ('Ruby-throated Hummingbird', 'Archilochus colubris'), + ('Cedar Waxwing', 'Bombycilla cedrorum'), +] + +# Matches birdnet.conf defaults, so seeded rows look like a real install. +CUTOFF = 0.7 +SENS = 1.25 +OVERLAP = 0.0 +LAT = 0.0 +LON = 0.0 +AUDIOFMT = 'mp3' + + +def random_confidence() -> float: + # Skewed toward high confidence, occasionally near the cutoff. + return round(min(0.99, max(CUTOFF, random.betavariate(6, 2))), 4) + + +def random_time_of_day(is_nocturnal: bool) -> tuple[int, int, int]: + if is_nocturnal: + hour = random.choice([20, 21, 22, 23, 0, 1, 2, 3, 4]) + else: + # Bimodal: dawn chorus and evening activity, with a long quiet + # midday tail and almost nothing overnight. + peak = random.choices(['dawn', 'day', 'dusk'], weights=[45, 25, 30])[0] + if peak == 'dawn': + hour = int(random.gauss(6.5, 1.2)) % 24 + elif peak == 'dusk': + hour = int(random.gauss(19, 1.5)) % 24 + else: + hour = random.randint(10, 16) + minute = random.randint(0, 59) + second = random.randint(0, 59) + return hour, minute, second + + +def build_row(day: datetime, common_name: str, sci_name: str, is_nocturnal: bool, override_time: datetime | None = None): + if override_time is not None: + dt = override_time + else: + hour, minute, second = random_time_of_day(is_nocturnal) + dt = day.replace(hour=hour, minute=minute, second=second) + + date_str = dt.strftime('%Y-%m-%d') + time_str = dt.strftime('%H:%M:%S') + confidence = random_confidence() + confidence_pct = round(confidence * 100) + week = dt.isocalendar()[1] + common_name_safe = common_name.replace("'", '').replace(' ', '_') + file_name = f'{common_name_safe}-{confidence_pct}-{date_str}-birdnet-{time_str}.{AUDIOFMT}' + + return (date_str, time_str, sci_name, common_name, confidence, LAT, LON, CUTOFF, week, SENS, OVERLAP, file_name) + + +def generate_rows(days: int): + rows = [] + today = datetime.now().replace(microsecond=0) + day_starts = [today - timedelta(days=offset) for offset in range(days)] + + for day in reversed(day_starts): + is_today = day.date() == today.date() + daily_count = random.randint(50, 250) + + for _ in range(daily_count): + if random.random() < 0.04: + name, sci = random.choice(NOCTURNAL_SPECIES)[:2] + rows.append(build_row(day, name, sci, is_nocturnal=True)) + else: + name, sci, _weight = random.choices( + REGULAR_SPECIES, + weights=[w for *_, w in REGULAR_SPECIES], + )[0] + rows.append(build_row(day, name, sci, is_nocturnal=False)) + + # Rare migrants only show up in the last 5 days. + if (today - day).days < 5 and random.random() < 0.6: + name, sci = random.choice(RECENT_RARITIES) + rows.append(build_row(day, name, sci, is_nocturnal=False)) + + # Guarantee a few detections in the last hour so "today"/"last hour" + # stats have something to show right after seeding. + if is_today: + for minutes_ago in (5, 18, 34, 52): + name, sci, _weight = random.choices( + REGULAR_SPECIES, + weights=[w for *_, w in REGULAR_SPECIES], + )[0] + rows.append( + build_row( + day, + name, + sci, + is_nocturnal=False, + override_time=today - timedelta(minutes=minutes_ago), + ) + ) + + rows.sort(key=lambda r: (r[0], r[1])) + return rows + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument('--db', default=DB_PATH, help='Path to birds.db') + parser.add_argument('--days', type=int, default=28, help='Number of days of history to generate, including today') + parser.add_argument('--append', action='store_true', help="Don't clear existing rows first") + parser.add_argument('--seed', type=int, default=None, help='Random seed, for reproducible output') + args = parser.parse_args() + + if args.seed is not None: + random.seed(args.seed) + + con = sqlite3.connect(args.db) + cur = con.cursor() + cur.execute(""" + CREATE TABLE IF NOT EXISTS detections ( + Date DATE, + Time TIME, + Sci_Name VARCHAR(100) NOT NULL, + Com_Name VARCHAR(100) NOT NULL, + Confidence FLOAT, + Lat FLOAT, + Lon FLOAT, + Cutoff FLOAT, + Week INT, + Sens FLOAT, + Overlap FLOAT, + File_Name VARCHAR(100) NOT NULL) + """) + + if not args.append: + cur.execute('DELETE FROM detections') + + rows = generate_rows(args.days) + cur.executemany( + 'INSERT INTO detections VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)', + rows, + ) + con.commit() + + total = cur.execute('SELECT COUNT(*) FROM detections').fetchone()[0] + species = cur.execute('SELECT COUNT(DISTINCT Com_Name) FROM detections').fetchone()[0] + con.close() + + print(f'Inserted {len(rows)} detections ({args.days} days).') + print(f'birds.db now has {total} total rows across {species} species.') + + +if __name__ == '__main__': + main() From 28881366a507f93adcde5835ac0e5f7de09e4be8 Mon Sep 17 00:00:00 2001 From: CalebLovell Date: Wed, 22 Jul 2026 16:42:21 -0500 Subject: [PATCH 2/2] Restyle web-ui with a single natural color palette, remove dark mode Replace the scaffold's default shadcn palette with a 6-color natural theme (cream paper, forest ink, moss, sage, tan, clay) derived from reference designs, applied uniformly through the existing token system so every shadcn component picks it up automatically. Remove dark mode entirely: drop ThemeToggle, the theme-init script, and the leftover dark: utility classes in the vendored button/badge primitives, so there's no OS-dependent theme switching anywhere. Add IBM Plex Mono for numeric data (stat counts, timestamps, confidence) as a field-journal-style signature texture, and a small illustrated hero band (hills/treeline/sun) on the overview page as the one distinctive visual moment, matching the reference art without overdoing it elsewhere. Verified with a production build and repeated dev-server browser testing across all three routes, including mobile viewport. Co-Authored-By: Claude Sonnet 5 --- web-ui/src/components/Header.tsx | 3 - web-ui/src/components/HeroBand.tsx | 33 ++++ web-ui/src/components/ThemeToggle.tsx | 34 ---- web-ui/src/components/ui/badge.tsx | 4 +- web-ui/src/components/ui/button.tsx | 9 +- web-ui/src/routes/__root.tsx | 14 +- web-ui/src/routes/detections.tsx | 10 +- web-ui/src/routes/index.tsx | 16 +- web-ui/src/routes/species.tsx | 8 +- web-ui/src/styles.css | 253 ++++++++------------------ 10 files changed, 144 insertions(+), 240 deletions(-) create mode 100644 web-ui/src/components/HeroBand.tsx delete mode 100644 web-ui/src/components/ThemeToggle.tsx diff --git a/web-ui/src/components/Header.tsx b/web-ui/src/components/Header.tsx index e84b629b..4d3c2497 100644 --- a/web-ui/src/components/Header.tsx +++ b/web-ui/src/components/Header.tsx @@ -1,7 +1,5 @@ import { Link } from "@tanstack/react-router"; -import { ThemeToggle } from "#/components/ThemeToggle.tsx"; - export function Header() { return (
@@ -32,7 +30,6 @@ export function Header() { > Species -
diff --git a/web-ui/src/components/HeroBand.tsx b/web-ui/src/components/HeroBand.tsx new file mode 100644 index 00000000..65fbe494 --- /dev/null +++ b/web-ui/src/components/HeroBand.tsx @@ -0,0 +1,33 @@ +// A quiet nod to the field-guide illustrations this app's data comes +// from -- rolling hills, a treeline, one sun. Used once, on the overview +// page, so it reads as a signature rather than decoration. +export function HeroBand() { + return ( + + ); +} diff --git a/web-ui/src/components/ThemeToggle.tsx b/web-ui/src/components/ThemeToggle.tsx deleted file mode 100644 index c7b603cc..00000000 --- a/web-ui/src/components/ThemeToggle.tsx +++ /dev/null @@ -1,34 +0,0 @@ -import { Moon, Sun } from "lucide-react"; -import { useEffect, useState } from "react"; - -import { Button } from "#/components/ui/button.tsx"; - -export function ThemeToggle() { - const [isDark, setIsDark] = useState(null); - - useEffect(() => { - setIsDark(document.documentElement.classList.contains("dark")); - }, []); - - function toggle() { - const next = !document.documentElement.classList.contains("dark"); - document.documentElement.classList.toggle("dark", next); - localStorage.setItem("theme", next ? "dark" : "light"); - setIsDark(next); - } - - return ( - - ); -} diff --git a/web-ui/src/components/ui/badge.tsx b/web-ui/src/components/ui/badge.tsx index faf2b5be..fc9f6916 100644 --- a/web-ui/src/components/ui/badge.tsx +++ b/web-ui/src/components/ui/badge.tsx @@ -5,7 +5,7 @@ import type * as React from "react"; import { cn } from "#/lib/utils.ts"; const badgeVariants = cva( - "inline-flex w-fit shrink-0 items-center justify-center gap-1 overflow-hidden rounded-full border border-transparent px-2 py-0.5 text-xs font-medium whitespace-nowrap transition-[color,box-shadow] focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 [&>svg]:pointer-events-none [&>svg]:size-3", + "inline-flex w-fit shrink-0 items-center justify-center gap-1 overflow-hidden rounded-full border border-transparent px-2 py-0.5 text-xs font-medium whitespace-nowrap transition-[color,box-shadow] focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 aria-invalid:border-destructive aria-invalid:ring-destructive/20 [&>svg]:pointer-events-none [&>svg]:size-3", { variants: { variant: { @@ -13,7 +13,7 @@ const badgeVariants = cva( secondary: "bg-secondary text-secondary-foreground [a&]:hover:bg-secondary/90", destructive: - "bg-destructive text-white focus-visible:ring-destructive/20 dark:bg-destructive/60 dark:focus-visible:ring-destructive/40 [a&]:hover:bg-destructive/90", + "bg-destructive text-white focus-visible:ring-destructive/20 [a&]:hover:bg-destructive/90", outline: "border-border text-foreground [a&]:hover:bg-accent [a&]:hover:text-accent-foreground", ghost: "[a&]:hover:bg-accent [a&]:hover:text-accent-foreground", diff --git a/web-ui/src/components/ui/button.tsx b/web-ui/src/components/ui/button.tsx index d23d30f4..70041830 100644 --- a/web-ui/src/components/ui/button.tsx +++ b/web-ui/src/components/ui/button.tsx @@ -5,19 +5,18 @@ import type * as React from "react"; import { cn } from "#/lib/utils.ts"; const buttonVariants = cva( - "inline-flex shrink-0 items-center justify-center gap-2 rounded-md text-sm font-medium whitespace-nowrap transition-all outline-none focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:pointer-events-none disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4", + "inline-flex shrink-0 items-center justify-center gap-2 rounded-md text-sm font-medium whitespace-nowrap transition-all outline-none focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:pointer-events-none disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-destructive/20 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4", { variants: { variant: { default: "bg-primary text-primary-foreground hover:bg-primary/90", destructive: - "bg-destructive text-white hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:bg-destructive/60 dark:focus-visible:ring-destructive/40", + "bg-destructive text-white hover:bg-destructive/90 focus-visible:ring-destructive/20", outline: - "border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:border-input dark:bg-input/30 dark:hover:bg-input/50", + "border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground", secondary: "bg-secondary text-secondary-foreground hover:bg-secondary/80", - ghost: - "hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50", + ghost: "hover:bg-accent hover:text-accent-foreground", link: "text-primary underline-offset-4 hover:underline", }, size: { diff --git a/web-ui/src/routes/__root.tsx b/web-ui/src/routes/__root.tsx index 82cc69e8..61087c73 100644 --- a/web-ui/src/routes/__root.tsx +++ b/web-ui/src/routes/__root.tsx @@ -5,16 +5,6 @@ import { Footer } from "#/components/Footer.tsx"; import { Header } from "#/components/Header.tsx"; import appCss from "../styles.css?url"; -// Applies the persisted theme before first paint, so there's no flash of the -// wrong theme while React hydrates. -const THEME_INIT_SCRIPT = ` -(function () { - var stored = localStorage.getItem('theme'); - var dark = stored ? stored === 'dark' : window.matchMedia('(prefers-color-scheme: dark)').matches; - document.documentElement.classList.toggle('dark', dark); -})(); -`; - export const Route = createRootRoute({ head: () => ({ meta: [ @@ -41,11 +31,9 @@ export const Route = createRootRoute({ function RootDocument({ children }: { children: React.ReactNode }) { return ( - + - {/* biome-ignore lint/security/noDangerouslySetInnerHtml: static constant, no user input */} -