829f7a0
commit 829f7a0
11 files changed
+109 -7
Showing 11 changed files+109-7
web/src/components/charts.tsxweb/src/components/layout/AppShell.tsxweb/src/components/shared.tsxweb/src/hooks/useBundle.tsweb/src/lib/bundle.tsweb/src/lib/motherduckData.tsweb/src/pages/AnalyticsPage.tsxweb/src/pages/DocsPage.tsxweb/src/pages/ExplorerPage.tsxweb/src/pages/LandingPage.tsxweb/src/pages/SqlPage.tsx
▼web/src/components/charts.tsx
+15-0
| @@ -23,6 +23,17 @@ const PALETTE = ["#34d399", "#22d3ee", "#60a5fa", "#a78bfa", "#fbbf24", "#fb7185 | ||
23 | 23 | const AXIS = { stroke: "#52525b", fontSize: 10, tickLine: false, axisLine: false }; |
24 | 24 | const GRID = { stroke: "rgba(255,255,255,0.06)", strokeDasharray: "3 3" }; |
25 | 25 | |
26 | +function NoData({ height, label = "No data in this window" }: { height: number; label?: string }) { | |
27 | + return ( | |
28 | + <div | |
29 | + className="grid place-items-center rounded-md border border-dashed border-white/10 text-xs text-zinc-600" | |
30 | + style={{ height }} | |
31 | + > | |
32 | + {label} | |
33 | + </div> | |
34 | + ); | |
35 | +} | |
36 | + | |
26 | 37 | const TOOLTIP_STYLE = { |
27 | 38 | contentStyle: { |
28 | 39 | background: "rgba(9,11,18,0.95)", |
| @@ -48,6 +59,7 @@ export function BarSeries({ | ||
48 | 59 | height?: number; |
49 | 60 | color?: string; |
50 | 61 | }) { |
62 | + if (!data.length) return <NoData height={height} />; | |
51 | 63 | return ( |
52 | 64 | <ResponsiveContainer width="100%" height={height}> |
53 | 65 | <BarChart data={data} margin={{ top: 8, right: 8, bottom: 0, left: -18 }}> |
| @@ -80,6 +92,7 @@ export function LineSeries({ | ||
80 | 92 | color?: string; |
81 | 93 | area?: boolean; |
82 | 94 | }) { |
95 | + if (!data.length) return <NoData height={height} />; | |
83 | 96 | if (area) { |
84 | 97 | return ( |
85 | 98 | <ResponsiveContainer width="100%" height={height}> |
| @@ -123,6 +136,7 @@ export function DonutSeries({ | ||
123 | 136 | valueKey?: string; |
124 | 137 | height?: number; |
125 | 138 | }) { |
139 | + if (!data.length) return <NoData height={height} />; | |
126 | 140 | return ( |
127 | 141 | <ResponsiveContainer width="100%" height={height}> |
128 | 142 | <PieChart> |
| @@ -158,6 +172,7 @@ export function ScatterSeries({ | ||
158 | 172 | height?: number; |
159 | 173 | color?: string; |
160 | 174 | }) { |
175 | + if (!data.length) return <NoData height={height} />; | |
161 | 176 | return ( |
162 | 177 | <ResponsiveContainer width="100%" height={height}> |
163 | 178 | <ScatterChart margin={{ top: 8, right: 8, bottom: 0, left: -18 }}> |
164 | 179 | |
▼web/src/components/layout/AppShell.tsx
+1-1
| @@ -89,7 +89,7 @@ export function AppShell() { | ||
89 | 89 | <div className="flex items-center gap-3"> |
90 | 90 | <span className="hidden items-center gap-1.5 text-xs text-zinc-500 xl:flex"> |
91 | 91 | <GitBranch className="h-3.5 w-3.5" /> |
92 | - {manifest ? `${Object.values(manifest.counts).reduce((a, b) => a + b, 0).toLocaleString()} records` : "bundle"} | |
92 | + {manifest ? `${Object.values(manifest.counts).reduce((a, b) => a + b, 0).toLocaleString()} records` : "connecting…"} | |
93 | 93 | </span> |
94 | 94 | <Clock /> |
95 | 95 | <a |
96 | 96 | |
▼web/src/components/shared.tsx
+34-0
| @@ -1,4 +1,6 @@ | ||
1 | 1 | import type { HTMLAttributes, ReactNode } from "react"; |
2 | +import { DatabaseZap } from "lucide-react"; | |
3 | +import { relative } from "@/lib/format"; | |
2 | 4 | import { cn } from "@/lib/utils"; |
3 | 5 | |
4 | 6 | export function Panel({ |
| @@ -98,6 +100,38 @@ export function EmptyState({ message }: { message: string }) { | ||
98 | 100 | ); |
99 | 101 | } |
100 | 102 | |
103 | +/** One-line "data as of X" freshness indicator for batch sections. */ | |
104 | +export function FreshnessLine({ asOf, className }: { asOf: string | null; className?: string }) { | |
105 | + return ( | |
106 | + <span className={cn("mono text-[10px] text-zinc-500", className)}> | |
107 | + {asOf ? `Gold data as of ${relative(asOf)}` : "Gold data freshness unknown"} | |
108 | + </span> | |
109 | + ); | |
110 | +} | |
111 | + | |
112 | +/** Banner shown when flight history is empty: explains why, keeps live context. */ | |
113 | +export function BatchEmptyState({ compact = false }: { compact?: boolean }) { | |
114 | + return ( | |
115 | + <div | |
116 | + className={cn( | |
117 | + "flex items-start gap-3 rounded-lg border border-amber-500/20 bg-amber-500/[0.06]", | |
118 | + compact ? "p-3" : "p-4" | |
119 | + )} | |
120 | + > | |
121 | + <DatabaseZap className="mt-0.5 h-4 w-4 flex-none text-amber-400" /> | |
122 | + <div className="text-xs leading-relaxed"> | |
123 | + <p className="font-semibold text-amber-200">No flight history in the warehouse yet</p> | |
124 | + <p className="mt-0.5 text-zinc-400"> | |
125 | + Delay, punctuality and traffic charts need OpenSky flight movements. Add{" "} | |
126 | + <span className="mono text-zinc-300">OPENSKY_USERNAME</span> /{" "} | |
127 | + <span className="mono text-zinc-300">OPENSKY_PASSWORD</span> to the collector and the | |
128 | + next pipeline runs will backfill them. Live airspace below is unaffected. | |
129 | + </p> | |
130 | + </div> | |
131 | + </div> | |
132 | + ); | |
133 | +} | |
134 | + | |
101 | 135 | export function ProgressBar({ value, tone = "emerald" }: { value: number; tone?: string }) { |
102 | 136 | const width = Math.max(0, Math.min(100, value * 100)); |
103 | 137 | const colors: Record<string, string> = { |
104 | 138 | |
▼web/src/hooks/useBundle.ts
+6-0
| @@ -10,6 +10,7 @@ import { | ||
10 | 10 | type Story, |
11 | 11 | type BundleManifest, |
12 | 12 | } from "../lib/bundle"; |
13 | +import type { BatchFreshness } from "../lib/motherduckData"; | |
13 | 14 | |
14 | 15 | /** |
15 | 16 | * Load a dashboard dataset at runtime (warehouse API / collector), refreshing |
| @@ -74,6 +75,11 @@ export function useKpis() { | ||
74 | 75 | return useRuntimeData<Kpis>("kpis.json", 30_000); |
75 | 76 | } |
76 | 77 | |
78 | +/** Warehouse freshness + whether flight history exists (drives empty states). */ | |
79 | +export function useBatchStatus() { | |
80 | + return useRuntimeData<BatchFreshness>("freshness.json", 60_000); | |
81 | +} | |
82 | + | |
77 | 83 | /** Live aircraft positions (runtime bundle; the map uses the live snapshot). */ |
78 | 84 | export function usePositions() { |
79 | 85 | const { data, loading } = useRuntimeData<Position[]>("positions.json", 30_000); |
80 | 86 | |
▼web/src/lib/bundle.ts
+2-0
| @@ -303,6 +303,8 @@ export async function loadBundle<T>(file: string): Promise<T> { | ||
303 | 303 | return (await md.fetchOps()) as T; |
304 | 304 | case "manifest.json": |
305 | 305 | return (await md.fetchManifest()) as T; |
306 | + case "freshness.json": | |
307 | + return (await md.fetchFreshness()) as T; | |
306 | 308 | case "positions.json": |
307 | 309 | return (await md.fetchLivePositions()) as T; |
308 | 310 | case "metars.json": |
309 | 311 | |
▼web/src/lib/motherduckData.ts
+26-0
| @@ -258,6 +258,32 @@ export async function fetchOps(): Promise<Ops> { | ||
258 | 258 | return { stream_health, pipeline, quality }; |
259 | 259 | } |
260 | 260 | |
261 | +export interface BatchFreshness { | |
262 | + /** Newest batch record across the warehouse, ISO string or null when empty. */ | |
263 | + asOf: string | null; | |
264 | + totalFlights: number; | |
265 | + hasFlights: boolean; | |
266 | +} | |
267 | + | |
268 | +/** When the Gold layer was last refreshed + whether flight history exists. */ | |
269 | +export async function fetchFreshness(): Promise<BatchFreshness> { | |
270 | + const [asOfFlights, asOfPositions, asOfWeather, totalFlights] = await Promise.all([ | |
271 | + scalar<string | null>("SELECT MAX(collected_at) FROM main.fact_flights", null), | |
272 | + scalar<string | null>("SELECT MAX(collected_at) FROM main.fact_positions", null), | |
273 | + scalar<string | null>("SELECT MAX(collected_at) FROM main.weather", null), | |
274 | + scalar<number>("SELECT COUNT(*) FROM main.fact_flights", 0), | |
275 | + ]); | |
276 | + const candidates = [asOfFlights, asOfPositions, asOfWeather].filter( | |
277 | + (v): v is string => typeof v === "string" && v.length > 0 | |
278 | + ); | |
279 | + candidates.sort(); | |
280 | + return { | |
281 | + asOf: candidates.length ? candidates[candidates.length - 1] : null, | |
282 | + totalFlights, | |
283 | + hasFlights: totalFlights > 0, | |
284 | + }; | |
285 | +} | |
286 | + | |
261 | 287 | export async function fetchManifest(): Promise<BundleManifest> { |
262 | 288 | const [airportCount, flightCount, positionCount] = await Promise.all([ |
263 | 289 | scalar<number>("SELECT COUNT(*) FROM main.dim_airport", 0).catch(() => 0), |
264 | 290 | |
▼web/src/pages/AnalyticsPage.tsx
+15-2
| @@ -12,10 +12,10 @@ import { | ||
12 | 12 | TrendingUp, |
13 | 13 | Wind, |
14 | 14 | } from "lucide-react"; |
15 | -import { Panel, SectionHeader, StatCard, Spinner, ErrorState } from "@/components/shared"; | |
15 | +import { Panel, SectionHeader, StatCard, Spinner, ErrorState, FreshnessLine, BatchEmptyState } from "@/components/shared"; | |
16 | 16 | import { BarSeries, DonutSeries, LineSeries, ScatterSeries } from "@/components/charts"; |
17 | 17 | import { NetworkMap } from "@/components/NetworkMap"; |
18 | -import { useAirports, useAnalytics, useKpis } from "@/hooks/useBundle"; | |
18 | +import { useAirports, useAnalytics, useBatchStatus, useKpis } from "@/hooks/useBundle"; | |
19 | 19 | import { useFleet } from "@/hooks/useFleet"; |
20 | 20 | import { useLiveWeather } from "@/hooks/useLiveWeather"; |
21 | 21 | import { airlineFromCallsign } from "@/lib/airlines"; |
| @@ -35,8 +35,11 @@ export function AnalyticsPage() { | ||
35 | 35 | const { data: analytics, loading, error } = useAnalytics(); |
36 | 36 | const { data: airports } = useAirports(); |
37 | 37 | const { data: kpis } = useKpis(); |
38 | + const { data: batch } = useBatchStatus(); | |
38 | 39 | const fleet = useFleet(); |
39 | 40 | const liveWeather = useLiveWeather(); |
41 | + const hasFlights = batch?.hasFlights ?? (kpis?.total_flights ?? 0) > 0; | |
42 | + const batchAsOf = batch?.asOf ?? null; | |
40 | 43 | |
41 | 44 | const routes = useMemo( |
42 | 45 | () => |
| @@ -199,8 +202,11 @@ export function AnalyticsPage() { | ||
199 | 202 | icon={<BarChart3 className="h-4 w-4" />} |
200 | 203 | title="Business Analytics" |
201 | 204 | subtitle="Gold-layer marts: traffic, punctuality, weather impact, network and emissions" |
205 | + right={<FreshnessLine asOf={batchAsOf} />} | |
202 | 206 | /> |
203 | 207 | |
208 | + {!hasFlights && !loading && <BatchEmptyState />} | |
209 | + | |
204 | 210 | <div className="grid grid-cols-2 gap-3 md:grid-cols-3 xl:grid-cols-6"> |
205 | 211 | <StatCard label="Flights observed" value={nf(kpis?.total_flights ?? 0)} icon={<Plane className="h-3.5 w-3.5" />} /> |
206 | 212 | <StatCard |
| @@ -299,6 +305,13 @@ export function AnalyticsPage() { | ||
299 | 305 | <td className="py-1.5 text-right text-emerald-400">{pct(r.on_time_rate)}</td> |
300 | 306 | </tr> |
301 | 307 | ))} |
308 | + {!analytics.gold_airport_metrics.length && ( | |
309 | + <tr> | |
310 | + <td colSpan={5} className="py-6 text-center font-sans text-xs text-zinc-600"> | |
311 | + No airport metrics yet — flight history will populate this table. | |
312 | + </td> | |
313 | + </tr> | |
314 | + )} | |
302 | 315 | </tbody> |
303 | 316 | </table> |
304 | 317 | </div> |
305 | 318 | |
▼web/src/pages/DocsPage.tsx
+1-1
| @@ -16,7 +16,7 @@ const LAYERS = [ | ||
16 | 16 | { |
17 | 17 | name: "Gold", |
18 | 18 | tone: "text-emerald-300 border-emerald-500/20 bg-emerald-500/5", |
19 | - body: "Business-ready marts modelled in dbt (staging → intermediate → marts), tested and documented. Registered as gold_* views in DuckDB and bundled for the dashboard.", | |
19 | + body: "Business-ready marts modelled in dbt (staging → intermediate → marts), tested and documented. Registered as gold_* views in MotherDuck and read live by the dashboard — no static export.", | |
20 | 20 | }, |
21 | 21 | ]; |
22 | 22 | |
23 | 23 | |
▼web/src/pages/ExplorerPage.tsx
+7-1
| @@ -241,7 +241,13 @@ export function ExplorerPage() { | ||
241 | 241 | </table> |
242 | 242 | </Panel> |
243 | 243 | ) : ( |
244 | - <EmptyState message={`No preview available for ${selected} in the static bundle.`} /> | |
244 | + <EmptyState | |
245 | + message={ | |
246 | + selected.startsWith("gold_") || selected.startsWith("fact_") || selected === "status_mix" | |
247 | + ? `No rows in ${selected} yet — this mart fills once flight history flows (OpenSky credentials on the collector).` | |
248 | + : `No preview available for ${selected} right now.` | |
249 | + } | |
250 | + /> | |
245 | 251 | )} |
246 | 252 | |
247 | 253 | {tableMeta && ( |
248 | 254 | |
▼web/src/pages/LandingPage.tsx
+1-1
| @@ -520,7 +520,7 @@ export function LandingPage() { | ||
520 | 520 | <HudStat label="Weather stns" value={nf(weatherState.stations.length)} /> |
521 | 521 | <HudStat |
522 | 522 | label="Avg delay" |
523 | - value={`${nf(kpis?.avg_delay_minutes ?? 0, 0)} min`} | |
523 | + value={(kpis?.total_flights ?? 0) > 0 ? `${nf(kpis?.avg_delay_minutes ?? 0, 0)} min` : "—"} | |
524 | 524 | tone={delayTone(kpis?.avg_delay_minutes)} |
525 | 525 | /> |
526 | 526 | <HudStat label="On-time" value={onTime !== null ? pct(onTime) : "—"} /> |
527 | 527 | |
▼web/src/pages/SqlPage.tsx
+1-1
| @@ -32,7 +32,7 @@ const EXAMPLES: { label: string; sql: string }[] = [ | ||
32 | 32 | }, |
33 | 33 | { |
34 | 34 | label: "Live altitude bands", |
35 | - sql: "SELECT\n CASE\n WHEN altitude < 10000 THEN '1 <10k ft'\n WHEN altitude < 25000 THEN '2 10-25k ft'\n WHEN altitude < 38000 THEN '3 25-38k ft'\n ELSE '4 >38k ft'\n END AS band,\n COUNT(*) AS aircraft,\n ROUND(AVG(velocity), 1) AS avg_speed\nFROM live_positions\nGROUP BY band\nORDER BY band;", | |
35 | + sql: "SELECT\n CASE\n WHEN altitude < 10000 THEN '1 <10k ft'\n WHEN altitude < 25000 THEN '2 10-25k ft'\n WHEN altitude < 38000 THEN '3 25-38k ft'\n ELSE '4 >38k ft'\n END AS band,\n COUNT(*) AS aircraft,\n ROUND(AVG(velocity), 1) AS avg_speed\nFROM fact_positions\nGROUP BY band\nORDER BY band;", | |
36 | 36 | }, |
37 | 37 | { |
38 | 38 | label: "Airline punctuality", |
39 | 39 | |