From a257017ce9a094656332bec2caa24af08dfc32f3 Mon Sep 17 00:00:00 2001 From: CalebLovell Date: Wed, 22 Jul 2026 17:58:08 -0500 Subject: [PATCH 1/4] Polish web-ui visual design: flatter background, tighter corners Remove the hill/tree/sun hero graphic and the dotted paper-grain background texture per feedback -- flat solid background everywhere. Also reduce the shared --radius token so cards, buttons, inputs, and badges read with tighter, less rounded corners site-wide, and add scrollbar-gutter: stable so pages that change height (e.g. filtering a grid) don't shift horizontally when the scrollbar appears/disappears. Co-Authored-By: Claude Sonnet 5 --- web-ui/src/components/HeroBand.tsx | 33 ------------------------------ web-ui/src/routes/detections.tsx | 2 +- web-ui/src/routes/index.tsx | 6 ++---- web-ui/src/styles.css | 21 ++++++++----------- 4 files changed, 11 insertions(+), 51 deletions(-) delete mode 100644 web-ui/src/components/HeroBand.tsx diff --git a/web-ui/src/components/HeroBand.tsx b/web-ui/src/components/HeroBand.tsx deleted file mode 100644 index 65fbe494..00000000 --- a/web-ui/src/components/HeroBand.tsx +++ /dev/null @@ -1,33 +0,0 @@ -// 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/routes/detections.tsx b/web-ui/src/routes/detections.tsx index 37aeb93a..cf16de4b 100644 --- a/web-ui/src/routes/detections.tsx +++ b/web-ui/src/routes/detections.tsx @@ -25,7 +25,7 @@ function Detections() { The last {detections.length} detections, most recent first.

