diff --git a/index.html b/index.html index aa273e6..0834bf5 100644 --- a/index.html +++ b/index.html @@ -2,9 +2,9 @@ - + - train-detection-app + Midnight Train
diff --git a/public/favicon.svg b/public/favicon.svg deleted file mode 100644 index 6893eb1..0000000 --- a/public/favicon.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/public/icons.svg b/public/icons.svg deleted file mode 100644 index e952219..0000000 --- a/public/icons.svg +++ /dev/null @@ -1,24 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/public/train-icon.png b/public/train-icon.png new file mode 100644 index 0000000..b57ed8f Binary files /dev/null and b/public/train-icon.png differ diff --git a/src/App.css b/src/App.css index 2208a80..2101e39 100644 --- a/src/App.css +++ b/src/App.css @@ -34,7 +34,7 @@ max-width: 1400px; margin: 0 auto; display: flex; - align-items: flex-start; + align-items: center; gap: 12px; } @@ -45,6 +45,15 @@ gap: 16px; } +.header-tagline { + font-size: 0.7rem; + font-weight: 600; + letter-spacing: 0.08em; + text-transform: uppercase; + color: var(--accent); + margin: 2px 0 6px; +} + .header-subtitles { display: flex; flex-direction: column; @@ -72,9 +81,9 @@ } .header-icon { - font-size: 2rem; - line-height: 1; - margin-top: 2px; + width: 48px; + height: 48px; + object-fit: contain; } .app-header h1 { @@ -94,6 +103,48 @@ gap: 24px; } +.app-footer { + margin-top: 16px; + padding: 24px; + text-align: center; + display: flex; + flex-direction: column; + align-items: center; + gap: 6px; + border-top: 1px solid var(--border); +} + +.footer-credit { + font-size: 0.85rem; + color: var(--text-muted); +} + +.footer-links { + display: flex; + align-items: center; + gap: 8px; + font-size: 0.85rem; +} + +.footer-links a { + color: var(--accent); + text-decoration: none; +} + +.footer-links a:hover { + text-decoration: underline; +} + +.footer-sep { + color: var(--text-muted); +} + +.footer-copy { + font-size: 0.75rem; + color: var(--text-muted); + opacity: 0.6; +} + /* ── Shared card ── */ .card { background: var(--surface); @@ -102,6 +153,35 @@ padding: 20px; } +/* ── Global time range selector ── */ +.global-time-range { + background: var(--surface); + border: 1px solid var(--border); + border-radius: var(--radius); + padding: 14px 20px; +} + +.global-time-range__presets { + display: flex; + gap: 8px; + flex-wrap: wrap; +} + +.global-time-range__custom { + display: flex; + flex-wrap: wrap; + gap: 12px; + align-items: flex-end; + margin-top: 12px; +} + +.custom-time-error { + width: 100%; + margin: 4px 0 0; + font-size: 0.8rem; + color: var(--red); +} + /* ── Section title ── */ .section-title { font-size: 0.95rem; @@ -313,22 +393,6 @@ margin-bottom: 16px; } -.preset-btn { - background: var(--surface-2); - border: 1px solid var(--border); - color: var(--text-muted); - border-radius: 6px; - padding: 5px 14px; - font-size: 0.8rem; - cursor: pointer; - transition: all 0.15s; -} - -.preset-btn:hover { - border-color: var(--accent); - color: var(--accent); -} - .query-inputs { display: flex; flex-wrap: wrap; @@ -386,7 +450,7 @@ display: flex; flex-direction: column; gap: 8px; - max-height: 420px; + max-height: 800px; overflow-y: auto; padding-right: 4px; } diff --git a/src/App.tsx b/src/App.tsx index 70e470c..2caf02f 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,37 +1,51 @@ +import { useState } from 'react'; import StatsPanel from './components/StatsPanel'; import LatestTrain from './components/LatestTrain'; import DetectionChart from './components/DetectionChart'; import TimeRangeQuery from './components/TimeRangeQuery'; +import GlobalTimeRange, { makePresetRange } from './components/GlobalTimeRange'; +import type { TimeRange } from './components/GlobalTimeRange'; import './App.css'; function App() { + const [timeRange, setTimeRange] = useState(() => makePresetRange('7d')); return (
- 🚂 ✨ +
-

Overnight Train Dashboard

+

Midnight Train

+

Train Detection Dashboard

Overnight recordings (11PM - 7AM) · Old Town, Tacoma - ·

- 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.

- + +
- +
- +
+
+

Created by Camille Ibsen

+
+ GitHub + · + LinkedIn +
+

© 2026 Midnight Train

+
); } 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 (
-

Detection History

-
- {DAYS_OPTIONS.map(d => ( - - ))} -
+

Event History

{loading &&
Loading chart…
} @@ -172,15 +208,15 @@ export default function DetectionChart() { String(n).padStart(2, '0'); + return ( + `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}` + + `T${pad(date.getHours())}:${pad(date.getMinutes())}` + ); +} + +export function makePresetRange(preset: Exclude): TimeRange { + const now = new Date(); + if (preset === 'all') return { preset, start: undefined, end: undefined }; + const hoursMap: Record, number> = { + '24h': 24, '3d': 72, '7d': 168, '14d': 336, '30d': 720, + }; + return { + preset, + start: new Date(now.getTime() - hoursMap[preset] * 60 * 60 * 1000).toISOString(), + end: now.toISOString(), + }; +} + +const PRESETS: { label: string; preset: Preset }[] = [ + { label: '24h', preset: '24h' }, + { label: '3 Days', preset: '3d' }, + { label: '7 Days', preset: '7d' }, + { label: '14 Days', preset: '14d' }, + { label: '30 Days', preset: '30d' }, + { label: 'All Time', preset: 'all' }, + { label: 'Custom', preset: 'custom' }, +]; + +interface Props { + value: TimeRange; + onChange: (range: TimeRange) => void; +} + +export default function GlobalTimeRange({ value, onChange }: Props) { + const now = new Date(); + const [customStart, setCustomStart] = useState( + toLocalInput(new Date(now.getTime() - 7 * 24 * 60 * 60 * 1000)) + ); + const [customEnd, setCustomEnd] = useState(toLocalInput(now)); + const [customError, setCustomError] = useState(null); + const [pendingCustom, setPendingCustom] = useState(value.preset === 'custom'); + + function handlePreset(preset: Preset) { + setCustomError(null); + if (preset === 'custom') { + setPendingCustom(true); + // Don't call onChange yet — wait for Apply + } else { + setPendingCustom(false); + onChange(makePresetRange(preset)); + } + } + + function handleApply() { + if (!customStart || !customEnd) { + setCustomError('Both start and end times are required.'); + return; + } + const startMs = new Date(customStart).getTime(); + const endMs = new Date(customEnd).getTime(); + if (isNaN(startMs) || isNaN(endMs)) { + setCustomError('Invalid date value.'); + return; + } + if (startMs >= endMs) { + setCustomError('Start time must be earlier than end time.'); + return; + } + setCustomError(null); + setPendingCustom(false); + onChange({ + preset: 'custom', + start: new Date(customStart).toISOString(), + end: new Date(customEnd).toISOString(), + }); + } + + const showCustom = pendingCustom || value.preset === 'custom'; + const activePreset = pendingCustom ? 'custom' : value.preset; + + return ( +
+
+ {PRESETS.map(p => ( + + ))} +
+ {showCustom && ( +
+ + + + {customError &&

{customError}

} +
+ )} +
+ ); +} diff --git a/src/components/LatestTrain.tsx b/src/components/LatestTrain.tsx index c3255c3..c6c650c 100644 --- a/src/components/LatestTrain.tsx +++ b/src/components/LatestTrain.tsx @@ -1,5 +1,5 @@ import { useEffect, useState } from 'react'; -import { fetchLatest } from '../api'; +import { fetchLatestConfirmed } from '../api'; import AudioButton from './AudioButton'; import type { Detection } from '../types'; @@ -42,7 +42,7 @@ export default function LatestTrain() { useEffect(() => { let cancelled = false; - fetchLatest(false) + fetchLatestConfirmed() .then(data => { if (!cancelled) setDetection(data); }) .catch((e: Error) => { if (!cancelled) setError(e.message); }) .finally(() => { if (!cancelled) setLoading(false); }); @@ -51,7 +51,7 @@ export default function LatestTrain() { return (
-

Last Train Detected

+

Last Confirmed Train

{loading &&

Loading…

} {(error || (!loading && !detection)) && (

No detections found

diff --git a/src/components/StatsPanel.tsx b/src/components/StatsPanel.tsx index a919be6..4b0055f 100644 --- a/src/components/StatsPanel.tsx +++ b/src/components/StatsPanel.tsx @@ -17,19 +17,26 @@ function StatCard({ label, value, highlight }: StatCardProps) { ); } -export default function StatsPanel() { +interface StatsPanelProps { + start: string | undefined; + end: string | undefined; +} + +export default function StatsPanel({ start, end }: StatsPanelProps) { const [stats, setStats] = useState(null); const [error, setError] = useState(null); const [loading, setLoading] = useState(true); useEffect(() => { let cancelled = false; - fetchStats() + setLoading(true); + setError(null); + fetchStats(undefined, start, end) .then(data => { if (!cancelled) setStats(data); }) .catch((e: Error) => { if (!cancelled) setError(e.message); }) .finally(() => { if (!cancelled) setLoading(false); }); return () => { cancelled = true; }; - }, []); + }, [start, end]); if (loading) return
Loading stats…
; if (error) return
Could not load stats: {error}
; @@ -41,10 +48,8 @@ export default function StatsPanel() { - - - - + + ); } diff --git a/src/components/TimeRangeQuery.tsx b/src/components/TimeRangeQuery.tsx index cf0046b..f0fa09c 100644 --- a/src/components/TimeRangeQuery.tsx +++ b/src/components/TimeRangeQuery.tsx @@ -1,17 +1,9 @@ -import { useState } from 'react'; +import { useEffect, useState } from 'react'; import { fetchDetections } from '../api'; import AudioButton from './AudioButton'; import ReviewButtons from './ReviewButtons'; import type { Detection, DetectionsResponse } from '../types'; -function toLocalInput(date: Date): string { - const pad = (n: number) => String(n).padStart(2, '0'); - return ( - `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}` + - `T${pad(date.getHours())}:${pad(date.getMinutes())}` - ); -} - function formatDateTime(iso: string): string { return new Intl.DateTimeFormat(undefined, { dateStyle: 'short', @@ -31,47 +23,26 @@ function statusClass(d: Detection): string { return 'status-suspected'; } -const PRESETS = [ - { label: 'Last Hour', hours: 1 }, - { label: 'Last 24h', hours: 24 }, - { label: 'Last 7 Days', hours: 168 }, - { label: 'Last 30 Days', hours: 720 }, -]; - -export default function TimeRangeQuery() { - const now = new Date(); - const yesterday = new Date(now.getTime() - 24 * 60 * 60 * 1000); +interface Props { + start: string | undefined; + end: string | undefined; +} - const [start, setStart] = useState(toLocalInput(yesterday)); - const [end, setEnd] = useState(toLocalInput(now)); +export default function TimeRangeQuery({ start, end }: Props) { const [results, setResults] = useState(null); - const [loading, setLoading] = useState(false); + const [loading, setLoading] = useState(true); const [error, setError] = useState(null); - function applyPreset(hours: number) { - const endDate = new Date(); - const startDate = new Date(endDate.getTime() - hours * 60 * 60 * 1000); - setStart(toLocalInput(startDate)); - setEnd(toLocalInput(endDate)); - setResults(null); - } - - async function handleQuery() { + useEffect(() => { + let cancelled = false; setLoading(true); setError(null); - try { - const data = await fetchDetections({ - start: new Date(start).toISOString(), - end: new Date(end).toISOString(), - limit: 200, - }); - setResults(data); - } catch (e) { - setError((e as Error).message); - } finally { - setLoading(false); - } - } + fetchDetections({ start, end, limit: 200 }) + .then(data => { if (!cancelled) setResults(data); }) + .catch((e: Error) => { if (!cancelled) setError(e.message); }) + .finally(() => { if (!cancelled) setLoading(false); }); + return () => { cancelled = true; }; + }, [start, end]); const trains = results?.data.filter(d => d.is_suspected_train) ?? []; const confirmed = trains.filter(d => d.is_confirmed_train === true); @@ -84,41 +55,12 @@ export default function TimeRangeQuery() { return (
-

Query Time Range

- -
- {PRESETS.map(p => ( - - ))} -
- -
- - - -
+

Events

+ {loading &&
Loading…
} {error &&
{error}
} - {results && ( + {!loading && !error && results && (
{trains.length === 0 ? ( diff --git a/src/types.ts b/src/types.ts index f8bf21b..31dd07f 100644 --- a/src/types.ts +++ b/src/types.ts @@ -23,8 +23,6 @@ export interface Stats { confirmed_trains: number; confirmed_false_positives: number; unreviewed_suspected: number; - suspected_last_24h: number; - suspected_last_7d: number; last_suspected_at: string | null; last_confirmed_at: string | null; avg_decibels: string;