Add Stats page: trends over time with URL-based period state - #3
Merged
Conversation
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 <noreply@anthropic.com>
…udio 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 <audio src> 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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
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 <Link to="/stats"> 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 <noreply@anthropic.com>
3 tasks
There was a problem hiding this comment.
Pull request overview
Adds a new Stats page (charts + summary cards) with URL-driven period state, plus supporting UI components and data/server utilities to power the Stats and updated Species experiences.
Changes:
- Adds
/statsroute with period switcher persisted in?period=...and multiple Recharts visualizations. - Reworks
/speciesinto a searchable/sortable card grid with image + playback actions, backed by new server data shaping and an audio streaming route. - Applies small global UI polish (radius token, stable scrollbar gutter, removes home hero band).
Reviewed changes
Copilot reviewed 21 out of 22 changed files in this pull request and generated 8 comments.
Show a summary per file
| File | Description |
|---|---|
| web-ui/src/styles.css | Updates radius token; adds scrollbar-gutter: stable; removes paper-grain overlay. |
| web-ui/src/routeTree.gen.ts | Registers new /stats and /api/audio/... routes in the generated route tree. |
| web-ui/src/routes/stats.tsx | New Stats page UI with URL-based period state and Recharts visualizations. |
| web-ui/src/routes/species.tsx | Replaces table with searchable/sortable/paginated card grid and audio playback controls. |
| web-ui/src/routes/index.tsx | Removes the HeroBand usage and adjusts spacing/radius on the home page. |
| web-ui/src/routes/detections.tsx | Minor radius tweak for consistent card styling. |
| web-ui/src/routes/api/audio/$date/$speciesAndFile.ts | New server route to read/serve extracted audio clips by date/species/file. |
| web-ui/src/lib/wikipedia.ts | Adds Wikipedia thumbnail lookup + in-memory cache and eBird search URL helper. |
| web-ui/src/lib/stats.ts | Adds server-side stats aggregation for the Stats page (trend/top species/hour activity). |
| web-ui/src/lib/detections.ts | Adds getLifeListCards server fn to power new Species cards (counts, latest audio, wiki info). |
| web-ui/src/lib/audio.ts | Adds base64url segment helpers and audio URL construction/parsing. |
| web-ui/src/lib/audio.server.ts | Adds extracted-dir resolution, traversal-safe path resolution, and MIME mapping. |
| web-ui/src/db/index.ts | Marks DB module as server-only. |
| web-ui/src/components/ui/toggle.tsx | Adds Toggle UI primitive wrapper. |
| web-ui/src/components/ui/toggle-group.tsx | Adds ToggleGroup wrapper used by Stats and Species sort/period controls. |
| web-ui/src/components/ui/pagination.tsx | Adds Pagination UI components used by Species page. |
| web-ui/src/components/ui/input.tsx | Adds Input UI component used by Species search. |
| web-ui/src/components/HeroBand.tsx | Removes unused hero SVG component. |
| web-ui/src/components/Header.tsx | Adds “Stats” navigation link. |
| web-ui/package.json | Adds Recharts and Zod dependencies. |
| web-ui/package-lock.json | Locks added dependencies (Recharts, Zod, and transitive deps). |
| scripts/seed_test_data.py | Adds optional placeholder audio generation under extracted clips directory. |
Files not reviewed (1)
- web-ui/package-lock.json: Generated file
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
Comment on lines
+36
to
+40
| style={{ "--gap": spacing } as React.CSSProperties} | ||
| className={cn( | ||
| "group/toggle-group flex w-fit items-center gap-[--spacing(var(--gap))] rounded-md data-[spacing=default]:data-[variant=outline]:shadow-xs", | ||
| className, | ||
| )} |
Comment on lines
+20
to
+25
| function base64UrlEncode(value: string): string { | ||
| return btoa(value) | ||
| .replaceAll("+", "-") | ||
| .replaceAll("/", "_") | ||
| .replaceAll("=", ""); | ||
| } |
Comment on lines
+27
to
+32
| 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); | ||
| } |
Comment on lines
+10
to
+12
| const [species, file] = splitSpeciesAndFile(params.speciesAndFile); | ||
| const relativePath = `By_Date/${params.date}/${species}/${file}`; | ||
| const resolved = resolveExtractedFile(relativePath); |
Comment on lines
+100
to
+104
| 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)); |
Comment on lines
+115
to
+119
| return Promise.all( | ||
| totals.map(async (row) => { | ||
| const latest = latestByName.get(row.comName); | ||
| const { imageUrl, wikipediaUrl } = await getSpeciesInfo(row.comName); | ||
| return { |
Comment on lines
+150
to
+152
| export const getStatsForPeriod = createServerFn({ method: "GET" }) | ||
| .validator((period: StatsPeriod) => period) | ||
| .handler(async ({ data: period }): Promise<StatsData> => { |
| onPause={() => setIsPlaying(false)} | ||
| onEnded={() => setIsPlaying(false)} | ||
| > | ||
| <track kind="captions" /> |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Depends on #2 (this branch is stacked on it — the diff below will shrink to just the Stats page once #2 merges).
?period=dayetc., via a Zod-validated route search schema (.default().catch()gives a required output type without forcing every<Link to="/stats">to passsearchexplicitly), stripped from the URL when it's the default. Back/forward and shareable links work correctly.Test plan
tsc --noEmitandbiome checkcleannpm run buildsucceedsCo-Authored-By: Claude Sonnet 5 noreply@anthropic.com