Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
62 changes: 49 additions & 13 deletions software/tracksight/frontend/app/Navbar.tsx
Original file line number Diff line number Diff line change
@@ -1,20 +1,56 @@
"use client";

import { ChevronDown, Loader2 } from "lucide-react";
import Link from "next/link";
import { usePathname } from "next/navigation";

import { ErrorRateIndicator } from "@/components/ErrorRateIndicator";
import { TimezoneSelector } from "@/components/common/TimezoneSelector";
import { useHistoricalSelection } from "@/lib/contexts/HistoricalSelectionContext";

function Navbar() {
return (
<nav className="fixed top-0 left-0 z-50 h-min w-screen bg-white border-b border-b-gray-200">
<div className="flex flex-row items-center justify-between px-8 py-4 select-none">
<div className="flex flex-row items-center gap-6">
<Link href="/">Home</Link>
<Link href="/live">Live Data</Link>
<Link href="/historical">Historical Data</Link>
<Link href="/sd/dump">SD Card Dump</Link>
function HistoricalNavButton({ label, value, onClick }: { label: string; value: string; onClick: () => void }) {
return (
<button type="button" onClick={onClick} className="flex items-center gap-2 rounded border border-black bg-white py-1.5 pl-3 pr-2 text-left transition-colors hover:cursor-pointer hover:border-black/75">
<span className="text-[0.6rem] font-semibold uppercase tracking-wide text-gray-500">{label}</span>
<span className="max-w-44 truncate text-sm font-semibold text-gray-900">{value}</span>
<ChevronDown className="size-4 shrink-0 text-gray-500" strokeWidth={2.25} />
</button>
);
}

function HistoricalNavControls() {
const { sourceLabel, selectedSession, openModal, isSyncing } = useHistoricalSelection();

return (
<div className="ml-auto flex items-center gap-2">
{isSyncing ? <Loader2 className="size-4 animate-spin text-blue-500" strokeWidth={2.4} /> : null}
<HistoricalNavButton label="Source" value={sourceLabel} onClick={() => openModal(1)} />
<HistoricalNavButton label="Session" value={selectedSession?.label ?? "Select"} onClick={() => openModal(3)} />
</div>
<ErrorRateIndicator />
</div>
</nav>
);
);
}

function Navbar() {
const pathname = usePathname();
const isHistorical = pathname === "/historical";

return (
<nav className="fixed top-0 left-0 z-50 h-min w-screen bg-white border-b border-b-gray-200">
<div className="flex flex-row items-center justify-between px-8 py-4 select-none">
<div className="flex flex-row items-center gap-6">
<Link href="/">Home</Link>
<Link href="/live">Live Data</Link>
<Link href="/historical">Historical Data</Link>
<Link href="/sd/dump">SD Card Dump</Link>
{isHistorical ? <HistoricalNavControls /> : null}
</div>
<div className="flex flex-row items-center gap-4">
<TimezoneSelector />
<ErrorRateIndicator />
</div>
</div>
</nav>
);
}

export default Navbar;
161 changes: 23 additions & 138 deletions software/tracksight/frontend/app/historical/page.tsx
Original file line number Diff line number Diff line change
@@ -1,49 +1,21 @@
"use client";

import { CalendarClock, Check, ChevronDown, HardDrive, RadioIcon } from "lucide-react";
import { useCallback, useEffect, useMemo, useState } from "react";
import { useCallback, useEffect, useRef, useState } from "react";

import { WidgetAdder } from "@/app/live/WidgetAdder";
import DataDashboard from "@/components/DataDashboard";
import CalendarDropdown from "@/components/icons/CalendarDropdown";
import DropdownTrigger from "@/components/icons/DropdownTrigger";
import SessionDropdown from "@/components/icons/SessionDropdown";
import HistoricalSelectionModal from "@/components/historical/HistoricalSelectionModal";
import { DisplayControlProvider } from "@/components/PausePlayControl";
import SyncedGraphContainer, { TimeRange } from "@/components/SyncedGraphContainer";
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog";
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
import { WidgetManager, useWidgetManager } from "@/components/widgets/WidgetManagerContext";
import AlertTimeline from "@/components/widgets/AlertTimeline";
import { HistoricalSignalSource } from "@/lib/api/historicalSignals";
import { useHistoricalSelection } from "@/lib/contexts/HistoricalSelectionContext";
import { HistoricalSignalStoreProvider } from "@/lib/contexts/signalStores/HistoricalSignalStoreContext";
import { useHistoricalSessionSelection } from "@/lib/hooks/useHistoricalSessionSelection";
import { cn } from "@/lib/utils";

const HISTORIC_WIDGET_STORAGE_KEY = "tracksight_historic_widgets_config_v1";
const HISTORIC_VIEWPORT_LOCK_STORAGE_KEY = "tracksight_historic_viewport_lock_state_v1";

const SOURCE_OPTIONS: { value: HistoricalSignalSource; label: string; description: string; Icon: typeof RadioIcon }[] = [
{
value: "Radio",
label: "Radio",
description: "Radio data",
Icon: RadioIcon,
},
{
value: "SdCard",
label: "SD Card",
description: "Dumped logger data",
Icon: HardDrive,
},
];

const toDateKey = (date: Date) => {
const year = date.getUTCFullYear();
const month = String(date.getUTCMonth() + 1).padStart(2, "0");
const day = String(date.getUTCDate()).padStart(2, "0");

return `${year}-${month}-${day}`;
};

const expandViewportFetchRange = (range: TimeRange, bounds: TimeRange): TimeRange => {
const width = Math.max(range.max - range.min, 1);
const padding = width * 0.5; // + 0.5x viewport range padding on both sides
Expand All @@ -54,48 +26,6 @@ const expandViewportFetchRange = (range: TimeRange, bounds: TimeRange): TimeRang
};
};

