diff --git a/web-ui/src/components/Header.tsx b/web-ui/src/components/Header.tsx index fc1efe64..83e93b3b 100644 --- a/web-ui/src/components/Header.tsx +++ b/web-ui/src/components/Header.tsx @@ -37,6 +37,13 @@ export function Header() { > Stats + + Now + diff --git a/web-ui/src/lib/use-polled-data.ts b/web-ui/src/lib/use-polled-data.ts new file mode 100644 index 00000000..e3d08476 --- /dev/null +++ b/web-ui/src/lib/use-polled-data.ts @@ -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( + fetcher: () => Promise, + 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); + + return () => { + cancelled = true; + clearInterval(id); + }; + }, [intervalMs]); + + return { data, lastUpdated }; +} diff --git a/web-ui/src/routeTree.gen.ts b/web-ui/src/routeTree.gen.ts index 273506a4..34381a0e 100644 --- a/web-ui/src/routeTree.gen.ts +++ b/web-ui/src/routeTree.gen.ts @@ -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' @@ -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', @@ -45,6 +51,7 @@ 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 @@ -52,6 +59,7 @@ export interface FileRoutesByFullPath { export interface FileRoutesByTo { '/': typeof IndexRoute '/detections': typeof DetectionsRoute + '/now': typeof NowRoute '/species': typeof SpeciesRoute '/stats': typeof StatsRoute '/api/audio/$date/$speciesAndFile': typeof ApiAudioDateSpeciesAndFileRoute @@ -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 @@ -69,6 +78,7 @@ export interface FileRouteTypes { fullPaths: | '/' | '/detections' + | '/now' | '/species' | '/stats' | '/api/audio/$date/$speciesAndFile' @@ -76,6 +86,7 @@ export interface FileRouteTypes { to: | '/' | '/detections' + | '/now' | '/species' | '/stats' | '/api/audio/$date/$speciesAndFile' @@ -83,6 +94,7 @@ export interface FileRouteTypes { | '__root__' | '/' | '/detections' + | '/now' | '/species' | '/stats' | '/api/audio/$date/$speciesAndFile' @@ -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 @@ -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' @@ -139,6 +159,7 @@ declare module '@tanstack/react-router' { const rootRouteChildren: RootRouteChildren = { IndexRoute: IndexRoute, DetectionsRoute: DetectionsRoute, + NowRoute: NowRoute, SpeciesRoute: SpeciesRoute, StatsRoute: StatsRoute, ApiAudioDateSpeciesAndFileRoute: ApiAudioDateSpeciesAndFileRoute, diff --git a/web-ui/src/routes/now.tsx b/web-ui/src/routes/now.tsx new file mode 100644 index 00000000..839a5cb7 --- /dev/null +++ b/web-ui/src/routes/now.tsx @@ -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>(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 ( +
+
+
+

Now

+

+ What's singing at your station, as it happens. +

+
+
+
+
+ + {latest ? ( +
+
Latest detection
+
+

+ {latest.Com_Name} +

+ + {latest.Sci_Name} + +
+
+ {latest.Date} {latest.Time} + {latest.Confidence != null && + ` ยท ${Math.round(latest.Confidence * 100)}% confidence`} +
+
+ ) : ( +

+ No detections yet. Once BirdNET-Pi's analysis pipeline writes to + birds.db, they'll show up here within {POLL_INTERVAL_MS / 1000}{" "} + seconds. +

+ )} + + {rest.length > 0 && ( + <> +

+ Recent activity +

+
+
    + {rest.map((d) => { + const key = keyFor(d); + return ( +
  • +
    +
    {d.Com_Name}
    +
    + {d.Sci_Name} +
    +
    +
    +
    {d.Time}
    + {d.Confidence != null && ( +
    {Math.round(d.Confidence * 100)}%
    + )} +
    +
  • + ); + })} +
+
+ + )} +
+ ); +} diff --git a/web-ui/src/styles.css b/web-ui/src/styles.css index d6d532d5..e0306f38 100644 --- a/web-ui/src/styles.css +++ b/web-ui/src/styles.css @@ -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);