-
+
diff --git a/web-ui/src/routes/index.tsx b/web-ui/src/routes/index.tsx index da4595a8..7be0171b 100644 --- a/web-ui/src/routes/index.tsx +++ b/web-ui/src/routes/index.tsx @@ -1,6 +1,5 @@ import { createFileRoute } from "@tanstack/react-router"; -import { HeroBand } from "#/components/HeroBand.tsx"; import { Card, CardContent, @@ -33,8 +32,7 @@ function Home() { return (
- -

+

What's singing right now

@@ -52,7 +50,7 @@ function Home() {

Recent detections

-
+
diff --git a/web-ui/src/styles.css b/web-ui/src/styles.css index 63a65605..d6d532d5 100644 --- a/web-ui/src/styles.css +++ b/web-ui/src/styles.css @@ -46,7 +46,7 @@ --chart-3: var(--sage); --chart-4: var(--clay); --chart-5: var(--ink); - --radius: 0.625rem; + --radius: 0.375rem; --sidebar: var(--paper-raised); --sidebar-foreground: var(--ink); --sidebar-primary: var(--moss); @@ -98,6 +98,13 @@ --color-sidebar-ring: var(--sidebar-ring); } +html { + /* Reserves the scrollbar's width always, so content that changes height + (e.g. filtering a grid) never shifts horizontally when the scrollbar + appears or disappears. */ + scrollbar-gutter: stable; +} + html, body, #app { @@ -114,18 +121,6 @@ body { -moz-osx-font-smoothing: grayscale; } -/* A faint paper grain -- texture, not glow. */ -body::before { - content: ''; - position: fixed; - inset: 0; - pointer-events: none; - z-index: -1; - opacity: 0.5; - background-image: radial-gradient(color-mix(in oklab, var(--moss) 35%, transparent) 0.6px, transparent 0.6px); - background-size: 3px 3px; -} - a { color: var(--moss); text-decoration-color: color-mix(in oklab, var(--moss) 45%, transparent); From 79efe59da6dd092d4f55b6c7fe84afb425f4bbe3 Mon Sep 17 00:00:00 2001 From: CalebLovell Date: Wed, 22 Jul 2026 17:58:30 -0500 Subject: [PATCH 2/4] Add Species page: card grid with search, sort, pagination, and real audio playback Rebuilds the species listing as a card grid (search box; sort by most recordings / recent / A-Z with icons and pagination), each card showing a Wikipedia thumbnail (with graceful fallback), this-hour/all-time counts, and Play/Wiki/eBird actions. Audio playback is fully wired, not a placeholder: adds BIRDNET_EXTRACTED_DIR config and a dynamic server route that streams clips from BirdNET-Pi's real extraction path (BirdSongs/Extracted/ By_Date/..., a sibling of the repo, matching production layout). Wikipedia images come from their public summary API (image + canonical page URL, no key needed); eBird links go through a scoped search since their species pages require login for anonymous requests. Worked around three real bugs in this bleeding-edge TanStack Start/ Nitro version along the way: a Node-only import leaking into the client bundle (fixed by marking db/index.ts server-only and splitting audio.ts's filesystem code into audio.server.ts), a 3-segment dynamic route silently failing to register (collapsed to 2 segments), and native
- - - Species - Scientific name - Last detected - Detections - - - - {species.length === 0 ? ( - - - No species detected yet. - - +
+ { + if (!value) return; + setSort(value as SortKey); + setPage(1); + }} + > + + + Most + + + + Recent + + + + A–Z + + + { + setSearch(e.target.value); + setPage(1); + }} + className="sm:max-w-xs" + /> +
+ + {pageItems.length === 0 ? ( +

+ No species match “{search}”. +

+ ) : ( +
+ {pageItems.map((card) => ( + + ))} +
+ )} + + {pageCount > 1 && ( + + + + { + e.preventDefault(); + setPage((p) => Math.max(1, p - 1)); + }} + className={ + currentPage === 1 ? "pointer-events-none opacity-50" : "" + } + /> + + {Array.from({ length: pageCount }, (_, i) => i + 1).map((p) => ( + + { + e.preventDefault(); + setPage(p); + }} + > + {p} + + + ))} + + { + e.preventDefault(); + setPage((p) => Math.min(pageCount, p + 1)); + }} + className={ + currentPage === pageCount + ? "pointer-events-none opacity-50" + : "" + } + /> + + + + )} + + ); +} + +function SpeciesCard({ card }: { card: LifeListCard }) { + const audioRef = useRef(null); + const objectUrlRef = useRef(null); + const [isPlaying, setIsPlaying] = useState(false); + const [isLoading, setIsLoading] = useState(false); + + useEffect(() => { + return () => { + if (objectUrlRef.current) URL.revokeObjectURL(objectUrlRef.current); + }; + }, []); + + async function togglePlay() { + const audio = audioRef.current; + if (!audio || !card.audioUrl) return; + + if (isPlaying) { + audio.pause(); + return; + } + + // The browser's native
+ {isPlaying ? "Pause" : "Play"} + + + + {card.audioUrl && ( + + )} +
); From a468b4ee7a3eebcaa42aa52591e61fa9aeec5a89 Mon Sep 17 00:00:00 2001 From: CalebLovell Date: Wed, 22 Jul 2026 17:58:48 -0500 Subject: [PATCH 3/4] Generate placeholder audio clips in the seed script For each species, synthesizes a short distinct tone at its most recent detection's extraction path (BirdSongs/Extracted/By_Date/...), matching the same BIRDNET_EXTRACTED_DIR default web-ui uses, so the new play-button feature has real audio to play during local dev. Co-Authored-By: Claude Sonnet 5 --- scripts/seed_test_data.py | 86 +++++++++++++++++++++++++++++++++++++-- 1 file changed, 83 insertions(+), 3 deletions(-) diff --git a/scripts/seed_test_data.py b/scripts/seed_test_data.py index 57384dbd..3618e260 100644 --- a/scripts/seed_test_data.py +++ b/scripts/seed_test_data.py @@ -6,19 +6,34 @@ the last hour so "today"/"last hour" stats aren't empty. Matches the exact schema scripts/createdb.sh creates. +Also generates one placeholder audio clip per species (for its most recent +detection) at BirdNET-Pi's real extraction path, so web-ui's play button +has something real to play locally -- the same BIRDNET_EXTRACTED_DIR +default web-ui itself uses. + 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 + 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 + python3 scripts/seed_test_data.py --no-audio # skip placeholder audio clips """ import argparse +import math import os import random import sqlite3 +import struct +import wave from datetime import datetime, timedelta DB_PATH = os.path.join(os.path.dirname(__file__), 'birds.db') +# Mirrors web-ui's default BIRDNET_EXTRACTED_DIR: BirdSongs lives as a +# sibling of the BirdNET-Pi checkout, never inside the repo itself. +DEFAULT_EXTRACTED_DIR = os.path.normpath( + os.path.join(os.path.dirname(__file__), '..', '..', 'BirdSongs', 'Extracted') +) + # (common_name, scientific_name, relative frequency weight) REGULAR_SPECIES = [ ('Northern Cardinal', 'Cardinalis cardinalis', 10), @@ -154,12 +169,74 @@ def generate_rows(days: int): return rows +def write_placeholder_wav(path: str, seed_text: str, duration: float = 1.2, framerate: int = 22050): + """Writes a short synthesized tone, distinct per species, so the + web-ui's play button has something real to play during local dev.""" + freq = 350 + (abs(hash(seed_text)) % 700) + frame_count = int(duration * framerate) + os.makedirs(os.path.dirname(path), exist_ok=True) + with wave.open(path, 'w') as wav_file: + wav_file.setnchannels(1) + wav_file.setsampwidth(2) + wav_file.setframerate(framerate) + frames = bytearray() + for i in range(frame_count): + t = i / framerate + envelope = max(0.0, min(1.0, t * 8, (duration - t) * 8)) + sample = int(32767 * 0.3 * envelope * math.sin(2 * math.pi * freq * t)) + frames += struct.pack('/ + /), and repoints that one row's File_Name at the + matching .wav so the DB and the file on disk agree.""" + cur = con.cursor() + cur.execute(""" + SELECT Com_Name, Date, Time, File_Name FROM detections + ORDER BY Date DESC, Time DESC + """) + seen = set() + updates = [] + for com_name, date, time, file_name in cur.fetchall(): + if com_name in seen: + continue + seen.add(com_name) + + com_name_safe = com_name.replace("'", '').replace(' ', '_') + # ':' is valid in filenames on the Pi's Linux filesystem (where the + # real format comes from) but illegal on Windows dev machines, so + # the placeholder file itself uses a filesystem-safe name. + stem = os.path.splitext(file_name)[0].replace(':', '-') + wav_name = f'{stem}.wav' + full_path = os.path.join(extracted_dir, 'By_Date', date, com_name_safe, wav_name) + write_placeholder_wav(full_path, com_name) + updates.append((wav_name, com_name, date, time)) + + cur.executemany( + 'UPDATE detections SET File_Name = ? WHERE Com_Name = ? AND Date = ? AND Time = ?', + updates, + ) + con.commit() + print(f'Generated {len(updates)} placeholder audio clips under {extracted_dir}') + + 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') + parser.add_argument( + '--extracted-dir', + default=os.environ.get('BIRDNET_EXTRACTED_DIR', DEFAULT_EXTRACTED_DIR), + help='Directory to write placeholder audio clips into', + ) + parser.add_argument( + '--no-audio', action='store_true', help='Skip generating placeholder audio clips' + ) args = parser.parse_args() if args.seed is not None: @@ -193,6 +270,9 @@ def main(): ) con.commit() + if not args.no_audio: + seed_placeholder_audio(con, args.extracted_dir) + total = cur.execute('SELECT COUNT(*) FROM detections').fetchone()[0] species = cur.execute('SELECT COUNT(DISTINCT Com_Name) FROM detections').fetchone()[0] con.close() From 12727d1831db3c07e2203c49579ccd945626af2d Mon Sep 17 00:00:00 2001 From: CalebLovell Date: Wed, 22 Jul 2026 18:16:13 -0500 Subject: [PATCH 4/4] Add Stats page: trends over time with URL-based period state Adds a Stats page with a period switcher (Last 24 Hours / Last 7 Days / Last 30 Days / All Time), backed by Recharts: a detections-over-time area chart, a top-species bar chart, and an activity-by-hour-of-day chart (which surfaces the dawn/dusk pattern already built into the seed data). Summary cards show total detections, unique species, the top species, and the busiest bucket for the selected period. "Last 24 Hours" is a rolling window (now minus 24h), not a calendar-day boundary, so it never looks emptied out right after midnight or early in the morning -- same rolling-window approach for the other periods. The selected period lives entirely in the URL (?period=day), validated via a Zod schema on the route (.default().catch() gives a required output type without forcing every to pass search explicitly) and stripped from the URL when it's the default, so /stats stays clean until the user picks something non-default. Back/forward and shareable links work correctly as a result. Co-Authored-By: Claude Sonnet 5 --- web-ui/package-lock.json | 382 ++++++++++++++++++++++++++++++- web-ui/package.json | 4 +- web-ui/src/components/Header.tsx | 7 + web-ui/src/lib/stats.ts | 188 +++++++++++++++ web-ui/src/routeTree.gen.ts | 32 ++- web-ui/src/routes/stats.tsx | 298 ++++++++++++++++++++++++ 6 files changed, 907 insertions(+), 4 deletions(-) create mode 100644 web-ui/src/lib/stats.ts create mode 100644 web-ui/src/routes/stats.tsx diff --git a/web-ui/package-lock.json b/web-ui/package-lock.json index 98a2a19b..dcd53c97 100644 --- a/web-ui/package-lock.json +++ b/web-ui/package-lock.json @@ -20,9 +20,11 @@ "radix-ui": "^1.6.4", "react": "^19.2.0", "react-dom": "^19.2.0", + "recharts": "^3.10.0", "tailwind-merge": "^3.0.2", "tailwindcss": "^4.1.18", - "tw-animate-css": "^1.3.6" + "tw-animate-css": "^1.3.6", + "zod": "^4.4.3" }, "devDependencies": { "@biomejs/biome": "2.4.5", @@ -3327,6 +3329,32 @@ "integrity": "sha512-xnXE7wG13PI+cxieVssYXlQJuYVRhH9NBoxt3KNwzghDIA69GMm7d4wXRouHIYjE+KvS6U/MsMO73NdS2MH9ZA==", "license": "MIT" }, + "node_modules/@reduxjs/toolkit": { + "version": "2.12.0", + "resolved": "https://registry.npmjs.org/@reduxjs/toolkit/-/toolkit-2.12.0.tgz", + "integrity": "sha512-KiT+RzZbp6mQET+Mg+h2c97+9j1sNflUxQkIHI7Yuzf6Peu+OYpmkn6nbHWmLLWj+1ZODUJFwGZ7gx3L9R9EOw==", + "license": "MIT", + "dependencies": { + "@standard-schema/spec": "^1.0.0", + "@standard-schema/utils": "^0.3.0", + "immer": "^11.0.0", + "redux": "^5.0.1", + "redux-thunk": "^3.1.0", + "reselect": "^5.1.0" + }, + "peerDependencies": { + "react": "^16.9.0 || ^17.0.0 || ^18 || ^19", + "react-redux": "^7.2.1 || ^8.1.3 || ^9.0.0" + }, + "peerDependenciesMeta": { + "react": { + "optional": true + }, + "react-redux": { + "optional": true + } + } + }, "node_modules/@rolldown/binding-android-arm64": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.2.0.tgz", @@ -3649,6 +3677,18 @@ "solid-js": "^1.6.12" } }, + "node_modules/@standard-schema/spec": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", + "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", + "license": "MIT" + }, + "node_modules/@standard-schema/utils": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/@standard-schema/utils/-/utils-0.3.0.tgz", + "integrity": "sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g==", + "license": "MIT" + }, "node_modules/@tailwindcss/node": { "version": "4.3.3", "resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.3.3.tgz", @@ -4600,6 +4640,69 @@ "@types/node": "*" } }, + "node_modules/@types/d3-array": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/@types/d3-array/-/d3-array-3.2.2.tgz", + "integrity": "sha512-hOLWVbm7uRza0BYXpIIW5pxfrKe0W+D5lrFiAEYR+pb6w3N2SwSMaJbXdUfSEv+dT4MfHBLtn5js0LAWaO6otw==", + "license": "MIT" + }, + "node_modules/@types/d3-color": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/@types/d3-color/-/d3-color-3.1.3.tgz", + "integrity": "sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==", + "license": "MIT" + }, + "node_modules/@types/d3-ease": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/@types/d3-ease/-/d3-ease-3.0.2.tgz", + "integrity": "sha512-NcV1JjO5oDzoK26oMzbILE6HW7uVXOHLQvHshBUW4UMdZGfiY6v5BeQwh9a9tCzv+CeefZQHJt5SRgK154RtiA==", + "license": "MIT" + }, + "node_modules/@types/d3-interpolate": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/d3-interpolate/-/d3-interpolate-3.0.4.tgz", + "integrity": "sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA==", + "license": "MIT", + "dependencies": { + "@types/d3-color": "*" + } + }, + "node_modules/@types/d3-path": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/@types/d3-path/-/d3-path-3.1.1.tgz", + "integrity": "sha512-VMZBYyQvbGmWyWVea0EHs/BwLgxc+MKi1zLDCONksozI4YJMcTt8ZEuIR4Sb1MMTE8MMW49v0IwI5+b7RmfWlg==", + "license": "MIT" + }, + "node_modules/@types/d3-scale": { + "version": "4.0.9", + "resolved": "https://registry.npmjs.org/@types/d3-scale/-/d3-scale-4.0.9.tgz", + "integrity": "sha512-dLmtwB8zkAeO/juAMfnV+sItKjlsw2lKdZVVy6LRr0cBmegxSABiLEpGVmSJJ8O08i4+sGR6qQtb6WtuwJdvVw==", + "license": "MIT", + "dependencies": { + "@types/d3-time": "*" + } + }, + "node_modules/@types/d3-shape": { + "version": "3.1.8", + "resolved": "https://registry.npmjs.org/@types/d3-shape/-/d3-shape-3.1.8.tgz", + "integrity": "sha512-lae0iWfcDeR7qt7rA88BNiqdvPS5pFVPpo5OfjElwNaT2yyekbM0C9vK+yqBqEmHr6lDkRnYNoTBYlAgJa7a4w==", + "license": "MIT", + "dependencies": { + "@types/d3-path": "*" + } + }, + "node_modules/@types/d3-time": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/d3-time/-/d3-time-3.0.4.tgz", + "integrity": "sha512-yuzZug1nkAAaBlBBikKZTgzCeA+k1uy4ZFwWANOfKw5z5LRhV0gNA7gNkKm7HoK+HRN0wX3EkxGk0fpbWhmB7g==", + "license": "MIT" + }, + "node_modules/@types/d3-timer": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/@types/d3-timer/-/d3-timer-3.0.2.tgz", + "integrity": "sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==", + "license": "MIT" + }, "node_modules/@types/node": { "version": "22.20.1", "resolved": "https://registry.npmjs.org/@types/node/-/node-22.20.1.tgz", @@ -4628,6 +4731,12 @@ "@types/react": "^19.2.0" } }, + "node_modules/@types/use-sync-external-store": { + "version": "0.0.6", + "resolved": "https://registry.npmjs.org/@types/use-sync-external-store/-/use-sync-external-store-0.0.6.tgz", + "integrity": "sha512-zFDAD+tlpf2r4asuHEj0XH6pY6i0g5NeAHPn+15wk3BV6JA69eERFXC1gyGThDkVa1zCyKr5jox1+2LbV/AMLg==", + "license": "MIT" + }, "node_modules/@vitejs/plugin-react": { "version": "6.0.4", "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-6.0.4.tgz", @@ -5024,6 +5133,127 @@ "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", "license": "MIT" }, + "node_modules/d3-array": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/d3-array/-/d3-array-3.2.4.tgz", + "integrity": "sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg==", + "license": "ISC", + "dependencies": { + "internmap": "1 - 2" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-color": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-color/-/d3-color-3.1.0.tgz", + "integrity": "sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-ease": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-ease/-/d3-ease-3.0.1.tgz", + "integrity": "sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-format": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/d3-format/-/d3-format-3.1.2.tgz", + "integrity": "sha512-AJDdYOdnyRDV5b6ArilzCPPwc1ejkHcoyFarqlPqT7zRYjhavcT3uSrqcMvsgh2CgoPbK3RCwyHaVyxYcP2Arg==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-interpolate": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-interpolate/-/d3-interpolate-3.0.1.tgz", + "integrity": "sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==", + "license": "ISC", + "dependencies": { + "d3-color": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-path": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-path/-/d3-path-3.1.0.tgz", + "integrity": "sha512-p3KP5HCf/bvjBSSKuXid6Zqijx7wIfNW+J/maPs+iwR35at5JCbLUT0LzF1cnjbCHWhqzQTIN2Jpe8pRebIEFQ==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-scale": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/d3-scale/-/d3-scale-4.0.2.tgz", + "integrity": "sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ==", + "license": "ISC", + "dependencies": { + "d3-array": "2.10.0 - 3", + "d3-format": "1 - 3", + "d3-interpolate": "1.2.0 - 3", + "d3-time": "2.1.1 - 3", + "d3-time-format": "2 - 4" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-shape": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/d3-shape/-/d3-shape-3.2.0.tgz", + "integrity": "sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA==", + "license": "ISC", + "dependencies": { + "d3-path": "^3.1.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-time": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-time/-/d3-time-3.1.0.tgz", + "integrity": "sha512-VqKjzBLejbSMT4IgbmVgDjpkYrNWUYJnbCGo874u7MMKIWsILRX+OpX/gTk8MqjpT1A/c6HY2dCA77ZN0lkQ2Q==", + "license": "ISC", + "dependencies": { + "d3-array": "2 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-time-format": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/d3-time-format/-/d3-time-format-4.1.0.tgz", + "integrity": "sha512-dJxPBlzC7NugB2PDLwo9Q8JiTR3M3e4/XANkreKSUxF8vvXKqm1Yfq4Q5dl8budlunRVlUUaDUgFt7eA8D6NLg==", + "license": "ISC", + "dependencies": { + "d3-time": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-timer": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-timer/-/d3-timer-3.0.1.tgz", + "integrity": "sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, "node_modules/dayjs": { "version": "1.11.21", "resolved": "https://registry.npmjs.org/dayjs/-/dayjs-1.11.21.tgz", @@ -5081,6 +5311,12 @@ } } }, + "node_modules/decimal.js-light": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/decimal.js-light/-/decimal.js-light-2.5.1.tgz", + "integrity": "sha512-qIMFpTMZmny+MMIitAB6D7iVPEorVw6YQRWkvarTkT4tBeSLLiHzcwj6q0MmYSFCiVpiqPJTJEYIrpcPzVEIvg==", + "license": "MIT" + }, "node_modules/decompress-response": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/decompress-response/-/decompress-response-6.0.0.tgz", @@ -5815,6 +6051,16 @@ } } }, + "node_modules/es-toolkit": { + "version": "1.49.0", + "resolved": "https://registry.npmjs.org/es-toolkit/-/es-toolkit-1.49.0.tgz", + "integrity": "sha512-G5iZ6Pc/FNRY/soKZHC+TxGDD83rHUDXxzaWhGCX44vAv/tMs56WMusnm/KMNK+luUPsgA9U28cGr4RDlSzL2g==", + "license": "MIT", + "workspaces": [ + "docs", + "benchmarks" + ] + }, "node_modules/esbuild": { "version": "0.28.1", "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.1.tgz", @@ -5865,6 +6111,12 @@ "node": ">=6" } }, + "node_modules/eventemitter3": { + "version": "5.0.4", + "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-5.0.4.tgz", + "integrity": "sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw==", + "license": "MIT" + }, "node_modules/expand-template": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/expand-template/-/expand-template-2.0.3.tgz", @@ -6081,6 +6333,16 @@ "optional": true, "peer": true }, + "node_modules/immer": { + "version": "11.1.15", + "resolved": "https://registry.npmjs.org/immer/-/immer-11.1.15.tgz", + "integrity": "sha512-VrNANlmnWQnh5COXIIOQXM9oOJw7naGKlBT74ZOOR6lpVXc3gFEu9FJLDFcpCJ2j+NWr8TIwtWD//T6ZX6TKiQ==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/immer" + } + }, "node_modules/inherits": { "version": "2.0.4", "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", @@ -6097,6 +6359,15 @@ "optional": true, "peer": true }, + "node_modules/internmap": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/internmap/-/internmap-2.0.3.tgz", + "integrity": "sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, "node_modules/is-fullwidth-code-point": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", @@ -7056,6 +7327,36 @@ "react": "^19.2.8" } }, + "node_modules/react-is": { + "version": "19.2.8", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-19.2.8.tgz", + "integrity": "sha512-s5un28nYxKJw5gvUHyW5PCC28CvBqLu9r3cWgzHT4Vo/5fqqkFcdRYsGcKf50WMPpjjFZS5d76fn3YCo2njKwQ==", + "license": "MIT", + "peer": true + }, + "node_modules/react-redux": { + "version": "9.3.0", + "resolved": "https://registry.npmjs.org/react-redux/-/react-redux-9.3.0.tgz", + "integrity": "sha512-KQopgqFo/p/fgmAs5qz6p5RWaNAzq40WAu7fJIXnQpYxFPbJYtsJPWvGeF2rOBaY/kEuV77AVsX8TsQzKm+A/g==", + "license": "MIT", + "dependencies": { + "@types/use-sync-external-store": "^0.0.6", + "use-sync-external-store": "^1.4.0" + }, + "peerDependencies": { + "@types/react": "^18.2.25 || ^19", + "react": "^18.0 || ^19", + "redux": "^5.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "redux": { + "optional": true + } + } + }, "node_modules/react-remove-scroll": { "version": "2.7.2", "resolved": "https://registry.npmjs.org/react-remove-scroll/-/react-remove-scroll-2.7.2.tgz", @@ -7154,6 +7455,51 @@ "url": "https://paulmillr.com/funding/" } }, + "node_modules/recharts": { + "version": "3.10.0", + "resolved": "https://registry.npmjs.org/recharts/-/recharts-3.10.0.tgz", + "integrity": "sha512-wulMvfncpIlmu2uFtRU/mE5/+NiVtASXkw2KdwJTdHs3WsASX0WxZlX+rpKgyn5BDbIhkPtCpUKkB9XNK5KE0w==", + "license": "MIT", + "workspaces": [ + "www" + ], + "dependencies": { + "@reduxjs/toolkit": "^1.9.0 || 2.x.x", + "clsx": "^2.1.1", + "decimal.js-light": "^2.5.1", + "es-toolkit": "^1.39.3", + "eventemitter3": "^5.0.1", + "immer": "^11.1.8", + "react-redux": "8.x.x || 9.x.x", + "reselect": "5.2.0", + "tiny-invariant": "^1.3.3", + "use-sync-external-store": "^1.2.2", + "victory-vendor": "^37.0.2" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", + "react-dom": "^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", + "react-is": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/redux": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/redux/-/redux-5.0.1.tgz", + "integrity": "sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w==", + "license": "MIT" + }, + "node_modules/redux-thunk": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/redux-thunk/-/redux-thunk-3.1.0.tgz", + "integrity": "sha512-NW2r5T6ksUKXCabzhL9z+h206HQw/NJkcLm1GPImRQ8IzfXwRGqjVhKJGauHirT0DAuyy6hjdnMZaRoAcy0Klw==", + "license": "MIT", + "peerDependencies": { + "redux": "^5.0.0" + } + }, "node_modules/require-directory": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", @@ -7164,6 +7510,12 @@ "node": ">=0.10.0" } }, + "node_modules/reselect": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/reselect/-/reselect-5.2.0.tgz", + "integrity": "sha512-AgZ3UOZm3YndfrJ4OYjgrT7bmCm/1iqkjvEfH/oYjzh6PD2qw4QuT3jjnXIrpdt4MTpMXclMT3lXbmRY+XRakw==", + "license": "MIT" + }, "node_modules/resolve-pkg-maps": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/resolve-pkg-maps/-/resolve-pkg-maps-1.0.0.tgz", @@ -7512,6 +7864,12 @@ "node": ">=6" } }, + "node_modules/tiny-invariant": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/tiny-invariant/-/tiny-invariant-1.3.3.tgz", + "integrity": "sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==", + "license": "MIT" + }, "node_modules/tinyglobby": { "version": "0.2.17", "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", @@ -7754,6 +8112,28 @@ "devOptional": true, "license": "MIT" }, + "node_modules/victory-vendor": { + "version": "37.3.6", + "resolved": "https://registry.npmjs.org/victory-vendor/-/victory-vendor-37.3.6.tgz", + "integrity": "sha512-SbPDPdDBYp+5MJHhBCAyI7wKM3d5ivekigc2Dk2s7pgbZ9wIgIBYGVw4zGHBml/qTFbexrofXW6Gu4noGxrOwQ==", + "license": "MIT AND ISC", + "dependencies": { + "@types/d3-array": "^3.0.3", + "@types/d3-ease": "^3.0.0", + "@types/d3-interpolate": "^3.0.1", + "@types/d3-scale": "^4.0.2", + "@types/d3-shape": "^3.1.0", + "@types/d3-time": "^3.0.0", + "@types/d3-timer": "^3.0.0", + "d3-array": "^3.1.6", + "d3-ease": "^3.0.1", + "d3-interpolate": "^3.0.1", + "d3-scale": "^4.0.2", + "d3-shape": "^3.1.0", + "d3-time": "^3.0.0", + "d3-timer": "^3.0.1" + } + }, "node_modules/vite": { "version": "8.1.5", "resolved": "https://registry.npmjs.org/vite/-/vite-8.1.5.tgz", diff --git a/web-ui/package.json b/web-ui/package.json index 2b9a2506..92d9a9bf 100644 --- a/web-ui/package.json +++ b/web-ui/package.json @@ -34,9 +34,11 @@ "radix-ui": "^1.6.4", "react": "^19.2.0", "react-dom": "^19.2.0", + "recharts": "^3.10.0", "tailwind-merge": "^3.0.2", "tailwindcss": "^4.1.18", - "tw-animate-css": "^1.3.6" + "tw-animate-css": "^1.3.6", + "zod": "^4.4.3" }, "devDependencies": { "@biomejs/biome": "2.4.5", diff --git a/web-ui/src/components/Header.tsx b/web-ui/src/components/Header.tsx index 4d3c2497..fc1efe64 100644 --- a/web-ui/src/components/Header.tsx +++ b/web-ui/src/components/Header.tsx @@ -30,6 +30,13 @@ export function Header() { > Species + + Stats + diff --git a/web-ui/src/lib/stats.ts b/web-ui/src/lib/stats.ts new file mode 100644 index 00000000..3478232f --- /dev/null +++ b/web-ui/src/lib/stats.ts @@ -0,0 +1,188 @@ +import { createServerFn } from "@tanstack/react-start"; +import { count, countDistinct, sql } from "drizzle-orm"; + +import { db } from "#/db/index.ts"; +import { detections } from "#/db/schema.ts"; + +export const STATS_PERIODS = ["day", "week", "month", "all"] as const; +export type StatsPeriod = (typeof STATS_PERIODS)[number]; + +export const STATS_PERIOD_LABELS: Record = { + day: "Last 24 Hours", + week: "Last 7 Days", + month: "Last 30 Days", + all: "All Time", +}; + +// Rolling windows (not calendar-day boundaries) so "Last 24 Hours" never +// looks emptied out right after midnight or early in the morning. +const PERIOD_HOURS: Record = { + day: 24, + week: 24 * 7, + month: 24 * 30, + all: null, +}; + +export type TrendPoint = { bucket: string; label: string; count: number }; +export type SpeciesCount = { comName: string; count: number }; +export type HourActivity = { hour: number; count: number }; + +export type StatsData = { + period: StatsPeriod; + totalDetections: number; + uniqueSpecies: number; + topSpecies: SpeciesCount | null; + busiest: { label: string; count: number } | null; + trend: TrendPoint[]; + topSpeciesList: SpeciesCount[]; + hourActivity: HourActivity[]; +}; + +function pad(n: number): string { + return n.toString().padStart(2, "0"); +} + +function periodFilter(period: StatsPeriod) { + const hours = PERIOD_HOURS[period]; + if (hours === null) return sql`1=1`; + return sql`datetime(${detections.Date} || ' ' || ${detections.Time}) >= datetime('now', ${`-${hours} hours`}, 'localtime')`; +} + +function hourBuckets(hoursBack: number): { key: string; label: string }[] { + const now = new Date(); + const buckets: { key: string; label: string }[] = []; + for (let i = hoursBack - 1; i >= 0; i--) { + const d = new Date(now.getTime() - i * 60 * 60 * 1000); + d.setMinutes(0, 0, 0); + const key = `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}T${pad(d.getHours())}:00:00`; + const label = d.toLocaleTimeString([], { hour: "numeric" }); + buckets.push({ key, label }); + } + return buckets; +} + +function dayBuckets(daysBack: number): { key: string; label: string }[] { + const now = new Date(); + const buckets: { key: string; label: string }[] = []; + for (let i = daysBack - 1; i >= 0; i--) { + const d = new Date(now); + d.setDate(d.getDate() - i); + const key = `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`; + const label = d.toLocaleDateString([], { month: "short", day: "numeric" }); + buckets.push({ key, label }); + } + return buckets; +} + +const MAX_ALL_TIME_DAYS = 365; + +async function daysSinceEarliestDetection(): Promise { + const [row] = await db + .select({ minDate: sql`min(${detections.Date})` }) + .from(detections); + if (!row?.minDate) return 1; + const earliest = new Date(`${row.minDate}T00:00:00`); + const diffDays = + Math.floor((Date.now() - earliest.getTime()) / (1000 * 60 * 60 * 24)) + 1; + return Math.min(MAX_ALL_TIME_DAYS, Math.max(1, diffDays)); +} + +async function getTrend(period: StatsPeriod): Promise { + const isHourly = period === "day"; + const bucketExpr = isHourly + ? sql`strftime('%Y-%m-%dT%H:00:00', ${detections.Date} || ' ' || ${detections.Time})` + : sql`${detections.Date}`; + + const rows = await db + .select({ bucket: bucketExpr, count: count() }) + .from(detections) + .where(periodFilter(period)) + .groupBy(bucketExpr); + const countByBucket = new Map(rows.map((r) => [r.bucket, r.count])); + + const expected = isHourly + ? hourBuckets(PERIOD_HOURS.day as number) + : dayBuckets( + period === "week" + ? 7 + : period === "month" + ? 30 + : await daysSinceEarliestDetection(), + ); + + return expected.map(({ key, label }) => ({ + bucket: key, + label, + count: countByBucket.get(key) ?? 0, + })); +} + +async function getTopSpecies( + period: StatsPeriod, + limit: number, +): Promise { + return db + .select({ comName: detections.Com_Name, count: count() }) + .from(detections) + .where(periodFilter(period)) + .groupBy(detections.Com_Name) + .orderBy(sql`count(*) desc`) + .limit(limit); +} + +async function getHourActivity(period: StatsPeriod): Promise { + const rows = await db + .select({ + hour: sql`strftime('%H', ${detections.Time})`, + count: count(), + }) + .from(detections) + .where(periodFilter(period)) + .groupBy(sql`strftime('%H', ${detections.Time})`); + const countByHour = new Map(rows.map((r) => [Number(r.hour), r.count])); + + return Array.from({ length: 24 }, (_, hour) => ({ + hour, + count: countByHour.get(hour) ?? 0, + })); +} + +export const getStatsForPeriod = createServerFn({ method: "GET" }) + .validator((period: StatsPeriod) => period) + .handler(async ({ data: period }): Promise => { + const [ + [{ totalDetections }], + [{ uniqueSpecies }], + trend, + topSpeciesList, + hourActivity, + ] = await Promise.all([ + db + .select({ totalDetections: count() }) + .from(detections) + .where(periodFilter(period)), + db + .select({ uniqueSpecies: countDistinct(detections.Com_Name) }) + .from(detections) + .where(periodFilter(period)), + getTrend(period), + getTopSpecies(period, 10), + getHourActivity(period), + ]); + + const busiest = trend.reduce( + (max, point) => (!max || point.count > max.count ? point : max), + null, + ); + + return { + period, + totalDetections, + uniqueSpecies, + topSpecies: topSpeciesList[0] ?? null, + busiest: busiest ? { label: busiest.label, count: busiest.count } : null, + trend, + topSpeciesList, + hourActivity, + }; + }); diff --git a/web-ui/src/routeTree.gen.ts b/web-ui/src/routeTree.gen.ts index 96196561..273506a4 100644 --- a/web-ui/src/routeTree.gen.ts +++ b/web-ui/src/routeTree.gen.ts @@ -12,6 +12,7 @@ import { Route as rootRouteImport } from './routes/__root' import { Route as IndexRouteImport } from './routes/index' import { Route as DetectionsRouteImport } from './routes/detections' import { Route as SpeciesRouteImport } from './routes/species' +import { Route as StatsRouteImport } from './routes/stats' import { Route as ApiAudioDateSpeciesAndFileRouteImport } from './routes/api/audio/$date/$speciesAndFile' const IndexRoute = IndexRouteImport.update({ @@ -29,6 +30,11 @@ const SpeciesRoute = SpeciesRouteImport.update({ path: '/species', getParentRoute: () => rootRouteImport, } as any) +const StatsRoute = StatsRouteImport.update({ + id: '/stats', + path: '/stats', + getParentRoute: () => rootRouteImport, +} as any) const ApiAudioDateSpeciesAndFileRoute = ApiAudioDateSpeciesAndFileRouteImport.update({ id: '/api/audio/$date/$speciesAndFile', @@ -40,12 +46,14 @@ export interface FileRoutesByFullPath { '/': typeof IndexRoute '/detections': typeof DetectionsRoute '/species': typeof SpeciesRoute + '/stats': typeof StatsRoute '/api/audio/$date/$speciesAndFile': typeof ApiAudioDateSpeciesAndFileRoute } export interface FileRoutesByTo { '/': typeof IndexRoute '/detections': typeof DetectionsRoute '/species': typeof SpeciesRoute + '/stats': typeof StatsRoute '/api/audio/$date/$speciesAndFile': typeof ApiAudioDateSpeciesAndFileRoute } export interface FileRoutesById { @@ -53,19 +61,30 @@ export interface FileRoutesById { '/': typeof IndexRoute '/detections': typeof DetectionsRoute '/species': typeof SpeciesRoute + '/stats': typeof StatsRoute '/api/audio/$date/$speciesAndFile': typeof ApiAudioDateSpeciesAndFileRoute } export interface FileRouteTypes { fileRoutesByFullPath: FileRoutesByFullPath fullPaths: - '/' | '/detections' | '/species' | '/api/audio/$date/$speciesAndFile' + | '/' + | '/detections' + | '/species' + | '/stats' + | '/api/audio/$date/$speciesAndFile' fileRoutesByTo: FileRoutesByTo - to: '/' | '/detections' | '/species' | '/api/audio/$date/$speciesAndFile' + to: + | '/' + | '/detections' + | '/species' + | '/stats' + | '/api/audio/$date/$speciesAndFile' id: | '__root__' | '/' | '/detections' | '/species' + | '/stats' | '/api/audio/$date/$speciesAndFile' fileRoutesById: FileRoutesById } @@ -73,6 +92,7 @@ export interface RootRouteChildren { IndexRoute: typeof IndexRoute DetectionsRoute: typeof DetectionsRoute SpeciesRoute: typeof SpeciesRoute + StatsRoute: typeof StatsRoute ApiAudioDateSpeciesAndFileRoute: typeof ApiAudioDateSpeciesAndFileRoute } @@ -99,6 +119,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof SpeciesRouteImport parentRoute: typeof rootRouteImport } + '/stats': { + id: '/stats' + path: '/stats' + fullPath: '/stats' + preLoaderRoute: typeof StatsRouteImport + parentRoute: typeof rootRouteImport + } '/api/audio/$date/$speciesAndFile': { id: '/api/audio/$date/$speciesAndFile' path: '/api/audio/$date/$speciesAndFile' @@ -113,6 +140,7 @@ const rootRouteChildren: RootRouteChildren = { IndexRoute: IndexRoute, DetectionsRoute: DetectionsRoute, SpeciesRoute: SpeciesRoute, + StatsRoute: StatsRoute, ApiAudioDateSpeciesAndFileRoute: ApiAudioDateSpeciesAndFileRoute, } export const routeTree = rootRouteImport diff --git a/web-ui/src/routes/stats.tsx b/web-ui/src/routes/stats.tsx new file mode 100644 index 00000000..d71bc3dc --- /dev/null +++ b/web-ui/src/routes/stats.tsx @@ -0,0 +1,298 @@ +import { createFileRoute, stripSearchParams } from "@tanstack/react-router"; +import { + CalendarDays, + CalendarRange, + Clock, + Infinity as InfinityIcon, +} from "lucide-react"; +import { + Area, + AreaChart, + Bar, + BarChart, + CartesianGrid, + ResponsiveContainer, + Tooltip, + XAxis, + YAxis, +} from "recharts"; +import { z } from "zod"; + +import { ToggleGroup, ToggleGroupItem } from "#/components/ui/toggle-group.tsx"; +import { + getStatsForPeriod, + STATS_PERIOD_LABELS, + STATS_PERIODS, + type StatsPeriod, +} from "#/lib/stats.ts"; + +const DEFAULT_PERIOD: StatsPeriod = "week"; + +// .default() makes `period` optional on input (so doesn't +// need to pass search) while guaranteeing a concrete value on output (so +// Route.useSearch().period is never undefined). Zod v4: use .catch(), not +// the @tanstack/zod-adapter fallback() helper (that's a Zod v3 workaround). +const statsSearchSchema = z.object({ + period: z.enum(STATS_PERIODS).default(DEFAULT_PERIOD).catch(DEFAULT_PERIOD), +}); + +export const Route = createFileRoute("/stats")({ + validateSearch: statsSearchSchema, + search: { + middlewares: [stripSearchParams({ period: DEFAULT_PERIOD })], + }, + loaderDeps: ({ search }) => ({ period: search.period }), + loader: ({ deps }) => getStatsForPeriod({ data: deps.period }), + component: Stats, +}); + +const PERIOD_ICONS: Record< + StatsPeriod, + React.ComponentType<{ className?: string }> +> = { + day: Clock, + week: CalendarDays, + month: CalendarRange, + all: InfinityIcon, +}; + +function Stats() { + const { period } = Route.useSearch(); + const navigate = Route.useNavigate(); + const stats = Route.useLoaderData(); + + return ( +
+

Stats

+

+ Trends and activity for your BirdNET-Pi station. +

+ + { + if (!value) return; + navigate({ + search: (prev) => ({ ...prev, period: value as StatsPeriod }), + }); + }} + className="mt-6" + > + {STATS_PERIODS.map((p) => { + const Icon = PERIOD_ICONS[p]; + return ( + + + {STATS_PERIOD_LABELS[p]} + + ); + })} + + +
+ + + + +
+ +

+ Detections over time +

+
+ + + + + + + + + + + + + + + +
+ +
+
+

Top species

+
+ + + + + + + + + +
+
+ +
+

+ Activity by hour of day +

+
+ + + + + hour === 0 + ? "12a" + : hour < 12 + ? `${hour}a` + : hour === 12 + ? "12p" + : `${hour - 12}p` + } + stroke="var(--muted-foreground)" + fontSize={12} + tickLine={false} + interval={3} + /> + + `${hour}:00`} + cursor={{ fill: "var(--sage)", fillOpacity: 0.2 }} + /> + + + +
+
+
+
+ ); +} + +function StatCard({ + label, + value, + sub, +}: { + label: string; + value: string | number; + sub?: string; +}) { + return ( +
+
+ {value} +
+
{label}
+ {sub && ( +
+ {sub} +
+ )} +
+ ); +}