Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions web-ui/src/components/Header.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,13 @@ export function Header() {
>
Stats
</Link>
<Link
to="/now"
className="nav-link"
activeProps={{ className: "nav-link is-active" }}
>
Now
</Link>
</nav>
</div>
</header>
Expand Down
46 changes: 46 additions & 0 deletions web-ui/src/lib/use-polled-data.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
import { useEffect, useRef, useState } from "react";

/**
* Re-runs `fetcher` on an interval and keeps the latest result in state.
* Client-only by nature (useEffect never runs during SSR) -- the caller
* supplies the loader's initial data so the first paint isn't empty.
*
* There's no server-push mechanism here (no WebSocket/SSE) -- this is a
* deliberately simple poll. The underlying query is small, indexed, and
* LIMIT-bounded, so even a Pi-class server handles a request every few
* seconds from a handful of viewers without breaking a sweat.
*/
export function usePolledData<T>(
fetcher: () => Promise<T>,
initialData: T,
intervalMs: number,
) {
const [data, setData] = useState(initialData);
const [lastUpdated, setLastUpdated] = useState(() => new Date());
const fetcherRef = useRef(fetcher);
fetcherRef.current = fetcher;

useEffect(() => {
let cancelled = false;

const id = setInterval(async () => {
try {
const next = await fetcherRef.current();
if (!cancelled) {
setData(next);
setLastUpdated(new Date());
}
} catch {
// A transient failure shouldn't kill polling -- just try again
// on the next tick.
}
}, intervalMs);
Comment on lines +23 to +37

return () => {
cancelled = true;
clearInterval(id);
};
}, [intervalMs]);

return { data, lastUpdated };
}
21 changes: 21 additions & 0 deletions web-ui/src/routeTree.gen.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,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 NowRouteImport } from './routes/now'
import { Route as SpeciesRouteImport } from './routes/species'
import { Route as StatsRouteImport } from './routes/stats'
import { Route as ApiAudioDateSpeciesAndFileRouteImport } from './routes/api/audio/$date/$speciesAndFile'
Expand All @@ -25,6 +26,11 @@ const DetectionsRoute = DetectionsRouteImport.update({
path: '/detections',
getParentRoute: () => rootRouteImport,
} as any)
const NowRoute = NowRouteImport.update({
id: '/now',
path: '/now',
getParentRoute: () => rootRouteImport,
} as any)
const SpeciesRoute = SpeciesRouteImport.update({
id: '/species',
path: '/species',
Expand All @@ -45,13 +51,15 @@ const ApiAudioDateSpeciesAndFileRoute =
export interface FileRoutesByFullPath {
'/': typeof IndexRoute
'/detections': typeof DetectionsRoute
'/now': typeof NowRoute
'/species': typeof SpeciesRoute
'/stats': typeof StatsRoute
'/api/audio/$date/$speciesAndFile': typeof ApiAudioDateSpeciesAndFileRoute
}
export interface FileRoutesByTo {
'/': typeof IndexRoute
'/detections': typeof DetectionsRoute
'/now': typeof NowRoute
'/species': typeof SpeciesRoute
'/stats': typeof StatsRoute
'/api/audio/$date/$speciesAndFile': typeof ApiAudioDateSpeciesAndFileRoute
Expand All @@ -60,6 +68,7 @@ export interface FileRoutesById {
__root__: typeof rootRouteImport
'/': typeof IndexRoute
'/detections': typeof DetectionsRoute
'/now': typeof NowRoute
'/species': typeof SpeciesRoute
'/stats': typeof StatsRoute
'/api/audio/$date/$speciesAndFile': typeof ApiAudioDateSpeciesAndFileRoute
Expand All @@ -69,20 +78,23 @@ export interface FileRouteTypes {
fullPaths:
| '/'
| '/detections'
| '/now'
| '/species'
| '/stats'
| '/api/audio/$date/$speciesAndFile'
fileRoutesByTo: FileRoutesByTo
to:
| '/'
| '/detections'
| '/now'
| '/species'
| '/stats'
| '/api/audio/$date/$speciesAndFile'
id:
| '__root__'
| '/'
| '/detections'
| '/now'
| '/species'
| '/stats'
| '/api/audio/$date/$speciesAndFile'
Expand All @@ -91,6 +103,7 @@ export interface FileRouteTypes {
export interface RootRouteChildren {
IndexRoute: typeof IndexRoute
DetectionsRoute: typeof DetectionsRoute
NowRoute: typeof NowRoute
SpeciesRoute: typeof SpeciesRoute
StatsRoute: typeof StatsRoute
ApiAudioDateSpeciesAndFileRoute: typeof ApiAudioDateSpeciesAndFileRoute
Expand All @@ -112,6 +125,13 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof DetectionsRouteImport
parentRoute: typeof rootRouteImport
}
'/now': {
id: '/now'
path: '/now'
fullPath: '/now'
preLoaderRoute: typeof NowRouteImport
parentRoute: typeof rootRouteImport
}
'/species': {
id: '/species'
path: '/species'
Expand Down Expand Up @@ -139,6 +159,7 @@ declare module '@tanstack/react-router' {
const rootRouteChildren: RootRouteChildren = {
IndexRoute: IndexRoute,
DetectionsRoute: DetectionsRoute,
NowRoute: NowRoute,
SpeciesRoute: SpeciesRoute,
StatsRoute: StatsRoute,
ApiAudioDateSpeciesAndFileRoute: ApiAudioDateSpeciesAndFileRoute,
Expand Down
133 changes: 133 additions & 0 deletions web-ui/src/routes/now.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,133 @@
import { createFileRoute } from "@tanstack/react-router";
import { useEffect, useRef, useState } from "react";

import type { Detection } from "#/db/schema.ts";
import { getRecentDetections } from "#/lib/detections.ts";
import { usePolledData } from "#/lib/use-polled-data.ts";

const POLL_INTERVAL_MS = 10_000;

export const Route = createFileRoute("/now")({
component: Now,
loader: () => getRecentDetections(),
});

function keyFor(d: Detection): string {
return `${d.Date}-${d.Time}-${d.File_Name}`;
}

function Now() {
const initial = Route.useLoaderData();
const { data: detections, lastUpdated } = usePolledData(
() => getRecentDetections(),
initial,
POLL_INTERVAL_MS,
);

const seenKeysRef = useRef(new Set(initial.map(keyFor)));
const [freshKeys, setFreshKeys] = useState<Set<string>>(new Set());

useEffect(() => {
const currentKeys = detections.map(keyFor);
const newlyArrived = currentKeys.filter((k) => !seenKeysRef.current.has(k));
seenKeysRef.current = new Set(currentKeys);
if (newlyArrived.length === 0) return;

setFreshKeys(new Set(newlyArrived));
const timeout = setTimeout(() => setFreshKeys(new Set()), 2400);
return () => clearTimeout(timeout);
}, [detections]);

const latest = detections[0] as Detection | undefined;
const rest = detections.slice(1);

return (
<div className="page-wrap py-8">
<div className="flex items-center justify-between">
<div>
<h1 className="display-title text-3xl font-semibold">Now</h1>
<p className="mt-2 text-muted-foreground">
What's singing at your station, as it happens.
</p>
</div>
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<span className="live-dot" aria-hidden="true" />
<span>
Live &middot; updated{" "}
{lastUpdated.toLocaleTimeString([], { timeStyle: "medium" })}
</span>
</div>
</div>

{latest ? (
<div
key={keyFor(latest)}
className={
freshKeys.has(keyFor(latest))
? "feature-card mt-6 rounded-lg p-6 flash-in"
: "feature-card mt-6 rounded-lg p-6"
}
>
<div className="island-kicker">Latest detection</div>
<div className="mt-2 flex flex-wrap items-baseline gap-x-3 gap-y-1">
<h2 className="display-title text-2xl font-bold">
{latest.Com_Name}
</h2>
<span className="italic text-muted-foreground">
{latest.Sci_Name}
</span>
</div>
<div className="tabular-data mt-2 text-sm text-muted-foreground">
{latest.Date} {latest.Time}
{latest.Confidence != null &&
` · ${Math.round(latest.Confidence * 100)}% confidence`}
</div>
</div>
) : (
<p className="mt-10 text-muted-foreground">
No detections yet. Once BirdNET-Pi's analysis pipeline writes to
birds.db, they'll show up here within {POLL_INTERVAL_MS / 1000}{" "}
seconds.
</p>
)}

{rest.length > 0 && (
<>
<h2 className="display-title mt-10 text-xl font-semibold">
Recent activity
</h2>
<div className="feature-card mt-4 rounded-lg p-2">
<ul className="divide-y divide-[var(--line)]">
{rest.map((d) => {
const key = keyFor(d);
return (
<li
key={key}
className={
freshKeys.has(key)
? "flex items-center justify-between gap-4 rounded-md px-3 py-2 flash-in"
: "flex items-center justify-between gap-4 rounded-md px-3 py-2"
}
>
<div className="min-w-0">
<div className="truncate font-medium">{d.Com_Name}</div>
<div className="truncate text-sm italic text-muted-foreground">
{d.Sci_Name}
</div>
</div>
<div className="tabular-data shrink-0 text-right text-sm text-muted-foreground">
<div>{d.Time}</div>
{d.Confidence != null && (
<div>{Math.round(d.Confidence * 100)}%</div>
)}
</div>
</li>
);
})}
</ul>
</div>
</>
)}
</div>
);
}
36 changes: 36 additions & 0 deletions web-ui/src/styles.css
Original file line number Diff line number Diff line change
Expand Up @@ -233,6 +233,42 @@ a {
}
}

/* Plays once when a freshly-arrived row/card mounts (or remounts via a
changed `key`) -- a brief highlight that fades back to nothing. */
.flash-in {
animation: flash-in 2.4s ease-out;
}

@keyframes flash-in {
0% {
background-color: color-mix(in oklab, var(--sand) 55%, transparent);
}
100% {
background-color: transparent;
}
}

.live-dot {
display: inline-block;
width: 0.5rem;
height: 0.5rem;
border-radius: 999px;
background: var(--moss);
animation: live-pulse 2s ease-in-out infinite;
}

@keyframes live-pulse {
0%,
100% {
opacity: 1;
box-shadow: 0 0 0 0 color-mix(in oklab, var(--moss) 45%, transparent);
}
50% {
opacity: 0.7;
box-shadow: 0 0 0 4px transparent;
}
}

.site-footer {
border-top: 1px solid var(--line);
background: var(--header-bg);
Expand Down