- Recording device positioned indoors on 30th Street, 2 blocks down from McCarver Street railroad crossing.
+ Recorded indoors on 30th St, 2 blocks from McCarver Street crossing.
-
+
+
-
+
-
+
+
);
}
diff --git a/src/api.ts b/src/api.ts
index 6f6ac34..2933a98 100644
--- a/src/api.ts
+++ b/src/api.ts
@@ -2,17 +2,19 @@ import type { Detection, DetectionsResponse, Stats } from './types';
const BASE_URL = (import.meta.env.VITE_API_BASE_URL as string | undefined) ?? 'http://localhost:3000';
-export async function fetchStats(source?: string): Promise {
+export async function fetchStats(source?: string, start?: string, end?: string): Promise {
const params = new URLSearchParams();
if (source) params.set('source', source);
+ if (start) params.set('start', start);
+ if (end) params.set('end', end);
const qs = params.toString();
const res = await fetch(`${BASE_URL}/api/detections/stats${qs ? `?${qs}` : ''}`);
if (!res.ok) throw new Error('Failed to fetch stats');
return res.json() as Promise;
}
-export async function fetchLatest(confirmedOnly = false): Promise {
- const res = await fetch(`${BASE_URL}/api/detections/latest?confirmed_only=${confirmedOnly}`);
+export async function fetchLatestConfirmed(): Promise {
+ const res = await fetch(`${BASE_URL}/api/detections/latest?confirmed_only=true`);
if (res.status === 404) throw new Error('No detections found');
if (!res.ok) throw new Error('Failed to fetch latest detection');
return res.json() as Promise;
diff --git a/src/components/DetectionChart.tsx b/src/components/DetectionChart.tsx
index bd01e2f..6d6b2fc 100644
--- a/src/components/DetectionChart.tsx
+++ b/src/components/DetectionChart.tsx
@@ -23,18 +23,52 @@ interface ChartPoint {
source: string;
}
-const DAYS_OPTIONS = [1, 7, 30] as const;
-
function formatXTick(ms: number, days: number): string {
if (days <= 1) {
- return new Intl.DateTimeFormat(undefined, { hour: '2-digit', minute: '2-digit' }).format(new Date(ms));
+ return new Intl.DateTimeFormat(undefined, { hour: 'numeric' }).format(new Date(ms));
}
if (days <= 7) {
- return new Intl.DateTimeFormat(undefined, { month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit' }).format(new Date(ms));
+ return new Intl.DateTimeFormat(undefined, { month: 'short', day: 'numeric', hour: 'numeric' }).format(new Date(ms));
}
return new Intl.DateTimeFormat(undefined, { month: 'short', day: 'numeric' }).format(new Date(ms));
}
+function generateTicks(startMs: number, endMs: number, days: number): number[] {
+ const ticks: number[] = [];
+
+ if (days <= 1) {
+ // Every hour, anchored to local hour boundaries
+ const d = new Date(startMs);
+ d.setMinutes(0, 0, 0);
+ let t = d.getTime();
+ if (t < startMs) t += 60 * 60 * 1000;
+ while (t <= endMs) { ticks.push(t); t += 60 * 60 * 1000; }
+ } else if (days <= 7) {
+ // Every 12 hours, anchored to local midnight → gives 12AM and 12PM ticks
+ const d = new Date(startMs);
+ d.setHours(0, 0, 0, 0);
+ let t = d.getTime();
+ const interval = 12 * 60 * 60 * 1000;
+ while (t <= endMs) { if (t >= startMs) ticks.push(t); t += interval; }
+ } else if (days <= 14) {
+ // Every 24 hours, anchored to local midnight → one tick per day
+ const d = new Date(startMs);
+ d.setHours(0, 0, 0, 0);
+ let t = d.getTime();
+ const interval = 24 * 60 * 60 * 1000;
+ while (t <= endMs) { if (t >= startMs) ticks.push(t); t += interval; }
+ } else {
+ // Every 2 days, anchored to local midnight
+ const d = new Date(startMs);
+ d.setHours(0, 0, 0, 0);
+ let t = d.getTime();
+ const interval = 48 * 60 * 60 * 1000;
+ while (t <= endMs) { if (t >= startMs) ticks.push(t); t += interval; }
+ }
+
+ return ticks;
+}
+
function timeAgo(iso: string): string {
const diff = Date.now() - new Date(iso).getTime();
const minutes = Math.floor(diff / 60_000);
@@ -86,11 +120,15 @@ function CustomTooltip({ active, payload }: CustomTooltipProps) {
);
}
-export default function DetectionChart() {
+interface DetectionChartProps {
+ start: string | undefined;
+ end: string | undefined;
+}
+
+export default function DetectionChart({ start, end }: DetectionChartProps) {
const [detections, setDetections] = useState([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
- const [days, setDays] = useState<(typeof DAYS_OPTIONS)[number]>(7);
const [selected, setSelected] = useState(null);
useEffect(() => {
@@ -98,14 +136,12 @@ export default function DetectionChart() {
setLoading(true);
setError(null);
setSelected(null);
- const end = new Date();
- const start = new Date(end.getTime() - days * 24 * 60 * 60 * 1000);
- fetchDetections({ start: start.toISOString(), end: end.toISOString(), limit: 500 })
+ fetchDetections({ start, end, limit: 500 })
.then(r => { if (!cancelled) setDetections(r.data); })
.catch((e: Error) => { if (!cancelled) setError(e.message); })
.finally(() => { if (!cancelled) setLoading(false); });
return () => { cancelled = true; };
- }, [days]);
+ }, [start, end]);
function handleDotClick(data: unknown) {
const point = data as ChartPoint;
@@ -139,23 +175,23 @@ export default function DetectionChart() {
else normal.push(point);
}
- const tickFormatter = (ms: number) => formatXTick(ms, days);
+ const dataMaxMs = detections.length > 0
+ ? detections.reduce((max, d) => Math.max(max, new Date(d.timestamp).getTime()), -Infinity)
+ : undefined;
+ const endMs = end ? new Date(end).getTime() : (dataMaxMs ?? Date.now());
+ const startMs = start ? new Date(start).getTime() : undefined;
+ const dataMinMs = detections.length > 0
+ ? detections.reduce((min, d) => Math.min(min, new Date(d.timestamp).getTime()), Infinity)
+ : undefined;
+ const effectiveStartMs = startMs ?? dataMinMs;
+ const rangeDays = effectiveStartMs !== undefined ? (endMs - effectiveStartMs) / (24 * 60 * 60 * 1000) : 365;
+ const ticks = effectiveStartMs !== undefined ? generateTicks(effectiveStartMs, endMs, rangeDays) : undefined;
+ const tickFormatter = (ms: number) => formatXTick(ms, rangeDays);
return (