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 (
-
-
-
-
- {[64, 128, 190, 246, 300].map((x, i) => (
-
- ))}
-
- );
-}
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 requests 404ing while identical fetch() calls
succeeded (worked around by fetching the clip and playing it from a
Blob URL). Verified in both dev and a production build.
Card layout also fixes two bugs found after building it: the image
wrapper's height varied across cards despite aspect-square (a flex/
aspect-ratio interaction quirk) -- replaced with an explicit fixed
height. Reordered content to name/sciname, then image, then stats,
then actions, top to bottom.
Co-Authored-By: Claude Sonnet 5
---
web-ui/src/components/ui/input.tsx | 21 ++
web-ui/src/components/ui/pagination.tsx | 125 +++++++
web-ui/src/components/ui/toggle-group.tsx | 82 +++++
web-ui/src/components/ui/toggle.tsx | 45 +++
web-ui/src/db/index.ts | 2 +
web-ui/src/lib/audio.server.ts | 42 +++
web-ui/src/lib/audio.ts | 49 +++
web-ui/src/lib/detections.ts | 74 +++-
web-ui/src/lib/wikipedia.ts | 53 +++
web-ui/src/routeTree.gen.ts | 31 +-
.../routes/api/audio/$date/$speciesAndFile.ts | 31 ++
web-ui/src/routes/species.tsx | 331 +++++++++++++++---
12 files changed, 828 insertions(+), 58 deletions(-)
create mode 100644 web-ui/src/components/ui/input.tsx
create mode 100644 web-ui/src/components/ui/pagination.tsx
create mode 100644 web-ui/src/components/ui/toggle-group.tsx
create mode 100644 web-ui/src/components/ui/toggle.tsx
create mode 100644 web-ui/src/lib/audio.server.ts
create mode 100644 web-ui/src/lib/audio.ts
create mode 100644 web-ui/src/lib/wikipedia.ts
create mode 100644 web-ui/src/routes/api/audio/$date/$speciesAndFile.ts
diff --git a/web-ui/src/components/ui/input.tsx b/web-ui/src/components/ui/input.tsx
new file mode 100644
index 00000000..89e8a56c
--- /dev/null
+++ b/web-ui/src/components/ui/input.tsx
@@ -0,0 +1,21 @@
+import type * as React from "react";
+
+import { cn } from "#/lib/utils.ts";
+
+function Input({ className, type, ...props }: React.ComponentProps<"input">) {
+ return (
+
+ );
+}
+
+export { Input };
diff --git a/web-ui/src/components/ui/pagination.tsx b/web-ui/src/components/ui/pagination.tsx
new file mode 100644
index 00000000..1ab6066f
--- /dev/null
+++ b/web-ui/src/components/ui/pagination.tsx
@@ -0,0 +1,125 @@
+import {
+ ChevronLeftIcon,
+ ChevronRightIcon,
+ MoreHorizontalIcon,
+} from "lucide-react";
+import type * as React from "react";
+import { type Button, buttonVariants } from "#/components/ui/button.tsx";
+import { cn } from "#/lib/utils.ts";
+
+function Pagination({ className, ...props }: React.ComponentProps<"nav">) {
+ return (
+
+ );
+}
+
+function PaginationContent({
+ className,
+ ...props
+}: React.ComponentProps<"ul">) {
+ return (
+
+ );
+}
+
+function PaginationItem({ ...props }: React.ComponentProps<"li">) {
+ return ;
+}
+
+type PaginationLinkProps = {
+ isActive?: boolean;
+} & Pick, "size"> &
+ React.ComponentProps<"a">;
+
+function PaginationLink({
+ className,
+ isActive,
+ size = "icon",
+ ...props
+}: PaginationLinkProps) {
+ return (
+
+ );
+}
+
+function PaginationPrevious({
+ className,
+ ...props
+}: React.ComponentProps) {
+ return (
+
+
+ Previous
+
+ );
+}
+
+function PaginationNext({
+ className,
+ ...props
+}: React.ComponentProps) {
+ return (
+
+ Next
+
+
+ );
+}
+
+function PaginationEllipsis({
+ className,
+ ...props
+}: React.ComponentProps<"span">) {
+ return (
+
+
+ More pages
+
+ );
+}
+
+export {
+ Pagination,
+ PaginationContent,
+ PaginationLink,
+ PaginationItem,
+ PaginationPrevious,
+ PaginationNext,
+ PaginationEllipsis,
+};
diff --git a/web-ui/src/components/ui/toggle-group.tsx b/web-ui/src/components/ui/toggle-group.tsx
new file mode 100644
index 00000000..7cb749db
--- /dev/null
+++ b/web-ui/src/components/ui/toggle-group.tsx
@@ -0,0 +1,82 @@
+"use client";
+
+import type { VariantProps } from "class-variance-authority";
+import { ToggleGroup as ToggleGroupPrimitive } from "radix-ui";
+import * as React from "react";
+import { toggleVariants } from "#/components/ui/toggle.tsx";
+import { cn } from "#/lib/utils.ts";
+
+const ToggleGroupContext = React.createContext<
+ VariantProps & {
+ spacing?: number;
+ }
+>({
+ size: "default",
+ variant: "default",
+ spacing: 0,
+});
+
+function ToggleGroup({
+ className,
+ variant,
+ size,
+ spacing = 0,
+ children,
+ ...props
+}: React.ComponentProps &
+ VariantProps & {
+ spacing?: number;
+ }) {
+ return (
+
+
+ {children}
+
+
+ );
+}
+
+function ToggleGroupItem({
+ className,
+ children,
+ variant,
+ size,
+ ...props
+}: React.ComponentProps &
+ VariantProps) {
+ const context = React.useContext(ToggleGroupContext);
+
+ return (
+
+ {children}
+
+ );
+}
+
+export { ToggleGroup, ToggleGroupItem };
diff --git a/web-ui/src/components/ui/toggle.tsx b/web-ui/src/components/ui/toggle.tsx
new file mode 100644
index 00000000..93d5aa21
--- /dev/null
+++ b/web-ui/src/components/ui/toggle.tsx
@@ -0,0 +1,45 @@
+import { cva, type VariantProps } from "class-variance-authority";
+import { Toggle as TogglePrimitive } from "radix-ui";
+import type * as React from "react";
+
+import { cn } from "#/lib/utils.ts";
+
+const toggleVariants = cva(
+ "inline-flex items-center justify-center gap-2 rounded-md text-sm font-medium whitespace-nowrap transition-[color,box-shadow] outline-none hover:bg-muted hover:text-muted-foreground 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 data-[state=on]:bg-accent data-[state=on]:text-accent-foreground [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
+ {
+ variants: {
+ variant: {
+ default: "bg-transparent",
+ outline:
+ "border border-input bg-transparent shadow-xs hover:bg-accent hover:text-accent-foreground",
+ },
+ size: {
+ default: "h-9 min-w-9 px-2",
+ sm: "h-8 min-w-8 px-1.5",
+ lg: "h-10 min-w-10 px-2.5",
+ },
+ },
+ defaultVariants: {
+ variant: "default",
+ size: "default",
+ },
+ },
+);
+
+function Toggle({
+ className,
+ variant,
+ size,
+ ...props
+}: React.ComponentProps &
+ VariantProps) {
+ return (
+
+ );
+}
+
+export { Toggle, toggleVariants };
diff --git a/web-ui/src/db/index.ts b/web-ui/src/db/index.ts
index a73eb3eb..3f5f5cbc 100644
--- a/web-ui/src/db/index.ts
+++ b/web-ui/src/db/index.ts
@@ -1,3 +1,5 @@
+import "@tanstack/react-start/server-only";
+
import { DatabaseSync } from "node:sqlite";
import { drizzle } from "drizzle-orm/sqlite-proxy";
diff --git a/web-ui/src/lib/audio.server.ts b/web-ui/src/lib/audio.server.ts
new file mode 100644
index 00000000..761d048d
--- /dev/null
+++ b/web-ui/src/lib/audio.server.ts
@@ -0,0 +1,42 @@
+import "@tanstack/react-start/server-only";
+
+import path from "node:path";
+
+// Matches BirdNET-Pi's RECS_DIR/EXTRACTED layout: BirdSongs lives as a
+// sibling of the BirdNET-Pi checkout (web-ui/../../BirdSongs/Extracted),
+// never inside the git repo itself. Point BIRDNET_EXTRACTED_DIR at the
+// real path in production. Read lazily (not at module scope) so this
+// works correctly under edge runtimes that inject env per-request too.
+function extractedDir(): string {
+ return path.resolve(
+ process.env.BIRDNET_EXTRACTED_DIR ?? "../../BirdSongs/Extracted",
+ );
+}
+
+const EXTENSION_MIME: Record = {
+ ".mp3": "audio/mpeg",
+ ".wav": "audio/wav",
+ ".flac": "audio/flac",
+ ".ogg": "audio/ogg",
+};
+
+/**
+ * Resolves a relative "By_Date///" path against the
+ * extracted-clips root, refusing anything that would escape it (path
+ * traversal via `..` segments).
+ */
+export function resolveExtractedFile(relativePath: string): string | null {
+ const root = extractedDir();
+ const resolved = path.resolve(root, relativePath);
+ if (resolved !== root && !resolved.startsWith(root + path.sep)) {
+ return null;
+ }
+ return resolved;
+}
+
+export function mimeTypeFor(filePath: string): string {
+ return (
+ EXTENSION_MIME[path.extname(filePath).toLowerCase()] ??
+ "application/octet-stream"
+ );
+}
diff --git a/web-ui/src/lib/audio.ts b/web-ui/src/lib/audio.ts
new file mode 100644
index 00000000..2501a97d
--- /dev/null
+++ b/web-ui/src/lib/audio.ts
@@ -0,0 +1,49 @@
+// Pure, isomorphic string helpers -- safe to import from client-rendered
+// components. Filesystem-touching code (EXTRACTED_DIR, resolveExtractedFile,
+// mimeTypeFor) lives in audio.server.ts, which is import-protected so it can
+// never end up in the client bundle.
+
+export function commonNameSafe(commonName: string): string {
+ return commonName.replaceAll("'", "").replaceAll(" ", "_");
+}
+
+// The server route only supports 2 chained dynamic segments (3+ breaks route
+// registration in the installed TanStack Start version), so species and
+// filename travel as one segment. A literal "." anywhere in a path segment
+// (e.g. the ".wav" extension) makes Nitro's dev server treat the request as
+// a static-asset lookup before it ever reaches a dynamic route -- and
+// percent-encoding it doesn't survive, since browsers normalize %2E back to
+// "." before sending the request. Base64url has no dots at all, so it
+// sidesteps the problem entirely instead of fighting URL normalization.
+const SEGMENT_SEPARATOR = "::";
+
+function base64UrlEncode(value: string): string {
+ return btoa(value)
+ .replaceAll("+", "-")
+ .replaceAll("/", "_")
+ .replaceAll("=", "");
+}
+
+function base64UrlDecode(value: string): string {
+ const padded = value.replaceAll("-", "+").replaceAll("_", "/");
+ const padding =
+ padded.length % 4 === 0 ? "" : "=".repeat(4 - (padded.length % 4));
+ return atob(padded + padding);
+}
+
+/** Builds the /api/audio URL for a given detection's extracted clip. */
+export function audioUrlFor(
+ date: string,
+ commonName: string,
+ fileName: string,
+): string {
+ const speciesAndFile = `${commonNameSafe(commonName)}${SEGMENT_SEPARATOR}${fileName}`;
+ return `/api/audio/${encodeURIComponent(date)}/${base64UrlEncode(speciesAndFile)}`;
+}
+
+export function splitSpeciesAndFile(
+ encoded: string,
+): [species: string, file: string] {
+ const [species, file] = base64UrlDecode(encoded).split(SEGMENT_SEPARATOR);
+ return [species ?? "", file ?? ""];
+}
diff --git a/web-ui/src/lib/detections.ts b/web-ui/src/lib/detections.ts
index 21b6153f..33a8b62e 100644
--- a/web-ui/src/lib/detections.ts
+++ b/web-ui/src/lib/detections.ts
@@ -1,8 +1,9 @@
import { createServerFn } from "@tanstack/react-start";
import { count, countDistinct, desc, sql } from "drizzle-orm";
-
import { db } from "#/db/index.ts";
import { type Detection, detections } from "#/db/schema.ts";
+import { audioUrlFor } from "#/lib/audio.ts";
+import { ebirdSearchUrl, getSpeciesInfo } from "#/lib/wikipedia.ts";
const isToday = sql`${detections.Date} = date('now', 'localtime')`;
const isLastHour = sql`datetime(${detections.Date} || ' ' || ${detections.Time}) >= datetime('now', '-1 hour', 'localtime')`;
@@ -58,24 +59,77 @@ export const getDetections = createServerFn({ method: "GET" }).handler(
},
);
-export type SpeciesSummary = {
+export type LifeListCard = {
comName: string;
sciName: string;
- count: number;
+ hourCount: number;
+ allTimeCount: number;
lastDetected: string;
+ audioUrl: string | null;
+ imageUrl: string | null;
+ wikipediaUrl: string;
+ ebirdUrl: string;
};
-export const getSpecies = createServerFn({ method: "GET" }).handler(
- async (): Promise => {
- return db
+export const getLifeListCards = createServerFn({ method: "GET" }).handler(
+ async (): Promise => {
+ const totals = await db
.select({
comName: detections.Com_Name,
sciName: detections.Sci_Name,
- count: count(),
- lastDetected: sql`max(${detections.Date})`,
+ allTimeCount: count(),
+ })
+ .from(detections)
+ .groupBy(detections.Com_Name, detections.Sci_Name);
+
+ const hourly = await db
+ .select({ comName: detections.Com_Name, hourCount: count() })
+ .from(detections)
+ .where(isLastHour)
+ .groupBy(detections.Com_Name);
+ const hourByName = new Map(
+ hourly.map((row) => [row.comName, row.hourCount]),
+ );
+
+ // Most-recent detection per species, ordered so the first occurrence
+ // of each Com_Name we see is the latest one.
+ const recent = await db
+ .select({
+ comName: detections.Com_Name,
+ date: detections.Date,
+ time: detections.Time,
+ fileName: detections.File_Name,
})
.from(detections)
- .groupBy(detections.Com_Name, detections.Sci_Name)
- .orderBy(sql`count(*) desc`);
+ .orderBy(desc(detections.Date), desc(detections.Time));
+ const latestByName = new Map<
+ string,
+ { date: string; time: string; fileName: string }
+ >();
+ for (const row of recent) {
+ if (!latestByName.has(row.comName)) {
+ latestByName.set(row.comName, row);
+ }
+ }
+
+ return Promise.all(
+ totals.map(async (row) => {
+ const latest = latestByName.get(row.comName);
+ const { imageUrl, wikipediaUrl } = await getSpeciesInfo(row.comName);
+ return {
+ comName: row.comName,
+ sciName: row.sciName,
+ allTimeCount: row.allTimeCount,
+ hourCount: hourByName.get(row.comName) ?? 0,
+ lastDetected: latest ? `${latest.date} ${latest.time}` : "",
+ audioUrl: latest
+ ? audioUrlFor(latest.date, row.comName, latest.fileName)
+ : null,
+ imageUrl,
+ wikipediaUrl,
+ ebirdUrl: ebirdSearchUrl(row.comName),
+ };
+ }),
+ );
},
);
diff --git a/web-ui/src/lib/wikipedia.ts b/web-ui/src/lib/wikipedia.ts
new file mode 100644
index 00000000..ef4fb52d
--- /dev/null
+++ b/web-ui/src/lib/wikipedia.ts
@@ -0,0 +1,53 @@
+export type SpeciesInfo = {
+ imageUrl: string | null;
+ wikipediaUrl: string;
+};
+
+type WikipediaSummary = {
+ thumbnail?: { source?: string };
+ content_urls?: { desktop?: { page?: string } };
+};
+
+// In-memory only -- cleared on server restart. Cheap insurance against
+// hammering Wikipedia on every page load for the same handful of species.
+const cache = new Map();
+
+export async function getSpeciesInfo(commonName: string): Promise {
+ const cached = cache.get(commonName);
+ if (cached) return cached;
+
+ const title = commonName.replaceAll(" ", "_");
+ const fallback: SpeciesInfo = {
+ imageUrl: null,
+ wikipediaUrl: `https://en.wikipedia.org/wiki/${encodeURIComponent(title)}`,
+ };
+
+ try {
+ const response = await fetch(
+ `https://en.wikipedia.org/api/rest_v1/page/summary/${encodeURIComponent(title)}`,
+ { signal: AbortSignal.timeout(5000) },
+ );
+ if (!response.ok) {
+ cache.set(commonName, fallback);
+ return fallback;
+ }
+ const data = (await response.json()) as WikipediaSummary;
+ const info: SpeciesInfo = {
+ imageUrl: data.thumbnail?.source ?? null,
+ wikipediaUrl: data.content_urls?.desktop?.page ?? fallback.wikipediaUrl,
+ };
+ cache.set(commonName, info);
+ return info;
+ } catch {
+ cache.set(commonName, fallback);
+ return fallback;
+ }
+}
+
+// eBird gates species/search pages behind a login wall for anonymous
+// requests, and a precise deep link needs a 6-letter species code we don't
+// have without their (key-gated) taxonomy API. A scoped web search reliably
+// gets a person to the right page regardless of their eBird auth state.
+export function ebirdSearchUrl(commonName: string): string {
+ return `https://www.google.com/search?q=${encodeURIComponent(`${commonName} ebird`)}`;
+}
diff --git a/web-ui/src/routeTree.gen.ts b/web-ui/src/routeTree.gen.ts
index 2ea5ef33..96196561 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 ApiAudioDateSpeciesAndFileRouteImport } from './routes/api/audio/$date/$speciesAndFile'
const IndexRoute = IndexRouteImport.update({
id: '/',
@@ -28,35 +29,51 @@ const SpeciesRoute = SpeciesRouteImport.update({
path: '/species',
getParentRoute: () => rootRouteImport,
} as any)
+const ApiAudioDateSpeciesAndFileRoute =
+ ApiAudioDateSpeciesAndFileRouteImport.update({
+ id: '/api/audio/$date/$speciesAndFile',
+ path: '/api/audio/$date/$speciesAndFile',
+ getParentRoute: () => rootRouteImport,
+ } as any)
export interface FileRoutesByFullPath {
'/': typeof IndexRoute
'/detections': typeof DetectionsRoute
'/species': typeof SpeciesRoute
+ '/api/audio/$date/$speciesAndFile': typeof ApiAudioDateSpeciesAndFileRoute
}
export interface FileRoutesByTo {
'/': typeof IndexRoute
'/detections': typeof DetectionsRoute
'/species': typeof SpeciesRoute
+ '/api/audio/$date/$speciesAndFile': typeof ApiAudioDateSpeciesAndFileRoute
}
export interface FileRoutesById {
__root__: typeof rootRouteImport
'/': typeof IndexRoute
'/detections': typeof DetectionsRoute
'/species': typeof SpeciesRoute
+ '/api/audio/$date/$speciesAndFile': typeof ApiAudioDateSpeciesAndFileRoute
}
export interface FileRouteTypes {
fileRoutesByFullPath: FileRoutesByFullPath
- fullPaths: '/' | '/detections' | '/species'
+ fullPaths:
+ '/' | '/detections' | '/species' | '/api/audio/$date/$speciesAndFile'
fileRoutesByTo: FileRoutesByTo
- to: '/' | '/detections' | '/species'
- id: '__root__' | '/' | '/detections' | '/species'
+ to: '/' | '/detections' | '/species' | '/api/audio/$date/$speciesAndFile'
+ id:
+ | '__root__'
+ | '/'
+ | '/detections'
+ | '/species'
+ | '/api/audio/$date/$speciesAndFile'
fileRoutesById: FileRoutesById
}
export interface RootRouteChildren {
IndexRoute: typeof IndexRoute
DetectionsRoute: typeof DetectionsRoute
SpeciesRoute: typeof SpeciesRoute
+ ApiAudioDateSpeciesAndFileRoute: typeof ApiAudioDateSpeciesAndFileRoute
}
declare module '@tanstack/react-router' {
@@ -82,6 +99,13 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof SpeciesRouteImport
parentRoute: typeof rootRouteImport
}
+ '/api/audio/$date/$speciesAndFile': {
+ id: '/api/audio/$date/$speciesAndFile'
+ path: '/api/audio/$date/$speciesAndFile'
+ fullPath: '/api/audio/$date/$speciesAndFile'
+ preLoaderRoute: typeof ApiAudioDateSpeciesAndFileRouteImport
+ parentRoute: typeof rootRouteImport
+ }
}
}
@@ -89,6 +113,7 @@ const rootRouteChildren: RootRouteChildren = {
IndexRoute: IndexRoute,
DetectionsRoute: DetectionsRoute,
SpeciesRoute: SpeciesRoute,
+ ApiAudioDateSpeciesAndFileRoute: ApiAudioDateSpeciesAndFileRoute,
}
export const routeTree = rootRouteImport
._addFileChildren(rootRouteChildren)
diff --git a/web-ui/src/routes/api/audio/$date/$speciesAndFile.ts b/web-ui/src/routes/api/audio/$date/$speciesAndFile.ts
new file mode 100644
index 00000000..e92ff825
--- /dev/null
+++ b/web-ui/src/routes/api/audio/$date/$speciesAndFile.ts
@@ -0,0 +1,31 @@
+import { readFile } from "node:fs/promises";
+import { createFileRoute } from "@tanstack/react-router";
+import { mimeTypeFor, resolveExtractedFile } from "#/lib/audio.server.ts";
+import { splitSpeciesAndFile } from "#/lib/audio.ts";
+
+export const Route = createFileRoute("/api/audio/$date/$speciesAndFile")({
+ server: {
+ handlers: {
+ GET: async ({ params }) => {
+ const [species, file] = splitSpeciesAndFile(params.speciesAndFile);
+ const relativePath = `By_Date/${params.date}/${species}/${file}`;
+ const resolved = resolveExtractedFile(relativePath);
+ if (!resolved) {
+ return new Response("Not found", { status: 404 });
+ }
+
+ try {
+ const data = await readFile(resolved);
+ return new Response(data, {
+ headers: {
+ "Content-Type": mimeTypeFor(resolved),
+ "Cache-Control": "public, max-age=31536000, immutable",
+ },
+ });
+ } catch {
+ return new Response("Not found", { status: 404 });
+ }
+ },
+ },
+ },
+});
diff --git a/web-ui/src/routes/species.tsx b/web-ui/src/routes/species.tsx
index 714a0bf8..3cfd3b75 100644
--- a/web-ui/src/routes/species.tsx
+++ b/web-ui/src/routes/species.tsx
@@ -1,66 +1,307 @@
import { createFileRoute } from "@tanstack/react-router";
+import {
+ ArrowDownAZ,
+ Binoculars,
+ Bird,
+ BookOpen,
+ Clock,
+ Loader2,
+ Mic2,
+ Pause,
+ Play,
+} from "lucide-react";
+import { useEffect, useMemo, useRef, useState } from "react";
-import { Badge } from "#/components/ui/badge.tsx";
+import { Button } from "#/components/ui/button.tsx";
+import { Input } from "#/components/ui/input.tsx";
import {
- Table,
- TableBody,
- TableCell,
- TableHead,
- TableHeader,
- TableRow,
-} from "#/components/ui/table.tsx";
-import { getSpecies } from "#/lib/detections.ts";
+ Pagination,
+ PaginationContent,
+ PaginationItem,
+ PaginationLink,
+ PaginationNext,
+ PaginationPrevious,
+} from "#/components/ui/pagination.tsx";
+import { ToggleGroup, ToggleGroupItem } from "#/components/ui/toggle-group.tsx";
+import { getLifeListCards, type LifeListCard } from "#/lib/detections.ts";
export const Route = createFileRoute("/species")({
component: Species,
- loader: () => getSpecies(),
+ loader: () => getLifeListCards(),
});
+type SortKey = "count" | "recent" | "alpha";
+
+const PAGE_SIZE = 24;
+
function Species() {
- const species = Route.useLoaderData();
+ const cards = Route.useLoaderData();
+ const [search, setSearch] = useState("");
+ const [sort, setSort] = useState("count");
+ const [page, setPage] = useState(1);
+
+ const filtered = useMemo(() => {
+ const query = search.trim().toLowerCase();
+ const matches = query
+ ? cards.filter(
+ (c) =>
+ c.comName.toLowerCase().includes(query) ||
+ c.sciName.toLowerCase().includes(query),
+ )
+ : cards;
+
+ return [...matches].sort((a, b) => {
+ if (sort === "alpha") return a.comName.localeCompare(b.comName);
+ if (sort === "recent")
+ return b.lastDetected.localeCompare(a.lastDetected);
+ return b.allTimeCount - a.allTimeCount;
+ });
+ }, [cards, search, sort]);
+
+ const pageCount = Math.max(1, Math.ceil(filtered.length / PAGE_SIZE));
+ const currentPage = Math.min(page, pageCount);
+ const pageItems = filtered.slice(
+ (currentPage - 1) * PAGE_SIZE,
+ currentPage * PAGE_SIZE,
+ );
return (
Species
- {species.length} species detected so far.
+ {cards.length} species detected so far.
-
-
-
-
- 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 loading doesn't reliably reach
+ // this app's dynamic audio route (some Sec-Fetch-Dest-specific
+ // behavior in this dev stack), but a plain fetch() always does --
+ // so fetch it ourselves and play from a local blob instead.
+ if (!objectUrlRef.current) {
+ setIsLoading(true);
+ try {
+ const response = await fetch(card.audioUrl);
+ if (!response.ok) throw new Error("Failed to fetch audio");
+ objectUrlRef.current = URL.createObjectURL(await response.blob());
+ audio.src = objectUrlRef.current;
+ } catch {
+ setIsLoading(false);
+ return;
+ }
+ setIsLoading(false);
+ }
+
+ audio.play().catch(() => setIsPlaying(false));
+ }
+
+ return (
+
+
+
{card.comName}
+
{card.sciName}
+
+
+
+ {card.imageUrl ? (
+
+ ) : (
+
+
+
+ )}
+
+
+
+
+
+
{card.hourCount}
+
This hour
+
+
+
+ {card.allTimeCount}
+
+
All time
+
+
+
+
+
+ {isLoading ? (
+
+ ) : isPlaying ? (
+
) : (
- species.map((s) => (
-
- {s.comName}
- {s.sciName}
-
- {s.lastDetected}
-
-
-
- {s.count}
-
-
-
- ))
+
)}
-
-
+ {isPlaying ? "Pause" : "Play"}
+
+
+
+
+ Wiki
+
+
+
+
+
+ eBird
+
+
+ {card.audioUrl && (
+
setIsPlaying(true)}
+ onPause={() => setIsPlaying(false)}
+ onEnded={() => setIsPlaying(false)}
+ >
+
+
+ )}
+
);
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}
+
+ )}
+
+ );
+}