swadhinbiswas

[email protected]
2026-09-12 03:14:15 +0600 committed NaN years ago
829f7a0
commit 829f7a0
11 files changed
+109 -7
▼web/src/components/charts.tsx
+15-0
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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