function SourceDropdown(props: { selectedSource: HistoricalSignalSource; onSourceSelect: (source: HistoricalSignalSource) => void }) {
const { selectedSource, onSourceSelect } = props;
const [isOpen, setIsOpen] = useState(false);
const activeSource = SOURCE_OPTIONS.find((source) => source.value === selectedSource) ?? SOURCE_OPTIONS[0];
const ActiveIcon = activeSource.Icon;

const handleSourceSelect = (source: HistoricalSignalSource) => {
onSourceSelect(source);
setIsOpen(false);
};

return (
<Popover open={isOpen} onOpenChange={setIsOpen}>
<PopoverTrigger asChild>
<DropdownTrigger className="min-w-48" open={isOpen} label="Source" value={activeSource.label} icon={<ActiveIcon className="size-5" strokeWidth={2.2} />} />
</PopoverTrigger>

<PopoverContent align="start" sideOffset={14} className="w-80 rounded-[1.5rem] border border-gray-200 bg-white p-3 shadow-[0_28px_70px_rgba(15,23,42,0.16)]">
<div className="grid gap-2">
{SOURCE_OPTIONS.map((source) => {
const isSelected = source.value === selectedSource;
const Icon = source.Icon;

return (
<button key={source.value} type="button" className={cn("flex items-center justify-between rounded-xl px-4 py-3 text-left transition-colors", isSelected && "bg-blue-600 text-white shadow-[0_8px_18px_rgba(37,99,235,0.28)]", !isSelected && "text-gray-800 hover:bg-gray-100")} onClick={() => handleSourceSelect(source.value)}>
<span className="flex items-center gap-3">
<Icon className="size-5" />
<span>
<span className="block text-base font-semibold">{source.label}</span>
<span className={cn("block text-xs font-semibold uppercase tracking-[0.16em]", isSelected ? "text-blue-100" : "text-gray-400")}>{source.description}</span>
</span>
</span>
{isSelected ? <Check className="size-5" /> : null}
</button>
);
})}
</div>
</PopoverContent>
</Popover>
);
}

function HistoricContent(props: { selectedRange: { min: number; max: number }; selectedSource: HistoricalSignalSource }) {
const { selectedRange, selectedSource } = props;
const { widgets } = useWidgetManager();
Expand All @@ -117,6 +47,7 @@ function HistoricContent(props: { selectedRange: { min: number; max: number }; s
return (
<SyncedGraphContainer initialTimeRange={selectedRange} onViewportSettled={handleViewportSettled}>
<HistoricalSignalStoreProvider startUtcMs={fetchRange.min} endUtcMs={fetchRange.max} source={selectedSource} selectedRange={selectedRange}>
<AlertTimeline />
{widgets.length === 0 ? <div className="grid h-full place-items-center text-gray-500">Select signals by adding a widget and choosing signals.</div> : <DataDashboard />}
<WidgetAdder />
</HistoricalSignalStoreProvider>
Expand All @@ -125,77 +56,31 @@ function HistoricContent(props: { selectedRange: { min: number; max: number }; s
}

export default function Historical() {
const [selectedDate, setSelectedDate] = useState<Date>(new Date());
const [selectedSource, setSelectedSource] = useState<HistoricalSignalSource>("Radio");
const [draftDate, setDraftDate] = useState<Date>(selectedDate);
const [selectionModalOpen, setSelectionModalOpen] = useState(false);
const selectedDateKey = useMemo(() => toDateKey(selectedDate), [selectedDate]);
const draftDateKey = useMemo(() => toDateKey(draftDate), [draftDate]);

// `live` drives the dashboard; `draft` is the staged selection inside the modal.
const live = useHistoricalSessionSelection(selectedDateKey, selectedSource);
const draft = useHistoricalSessionSelection(draftDateKey, selectedSource);

const selectedRange = useMemo(() => (live.selectedSession ? { min: live.selectedSession.startUtcMs, max: live.selectedSession.endUtcMs } : null), [live.selectedSession]);
const sourceLabel = SOURCE_OPTIONS.find((source) => source.value === selectedSource)?.label ?? "Historical";
const { source, selectedRange, selectedSession, isModalOpen, openModal } = useHistoricalSelection();

const openSelectionModal = () => {
setDraftDate(selectedDate);
draft.setSelectedSessionId(live.selectedSessionId);
setSelectionModalOpen(true);
};

const handleLoadData = () => {
setSelectedDate(draftDate);
live.setSelectedSessionId(draft.selectedSessionId);
setSelectionModalOpen(false);
};
const hasAutoOpenedRef = useRef(false);
useEffect(() => {
if (!hasAutoOpenedRef.current && !selectedSession && !isModalOpen) {
hasAutoOpenedRef.current = true;
openModal(1);
}
}, [selectedSession, isModalOpen, openModal]);

return (
<DisplayControlProvider defaultViewportLocked={false} viewportLockStorageKey={HISTORIC_VIEWPORT_LOCK_STORAGE_KEY}>
<div className="mt-20 h-[calc(100vh-72px)] flex flex-col overflow-hidden">
<div className="mx-4 mb-4 flex flex-wrap items-center gap-4 shrink-0">
<SourceDropdown selectedSource={selectedSource} onSourceSelect={setSelectedSource} />
<button type="button" className="flex min-w-72 items-center justify-between gap-4 rounded-3xl border border-gray-200 bg-white px-5 py-4 text-left shadow-[0_10px_30px_rgba(15,23,42,0.10)] transition-colors hover:bg-gray-50" onClick={() => openSelectionModal()}>
<div className="flex items-center gap-4">
<div className="flex size-11 items-center justify-center rounded-2xl bg-gray-100 text-gray-900">
<CalendarClock className="size-6" strokeWidth={2.2} />
</div>
<div>
<div className="text-sm font-semibold uppercase tracking-[0.16em] text-gray-500">Date and Session</div>
<div className="text-[1.45rem] font-semibold leading-none text-gray-950">{live.selectedSession?.label ?? "Select session"}</div>
</div>
</div>
<ChevronDown className="size-6 shrink-0 text-gray-500" strokeWidth={2.25} />
</button>
</div>

<div className="mx-4 mb-3 text-sm font-medium text-gray-600 shrink-0">Showing {sourceLabel} data. All times shown in UTC.</div>

<div className="flex-1 min-h-0 relative w-full">
<WidgetManager storageKey={HISTORIC_WIDGET_STORAGE_KEY}>{selectedRange ? <HistoricContent selectedRange={selectedRange} selectedSource={selectedSource} /> : <div className="mx-4 grid h-full place-items-center text-gray-500">{live.query.isPending ? "Loading sessions..." : "No historical session selected."}</div>}</WidgetManager>
<div className="mt-20 flex h-[calc(100vh-72px)] flex-col overflow-hidden">
<div className="relative min-h-0 w-full flex-1">
<WidgetManager storageKey={HISTORIC_WIDGET_STORAGE_KEY}>
{selectedRange ? (
<HistoricContent selectedRange={selectedRange} selectedSource={source} />
) : (
<div className="mx-4 grid h-full place-items-center text-gray-500">No historical session selected.</div>
)}
</WidgetManager>
</div>
</div>

<Dialog open={selectionModalOpen} onOpenChange={setSelectionModalOpen}>
<DialogContent className="max-w-6xl gap-6 rounded-[1.5rem] bg-white">
<DialogHeader>
<DialogTitle>Select Historical Data</DialogTitle>
<DialogDescription>Choose the date and session to load into the historical dashboard.</DialogDescription>
</DialogHeader>

<div className="grid gap-4 md:grid-cols-[minmax(16rem,1fr)_minmax(22rem,1.2fr)]">
<CalendarDropdown selectedDate={draftDate} onDateSelect={setDraftDate} />
<SessionDropdown sessions={draft.sessions} selectedSessionId={draft.selectedSessionId} isLoading={draft.query.isPending} error={draft.query.error} onSessionSelect={draft.setSelectedSessionId} />
</div>

<DialogFooter>
<button type="button" className="rounded-xl bg-blue-600 px-5 py-3 text-sm font-semibold text-white shadow-[0_8px_18px_rgba(37,99,235,0.28)] transition-colors hover:bg-blue-700 disabled:cursor-not-allowed disabled:opacity-60" disabled={!draft.selectedSession} onClick={handleLoadData}>
Load Data
</button>
</DialogFooter>
</DialogContent>
</Dialog>
<HistoricalSelectionModal />
</DisplayControlProvider>
);
}
14 changes: 10 additions & 4 deletions software/tracksight/frontend/app/layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@ const inter = Inter({ subsets: ["latin"] });

import Navbar from "./Navbar";
import QueryProvider from "@/lib/contexts/QueryProvider";
import { HistoricalSelectionProvider } from "@/lib/contexts/HistoricalSelectionContext";
import { TimezoneProvider } from "@/lib/contexts/TimezoneContext";
import Script from "next/script";

export const metadata: Metadata = {
Expand Down Expand Up @@ -36,10 +38,14 @@ export default async function RootLayout({
<link rel="manifest" href="/favicon/site.webmanifest" />
</head>
<body className={`${inter.className} overflow-y-hidden`} style={{ overflowX: "overlay" }}>
<QueryProvider>
<Navbar />
<main>{children}</main>
</QueryProvider>
<TimezoneProvider>
<QueryProvider>
<HistoricalSelectionProvider>
<Navbar />
<main>{children}</main>
</HistoricalSelectionProvider>
</QueryProvider>
</TimezoneProvider>
</body>
</html>
);
Expand Down
1 change: 0 additions & 1 deletion software/tracksight/frontend/components/DataDashboard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,6 @@ import { useRef } from "react";
function DataDashboard() {
const { widgets } = useWidgetManager();


const hoveredSignal = useRef<string | null>(null);

return (
Expand Down
Loading